Repository navigation
perf: make the deploy cost proportional to the change, not the whole site - #64
Merged
Merged
Conversation
The deploy uploads the WHOLE site as a single Pages artifact, and Pages caps that at 1 GB. blueapi is at 452,549,958 bytes across 131 released docs.zips and climbing ~5 MB per release — roughly 110 releases of headroom before deploys hard-fail, with no warning on the way. Every deploy also re-downloads all 453 MB. Adds `selectReleases`, which decides what each listed release contributes, and a `select-releases` subcommand that runs BEFORE the gather so publish.yml downloads only what it will actually publish. The rules were previously an untested bash `case` in publish.yml; moving them here matches the split the repo already uses — bash does the IO, the kernel does the logic and is unit-tested. Ranking is on `created_at`, not `published_at`. published_at records when the release record was last published, so re-publishing an old one makes it look new: blueapi's 1.3.2-a9 was created 2025-10-01 but published 2026-07-30, and under published_at it outranked the genuinely newer 1.11.3. No version-number parsing is involved either way — tags are too inconsistent across repos to parse safely, and the API already knows the order. The seed release is exempt from the cap: it stands in for the default branch rather than being a version. Skipped releases don't consume a cap slot either. Verified against blueapi's real 188-release payload: 131 selected unlimited, and `--max-releases 20` keeps the 20 most recent (73.1 MB, down from 453 MB). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On blueapi the deploy job took 654 s against a 27–45 s docs build, and the cost was independent of what changed — every event reconstructed the whole site. Three of those steps are addressed here. Gather release artifacts (114 s): releases were listed and downloaded one at a time, all 131 of them, on every deploy. Now assemble.mjs picks the set first, an actions/cache entry keyed on exactly that set of asset ids supplies the zips already seen, and only the misses are fetched — in parallel. Release assets are immutable, so steady state is a full hit and a new release downloads exactly one zip. Cached files are named by asset id rather than tag, so a re-cut release can never be served stale, and the cache is pruned to the published set so a capped site keeps a capped cache. Saving happens only on the default branch: a cache written on a PR ref is scoped to that PR and would just churn the 10 GB quota. Gather branch CI artifacts (175 s): the artifacts API was re-paginated once per open PR — 27 identical passes over thousands of artifacts for blueapi. Now it paginates once, indexes newest-artifact-per-head-SHA, and downloads in parallel. max-releases input: the site deploys as ONE Pages artifact against a hard 1 GB cap. blueapi is at 452 MB over 131 releases and grows ~5 MB per release, so it has a few years before deploys simply start failing. The cap is opt-in (0 = unlimited, so nothing disappears from an existing site on upgrade) and set in the shim's `with:` so it applies on every path rather than only the inline one. A warning past 700 MB means the ceiling can no longer arrive unannounced. Also adds test/workflow-harness: these gather steps were previously verifiable only by merging and watching a real deploy. The harness loads them out of the YAML — so the tests cannot drift from the workflow — and runs them against a mock gh, covering the cap, cache hit/miss/prune, the artifact index, fork approval, and each of the four failure modes. It caught two real bugs while being written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Actions cache Releases are durable and PRs are ephemeral by design, but the default branch had no permanent source — it is gathered from its latest CI artifact, which expires, after which it drops out and the guard hard-fails. That was solved by publishing the zip into the site itself as _sources/<default>.zip: permanent, but it shipped a multi-megabyte zip inside every Pages artifact forever, on a site already pressing against the 1 GB cap. The Actions cache holds it instead, content-addressed so an unchanged default branch adds no new entry, and saved only from the default branch (a cache written on a PR ref is scoped to that PR and unreadable by anything else). The trade is deliberate and worse: cache entries are evicted after 7 days without a read and by LRU past 10 GB, so a repo that goes quiet for over a week AND whose main CI artifact has expired loses this rung, where _sources/ was forever. Accepted — the common case is a repo that deploys more often than weekly, and the payoff is a smaller site on every single deploy. _sources/ is no longer written. Its READ survives as a deprecated rung so a site last deployed by an older publish.yml still has a source on its first upgraded deploy — one-shot, since that deploy republishes the whole tree without it. Harness extended to the full fallback chain (fresh / cache / deprecated in-site / hard-fail), the no-op-save case, and the extract step's zip-shape contract and size guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… branch Documents what the preceding commits changed, and fixes the one place they broke something: scripts/migrate.sh gated the irreversible gh-pages delete on _sources/<default>.zip returning 200 from the live site, and that file is no longer published. The replacement guard is stronger than the one it replaces. It now requires both that /<default>/ is live in the new Actions-deployed site AND that a non-expired `docs` artifact exists for the default branch — i.e. its CI really builds docs.zip now. The old check passed while /<default>/ was still coming from the migration seed, which is exactly the state where gh-pages remains the only real copy; it was only safe because _sources was permanent. With a cache-backed (evictable) durable copy it would not have been, so the guard waits for the condition it always should have used. Also recovers docs/how-to/choose-publish-events.md, which was written for the closed PR #62 and is independent of it: the "publish only on tags" gate is what leaves a site unable to self-heal, and blueapi needs that written down. Its claim about a job-summary table is corrected to describe what this branch's deploy actually prints. New docs/how-to/keep-the-site-small.md covers max-releases, what the cap does and does not delete, and what is exempt. architecture.md gains a "why the site has a size limit" section; the tutorial's shim ships max-releases: 20; both new pages are added to the toc (myst.yml has an explicit one, so a new file is otherwise silently unpublished). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Handover text for another repo, swept in by a `git add -A`. It was deliberately untracked; keep it that way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`git add -A` picked up two uncommitted local changes that are not part of this work: a devcontainer --device=/dev/net/tun tweak and a package-lock edit. Restored to origin/main; both are back in the working tree, uncommitted, as they were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`biome check --write .` reformatted renovate.json (it is 2-space in main, tabs under biome's config) and `git add -A` shipped it along with the harness's .pyc files. Neither belongs in this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the tree The guard compared `du -sb` of the site directory against the 1 GB Pages cap, but that cap applies to the compressed artifact. Caught on this branch's own deploy (run 32471577932): the tree was 734 MiB and the warning fired, while upload-pages-artifact reported 233,824,999 bytes — 22% of the limit, not 73%. HTML and JS pack about 3x, so `du` cries wolf at a third of real usage. Now the tree size is reported for information, and the artifact is actually packed and measured only once the tree passes SIZE_PROBE_BYTES (1.5 GiB) — packing costs real seconds, so it is not worth doing on every deploy of a small site. `tar -ch` matches upload-pages-artifact's --dereference, which inflates the `stable/` symlink into a full copy of a release; measuring it as a symlink would under-read by a whole version. Both thresholds come from job env, which makes the warning path testable without a multi-gigabyte fixture — the harness now covers probe/no-probe, warn/no-warn. blueapi's 452 MB figure was already the artifact size, so that claim stands; the docs that implied `du` was the measure are corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-releases The real deploy warned that actions/cache/restore@v4 targets deprecated Node 20 and is being forced onto Node 24. v6 is only an ESM/packages migration — same inputs and outputs — and matches the recency of everything else pinned here (checkout@v7, download-artifact@v8, deploy-pages@v5). Also records why this repo's shim says max-releases: "0" while the tutorial ships "20": our site is ~223 MB packed across 24 releases, a fifth of the cap, so capping would drop old versions for nothing. Worth stating, because CLAUDE.md claims this repo dogfoods the shim consumers copy and this is a deliberate divergence in behaviour rather than just the `uses:` path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 21, 2026
This branch was previously deployed
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.
blueapi adopted this pipeline on 2026-08-05 and switched the PR previews off on 2026-08-06 because publish was adding many minutes to every PR. That was the right call on the evidence, and this is the fix.
Independent of the
workflow_runexperiment — correct under either trigger.The measurement
Gather release artifacts— 188 releases listed, 131docs.zipdownloaded one at a timeGather branch CI artifacts— 26 open PRs, one full artifacts-API pagination eachUpload Pages artifact— 452,549,958 bytesDeploy to GitHub PagesThe docs build is 27–45 s; everything else in their CI finishes by ~200 s. Publish alone took the run from ~200 s to ~750 s.
The cause is that the deploy's cost is O(whole site), not O(what changed): every event re-downloaded ~453 MB of immutable release assets and re-uploaded a 452 MB artifact, doing identical work regardless of which PR fired it.
And 452 MB is 45% of Pages' hard 1 GB artifact limit, growing ~5 MB per release. That is ~110 more releases until deploys simply start failing, with nothing on the way to say so.
What changed
Release gather —
assemble.mjspicks the set first, anactions/cacheentry keyed on exactly that set of asset ids supplies the zips already seen, and only misses are fetched, in parallel. Assets are immutable, so steady state is a full hit and a new release downloads one zip. Cached files are named by asset id, not tag, so a re-cut release can never be served stale; the cache is pruned to the published set. Saved only from the default branch — a cache written on a PR ref is scoped to that PR and would just churn the 10 GB quota.Artifact gather — paginate once and index newest-artifact-per-head-SHA, instead of re-paginating per open PR. Downloads in parallel.
max-releases— publish only the N most recent releases. Defaults to0(unlimited), so upgrading never silently deletes versions from an existing site; the tutorial's shim ships20. It goes in the shim'swith:as a literal, not threaded fromci.yml, so it applies on every path rather than only the inline one. Past 700 MB a deploy warns.Ranking is on
created_at, never on parsing version numbers.published_atlies: blueapi's1.3.2-a9was created 2025-10 and re-published 2026-07, and underpublished_atit outranked the genuinely newer1.11.3. Verified against their real 188-release payload —max-releases: 20takes the released half from 453 MB to 73 MB._sources/→actions/cache— the default branch's durabledocs.zipwas published into the site, shipping a multi-megabyte zip inside every Pages artifact forever. It moves to the Actions cache, content-addressed so an unchanged branch adds no entry.The trade is real and worse, deliberately: cache entries are evicted after 7 days unread and by LRU past 10 GB, so a repo quiet for over a week whose main CI artifact has also expired loses the rung and hard-fails — loudly, and fixed by one push.
_sources/is no longer written; its read survives as a deprecated one-shot rung so a site last deployed by an olderpublish.ymlstill has a source on its first upgraded deploy.The bit that needed care
scripts/migrate.shgated the irreversiblegh-pagesdelete on_sources/<default>.zipreturning 200 from the live site. That file no longer exists, so the guard is replaced — with a stronger one. It now requires both that/<default>/is live in the new site and that a non-expireddocsartifact exists for the default branch, i.e. its CI really buildsdocs.zipnow.The old check passed while
/<default>/was still coming from the migration seed — exactly the state wheregh-pagesremains the only real copy. It was only safe because_sourceswas permanent; with an evictable cache-backed copy it would not have been.Testing
publish.yml's gather steps were previously verifiable only by merging and watching a real deploy.test/workflow-harness/loads them straight out of the YAML — so the tests cannot drift from the workflow — and runs them against a mockgh. 27 checks covering the cap, cache hit/miss/prune, the artifact index, fork approval, the full default-branch fallback chain, the zip-shape contract, the size guard, an empty repo, and each of the four failure modes.It caught two real bugs while being written.
npm run test:workflows, wired into_test.yml.Also 11 new
selectReleasesunit tests (34 total, all passing), and the strict docs build is clean.Expected result
gather ~25 s, extract ~5 s, upload ~12 s, deploy ~30–60 s ⇒ ~100 s. Starting after
docs(~50 s) that finishes around 150 s, inside blueapi's 195 ssystem-test— off the critical path in wall-clock terms without changing the trigger at all.Once tagged, blueapi should bump the pin, set
max-releases, and revert #1614: their tags-only gate is why the site never self-heals. Handover text is written up separately.🤖 Generated with Claude Code