Skip to content
Closed
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
213 changes: 213 additions & 0 deletions .github/workflows/web-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# A preview of a pull request's head at https://pr-N-fizzy-previews.fizzyedit.workers.dev/, for the
# PRs a person asked QA on: **only those labelled `qa:needed`**, so unlabelled PRs cost no CI.
#
# - Built when the label is applied, and on each push while the PR carries it.
# - A Debug build that loads plugins through the store's test channel, like
# fizzyed.it/testapp/debug/ (web.yml), with browser storage of its own per PR.
# - Served from a **separate origin** (a Cloudflare Workers preview of the Worker `fizzy-previews`,
# behind Cloudflare Access), never fizzyed.it: a preview runs a PR's code before it is reviewed,
# and must not share browser storage or OAuth state with the real site.
# - Removed when the PR closes or loses the label, or when its build fails.
# - The URL is commented on the PR's private thread (the fizzyedit/work issue titled
# `PR #N: ...`), never on the PR, whose watchers are public.
#
# Same-repo PRs only: a fork's code never gets a build here. Three kinds of job, three secrets:
# `build` runs the PR's code with no secret; `publish` and `remove` hold the Cloudflare secrets
# and run none of it (they unzip the build into a directory and hand it to wrangler); `comment`
# holds WORK_THREAD_TOKEN and checks out nothing.
#
# Needs the secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (Workers edit), and
# WORK_THREAD_TOKEN: Issues read/write on fizzyedit/work (the thread comment is skipped without it).

name: Web PR preview

on:
pull_request:
types: [labeled, unlabeled, synchronize, reopened, closed]

env:
ZIG_VERSION: "0.17.0"
WORKER: fizzy-previews
SUBDOMAIN: fizzyedit
WRANGLER: wrangler@4.135.0

# Two groups per PR. `web-pr-N-build` is cancel-in-progress: a push replaces the build in flight,
# and a skipped job never joins it, so a label event for anything but `qa:needed` cannot cancel a
# running build. `web-pr-N-deploy` (publish, remove) queues and never cancels, so a deploy is
# never cut off halfway and a build's cancellation cannot be caused by an older run's publish; each
# deploy job checks the PR's current head before it acts.
jobs:
build:
concurrency:
group: web-pr-${{ github.event.pull_request.number }}-build
cancel-in-progress: true
# Labelled qa:needed, and (for the labeled event) by that very label.
if: >-
github.event.action != 'closed'
&& github.event.pull_request.head.repo.full_name == github.repository
&& contains(github.event.pull_request.labels.*.name, 'qa:needed')
&& (github.event.action != 'labeled' || github.event.label.name == 'qa:needed')
&& github.event.action != 'unlabeled'
permissions:
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Zig cache key
id: zig-cache-key
run: |
prefix="zig-${{ env.ZIG_VERSION }}-web-pr-${{ runner.os }}-${{ runner.arch }}-"
echo "key=${prefix}${{ hashFiles('**/build.zig.zon') }}" >> "$GITHUB_OUTPUT"
- uses: mlugg/setup-zig@v2
with:
version: ${{ env.ZIG_VERSION }}
use-cache: false
# Restore only: a PR's build never writes the cache main's builds share.
- name: Restore Zig cache
uses: actions/cache/restore@v4
with:
path: .zig-cache
key: ${{ steps.zig-cache-key.outputs.key }}
restore-keys: zig-${{ env.ZIG_VERSION }}-
- name: Pre-create Zig cache tmp/
run: mkdir -p "$ZIG_GLOBAL_CACHE_DIR/tmp" "$ZIG_LOCAL_CACHE_DIR/tmp"
- name: Fetch dependencies (with retries)
run: bash scripts/ci-retry.sh zig build --fetch
- name: Build web app
run: |
bash scripts/ci-retry.sh zig build web -Doptimize=Debug \
-Dweb-storage="fizzy-testapp-pr-${{ github.event.pull_request.number }}" \
-Dapp-registry-url=https://plugins.fizzyed.it/test/catalog
- name: Zip the build
env:
SHA: ${{ github.event.pull_request.head.sha }}
PR: ${{ github.event.pull_request.number }}
run: |
printf '{"channel":"pr-%s","sha":"%s","run":"%s/%s/actions/runs/%s"}\n' \
"$PR" "$SHA" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" > zig-out/web/build.json
(cd zig-out/web && zip -qr "$GITHUB_WORKSPACE/web.zip" .)
- uses: actions/upload-artifact@v4
with:
name: web-pr
path: web.zip
retention-days: 1

