Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/quiet-previews-deploy.md
Original file line number Diff line number Diff line change
@@ -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/<n>/`
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.
98 changes: 75 additions & 23 deletions .github/workflows/deploy-website-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
Expand All @@ -29,6 +44,11 @@ concurrency:
permissions:
contents: read
pull-requests: write
# PR conversation comments are issue comments: `gh pr comment` and the
# `issues/<n>/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:
Expand All @@ -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"
Expand Down Expand Up @@ -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: <!-- site-preview-comment -->
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
Expand All @@ -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 }}/
Expand Down Expand Up @@ -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: <!-- site-preview-comment -->
Expand Down
28 changes: 28 additions & 0 deletions .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,31 @@ jobs:
# The router worker: serves the production assets itself and proxies
# /preview/<n> 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."
Loading