Skip to content

feat!: publish on workflow_run, deleting the trampoline, shim and injection - #66

Merged
coretl merged 3 commits into
mainfrom
feat/workflow-run-publish
Aug 21, 2026
Merged

coretl merged 3 commits into
mainfrom
feat/workflow-run-publish

Conversation

@coretl

@coretl coretl commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Implements the migration the live experiment proved viable. Net −119 lines while adding a test file, because this is almost entirely subtraction.

What the experiment settled

Pages stamps every deployment with pages_build_version = GITHUB_SHA and silently no-ops a repeat (deploy-pages#383) — but only for some events. Four consecutive deploys at the identical build version b24237484c3b445469c2db4ef161410a185fcdbc — a push to main, a tag cut on that same commit, and two pushes to one PR — and every one updated the live origin. workflow_run forces a re-serve exactly as workflow_dispatch does.

That was not predictable from the docs, which is why it was tested rather than reasoned about.

What that deleted

gone why it existed
re-dispatch job a tag's same-SHA deploy was dropped; it bounced through workflow_dispatch. Now it re-serves directly
publish-dispatch.yml a reusable workflow can't be workflow_dispatch'd cross-repo, so the trampoline needed a local file to re-fire
version-name injection + threading a nested publish ran inside the build's own run, so the gather couldn't discover it. workflow_run fires after completion — verified in the baseline, where both injection steps skipped and the site still assembled
warn job a fork's CI never reaches the engine now
retry-until, wedged-origin retry tied to the trampoline; the engine no longer needs actions: write

Also gone: the tag/docs.zip asset race that made blueapi permanently one release behind. release.yml runs inside the CI run that publish waits on, so the asset is always attached first. That's why #62 was closed rather than merged.

Shape

ci.yml builds and stops. publish.yml — the file consumers carry, and the one place <tag> is pinned — triggers on workflow_run (CI completing) plus workflow_dispatch (fork previews, manual re-deploys), and calls publish-gh-pages.yml.

The engine is renamed from publish.yml so this repo can dogfood the consumer filename exactly rather than approximating it.

The trap this introduces

Under workflow_run, GITHUB_SHA and github.ref are always the default branch's HEAD, never the built commit — a PR-triggered deploy reports refs/heads/main.

Both cache-save steps gated on github.ref. That would have been true for every PR-triggered deploy, each writing a cache scoped to a PR nothing else can read. They now test github.event.workflow_run.head_branch.

That lives in an if: expression, which the gather harness can't reach — hence test_shape.py, 21 structural assertions covering the cache conditions, the caller's two guards, and the absence of the deleted machinery. All of those fail open, so nothing would announce a regression.

Security

workflow_run runs with a write token even when a fork's PR triggered it — the pwn-request shape. The caller requires head_repository.full_name == github.repository, so a fork's build never reaches the engine automatically. Maintainers preview one by dispatching with pr, which pins approval to that head SHA.

Also here

cancel-in-progress: true — safe now that publish has its own run (it previously marked unrelated PRs' CI cancelled), and correct because every deploy reconstructs everything.

One unrelated fix: .devcontainer/devcontainer.json and package-lock.json were swept into #64 by a git add -A. The lockfile change stripped libc fields from Biome's platform-specific optional deps, which is how npm picks glibc vs musl. Both restored to their pre-#64 state.

Verification

npm test (34), npm run test:workflows (gather + shape), npm run docs --strict all pass. The real proof is the merge deploy and the v0.25.0 tag that follows.

BREAKING: consumers add publish.yml, delete publish-dispatch.yml, remove the publish job from ci.yml, and re-pin to publish-gh-pages.yml. Migration guide written up for blueapi.

🤖 Generated with Claude Code

coretl and others added 3 commits August 21, 2026 14:33
Two local working-tree edits were swept into #64 by a `git add -A`. A commit on that
branch reverted them, and a later `git add -A` in the same branch put them back, so
they reached main inside the squash merge.

Neither belongs there. The devcontainer gained `--device=/dev/net/tun`, a host-specific
tweak that fails on hosts without that device. The package-lock change is a regression:
it stripped the `libc` fields from Biome's platform-specific optional dependencies,
which is what an older npm does when it rewrites the file — those fields are how npm
picks the glibc vs musl build.

Restored both to a354613, the state main had before #64 and the state CI was green on.
Also ignores node_modules.stale, left behind by an NFS lock that blocks unlink.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ection

The live experiment on 2026-08-21 settled the question this design was built around.
Pages stamps every deployment with pages_build_version = GITHUB_SHA and silently no-ops
a repeat (actions/deploy-pages#383) — but only for some events. Four consecutive deploys
at the identical build version b242374 — a push to main,
a tag cut on that same commit, and two pushes to one PR — EVERY one of which updated the
live origin. workflow_run forces a re-serve exactly as workflow_dispatch does.

That removes the reason for most of this machinery:

- The `re-dispatch` trampoline is gone: a tag's deploy re-serves directly.
- `publish-dispatch.yml` is gone. It existed only because a reusable workflow cannot be
  workflow_dispatch'd cross-repo, so the trampoline needed a local file to re-fire.
- The `version-name` injection is gone, along with its threading through ci.yml and the
  shim. workflow_run fires after the triggering run has COMPLETED, so that run's `docs`
  artifact is discoverable by the ordinary gather — verified in the experiment's baseline,
  where both injection steps were skipped and the site still assembled correctly.
- The `warn` job is gone: a fork's CI never reaches the engine.
- `retry-until` and the wedged-origin retry-dispatch are gone, and with them the engine's
  need for `actions: write`.

publish.yml is now the file each consumer carries: workflow_run on their CI completing,
plus workflow_dispatch for fork previews and manual re-deploys. The engine it calls is
renamed publish-gh-pages.yml so this repo can dogfood that filename exactly rather than
approximating it. ci.yml no longer publishes at all and needs no elevated permissions.

Publishing also leaves every PR's critical path, which is the point. Its cost is O(the
whole site) and independent of the change, which is why the first consumer switched PR
previews off a day after adopting; and a Pages failure now stops showing as a red check
against an author with no control over that infrastructure.

One trap this introduces, and the reason for the new test_shape.py: under workflow_run,
github.ref and GITHUB_SHA are ALWAYS the default branch's HEAD, never the commit that was
built. Both cache-save conditions gated on github.ref, which would have been true for
every PR-triggered deploy — each one writing a cache scoped to a PR that nothing else can
read. They now test github.event.workflow_run.head_branch. That is an `if:` expression,
so the gather harness cannot reach it; test_shape.py asserts it, along with the fork
guard, whose failure mode is to fail open.

cancel-in-progress is now true. It was unsafe while publish was nested in ci.yml, where
cancelling marked an unrelated PR's CI run cancelled; with publish on its own run there
is no such collateral, and every deploy reconstructs the whole site so a superseded one
holds nothing unique.

BREAKING CHANGE: consumers must add publish.yml, delete publish-dispatch.yml, remove the
publish job from ci.yml, and re-pin to publish-gh-pages.yml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trigger change deleted three pieces of machinery, and the docs described all of
them at length: the tag re-dispatch trampoline, the publish-dispatch.yml shim, and the
in-run artifact injection with its version-name threading. All of that is gone, so the
explanations that justified it are replaced rather than amended.

architecture.md gains "why publishing listens instead of being called" (the O(whole site)
cost on every PR's critical path, and that a wedged Pages origin was never the PR
author's to act on), "what listening deleted", and the trap it brings: under workflow_run
github.ref is always the default branch, so anything asking "was this the default
branch?" must read workflow_run.head_branch. The deferred release-layer cache section is
now a description of the implemented one.

choose-publish-events.md is rewritten: its whole premise was an if:-gated job in ci.yml,
which no longer exists. Narrowing now means filtering in publish.yml's own if:, and the
"it slows my PRs down" reason for narrowing is gone entirely.

The tutorial and the reference carry the new two-file shape — ci.yml builds, publish.yml
listens — and both spell out that the caller's two guards fail open, since that is the
failure mode nobody notices.

blueapi-issue.md (untracked) is rewritten as a migration guide rather than a bug report,
since all three problems it described are now fixed upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coretl
coretl merged commit ec90c35 into main Aug 21, 2026
4 checks passed
@coretl
coretl deleted the feat/workflow-run-publish branch August 21, 2026 14:46
coretl added a commit that referenced this pull request Aug 25, 2026
…face, wedge detection (#70)

* fix: rank and detect prereleases the way the tags are actually written

`isPrerelease` required the marker to follow a digit IMMEDIATELY, so every
hyphenated semver spelling (`1.1.0-beta.1`, `2.0.0-rc.1`, `v1.2.3-alpha`) read as
stable — and `git tag --sort=-v:refname` independently ranks such a tag ABOVE the
release it qualifies. Together that made `preferredVersion` return the beta, so
`stable/` aliased it and the root redirect pointed at it.

Widen the marker set (PEP 440 + the hyphenated/dotted forms + the long spellings)
and keep the digit anchor, so `release-1.0` and `beta-program` stay stable; a
trailing lookahead keeps `1.0-candidate` out too. Order the tags here instead of
in git: `compareTags` compares digit runs numerically and ranks a prerelease below
its own release, so "what order" and "what counts as a prerelease" share one
marker list rather than needing `versionsort.suffix` kept in step by hand.

release.yml's `--prerelease` test carried the same rule in `case` globs, which are
case-sensitive where the regex is not (`1.0.0RC1` was published stable). It now
uses the identical marker list via `grep -Eqi`, and a new harness test extracts
both implementations and cross-checks 27 tag spellings through each — the drift
guard the previous "mirrors assemble.mjs" comment was standing in for.

Also stop hardcoding main/master while we are in the ordering: the engine already
knows the default branch, so thread it through `generate --default-branch`. A
consumer whose default branch is `develop` had it sorted into the leftover bucket
among the pr-<n> previews, and it could never be the preferred version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep context values out of docs.yml's shell, and validate the version name

publish-gh-pages.yml documents the rule — every context value comes through `env:`,
never interpolated into the script — but docs.yml's "Compute version name" step
substituted `github.ref_name` straight into the body, where GitHub expands it as
script text before bash parses it. Git accepts `$( )`, backticks and quotes in a ref
name, and docs.yml is REUSABLE: a consumer triggering it on `push: branches: ['**']`
hands that string, and so shell execution in their docs job, to anyone who can push
a branch.

The ref now comes through `env:`, and the resulting name is checked against
`[A-Za-z0-9._-]` rather than assumed clean — the old comment's "no sanitisation"
was only true given THIS repo's trigger set. A rejected name fails the build with
the likely cause named.

The general guard matters more than the fix: test_shape.py now walks every job of
every workflow and fails on any `${{` inside a `run:` body, with no allow-list —
those three lines were the only ones in the repo, so the rule starts clean.

`renderRedirect` also escapes HTML metacharacters now. The version name it renders
is validated one file away, and loosening that character set should not be able to
inject markup here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: declare permissions and a timeout on every job

`ci.yml` set `permissions` only on its `release` job, and `_lint.yml`/`_test.yml`
declared none at all — so the token those jobs received was whatever the repo or org
default happened to be, potentially write-all, for two jobs that need nothing but a
checkout. No job anywhere set `timeout-minutes`, leaving a hung build-command or a
stuck `deploy-pages` to burn the 6-hour default.

`release.yml` now declares the `contents: write` it actually uses; the caller still
has to grant it, since a reusable workflow can only narrow what it is given.

test_shape.py asserts both, so a new workflow cannot quietly skip them. The timeout
check only applies to jobs with `steps` — a job that is just `uses:` cannot set one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: verify the origin against a per-deploy stamp, and size the site as uploaded

The origin verify existed to catch a wedged Pages backend serving stale bytes while
every deploy reports OK. It compared the live `switcher.json` with the assembled one
— but that file lists only the version set, so an ordinary docs edit on the default
branch reproduces it byte-for-byte. `got == want` on attempt 1 whether or not the
origin moved, i.e. the detector was blind in exactly the case where a wedge goes
unnoticed longest.

`generate` now also writes `site/deploy-id.txt` (run + attempt + UTC), which is
unique per deploy by construction, and the verify polls that as the primary
assertion with `switcher.json` kept as a secondary one — a current stamp with a
stale switcher means a partially-served deploy. A file at the site root is safe:
`discoverVersions` returns directories only.

While in the same step, move the size report there from the extract step and measure
with `du -shL`. It ran BEFORE the `stable/` symlink was created, and would not have
followed it anyway, while `upload-pages-artifact` tars with `--dereference` — so the
newest release is uploaded twice and the number an operator tunes `max-releases`
against omitted it. It now names the duplication explicitly, as does
keep-the-site-small.md.

The harness gets a mock `curl`, so the wedge cases are tested rather than reasoned
about: stale stamp with an identical switcher.json fails, both current passes on the
first attempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: make the gather's caps, tie-breaks and asset fetch mean what they claim

Three narrow defects in the gather, each where the code's own comment already
stated the intended rule:

- **A mistyped cap disabled the cap.** `Number(values["max-releases"]) || 0` turns
  `"3O"` into NaN and NaN into 0 — which means unlimited, so a typo in a consumer's
  publish.yml silently switched off the guard that exists to stop a 1 GB deploy
  failure. `parseCap` accepts a non-negative integer or throws, and the step exits
  non-zero.
- **Two tie-break rules for "newest artifact".** `newest()` breaks a timestamp tie on
  id; the per-SHA index kept whichever artifact the API returned last, i.e. page
  order — not a rule at all, and reachable whenever two artifacts share a second (a
  matrix, a re-run). Both now go through the one comparator, as the comment claimed.
- **Release zips selected by asset id, downloaded by tag.** The cache is
  id-addressed because "an id names immutable bytes", but `gh release download <tag>
  -p docs.zip` would hand a release re-cut since the listing to the OLD id, cached
  for every later deploy. Fetch the asset endpoint by id instead, and check the zip
  magic: with stdout redirected to a file, a failed request still writes an error
  body, so the exit status alone would let a non-zip into the cache.

Also warn when `gh pr list --limit 200` truncates: 201 open PRs looked exactly like
200, with nothing in the log to explain why some PRs had no preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: add a base-path input so the site can be served from the root

The engine resolves the site's URL from the Pages API precisely so a custom domain
works, and the reference doc advertised that — but docs.yml hardcoded
`BASE_URL=/<repo>/<version>`. On a CNAME'd domain or an ORG.github.io repo the site
is served at `/`, so switcher.json's links were right while every page they pointed
at had 404ing assets: the worse half of the two failure modes.

`base-path` defaults to `""` (→ `/<repo>`, unchanged for every current consumer) and
takes `/` for a root-served site. BASE_URL is now composed in the compute step rather
than an `env:` expression, because normalising the slashes needs a shell.

Adds docs/how-to/use-a-custom-domain.md, and scopes the reference's CNAME claim to
what is actually true of each half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: cover the version-name validation and base-path in the harness

The harness ran docs.yml's compute step for the version-name contract only. It now
also reads back BASE_URL — the other half of that contract — across the base-path
default, a root-served site and a slash-normalisation case, and asserts the four
version names that must be rejected outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: queue superseded Pages deploys instead of cancelling them

`cancel-in-progress: true` was justified from artifact correctness — every deploy
reconstructs the whole site, so the replacement gathers whatever the cancelled one
would have. That is still true and still not the question: the cancellation lands
inside `deploy-pages`, in the window between the artifact upload and the Pages
backend finishing with it, and that state is not ours to reason about. GitHub's own
Pages starter workflow uses `false` for this reason, and this workflow carries a
whole step for detecting a wedged origin, so the failure mode is not hypothetical
here.

The cost is bounded and worth stating: a concurrency group holds one pending run, and
a newer arrival replaces the pending one before it starts, so a burst of pushes costs
one extra full-site deploy rather than a queue of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: pin and verify actionlint instead of running whatever is latest

`_lint.yml` resolved `rhysd/actionlint/releases/latest` at run time and executed the
tarball unverified — the one unpinned, unchecked binary in a repo that pins
`astral-sh/setup-uv` to a full version and explains why in a comment. It also made
every lint run depend on the releases API being up and un-rate-limited.

The version is now a literal with its SHA-256 next to it, checked before extraction.
renovate.json gets a custom manager for the version; Renovate cannot compute the new
digest, so a bump PR fails the checksum until the digest is updated in the same PR —
fail-closed, and obvious when it happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf: fetch the consumer's tags without their history, and document .mvs

`publish-gh-pages.yml` cloned the consumer's whole repo (`fetch-depth: 0`) for one
reason: `assemble.mjs` runs `git tag` to order their versions. `fetch-depth: 1` plus
`fetch-tags: true` gets exactly that, on every deploy of every consumer — some with
years of history. docs.yml is deliberately left cloning in full, since a
build-command may run `git describe`.

Also names `.mvs/` as a reserved path in the consumer's workspace, in the reference
and next to the checkout that creates it, and documents what approving a fork preview
actually grants: an approved preview runs on the org-wide `ORG.github.io` origin, so
it is a review of the built output, not a "CI is green" check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: correct the claims these changes invalidated, and one that predates them

- workflows.md described a gather with three rungs "…then the current build highest".
  There is no current-build rung: #66 moved publishing to `workflow_run`, which fires
  after the triggering run completed, so that build is simply the newest CI artifact.
  Now describes the two real rungs plus the default branch's cache fallback.
- publish.yml claimed the local `uses:` means "the working tree is what gets tested".
  True post-merge, misleading pre-merge: under `workflow_run` GitHub runs the DEFAULT
  branch's copy, and `uses: ./…` resolves against that same commit, so a PR touching
  the engine is never exercised by a real deploy before it merges — the harness is
  what covers it. That second half is the part a maintainer needs.
- The version-name contract said "no sanitisation"; it is validated now.
- CLAUDE.md and architecture.md carried the old prerelease rule, `cancel-in-progress:
  true`, the pre-symlink `du -sh`, and no mention of `base-path`.
- Documents, in both the reference and CLAUDE.md, that `pr-<n>` entries in the public
  switcher are deliberate — so the next reader does not "fix" them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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