From 94bbdccf67bb908ed2033c7a475b48161e394d24 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Mon, 31 Aug 2026 16:43:23 +0100 Subject: [PATCH 1/9] fix(claim): stop claim page crashing on links with no events relation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API stopped selecting the SendLink `events` relation post-ledger-collapse, so `claimLinkData.events` is now always undefined. Claim.tsx dereferenced `events[0]` with the optional chain on the wrong side of the index, throwing "Cannot read properties of undefined (reading '0')" inside a render-phase useMemo — which Next.js surfaces as "Application error: a client-side exception has occurred" (Sentry PEANUT-UI-SJ0). Guard the array itself and mark `events` optional on the SendLink type so the compiler catches the next orphaned access. --- src/components/Claim/Claim.tsx | 5 ++++- src/services/services.types.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/Claim/Claim.tsx b/src/components/Claim/Claim.tsx index dbd6cda8dd..ff197595e6 100644 --- a/src/components/Claim/Claim.tsx +++ b/src/components/Claim/Claim.tsx @@ -181,7 +181,10 @@ export const Claim = ({}) => { initials: getInitialsFromName(recipientName), memo: claimLinkData.textContent, attachmentUrl: claimLinkData.fileUrl, - cancelledDate: status === 'cancelled' ? new Date(claimLinkData.events[0]?.timestamp) : undefined, + cancelledDate: + status === 'cancelled' && claimLinkData.events?.[0] + ? new Date(claimLinkData.events[0].timestamp) + : undefined, txHash: claimLinkData.claim?.txHash, extraDataForDrawer: { isLinkTransaction: true, diff --git a/src/services/services.types.ts b/src/services/services.types.ts index 4cc97869fe..99be569dcc 100644 --- a/src/services/services.types.ts +++ b/src/services/services.types.ts @@ -374,7 +374,8 @@ export type SendLink = { }[] } } - events: { + /** Absent post-ledger-collapse: the API no longer selects the `events` relation. */ + events?: { timestamp: Date status: SendLinkStatus reason?: string From 93bfdae0e751bff55508c60d4d18a975b7bfd202 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:56:58 +0530 Subject: [PATCH 2/9] hotfix: activate the ds-shots PR comment (publisher to main) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow_run workflows fire only from the default branch, so the publisher that peanut-ui#2897 landed on dev is inert until a release. This carries the three publisher files to main unchanged — the workflow, the render/validate script it runs from the main checkout, and its test — which turns the sticky visual-diff comment on for every PR immediately. The ds-shots job side already runs on dev-based PRs from dev's tests.yml and is already uploading report artifacts. Files are byte-identical to dev (merged #2897, ten Chip review rounds, final round clean), so the next dev -> main release merges over this with no conflict and no back-merge debt. TASK-22044 --- .github/workflows/ds-shots-comment.yml | 327 +++++++++++++++++++++ scripts/__tests__/ds-shots-publish.test.js | 120 ++++++++ scripts/ds-shots-publish.mjs | 160 ++++++++++ 3 files changed, 607 insertions(+) create mode 100644 .github/workflows/ds-shots-comment.yml create mode 100644 scripts/__tests__/ds-shots-publish.test.js create mode 100644 scripts/ds-shots-publish.mjs diff --git a/.github/workflows/ds-shots-comment.yml b/.github/workflows/ds-shots-comment.yml new file mode 100644 index 0000000000..4ad515deab --- /dev/null +++ b/.github/workflows/ds-shots-comment.yml @@ -0,0 +1,327 @@ +# Publishes the ds-shots visual-diff result as one sticky PR comment. +# +# Why a separate workflow: the ds-shots job in tests.yml checks out and runs +# PR code (pnpm install, next build, playwright), so it must hold no write +# token — a postinstall can plant a GITHUB_PATH or GITHUB_ENV hook that fires +# in any later step of the same job (see the permissions comment on ds-shots, +# PR #2819). A workflow_run workflow executes THIS file from the default +# branch and never checks out PR code, so it can safely hold +# pull-requests: write. +# +# Trust boundary: the artifact was produced by a job that ran PR code, so its +# contents are attacker-controlled. This workflow extracts exactly one member +# (report.json) by name — no archive path is ever written to disk — and +# scripts/ds-shots-publish.mjs validates it strictly before any of it reaches +# markdown. The PR number is parsed from the artifact name (the PR's build +# chose it), so it is bound back to the trigger: the numbered PR's head must +# be the exact commit the run tested, or nothing is posted. +# +# Outcome table (report artifact × PR binding × existing comment) — every +# path must end in one of these cells, so audit changes against the table: +# report usable + head matches the named PR → render, post or update +# report usable + that PR's head moved → no-op, the newer run owns it +# report usable + named PR is a true 404 → no-op (attacker-chosen name) +# report unusable (bad zip / bad schema) → clear to no-result, fail red +# no report + comment is for this head → keep (a sibling run posted it) +# no report + comment is for an older head → clear to no-result +# no report + no comment → stay quiet +# any operational API failure → red job, comment untouched +# +# Supersession is enforced by head-SHA binding, not by cancellation. The +# concurrency group below serializes publishers for the SAME head only — +# cancel-in-progress stays false, because one branch can hold two open PRs +# (dev and main base) whose publishers must both run. Serialization closes +# the read-then-write race where a no-report run could overwrite the result +# a sibling same-head run posted between its marker read and its PATCH; +# once serialized, either order ends correctly, because the marker-head +# guard makes a no-report run keep any comment already posted for its head. +# queue: max keeps EVERY same-head publisher (FIFO, up to 100) — the default +# one-slot queue would let a third run drop a sibling PR's only pending +# publisher and leave that PR's comment stale. +# +# Deploy note: workflow_run only fires once this file exists on the DEFAULT +# branch (main). It lands on dev first, so the comment starts appearing after +# the next dev → main release. Until then this file is inert. +name: ds-shots comment + +on: + workflow_run: + workflows: [Tests] + types: [completed] + +permissions: + contents: read # checkout of the default branch, for the builder script + actions: read # list + download the triggering run's artifacts + pull-requests: write # the sticky comment + +concurrency: + group: ds-shots-comment-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + queue: max + +jobs: + comment: + # Same-repo PRs only. Fork PRs never produce the artifact anyway + # (ds-shots-filter skips them), so this is belt and braces. Cancelled + # runs are deliberately NOT excluded: a manually cancelled run for the + # current head must clear an older sticky result, and a run cancelled + # by concurrency after a newer push is already a no-op — its head SHA + # no longer matches the PR, so both the bind and the stale path skip. + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # For a workflow_run event github.sha is the default branch head, + # so this checks out trusted code — never the PR. + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Find the report artifact on the triggering run + id: art + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + ROWS=$(gh api --paginate "repos/$REPO/actions/runs/$RUN_ID/artifacts?per_page=100" \ + --jq '.artifacts[] | "\(.id)\t\(.name)"') + # Artifacts persist across reruns of the same run id, and + # every attempt of a run tests the SAME head SHA. Take the + # highest attempt's report: "Re-run failed jobs" that skips + # a succeeded ds-shots keeps attempt 1's still-valid result, + # while a full rerun's fresh report wins by attempt number. + # The attempt suffix exists because v4 uploads are immutable + # — a fixed name would collide on rerun. + REPORT=$(printf '%s\n' "$ROWS" | grep -E $'\t''visual-diff-report-[0-9]+-[0-9]+$' | sort -t- -k5,5n | tail -1 || true) + if [ -z "$REPORT" ]; then + echo 'found=false' >> "$GITHUB_OUTPUT" + echo 'No attempt of this run produced a diff report: ds-shots was skipped, failed before the diff, or had no baseline.' + exit 0 + fi + REPORT_ID=${REPORT%%$'\t'*} + NAME=${REPORT#*$'\t'} + ATTEMPT_USED=${NAME##*-} + PR=$(printf '%s' "$NAME" | sed -E 's/^visual-diff-report-([0-9]+)-[0-9]+$/\1/') + # The PNG artifact only exists when a screen moved; its id + # becomes the images download link in the comment. Same + # attempt as the report it belongs to. + IMG_ID=$(printf '%s\n' "$ROWS" | grep -E $'\t'"visual-diff-$PR-$ATTEMPT_USED\$" | sed -n '1p' | cut -f1 || true) + { + echo 'found=true' + echo "report_id=$REPORT_ID" + echo "pr=$PR" + echo "img_id=$IMG_ID" + } >> "$GITHUB_OUTPUT" + echo "Report artifact $REPORT_ID for PR #$PR (images: ${IMG_ID:-none})" + + - name: Bind the artifact's PR number back to the run + if: steps.art.outputs.found == 'true' + id: bind + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ steps.art.outputs.pr }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + # The artifact name — and so the PR number — was chosen by + # the PR's own build code. Require the numbered PR's head to + # be the exact commit this run tested: a PR naming its + # artifact after somebody else's PR posts nothing, and an + # out-of-order older run cannot overwrite a newer comment. + # + # A true 404 (the number points at no PR) is that attack and + # stays a no-op. Any other failure — 5xx, rate limit, + # network — must fail this job loudly: swallowed, it would + # exit green while an older result stands as current. + set +e + OUT=$(gh api "repos/$REPO/pulls/$PR" --jq '.head.sha' 2>&1) + STATUS=$? + set -e + if [ "$STATUS" -ne 0 ]; then + if printf '%s' "$OUT" | grep -q 'HTTP 404'; then + echo "PR #$PR does not exist — the artifact name pointed at nothing. Skipping." + echo 'match=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::PR-head lookup for #$PR failed: $OUT" + exit 1 + fi + HEAD=$OUT + if [ "$HEAD" != "$RUN_HEAD_SHA" ]; then + echo "PR #$PR head ${HEAD:-} != run head $RUN_HEAD_SHA — mismatched artifact name or stale run. Skipping." + echo 'match=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo 'match=true' >> "$GITHUB_OUTPUT" + + # The download is a plain API call: a transient failure here is + # operational, must go red with the comment untouched, and must + # never be misread as a tampered artifact — so it lives OUTSIDE + # the continue-on-error step below. + - name: Download the report artifact + if: steps.bind.outputs.match == 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + REPORT_ID: ${{ steps.art.outputs.report_id }} + run: | + set -euo pipefail + gh api "repos/$REPO/actions/artifacts/$REPORT_ID/zip" > report.zip + + # Extraction and rendering consume attacker-controlled bytes, so + # either may refuse them: a broken archive fails unzip, an + # out-of-contract report is rejected by the validator. + # continue-on-error lets the run reach the fallback and upsert + # below — the last step of the job still turns an unusable report + # into a red run, so tampering stays loud. + - name: Extract and render the report + if: steps.bind.outputs.match == 'true' + id: render + continue-on-error: true + env: + REPO: ${{ github.repository }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_ID: ${{ github.event.workflow_run.id }} + IMG_ID: ${{ steps.art.outputs.img_id }} + run: | + set -euo pipefail + # Extract the one member by exact name, to stdout: no path + # from the (untrusted) archive is ever written to disk, so + # zip-slip has nothing to work with. A missing member fails + # the step loudly. + unzip -p report.zip report.json > report.json + if [ -n "$IMG_ID" ]; then + export ARTIFACT_URL="https://github.com/$REPO/actions/runs/$RUN_ID/artifacts/$IMG_ID" + fi + node scripts/ds-shots-publish.mjs report.json > body.md + echo 'ok=true' >> "$GITHUB_OUTPUT" + + # A report that exists but cannot be used must not leave the + # previous head's result standing as current (same stale-success + # hazard as a missing report). Overwrite with the trusted generic + # body — never with anything taken from the report. + - name: Fall back to the no-result body when the report is unusable + if: steps.bind.outputs.match == 'true' && steps.render.outputs.ok != 'true' + id: fallback + env: + RUN_URL: ${{ github.event.workflow_run.html_url }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + { + echo "" + echo + echo '## 🖼 Visual diff — ⚠️ no result for the latest push' + echo + echo "The newest [Tests run]($RUN_URL) uploaded a diff report that could not be used: a broken archive, or a report that failed validation." + echo 'The previous result on this comment no longer reflects the latest run, so it was cleared. This publisher run fails on purpose so someone looks.' + echo + echo 'Fixture screenshots, no backend. Advisory — this check never blocks a merge.' + } > body.md + echo 'used=true' >> "$GITHUB_OUTPUT" + + # A run with no report must not leave an older result standing as + # if it were current (the stale-success hazard). Which PR a run + # belongs to cannot be known exactly — one branch can hold two + # open PRs, and any branch can be parked on any commit — so this + # never guesses. It checks EVERY open PR whose head is exactly + # this run's branch and commit, and clears a comment only when + # the head recorded in its marker differs from this run's head: + # only then does the comment provably describe an older commit of + # that PR. A comment already carrying this head was posted by a + # sibling run that had a report, and stays. A PR with no comment + # stays quiet, so docs-only PRs get no spam. Every identity input + # is a trusted event field or GitHub API response — never + # anything the PR's build produced. + - name: Clear stale comments when this run has no report + if: steps.art.outputs.found != 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + run: | + set -euo pipefail + # env.* inside the jq program, not shell interpolation — a + # branch name must never be able to edit the filter. + CANDIDATES=$(gh api "repos/$REPO/commits/$RUN_HEAD_SHA/pulls" \ + --jq '.[] | select(.state == "open" and .head.sha == env.RUN_HEAD_SHA and .head.ref == env.HEAD_BRANCH) | .number') + if [ -z "$CANDIDATES" ]; then + echo 'No open PR has this branch+commit as its head — superseded or stale run. Nothing to do.' + exit 0 + fi + { + echo "" + echo + echo '## 🖼 Visual diff — ⚠️ no result for the latest push' + echo + echo "The newest [Tests run]($RUN_URL) produced no diff report in any attempt: ds-shots was skipped, failed before the diff, or had no cached baseline." + echo 'The previous result on this comment no longer reflects the latest run, so it was cleared.' + echo + echo 'Fixture screenshots, no backend. Advisory — this check never blocks a merge.' + } > stale-body.md + for PR in $CANDIDATES; do + ROW=$(gh api --paginate "repos/$REPO/issues/$PR/comments?per_page=100" \ + --jq '.[] | select(.user.login == "github-actions[bot]") + | select(.body | startswith("`) + expect(result.stdout).toContain('2 screens moved') + expect(result.stdout).toContain('3 of 120 shots changed') + expect(result.stdout).toContain('`aaaaaaa` → head `bbbbbbb`') + // worst screen first, both widths folded into one row + expect(result.stdout).toContain('| 100.00% | `qr-pay` — resized 430x900 -> 430x1200 | 430 |') + expect(result.stdout).toContain('| 3.21% | `home` | 320, 375 |') + expect(result.stdout).toContain('new screens (1)') + expect(result.stdout).toContain('[job summary](https://github.com/peanutprotocol/peanut-ui/actions/runs/1)') + expect(result.stdout).toContain('artifacts/2') + }) + + it('still posts (green) when nothing moved', () => { + const result = run(valid({ changed: [], added: [], removed: [], unchanged: 120 }), { HEAD_SHA: '' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('') + expect(result.stdout).toContain('no screen moved') + expect(result.stdout).toContain('120 shots, all identical') + }) + + it.each([ + ['path traversal', { file: '../../../etc/passwd@375.png', percent: 1, pixels: 1 }], + ['absolute path', { file: '/etc/passwd@375.png', percent: 1, pixels: 1 }], + ['markdown table breakout', { file: 'a|b@375.png', percent: 1, pixels: 1 }], + ['html injection', { file: 'a@375.png', percent: 1, pixels: 1 }], + ['backtick breakout', { file: 'a`code`@375.png', percent: 1, pixels: 1 }], + ['newline smuggling', { file: 'a\n## fake@375.png', percent: 1, pixels: 1 }], + ['overlong name', { file: `${'a'.repeat(200)}@375.png`, percent: 1, pixels: 1 }], + ['non-string file', { file: 42, percent: 1, pixels: 1 }], + ['NaN percent', { file: 'home@375.png', percent: NaN, pixels: 1 }], + ['percent out of range', { file: 'home@375.png', percent: 101, pixels: 1 }], + ['string percent', { file: 'home@375.png', percent: '3.2', pixels: 1 }], + ['injected note', { file: 'home@375.png', percent: 1, pixels: 1, note: '](x)