Skip to content

CI: only deploy the website from the head of main - #4230

Merged
nstepien merged 6 commits into
mainfrom
pages-head-check
Oct 24, 2026
Merged

nstepien merged 6 commits into
mainfrom
pages-head-check

Conversation

@nstepien

@nstepien nstepien commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Re-running the CI workflow of an older commit on main deploys that commit's website over the newer one. A re-run keeps the original event's context (push, refs/heads/main, the old github.sha), so the deploy job's condition still passes, and actions/deploy-pages doesn't check whether the commit is still the head of main. Tests finishing out of order, after two quick pushes, have the same effect.

The pages concurrency group didn't prevent it either. By default, a group keeps at most one pending job, and a newly queued job cancels it, so an older commit's deployment could cancel a newer commit's pending deployment, leaving the website outdated until the next push.

Before uploading the website, the test job now reads the head of main with git ls-remote origin, which needs no token since the repository is public. It only uploads the website, and lets deploy run, when the head is still the workflow's commit. When main has moved on, it skips the deployment with a notice instead of failing the run. GitHub has no built-in setting for this, and other projects use the same kind of check, e.g., souravmondalshuvo/Shohoj#856.

The check runs before deploy rather than in it, because a job that references an environment records a deployment whenever it runs: a check inside deploy would still list each skipped commit as the latest github-pages deployment. A job's if: can't run commands, so the result reaches deploy through a test output, like the artifact name.

The pages group also uses queue: max now (since May 2026), so up to 100 deployments wait in turn instead of each queued one canceling the pending one.

shell: bash

All workflows now default to shell: bash. Unlike the default bash -e, it also sets pipefail, so a failing command in a pipe fails the step: the head check relies on it, so a failing git ls-remote doesn't look like main having moved on. The only other pipes, in update-screenshots.yml, behave the same unless git ls-files fails.

Alternatives

  • A separate job for the check would keep test focused on testing, but starts another runner on every push to main.

  • Comparing with the deployed commit, rather than with the head of main, would keep deploying an older passing commit when a newer one fails its tests. But it needs the check to run inside deploy, which records a deployment as described above, and more API calls.

  • The default github-pages artifact name would still break re-running all jobs of the head's own run, within the artifact's one-day retention: the new attempt uploads a second artifact with the same name in the same run, which deploy-pages rejects (Allow deploy by artifact_id actions/deploy-pages#338). Neither action can work around it: upload-pages-artifact has no overwrite input, and deploy-pages only takes an artifact name, not the artifact_id the upload outputs.

Limitations

  • A re-run uses the workflow file of its commit, so this only protects commits from this one on.
  • Re-running only the deploy job reuses the test job's output from the earlier attempt. That artifact only lasts a day, though (upload-pages-artifact's default retention).
  • When commits land in quick succession and the newest one fails its tests, the website keeps the commit before them until main is fixed, instead of deploying an older commit that passed.
  • actionlint doesn't know queue yet, like cache-mode, but it's documented for job-level concurrency.

The deployment steps only run on pushes to main, so this can only be verified after merging: after the next push to main, re-run this PR's merge commit's workflow with "Re-run all jobs", and check that deploy is skipped.

🤖 Generated with Claude Code

nstepien and others added 6 commits October 24, 2026 21:43
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@nstepien
nstepien marked this pull request as ready for review October 24, 2026 22:08
@nstepien
nstepien merged commit 49e254f into main Oct 24, 2026
5 checks passed
@nstepien
nstepien deleted the pages-head-check branch October 24, 2026 22:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant