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..9771288da 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: @@ -49,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" @@ -92,28 +122,6 @@ jobs: mkdir -p "dist/preview/$n" find dist -mindepth 1 -maxdepth 1 ! -name preview -exec cp -r {} "dist/preview/$n/" \; - - name: Mark the preview comment as rendering - if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' - 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. - 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 - - name: Deploy the preview worker if: steps.guard.outputs.skip == 'false' && github.event.action != 'closed' working-directory: website/site @@ -125,8 +133,51 @@ jobs: --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 + # 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" \ + -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 + + # 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 env: GH_TOKEN: ${{ github.token }} URL: https://ema.tnsor.network/preview/${{ github.event.pull_request.number }}/ @@ -173,6 +224,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."