publish:
needs: build
if: success()
concurrency:
group: web-pr-${{ github.event.pull_request.number }}-deploy
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
runs-on: ubuntu-latest
env:
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
outputs:
url: ${{ steps.deploy.outputs.url }}
steps:
# A newer push, a closed PR or a removed label since this run's build: publish nothing, so
# an older build never overwrites a newer one nor brings back a removed preview.
- name: Is this still the PR's head?
id: fresh
env:
GH_TOKEN: ${{ github.token }}
run: |
read -r state head labelled < <(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" \
--jq '[.state, .head.sha, ([.labels[].name] | index("qa:needed") != null)] | @tsv')
if [ "$state" = open ] && [ "$head" = "$SHA" ] && [ "$labelled" = true ]; then
echo "current=true" >> "$GITHUB_OUTPUT"
else
echo "Stale run: PR is $state at $head (labelled: $labelled), this build is $SHA. Nothing published."
echo "current=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/download-artifact@v4
if: steps.fresh.outputs.current == 'true'
with:
name: web-pr
- name: Deploy the preview
id: deploy
if: steps.fresh.outputs.current == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
echo "::error title=No Cloudflare secrets::CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set for previews."
exit 1
fi
# The build is data here: unzipped, never run.
mkdir site deploy
unzip -q web.zip -d site
printf '{"name":"%s","compatibility_date":"2026-10-01","assets":{"directory":"../site"}}\n' "$WORKER" > deploy/wrangler.jsonc
(cd deploy && npx --yes "$WRANGLER" preview --name "pr-$PR")
echo "url=https://pr-$PR-$WORKER.$SUBDOMAIN.workers.dev/" >> "$GITHUB_OUTPUT"

# A job of its own: WORK_THREAD_TOKEN never sits beside the PR's code or the Cloudflare secrets.
# Once per PR: a later push replaces the build under the same URL.
comment:
needs: publish
if: needs.publish.outputs.url != ''
permissions: {}
runs-on: ubuntu-latest
env:
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
URL: ${{ needs.publish.outputs.url }}
steps:
- name: Tell the PR's private thread
env:
GH_TOKEN: ${{ secrets.WORK_THREAD_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then echo "No WORK_THREAD_TOKEN: the thread is not told."; exit 0; fi
thread=$(gh issue list -R fizzyedit/work --label pr-thread --state all --search "\"PR #$PR:\" in:title" \
--json number,title --jq "[.[] | select(.title | startswith(\"PR #$PR:\"))][0].number // empty")
if [ -z "$thread" ]; then echo "No thread for PR #$PR: nothing to comment on."; exit 0; fi
if gh issue view "$thread" -R fizzyedit/work --json comments --jq '.comments[].body' | grep -qF "Preview: $URL"; then
echo "Thread already has the URL."; exit 0
fi
printf 'Preview: %s\n\nA Debug build of %s on an origin of its own, loading plugins from the store test channel. It is rebuilt on each push while the PR is labelled `qa:needed`, and removed when it closes or loses the label.\n' \
"$URL" "$SHA" | gh issue comment "$thread" -R fizzyedit/work --body-file -

# The preview goes when the PR closes or loses the label, and when this push's build or publish
# fails, so the URL never serves an older push's build as if it were this one. A failure only
# removes while this run's commit is still the PR's head: a newer push owns the preview.
remove:
needs: [build, publish]
if: >-
!cancelled()
&& github.event.pull_request.head.repo.full_name == github.repository
&& (github.event.action == 'closed'
|| (github.event.action == 'unlabeled' && github.event.label.name == 'qa:needed')
|| needs.build.result == 'failure'
|| needs.publish.result == 'failure')
concurrency:
group: web-pr-${{ github.event.pull_request.number }}-deploy
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
runs-on: ubuntu-latest
env:
PR: ${{ github.event.pull_request.number }}
SHA: ${{ github.event.pull_request.head.sha }}
steps:
- name: Remove the preview
env:
GH_TOKEN: ${{ github.token }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
echo "::error title=No Cloudflare secrets::CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID must be set for previews."
exit 1
fi
if [ "${{ github.event.action }}" = synchronize ] || [ "${{ github.event.action }}" = labeled ] || [ "${{ github.event.action }}" = reopened ]; then
head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .head.sha)
if [ "$head" != "$SHA" ]; then echo "A newer push ($head) owns the preview; leaving it."; exit 0; fi
fi
npx --yes "$WRANGLER" preview delete --name "pr-$PR" --skip-confirmation || echo "No preview pr-$PR to remove."
34 changes: 27 additions & 7 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@
# a clean release. A record, not a tag: pointing a ref at a release commit whose workflows differ
# from main's counts as changing workflows, which this workflow's token may never do.
#
# - **fizzyed.it/testapp/debug/ is `main` as a Debug build that loads its plugins through the
# store's test channel** (`-Dapp-registry-url=<test root>`; design/store.md in fizzyedit/plans),
# so find, install, update and load are tried end to end without going live. Nothing is compiled
# in: the web cannot load plugin libraries, but it can fetch the store's wasm builds. Its
# storage name is its own too.
#
# Each copy goes to a release in fizzyedit/website (`web-main` for the app, `web-preview` for the
# test copy), its zip replaced every time, and the website is asked to redeploy. Pull requests
# publish nothing: ci.yml compiles them for the web (`check-web`).
# test copy, `web-debug` for the debug copy), its zip replaced every time, and the website is asked
# to redeploy. Pull requests publish nothing here: ci.yml compiles them for the web
# (`check-web`), and web-pr.yml previews the ones labelled `qa:needed`.
#
# A release published here asks for a redeploy too, without a build: the site links releases.
#
Expand Down Expand Up @@ -107,14 +114,19 @@ jobs:
live_tag=$(gh api "repos/$GITHUB_REPOSITORY/deployments?environment=web-live&per_page=1" --jq '.[0].description // ""')
live=""
if [ -n "$live_tag" ]; then live=$(git show "$live_tag:sdk/src/version.zig" 2>/dev/null | fingerprint || true); fi
copies='[{"channel":"testapp","release":"web-preview","storage":"fizzy-testapp","ref":"'"$GITHUB_SHA"'"}'
# `optimize` and `registry` per copy: the live store for the real ones, the test channel
# (plugins built against main's SDK) for the Debug copy.
live_reg=https://plugins.fizzyed.it/catalog
test_reg=https://plugins.fizzyed.it/test/catalog
copies='[{"channel":"testapp","release":"web-preview","storage":"fizzy-testapp","optimize":"ReleaseSmall","registry":"'"$live_reg"'","ref":"'"$GITHUB_SHA"'"}'
copies="$copies"',{"channel":"debug","release":"web-debug","storage":"fizzy-testapp-debug","optimize":"Debug","registry":"'"$test_reg"'","ref":"'"$GITHUB_SHA"'"}'
if [ -n "$live" ] && [ "$here" = "$live" ]; then
echo "main is on the live plugin SDK ($live): the app goes up from it."
copies="$copies"',{"channel":"app","release":"web-main","storage":"fizzy","ref":"'"$GITHUB_SHA"'"}'
copies="$copies"',{"channel":"app","release":"web-main","storage":"fizzy","optimize":"ReleaseSmall","registry":"'"$live_reg"'","ref":"'"$GITHUB_SHA"'"}'
elif [ -n "${{ inputs.go_live }}" ]; then
ref=$(git rev-parse "$live_tag^{commit}")
echo "main has moved past the live SDK: the app goes up from its release commit ($ref)."
copies="$copies"',{"channel":"app","release":"web-main","storage":"fizzy","ref":"'"$ref"'"}'
copies="$copies"',{"channel":"app","release":"web-main","storage":"fizzy","optimize":"ReleaseSmall","registry":"'"$live_reg"'","ref":"'"$ref"'"}'
else
why="main's plugin SDK fingerprint ($here) is not the live one (${live_tag:-nothing live yet}: ${live:-none})"
echo "::notice title=fizzyed.it/app held::$why. It goes up when that SDK goes live (go_live)."
Expand Down Expand Up @@ -169,9 +181,15 @@ jobs:
# class* into its plugin ABI fingerprint — Debug/ReleaseSafe are "safe", ReleaseFast and
# ReleaseSmall are "fast" — and the plugin store publishes "fast" builds only. Built
# ReleaseSafe, the app asks the registry for a shard nobody publishes under, so every
# plugin in a healthy store reads "No store build" and nothing can be installed.
# plugin in a healthy store reads "No store build" and nothing can be installed. The debug
# copy is the exception on purpose: a Debug host's class is "safe", so it reads the test
# channel's "safe" shard (ReleaseSafe plugin builds against main's SDK). Until the app asks
# for the class it was built with (work#188 step d, `optimize_classes`), it still asks for
# "fast" and installs nothing from the test channel.
- name: Build web app
run: bash scripts/ci-retry.sh zig build web -Doptimize=ReleaseSmall -Dweb-storage="${{ matrix.storage }}"
run: |
bash scripts/ci-retry.sh zig build web -Doptimize="${{ matrix.optimize }}" \
-Dweb-storage="${{ matrix.storage }}" -Dapp-registry-url="${{ matrix.registry }}"

- name: Save Zig cache
if: success() && steps.zig-cache.outputs.cache-matched-key == ''
Expand Down Expand Up @@ -226,6 +244,8 @@ jobs:
run: |
if [ "$CHANNEL" = app ]; then
notes="The app at https://fizzyed.it/app/, the live plugin SDK, built from $GITHUB_REPOSITORY@$SHA"
elif [ "$CHANNEL" = debug ]; then
notes="main as Debug with the store's test channel, at https://fizzyed.it/testapp/debug/, built from $GITHUB_REPOSITORY@$SHA"
else
notes="main, at https://fizzyed.it/testapp/, built from $GITHUB_REPOSITORY@$SHA"
fi
Expand Down
Loading