Skip to content

GitHub Pages performance

how a push becomes public: the publication path separated from the gates that decide merging, controlled against external latency, the derived trigger, the input fingerprint that replaces a generation, the caches and what each is worth, the budgets and where they live, how publication itself is measured, and the bottlenecks that remain

Rendered from docs/GITHUB_PAGES_PERFORMANCE.md — the same Markdown GitHub shows.

How a push to master becomes public, what it is allowed to cost, how that is measured, and what stops the next change from quietly taking the speed back.

GITHUB_PAGES_ARCHITECTURE.md says what the site is — the canonical sources, the generators, the checks. This document is about the path: latency, budgets and the evidence behind both. CI.md is the validation pipeline beside it. PAGES_STATUS.md carries the current numbers, generated; nothing here is a measured value, on purpose.

The objective

The number that matters is not how long a workflow runs. It is

T_public = when the public site serves this commit  −  when the push was accepted

The target is T_public < 60 s whenever GitHub's own scheduling is not the limiting factor, and the distinction in that sentence is load-bearing. Two halves:

controlled   checkout, tool setup, build, check, the push to gh-pages
external     the Actions queue, runner allocation, GitHub's own "pages build and deployment"

Only the first is this repository's to spend. Both are measured; only the first is budgeted and only the first can fail a run. A deployment whose controlled path was 30 s and whose runner waited 4 minutes in a queue is not a regression here, and the job summary says so in those words rather than reporting one number that hides which half moved.

The shape

flowchart LR
  push["push to master<br>(on paths that can<br>change the site)"]
  push --> validate["validate.yml<br>decides whether a<br>change may merge"]
  validate --> plan["plan"]
  plan --> structure["structure"]
  plan --> suite["suite"]
  plan --> rust["rust"]
  plan --> coverage["coverage"]
  plan --> bench["bench"]
  plan --> site["site<br>(build, check,<br>browser probe)"]
  plan --> macos["macos"]
  structure & suite & rust & coverage & bench & site & macos --> ci["ci<br>the status a branch<br>rule requires"]
  push --> pages["pages.yml<br>decides what the<br>public site shows"]
  pages --> rustdocjob["rustdoc job<br>the crate's reference"]
  rustdocjob -->|"needs, the tree"| job["deploy job"]
  job --> checkout["checkout"] --> setup["setup"] --> build["build"] --> check["check"] --> ghpages["push gh-pages"] --> measure["measure publication"]

The two run beside each other on the same commit. Publication used to be a job inside validate.yml that needed plan, structure, suite, rust and site — the set that decides whether a change may merge, most of which cannot change a published byte. The site therefore went public tens of minutes after the push that changed it.

