Repository navigation
feat!: publish on workflow_run, deleting the trampoline, shim and injection - #66
Merged
Merged
Conversation
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>
This was referenced Aug 21, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_SHAand silently no-ops a repeat (deploy-pages#383) — but only for some events. Four consecutive deploys at the identical build versionb24237484c3b445469c2db4ef161410a185fcdbc— a push tomain, a tag cut on that same commit, and two pushes to one PR — and every one updated the live origin.workflow_runforces a re-serve exactly asworkflow_dispatchdoes.That was not predictable from the docs, which is why it was tested rather than reasoned about.
What that deleted
re-dispatchjobworkflow_dispatch. Now it re-serves directlypublish-dispatch.ymlworkflow_dispatch'd cross-repo, so the trampoline needed a local file to re-fireversion-nameinjection + threadingworkflow_runfires after completion — verified in the baseline, where both injection steps skipped and the site still assembledwarnjobretry-until, wedged-origin retryactions: writeAlso gone: the tag/
docs.zipasset race that made blueapi permanently one release behind.release.ymlruns 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.ymlbuilds and stops.publish.yml— the file consumers carry, and the one place<tag>is pinned — triggers onworkflow_run(CI completing) plusworkflow_dispatch(fork previews, manual re-deploys), and callspublish-gh-pages.yml.The engine is renamed from
publish.ymlso this repo can dogfood the consumer filename exactly rather than approximating it.The trap this introduces
Under
workflow_run,GITHUB_SHAandgithub.refare always the default branch's HEAD, never the built commit — a PR-triggered deploy reportsrefs/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 testgithub.event.workflow_run.head_branch.That lives in an
if:expression, which the gather harness can't reach — hencetest_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_runruns with a write token even when a fork's PR triggered it — the pwn-request shape. The caller requireshead_repository.full_name == github.repository, so a fork's build never reaches the engine automatically. Maintainers preview one by dispatching withpr, 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.jsonandpackage-lock.jsonwere swept into #64 by agit add -A. The lockfile change strippedlibcfields 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 --strictall pass. The real proof is the merge deploy and thev0.25.0tag that follows.BREAKING: consumers add
publish.yml, deletepublish-dispatch.yml, remove the publish job fromci.yml, and re-pin topublish-gh-pages.yml. Migration guide written up for blueapi.🤖 Generated with Claude Code