diff --git a/.github/workflows/web-pr.yml b/.github/workflows/web-pr.yml new file mode 100644 index 000000000..6ad2e764e --- /dev/null +++ b/.github/workflows/web-pr.yml @@ -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." diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index a74d33b3f..e506499b4 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -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=`; 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. # @@ -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)." @@ -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 == '' @@ -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