From df4e3f42c246b78346bca1551400f8a46ccfc0b7 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sun, 18 Oct 2026 10:49:42 -0400 Subject: [PATCH 1/2] fix(website): deploy previews before commenting, and purge the CDN cache A comment must never prevent a deploy. The preview workflow's comment steps ran first, so a pull request opened from a fork -- whose GITHUB_TOKEN is read-only whatever `permissions:` grants -- failed at `GraphQL: Resource not accessible by integration (addComment)` and skipped the deploy entirely (run 39134047965), leaving the previous build served. - Deploy the preview worker first; purge, then update the comment. - Every GitHub-comment step is `continue-on-error`. - Grant `issues: write`: PR conversation comments are issue comments, and `pull-requests: write` alone does not cover them. - Purge the CDN cache after deploying, scoped to `https://ema.tnsor.network/preview//` via `prefixes`; the production workflow purges its whole zone after the router deploy. Both skip with a notice when `CLOUDFLARE_ZONE_ID` is unset for the environment. - A router change now refreshes previews too: `deploy-website.yml` and `website/site/worker/**` joined the preview trigger paths. --- .changeset/quiet-previews-deploy.md | 24 +++++ .github/workflows/deploy-website-preview.yml | 92 +++++++++++++++++--- .github/workflows/deploy-website.yml | 28 ++++++ 3 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 .changeset/quiet-previews-deploy.md diff --git a/.changeset/quiet-previews-deploy.md b/.changeset/quiet-previews-deploy.md new file mode 100644 index 000000000..cb62930c3 --- /dev/null +++ b/.changeset/quiet-previews-deploy.md @@ -0,0 +1,24 @@ +--- +'nostream-website': patch +--- + +A pull request's site preview now deploys even when the comment cannot be +written, and the CDN cache is purged after a deploy so a fresh preview is not +served the previous build. + +**A comment can no longer prevent a deploy.** The preview workflow's comment +steps ran before the deploy, so a pull request opened from a fork — whose +`GITHUB_TOKEN` is read-only whatever the `permissions:` block grants — failed at +`GraphQL: Resource not accessible by integration (addComment)` and skipped the +deploy entirely ([run 39134047965](https://github.com/cameri/nostream/actions/runs/39134047965)). +The deploy now runs first, every comment step is `continue-on-error`, and the +job grants `issues: write` (PR conversation comments are issue comments; +`pull-requests: write` alone does not cover them). + +**The cache is purged after deploying**, scoped to `https://ema.tnsor.network/preview//` +via the `prefixes` purge, so a stale window cannot be served — on 2026-10-18 +`/preview/885/` returned production HTML for about three minutes after the +build landed. The production workflow purges the whole zone after its router +deploy. Both purges skip with a notice when `CLOUDFLARE_ZONE_ID` is not set for +their environment, so an unconfigured environment deploys normally instead of +failing. diff --git a/.github/workflows/deploy-website-preview.yml b/.github/workflows/deploy-website-preview.yml index 8ea72e33c..6b3ac1373 100644 --- a/.github/workflows/deploy-website-preview.yml +++ b/.github/workflows/deploy-website-preview.yml @@ -9,6 +9,15 @@ name: Deploy Website Preview # CLOUDFLARE_API_TOKEN (Workers Scripts edit) and CLOUDFLARE_ACCOUNT_ID — # one-time setup. While the environment or its secrets are absent, this # workflow skips with a notice instead of failing the PR. +# +# Ordering rule for this workflow: the deploy runs FIRST among the side +# effects, and every GitHub-comment step is cosmetic (`continue-on-error`). +# Measured 2026-10-18: a pull request opened from a FORK gets a read-only +# GITHUB_TOKEN whatever the `permissions:` block says, so its comment call +# answers `GraphQL: Resource not accessible by integration (addComment)` — +# and because that step ran BEFORE the deploy, run 39134047965 failed at +# 14:01Z with the deploy skipped, leaving the previous build being served. +# A comment must never be able to prevent a deploy. on: pull_request: @@ -21,6 +30,12 @@ on: - "website/site/**" - "website/hero-animation/**" - ".github/workflows/deploy-website-preview.yml" + # The production workflow deploys the router that serves /preview/*, so + # a router change has to refresh the previews or they keep serving the + # old routing. (This file's own entry is what made run 39134047965 fire + # at all: PR 885 touches none of the website paths.) + - ".github/workflows/deploy-website.yml" + - "website/site/worker/**" concurrency: group: website-preview-${{ github.event.pull_request.number }} @@ -29,6 +44,11 @@ concurrency: permissions: contents: read pull-requests: write + # PR conversation comments are issue comments: `gh pr comment` and the + # `issues//comments` API need this, and `pull-requests: write` alone + # does not grant it. Same-repo runs worked only because the repo's default + # GITHUB_TOKEN permissions already included it; a fork PR never does. + issues: write jobs: preview: @@ -92,16 +112,71 @@ jobs: mkdir -p "dist/preview/$n" find dist -mindepth 1 -maxdepth 1 ! -name preview -exec cp -r {} "dist/preview/$n/" \; + - name: Deploy the preview worker + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + working-directory: website/site + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + npx wrangler deploy \ + --config wrangler.preview.jsonc \ + --name nostream-web-pr-${{ github.event.pull_request.number }} + + - name: Purge the preview's CDN cache + if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + # Part of making a deploy visible, never a gate on it: a missing or + # under-scoped purge credential must not fail a job whose deploy + # already succeeded. + continue-on-error: true + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }} + run: | + # A newly deployed preview worker sits behind the CDN for its own + # domain, so a request that landed mid-deploy can keep being served + # the previous build after the new one is live. Measured 2026-10-18: + # https://ema.tnsor.network/preview/885/ returned production HTML + # with no x-preview-pr header at 14:01:53Z and answered correctly + # three minutes later — a stale window, not a broken deploy. + # + # The purge is scoped to this PR's path prefix, so it cannot affect + # the production site or another PR's preview. + n="${{ github.event.pull_request.number }}" + if [ -z "$CLOUDFLARE_ZONE_ID" ]; then + echo "::notice::CLOUDFLARE_ZONE_ID is not set for the website-preview environment — preview deployed, cache purge skipped. Add it as an environment VARIABLE (the zone id is on the ema.tnsor.network zone Overview page) to enable purging." + exit 0 + fi + body="{\"prefixes\":[\"https://ema.tnsor.network/preview/$n/\"]}" + code=$(curl -sS -o /tmp/purge.json -w '%{http_code}' \ + -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ + -H "authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H 'content-type: application/json' \ + --data "$body") + if [ "$code" = "200" ]; then + echo "purged https://ema.tnsor.network/preview/$n/ (prefix)" + else + echo "::warning::cache purge answered HTTP $code — $(head -c 400 /tmp/purge.json)" + fi + + # Everything below is cosmetic and runs AFTER the deploy, so a token + # that cannot comment (a fork PR's read-only GITHUB_TOKEN) costs the + # comment, never the preview. - name: Mark the preview comment as rendering if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + continue-on-error: true env: GH_TOKEN: ${{ github.token }} URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ WORKER_URL: https://nostream-web-pr-${{ github.event.pull_request.number }}.cameri.workers.dev/preview/${{ github.event.pull_request.number }}/ MARKER: run: | - # Flip the single preview comment to a "rendering" state before the - # deploy starts, so the link always exists but signals staleness. + # Tidy the single preview comment's stale "rendering" state. It runs + # after the deploy, so on a fork PR, where this write is refused, the + # comment keeps whatever state it had: a fork-only PR whose preview + # comment has never been written simply has no comment, which is the + # cosmetic cost this ordering buys. The link is only lost from the + # conversation, never the preview. COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments?per_page=100" \ --jq '.[] | select(.body | startswith(env.MARKER)) | .id' | tail -1) BODY="${MARKER} @@ -114,19 +189,9 @@ jobs: echo "created new preview comment (rendering)" fi - - name: Deploy the preview worker - if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' - working-directory: website/site - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - run: | - npx wrangler deploy \ - --config wrangler.preview.jsonc \ - --name nostream-web-pr-${{ github.event.pull_request.number }} - - name: Link the preview on the PR if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' + continue-on-error: true env: GH_TOKEN: ${{ github.token }} URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ @@ -173,6 +238,7 @@ jobs: - name: Note the cleanup on the PR if: steps.guard.outputs.skip == 'false' && github.event.action == 'closed' + continue-on-error: true env: GH_TOKEN: ${{ github.token }} MARKER: diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 5eac20646..802618927 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -67,3 +67,31 @@ jobs: # The router worker: serves the production assets itself and proxies # /preview/ to per-PR preview workers (deploy-website-preview.yml). run: pnpm exec wrangler deploy --config wrangler.router.jsonc + + - name: Purge the site's CDN cache + # Same reason as the preview workflow's purge: a request that lands + # mid-deploy can keep being served the previous build after the new + # one is live. Production deploys the router, so everything changes + # and the purge is unfiltered (`purge_everything`). Guarded on the + # variable so an unconfigured environment skips instead of POSTing to + # a zone id of "" and the notice says what is missing. + if: vars.CLOUDFLARE_ZONE_ID != '' + continue-on-error: true + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ vars.CLOUDFLARE_ZONE_ID }} + run: | + code=$(curl -sS -o /tmp/purge.json -w '%{http_code}' \ + -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ + -H "authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + -H 'content-type: application/json' \ + --data '{"purge_everything":true}') + if [ "$code" = "200" ]; then + echo "purged the whole ema.tnsor.network cache" + else + echo "::warning::cache purge answered HTTP $code — $(head -c 400 /tmp/purge.json)" + fi + + - name: Note a skipped purge + if: vars.CLOUDFLARE_ZONE_ID == '' + run: echo "::notice::CLOUDFLARE_ZONE_ID is not set for the website environment — deployed, cache purge skipped. Add it as an environment VARIABLE to enable purging." From 826d43cdca0886a861dadad650d651be542dd9f5 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sun, 18 Oct 2026 10:59:54 -0400 Subject: [PATCH 2/2] fix(website): reject fork runs, purge with a scheme-less prefix, one comment Three review findings, each verified before the fix. 1. The purge prefix carried a scheme. Cloudflare's purge-by-prefix takes a hostname and path - `www.example.com/foo` - and a value beginning with `https://` is rejected outright, so the purge would have failed on every preview once a zone id existed, silently, as a ::warning:: in a run nobody reads. Now `ema.tnsor.network/preview//`. Checked against the documented format, and the body parses with no scheme. 2. A fork PR could still reach `wrangler deploy`. The reorder stopped a comment from killing the deploy; it did not stop a run that can never deploy from trying. A `pull_request` run from a fork receives no repository secrets, so the guard now skips fork runs with a notice before any setup, instead of failing at the deploy with an empty token. Serving forks needs a `workflow_run` deployment on a trusted branch; that is not something this workflow can fake, and it does not pretend to. 3. The "rendering" comment step is removed. It ran after the deploy but overwrote the link with "still rendering", then relied on a second API write to restore it - so a failed second write or a cancelled run left a finished preview reading as unfinished. The comment is now written once, in its final state, after the deploy. actionlint: clean apart from the four pre-existing checkout@v3/setup-node@v3 warnings. Guard truth table exercised: fork head -> skip, same-repo head -> the environment check as before. --- .github/workflows/deploy-website-preview.yml | 50 +++++++------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/.github/workflows/deploy-website-preview.yml b/.github/workflows/deploy-website-preview.yml index 6b3ac1373..9771288da 100644 --- a/.github/workflows/deploy-website-preview.yml +++ b/.github/workflows/deploy-website-preview.yml @@ -69,7 +69,17 @@ jobs: # environment secrets needs permissions github.token does not carry, # and probing them here once made every run skip with "lacks # CLOUDFLARE_API_TOKEN" even after the secrets were set. - if gh api repos/${{ github.repository }}/environments/website-preview >/dev/null 2>&1; then + # A pull_request run from a fork gets no repository secrets at all - + # GitHub does not hand them to code the PR author controls, whatever + # the environment says. Such a run can never deploy a preview, so it + # must stop here rather than reach `wrangler deploy` with an empty + # token and fail there. The fix that would serve forks is a + # workflow_run deployment on a trusted branch; this workflow does + # not implement one, so it skips instead of pretending. + if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::pull request is from a fork — preview skipped (fork runs receive no repository secrets). The site deploy runs on main after merge." + elif gh api repos/${{ github.repository }}/environments/website-preview >/dev/null 2>&1; then echo "skip=false" >> "$GITHUB_OUTPUT" else echo "skip=true" >> "$GITHUB_OUTPUT" @@ -147,7 +157,8 @@ jobs: echo "::notice::CLOUDFLARE_ZONE_ID is not set for the website-preview environment — preview deployed, cache purge skipped. Add it as an environment VARIABLE (the zone id is on the ema.tnsor.network zone Overview page) to enable purging." exit 0 fi - body="{\"prefixes\":[\"https://ema.tnsor.network/preview/$n/\"]}" + # Hostname and path only: a scheme-prefixed value is rejected outright. + body="{\"prefixes\":[\"ema.tnsor.network/preview/$n/\"]}" code=$(curl -sS -o /tmp/purge.json -w '%{http_code}' \ -X POST "https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID/purge_cache" \ -H "authorization: Bearer $CLOUDFLARE_API_TOKEN" \ @@ -159,36 +170,11 @@ jobs: echo "::warning::cache purge answered HTTP $code — $(head -c 400 /tmp/purge.json)" fi - # Everything below is cosmetic and runs AFTER the deploy, so a token - # that cannot comment (a fork PR's read-only GITHUB_TOKEN) costs the - # comment, never the preview. - - name: Mark the preview comment as rendering - if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ - WORKER_URL: https://nostream-web-pr-${{ github.event.pull_request.number }}.cameri.workers.dev/preview/${{ github.event.pull_request.number }}/ - MARKER: - run: | - # Tidy the single preview comment's stale "rendering" state. It runs - # after the deploy, so on a fork PR, where this write is refused, the - # comment keeps whatever state it had: a fork-only PR whose preview - # comment has never been written simply has no comment, which is the - # cosmetic cost this ordering buys. The link is only lost from the - # conversation, never the preview. - COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments?per_page=100" \ - --jq '.[] | select(.body | startswith(env.MARKER)) | .id' | tail -1) - BODY="${MARKER} - ⏳ Preview is rendering for the latest push — ${WORKER_URL} will serve the new build in about a minute." - if [ -n "$COMMENT_ID" ]; then - gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -f body="$BODY" >/dev/null - echo "marked comment $COMMENT_ID as rendering" - else - gh pr comment ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --body "$BODY" >/dev/null - echo "created new preview comment (rendering)" - fi - + # The comment is written ONCE, after the deploy, in its final state. A + # separate "rendering" write that a later step has to undo would leave a + # finished preview reading as unfinished whenever the second write failed + # or the run was cancelled - and it no longer signals anything useful now + # that it runs after the deploy rather than before it. - name: Link the preview on the PR if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' continue-on-error: true