What is on the publication path is everything that can make the published bytes wrong:

  • the committed derived data is current for this tree,
  • the crate's rustdoc reference is produced from this commit — it is part of the published bytes and is never committed (RUSTDOC.md),
  • the site builds from both, with the reference composed at /rustdoc,
  • every static check over the output passes (scripts/site-check: metadata and landmarks on every route, no unrendered template delimiter, every internal link resolving, no remote asset, no credential, every tile a page, the graphs connected, the served provenance this commit's, every composed surface present and built from this commit).

What is not, and why it is safe: the behavioural suite, the crate's gates other than the documentation step that produces the reference, coverage, the macOS suite, the benchmark check and the browser probe. Each of those guards the repository rather than the bytes, each still runs on the same commit in validate.yml, and each still gates merging through the ci status. Moving a gate off the publication path is only sound while it is still somewhere; test/cases/97_pages_fast_path.sh fails if one of them appears in pages.yml or disappears from validate.yml.

The browser probe is the interesting case. It can find a real defect in the published bytes — a route that overflows at 320 px — and it costs minutes. It stays in validate.yml, where it gates merging, because a route that overflows is a defect to fix rather than a deployment to block: holding every publication for it would cost every commit the full probe to protect against the rare one that regresses, and the commit that regresses is not published any faster by having waited.

What made it slow, and the shape of each fix

Every value named below is measured, and none of them is written here. The current ones are in the tracked baseline of the machine that measured them, .ai/repo/benchmarks/pages/, written by scripts/pages benchmark --write-baseline; the before-and-after of each change is in the commit that made it, which is where project.performance-evidence puts them. Where the time goes inside one generation is MJ_TIMING=1 scripts/generate-site-data, ranked by phase.

The generator ran three times for one commit

site-build called generate-site-data; site-check's first assertion was generate-site-data --check; and the site job ran generate-site-data --check before both of them. Three full generations of the same data for one commit, each of them dominated by majordomus usecase run, which executes all 31 use-case scenarios in a disposable repository each — a test suite, sitting inside a data generator, on the deployment critical path.

The fix is not to make generation faster; it is to stop asking the same question three times. site-build --no-data renders committed data; site-check --no-sync skips the sync assertion; the site job keeps exactly one generation, the --check pass, which is the only one of the three that actually proves anything (it stages a fresh generation from the canonical sources and diffs it against what is committed).

The publication path needs the same answer and cannot afford even one generation, so it asks a cheaper question with the same meaning:

scripts/generate-site-data --fingerprint

This is the generator's own source_hash — the hash of its own 467-entry canonical input list, in the line format source.json has always been computed from — evaluated in one hashing process instead of 467, which is the difference between a fifth of a second and the better part of a minute. mj_inputs_hash is now the single implementation of it, so --fingerprint and source.json cannot disagree. scripts/pages current compares the two: equal means the committed projection is current for this tree and rendering it is rendering the canonical sources.

A tree whose committed derived data is stale is refused, not regenerated. The derived data is a projection; a projection that disagrees with its sources is a defect to fix at the source, and a publication path that quietly papers over it would be publishing something no gate had seen.

The derived content is committed for the same reason

site/data/generated is only half of what the generator produces. The other half is the derived content — site/content/{docs,guarantees,commands,doctrines,plan,use-cases, applications,skills,registry,profiles,supervises,why,features}/ and the projections of site/content-src/*.md — and Zola renders that, not the JSON. So it is committed too. A fresh clone has no other way to obtain it: site-build --no-data does not run the generator, which is the whole of why the build fits its budget, and generating the content instead would cost about twice the twenty seconds build is allowed. Left ignored, the fast path could not build a clone at all; the first link into a section that does not exist fails the whole render.

What keeps a committed projection honest is not ignoring it but the same gate the data lives under. scripts/generate-site-data --check diffs every derived page against a fresh staging generation and names every orphan, so an edited or stale page is refused exactly as stale data is, and scripts/site-check asserts the sections are committed and unignored rather than the reverse. Nothing under site/content/ is anybody's to edit by hand: the file it is projected from is the original, and --check says so the moment the projection moves.

site-check spawned seven thousand processes

Five loops over the 416 built pages, each spawning grep, sed and awk per page: about eighteen processes per route. scripts/lib/site-pages.awk carries all of those assertions in one process. It is a transcription, not a redesign — output is byte-identical on this site, and fifteen deliberately mutated pages (one per assertion, plus a control) produce the same findings from the old implementation and the new.

The result

scripts/pages benchmark -n 5                       # the distribution, as JSON
scripts/pages benchmark -n 5 --write-baseline      # and into this platform's baseline
cat .ai/repo/benchmarks/pages/baseline.*.json

An order of magnitude off the controlled build-and-check path, and almost all of what is left is site-check. That is the next thing worth attacking, and it is named as such under Remaining bottlenecks rather than left implicit.

The model

Everything the path is allowed to cost, and everything it triggers on, is declared once in .ai/repo/ci/pages.yaml. Neither the workflow nor the script carries a budget, a path list or a gate name of its own.

scripts/pages budget          # the controlled budgets
scripts/pages budget --json   # the same, for a reader that is not a person
scripts/pages paths           # what a push must touch for the site to be able to change

Trigger invalidation

The trigger paths are derived, never written twice. The model names the gate that builds the site (site-build); the classes of gates.yaml that name that gate are the classes whose paths can change the site; their pathspecs plus the model's own extra are the workflow's paths: block.

A class that escalates the whole plan (gates: full) is deliberately not one of them. That escalation says how much validation a change to the pipeline deserves; it says nothing about what the published bytes are derived from. Changing scripts/ci-plan or the justfile makes every gate run and cannot move a pixel, so it starts no deployment. Zero work is faster than optimised useless work.

test/cases/97_pages_fast_path.sh diffs the workflow's block against scripts/pages paths and fails on any difference, so the two cannot drift. When you change a class in gates.yaml, regenerate the block:

scripts/pages paths | sed 's/^/      - /'     # paste into the paths: block of pages.yml

Budgets

Budgets are controlled latency only, in seconds, warm, on a GitHub-hosted Linux runner. They are set from measurement — the timing rows every deployment writes — and a change to one is expected to cite the run that justified it (project.performance-evidence). A cold run pays for caches it cannot restore and has its own, looser bound. The end-to-end target is reported against every deployment and not enforced, because the larger half of it is GitHub's.

Two structural rules, both of which the first set of budgets broke and test/cases/97_pages_fast_path.sh now asserts:

  • Every row the run measures has a budget. The timings file gets six rows — checkout-setup from the workflow, fingerprint and build from scripts/pages build, check and local-check from scripts/pages check, publish from the workflow. The original model named checkout and setup separately and named nothing for fingerprint or local-check, so three of the six rows of every job summary printed an empty budget cell while still being summed into the total.
  • controlled is reachable by adding up its own rows. It was 75 while the phases summed to 105, which made the aggregate stricter than the parts it is made of: a run could be inside every single phase budget and still breach, with nothing to point at.

A budget is a warning and a step bound is a kill, and the two are tied. A step of pages.yml named after a warm row (build, check, publish), or a job named after one (rustdoc), carries a timeout-minutes of at least budget.bound.percent of that row's budget, and scripts/ci/step-bound-check refuses a smaller one. scripts/pages report names a row NEAR when it spent more than budget.bound.near_percent of its step's bound, as a warning on the run, before the run that crosses it. Until 2026-10-20 nothing tied them: the check step kept the 3 minutes set while the site was English only and took 167-171 s against them; the Czech copy doubled its pages, run 39392245576 was killed at 192 s, publish was skipped and the site stayed old.

So a change that multiplies the pages — a language copy, a new family of generated pages, a newly composed surface — re-derives the warm rows from a measured run of that change, in the same pull request, and raises the step bounds with them. The rows are read per step from gh api repos/korczis/prismatic-majordomus/actions/runs/<id>/jobs, as below; a row nobody re-derived reads the old site, and its bound kills the new one.

The re-derivation of 2026-10-20

The Czech copy (ADR 0218) doubled what every phase reads. Six runs of pages.yml that passed check with it, 39438897759 to 39485870985 (2026-10-20T14:24Z to 19:43Z); four of them published, so publish and controlled are read from four.

rowminmedianp90maxbudget
checkout-setup1821222532
build200270294314400
check296504509517660
publish3254545470
rustdoc172179181183230
controlled7231011101110571398

fingerprint and local-check are not shown apart from the steps that run them and keep 3 s each. The rustdoc row is now measured (the job, checkout to upload, plus the download of its artifact) and replaces the provisional 90. The cold bound keeps its allowances over the warm total: 1398 + 105 + 150 = 1653. With budget.bound.percent at 150 the bounds follow: build 11 minutes, check 18, publish 3, the rustdoc job 20.

Not all of the growth is the corpus. Twice the pages explains twice the check, and it took three times; it does not explain eighteen times the build (15 s on the English site, run 39228746039). The budgets describe what the path costs now, so that a breach means a change; why the build costs this is the first entry under "Remaining bottlenecks" below.

The re-derivation of 2026-09-12

The set before the one above, over the English site. The budgets written when this path was built were never changed after it, and by 2026-09-12 they were failing every deployment. They came from .ai/repo/benchmarks/pages/baseline.macos-arm64.json — a local macOS arm64 laptop, five samples, check median 9799 ms — over a site of 417 routes. The site check walks is now 1021 routes, 2.45x that baseline, and that directory's own README says a baseline of one machine says nothing about another.

The replacement is measured on the machine it judges: twenty-five runs of pages.yml that reached the publish step, 2026-09-10T22:56Z to 2026-09-12T06:38Z, read from gh api repos/korczis/prismatic-majordomus/actions/runs/<id>/jobs.

rowminmedianp90maxbudget
checkout-setup710121518
fingerprint00013
build44568
check3038464760
local-check00013
publish1320242428
rustdoc————90
controlled60758486210

The rustdoc row is the job that produces the crate's reference (ADR 86), checkout to upload, and the download of its artifact in the deploy job. No run of it is in the window above, so its budget is provisional: scripts/rust-check --doc measured 12, 6 and 6 s warm and 34 s cold on an Apple M5 Pro on 2026-09-24, and 90 allows that step about three times over on a four-vCPU runner plus about 60 s of toolchain, cache, checkout and artifact transfer. .ai/repo/ci/pages.yaml says so beside the number, and the first twenty runs' timing rows replace it. The controlled budget is still the sum of its rows: 120 before the reference, 210 with it.

Nothing had regressed. Over those same runs checkout-setup held at 7–15 s and build at 4–6 s, so both cache domains below restore; and check cost 41 ms per route at 896 routes on 2026-09-10 (37 s) against 45 ms per route at 1022 routes on 2026-09-12 (46 s). Its growth is the corpus it walks, not a defect in how it walks it. What had gone stale was the budget.

The cold bound moved with it. No cold run appears in that window, so it keeps the allowance the old pair expressed — 180 against a warm 75, i.e. 105 s for an uncached npm ci and Zola download — over the new warm budget: 120 + 105 = 225. The rustdoc job adds its own cold cost, as provisional as its warm row — every dependency's metadata built before the crate is documented, about 100 s more on a runner and 150 with the toolchain download — so the cold bound is now 210 + 105 + 150 = 465.

Caches

Each domain is invalidated by its own inputs and nothing else, so a documentation change does not evict a toolchain and a lockfile change does not evict the site.

cachekeywhat a hit saves
node_modulesrunner, Node major, package-lock.jsonthe whole npm ci, not merely its downloads
npm's own cacheactions/setup-nodethe downloads, when the first misses
Zolataiki-e/install-actionthe release download

There is deliberately no cache of generated site data or of rendered output. The committed projection already plays that role, it is a tracked file rather than a cache, and it is proved current in 169 ms — a cache in front of that would be a second source of truth bought for nothing (project.cache-is-invisible, project.derived-once).

To invalidate a cache on purpose, change its key input: bump the Node major or the lockfile, or the Zola version in .github/actions/setup-site/action.yml. Deleting every cache must produce the same published bytes; that is not an aspiration but an assertion — test/cases/97_pages_fast_path.sh compares a build that skipped the generation with one that did not, byte for byte, and test/cases/51_derived_artifacts_committed.sh regenerates every derived artifact from its canonical sources and refuses a difference.

How publication is measured

Workflow success is not publication. Every build writes site/static/build.json, served at /build.json, naming the commit it was built from — a fact of the build, never a committed file. After the push to gh-pages, the job polls that URL with the cache defeated until it names this commit:

scripts/pages verify --commit "$(git rev-parse HEAD)"

That wait is GitHub's own "pages build and deployment", which serves the branch. It is reported as external latency; it fails nothing, because GitHub being slow to serve is not this repository failing.

What the site serves once it serves this commit is a different question, and it is enforced. /build.json names only the commit the site was built from, so a deployment that had lost the crate's reference, or carried one from an older build, would pass the wait above. scripts/pages verify-rustdoc --commit "$(git rev-parse HEAD)" reads the public reference itself — the composed surface recorded in /build.json and its own /rustdoc/surface.json, the landing page, the library's index with its stylesheets and scripts, a module page, the search index and the crate's COMMIT constant page — and the deploy fails when any of them is absent or names another commit (RUSTDOC.md).

The job summary of every deployment therefore carries the phases against their budgets, the controlled total against its budget, the queue and the Pages build beside them, and one verdict line:

Pages SLO: PASS — controlled path <n> s / <budget> s, push to public <n> s / <target> s

and, when the controlled path is inside its budget and the total is not, the summary says so in those words rather than reporting a failure: the remainder is the Actions queue and GitHub's own Pages build, which this repository does not spend, and that is an external limit rather than a regression here.

What makes the run red

A deploy run is red when, and only when, the deployment failed. That was not true until 2026-09-12. scripts/pages report exits 10 on a budget breach, and the workflow ran it as an ordinary step after the push to gh-pages — so a measurement of work already finished became the verdict on that work.

Between 2026-09-11T22:36Z and 2026-09-12T06:38Z, eleven runs of this workflow reported failure from that step (90a875cf2, 8ce9d3157, e98bfd44a, 48a546ebb, 0136bd9eb, b144b2110, bbd5a9791, 94a018ded, 497d56d40, 180687c8f, 4a4ad6aac). Every one of them had pushed gh-pages successfully, and the newest was verified end to end: the live site served 4a4ad6aac. In the same window exactly one run genuinely failed to publish — d5e27a500, whose build step failed, whose publish step was skipped, and whose report step therefore passed, because there was almost nothing to add up.

So the run that had not published was the one where the budget check was green, and it carried the same failure conclusion as the eleven that had. At the level anybody reads first — the tick on the commit, gh run list, the notification — the two were identical; only an annotation inside the run told them apart. And the report's Markdown goes to $GITHUB_STEP_SUMMARY rather than to the log, so opening the log gave Process completed with exit code 10 with no number above it.

flowchart TD
    P{"did the push to gh-pages happen?"}
    P -- no --> R["run is RED<br/>::error Not published<br/>pages-live refuses on the next validation"]
    P -- yes --> G{"did GitHub's own build of gh-pages error?"}
    G -- yes --> R2["run is RED<br/>::error GitHub's Pages build errored"]
    G -- "no, or not yet known" --> N["::notice Published<br/>run is GREEN"]
    N --> B{"controlled path within budget?"}
    B -- yes --> OK["Pages SLO: PASS in the job summary"]
    B -- no --> W["run stays GREEN<br/>::warning naming the total and the phase that spent it<br/>OVER BUDGET in the summary, pages-slo in the log"]

The budget is not softened anywhere else. scripts/pages report still exits 10 on a breach for a person and for any gate that calls it; --advisory is passed by exactly one caller — the post-publish step that measures a deployment which has already happened — and test/cases/97_pages_fast_path.sh asserts that it stays exactly one. A breach still produces, on every run:

  • a ::warning annotation on the run, carrying the total, the budget and the phase over its own;
  • the full phase table and a bold OVER BUDGET verdict in the job summary;
  • a pages-slo verdict=… controlled=… budget=… over=… line in the log, which is where the eleven red runs above were read from and where nothing used to be printed at all;
  • verdict, controlled, budget and over as outputs of the step, for a reader that is not a person.

A row near its step bound is reported the same way and never changes the verdict: a ::warning titled "Pages step near its bound", a NEAR line in the job summary, and near= in the pages-slo line and the step's outputs.

This is the same division the publication probe above already makes, and for the same reason: a gate on a deployment that has already happened is a gate that gets waived. What refuses a publication that never happened is the pages-live gate of .ai/repo/ci/gates.yaml, on the next validation of master.

Locally

The same commands CI runs. There is no GitHub-only build semantics.

scripts/pages current            # is the committed derived data current for this tree?
scripts/rust-check --doc         # the crate's reference, which the build composes at /rustdoc
scripts/pages build              # render it
scripts/pages check              # every static check over the output
scripts/pages benchmark -n 5     # the controlled path, as JSON
scripts/site-deploy              # the full gate and the push, the way a person deploys

scripts/pages build refuses a tree whose derived data is stale and tells you the two hashes.

scripts/pages current also records its verdict, with the tree it checked, at .ai/local/state/generation/check.json. The pre-commit hook runs it, so after a commit that tree is HEAD's, and the preflight's verification.docs check reads the record (docs/PREFLIGHT.md). An unstaged edit is recorded with no tree, because the files that were checked are not the tree the index names. The cure is always the same: scripts/generate-site-data (or just derive) and commit the result.

Deploying by hand is still the fastest path

The site is served from the gh-pages branch, and whoever pushes that branch deploys (docs/claims/site-deploy-one-path.md). A person at a terminal therefore has no Actions queue in front of them at all:

scripts/site-deploy              # gate, build, check, push

This is why publication was not moved to the native Pages artifact flow (actions/configure-pages → upload-pages-artifact → deploy-pages), which would shave the seconds GitHub spends building the branch: pointing GitHub Pages at "GitHub Actions" as its source would make a workflow run the only way to publish, and would put the Actions queue in front of the one path that today has none. The branch source costs GitHub's own "pages build and deployment" and keeps the hand deploy. What that costs is measurable rather than memorable:

gh run list --workflow "pages-build-deployment" --limit 20 \
  --json createdAt,updatedAt --jq '.[] | (.updatedAt | fromdate) - (.createdAt | fromdate)'

If the queue ever stops being the dominant term and that becomes the binding constraint, the trade is worth revisiting — with the measurement, not with the preference.

Remaining bottlenecks, by expected benefit

  1. build and check with the Czech copy: 270 and 504 s, median, of a 1011 s controlled path (2026-10-20). build was 15 s over the English site, so doubling the pages made it about eighteen times slower, which the corpus does not explain and a profile of scripts/site-build should. check was 167-171 s and is now three times that. Before the Czech copy its remaining cost was the per-record loops that shell out to jq once per claim, command, capability, use case and plan record; the same treatment that took the per-page loops from 25 s to under a second applies: one jq program per family instead of one process per record. Until those land, the pages the site adds are what moves controlled, and the budget above is what will say so.
  2. The Actions queue. On this repository, with several worktrees pushing at once, runs have waited minutes for a runner (gh run list --json createdAt,startedAt). Nothing in these files shortens that. The escalation, if the controlled path is already comfortably inside its budget and the queue is what keeps T_public over a minute, is a warm self-hosted runner labelled for this job only — evaluated on measurements, with its own security, isolation and maintenance cost stated, and not before.
  3. GitHub's own Pages build of gh-pages. Removable only by giving up the hand deploy path, as above.
  4. npm ci on a cold cache. Bounded by the node_modules cache; the cold path is what the cold budget covers.

Changing any of this

  • A new canonical input of the site generator goes into the INPUTS list of scripts/generate-site-data and nowhere else; the fingerprint, the freshness check and the trigger follow from it.
  • A new path that can change the site goes into a class of gates.yaml that names the site-build gate; then regenerate the workflow's paths: block as above.
  • A new budget, or a changed one, goes into .ai/repo/ci/pages.yaml and cites the runs that justified it, in the file, with their numbers — the block there is the worked example. A budget derived on a laptop does not bound a runner, and one derived over a site of half the size does not bound this one. controlled must stay at least the sum of its phases, and every row the run measures must have a key. A raised row raises the bound of the step that runs it in the same commit, to at least budget.bound.percent of the row, or scripts/ci/step-bound-check refuses it.
  • A new timing row — a row or timed call in scripts/pages, or a printf 'pages\t…' in the workflow — needs a budget key of the same name in the same commit, or it is summed into the total while showing an empty budget cell. test/cases/97_pages_fast_path.sh refuses that.
  • Nothing measured after the push to gh-pages may become the run's verdict. The run is red for a failed deployment and for nothing else; a latency finding is a ::warning, a summary and a machine-readable line. A public reference at /rustdoc/ that is absent, broken or built from another commit is a failed deployment rather than a measurement, which is why scripts/pages verify-rustdoc is a hard step (ADR 86) while scripts/pages verify, whose wait is GitHub's, stays a report.
  • A new check over the published bytes goes into scripts/site-check, which the publication path runs. A new check over the repository goes into the gate model, which it does not.

Claims this document defines

Each links to its own page with implementation, test and where it is used.

Searches the titles and descriptions of every page this site publishes.