From 4e849ed944294e33a9029f853a1601e26461dae8 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Sat, 24 Oct 2026 21:43:40 +0100 Subject: [PATCH 1/6] CI: only deploy the website from the head of `main` Re-running the CI workflow of an older commit on `main` deployed that commit's website over the newer one: a re-run keeps the original event's context, so the `deploy` job's condition still passed. Tests finishing out of order, after two quick pushes, had the same effect. The `pages` concurrency group doesn't prevent it either: a job queued in a group cancels the pending one, so an older commit's deployment could cancel a newer commit's pending deployment and leave the website outdated until the next push. A new `check-head` job reads the head of `main` and only lets `deploy` run when it is still the workflow's commit. It runs before `deploy`, so a skipped deployment never queues in the `pages` group, and it skips with a notice instead of failing the run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36375c7439..111daa1f95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,12 +89,40 @@ jobs: name: github-pages-${{ github.run_attempt }} path: dist - deploy: + # re-running an older commit's workflow, or tests finishing out of order, must not deploy an outdated website, + # and the check must happen before `deploy` queues in the `pages` concurrency group, + # as queuing there cancels the pending deployment, even when that one is for a newer commit + check-head: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: test runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read # read the head of main + + outputs: + is-head: ${{ steps.check.outputs.is-head }} + + steps: + - name: Check that the commit is still the head of main + id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + head=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha) + if [ "$head" = "$GITHUB_SHA" ]; then + echo 'is-head=true' >> "$GITHUB_OUTPUT" + else + echo "::notice::Skipping the deployment, main is now at $head" + fi + + deploy: + if: needs.check-head.outputs.is-head == 'true' + needs: [test, check-head] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: pages: write # deploy to GitHub Pages id-token: write # verify the deployment originates from this workflow From fb9122eca99745441f00e6cb4c2054eac9f431de Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Sat, 24 Oct 2026 21:46:59 +0100 Subject: [PATCH 2/6] CI: explain why the head check is its own job Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 111daa1f95..4ff17bd57f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,9 +89,9 @@ jobs: name: github-pages-${{ github.run_attempt }} path: dist - # re-running an older commit's workflow, or tests finishing out of order, must not deploy an outdated website, - # and the check must happen before `deploy` queues in the `pages` concurrency group, - # as queuing there cancels the pending deployment, even when that one is for a newer commit + # re-running an older commit's workflow, or tests finishing out of order, must not deploy an outdated website; + # checking here, rather than in `deploy`, skips that job before it records a deployment of the outdated commit + # or queues in the `pages` concurrency group, where it would cancel a newer commit's pending deployment check-head: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: test From dfc354094e9a1411a9869f2a95168b8b095a9d54 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Sat, 24 Oct 2026 21:55:24 +0100 Subject: [PATCH 3/6] CI: read the head of `main` with `git ls-remote` The repository is public, so `git ls-remote` needs no token, and `check-head` no longer needs any permission. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ff17bd57f..9e458efa29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,19 +98,17 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 - permissions: - contents: read # read the head of main - outputs: is-head: ${{ steps.check.outputs.is-head }} steps: + # the repository is public, so `git ls-remote` reads the head of main without a token, + # and unlike the default shell, `shell: bash` sets `pipefail`, so the step fails if `git ls-remote` does - name: Check that the commit is still the head of main id: check - env: - GH_TOKEN: ${{ github.token }} + shell: bash run: | - head=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha) + head=$(git ls-remote "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" refs/heads/main | cut -f1) if [ "$head" = "$GITHUB_SHA" ]; then echo 'is-head=true' >> "$GITHUB_OUTPUT" else From 22fa3c60d805666e0dc0174c81b99077e4fffcc7 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Sat, 24 Oct 2026 22:00:42 +0100 Subject: [PATCH 4/6] CI: default to `shell: bash` in all workflows Unlike the default `bash -e`, `shell: bash` also sets `pipefail`, so a failing command in a pipe fails the step. `check-head` relied on it for `git ls-remote`, and `update-screenshots.yml`'s `git ls-files` pipes no longer hide a failing `git ls-files`. No existing step behaves differently otherwise. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 9 ++++++--- .github/workflows/publish.yml | 5 +++++ .github/workflows/update-screenshots.yml | 5 +++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e458efa29..98f15cca7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,11 @@ cache-mode: none env: FORCE_COLOR: 1 +# unlike the default `bash -e`, `shell: bash` also sets `pipefail`, so a failing command in a pipe fails the step +defaults: + run: + shell: bash + jobs: test: runs-on: ubuntu-latest @@ -102,11 +107,9 @@ jobs: is-head: ${{ steps.check.outputs.is-head }} steps: - # the repository is public, so `git ls-remote` reads the head of main without a token, - # and unlike the default shell, `shell: bash` sets `pipefail`, so the step fails if `git ls-remote` does + # the repository is public, so `git ls-remote` reads the head of main without a token - name: Check that the commit is still the head of main id: check - shell: bash run: | head=$(git ls-remote "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" refs/heads/main | cut -f1) if [ "$head" = "$GITHUB_SHA" ]; then diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d1375fd564..a48a647436 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,6 +28,11 @@ cache-mode: none env: FORCE_COLOR: 1 +# Unlike the default `bash -e`, `shell: bash` also sets `pipefail`, so a failing command in a pipe fails the step +defaults: + run: + shell: bash + # One release at a time, never canceled midway concurrency: group: ${{ github.workflow }} diff --git a/.github/workflows/update-screenshots.yml b/.github/workflows/update-screenshots.yml index 6a91323eee..4b00294643 100644 --- a/.github/workflows/update-screenshots.yml +++ b/.github/workflows/update-screenshots.yml @@ -15,6 +15,11 @@ env: # and only images, so the job that pushes can only commit images SCREENSHOTS: 'test/**/screenshots/**/*.png' +# unlike the default `bash -e`, `shell: bash` also sets `pipefail`, so a failing command in a pipe fails the step +defaults: + run: + shell: bash + # dependencies only run in the read-only `screenshots` job, # the jobs with write permissions only run gh and git jobs: From b94ff70c2df3377be9d38f9a16e88e035410593e Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Sat, 24 Oct 2026 22:33:10 +0100 Subject: [PATCH 5/6] CI: check the head of `main` in `test`, queue deployments The check moves to the last step of the `test` job, which saves starting a runner on every push to `main`, and passes its result to `deploy` through a job output, like the artifact name. The `pages` concurrency group now uses `queue: max`, so deployments wait in turn instead of each queued one canceling the pending one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98f15cca7e..d737397458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,11 +29,12 @@ jobs: # restore and save the Vite Task cache cache-mode: write - # a re-run keeps the artifacts of previous attempts, and `deploy-pages` fails when - # several share a name, so each attempt uploads the website under its own name: - # https://github.com/actions/deploy-pages/issues/338 outputs: + # a re-run keeps the artifacts of previous attempts, and `deploy-pages` fails when + # several share a name, so each attempt uploads the website under its own name: + # https://github.com/actions/deploy-pages/issues/338 pages-artifact: github-pages-${{ github.run_attempt }} + is-head: ${{ steps.check-head.outputs.is-head }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -94,22 +95,12 @@ jobs: name: github-pages-${{ github.run_attempt }} path: dist - # re-running an older commit's workflow, or tests finishing out of order, must not deploy an outdated website; - # checking here, rather than in `deploy`, skips that job before it records a deployment of the outdated commit - # or queues in the `pages` concurrency group, where it would cancel a newer commit's pending deployment - check-head: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: test - runs-on: ubuntu-latest - timeout-minutes: 5 - - outputs: - is-head: ${{ steps.check.outputs.is-head }} - - steps: - # the repository is public, so `git ls-remote` reads the head of main without a token + # re-running an older commit's workflow, or tests finishing out of order, must not deploy an outdated website, + # and checking in this job's last step, rather than in `deploy`, skips that job before it records a deployment + # of the outdated commit; the repository is public, so `git ls-remote` reads the head of main without a token - name: Check that the commit is still the head of main - id: check + id: check-head + if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | head=$(git ls-remote "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" refs/heads/main | cut -f1) if [ "$head" = "$GITHUB_SHA" ]; then @@ -119,8 +110,8 @@ jobs: fi deploy: - if: needs.check-head.outputs.is-head == 'true' - needs: [test, check-head] + if: needs.test.outputs.is-head == 'true' + needs: test runs-on: ubuntu-latest timeout-minutes: 5 @@ -128,10 +119,12 @@ jobs: pages: write # deploy to GitHub Pages id-token: write # verify the deployment originates from this workflow - # one deployment at a time, never cancel one midway + # one deployment at a time, never cancel one midway, + # and queue the others instead of canceling the pending one concurrency: group: pages cancel-in-progress: false + queue: max environment: name: github-pages From b0d5cd8e28d2470b20c8507868f503e1e20e1526 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Sat, 24 Oct 2026 22:52:48 +0100 Subject: [PATCH 6/6] CI: only upload the website from the head of `main` The head check now runs before `Upload website`, which only runs when the commit is still the head of `main`, and it reads the head through the `origin` remote that `actions/checkout` configured. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d737397458..272a89e9b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,27 +88,27 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} - - name: Upload website - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - name: github-pages-${{ github.run_attempt }} - path: dist - # re-running an older commit's workflow, or tests finishing out of order, must not deploy an outdated website, - # and checking in this job's last step, rather than in `deploy`, skips that job before it records a deployment - # of the outdated commit; the repository is public, so `git ls-remote` reads the head of main without a token + # and checking here, rather than in `deploy`, skips that job before it records a deployment of the outdated commit; + # the repository is public, so `git ls-remote` reads the head of main without a token - name: Check that the commit is still the head of main id: check-head if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | - head=$(git ls-remote "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" refs/heads/main | cut -f1) + head=$(git ls-remote origin refs/heads/main | cut -f1) if [ "$head" = "$GITHUB_SHA" ]; then echo 'is-head=true' >> "$GITHUB_OUTPUT" else echo "::notice::Skipping the deployment, main is now at $head" fi + - name: Upload website + if: steps.check-head.outputs.is-head == 'true' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + name: github-pages-${{ github.run_attempt }} + path: dist + deploy: if: needs.test.outputs.is-head == 'true' needs: test