The CI pipeline: what runs, what gets skipped, and why
Every pull request used to compile the whole workspace on Linux, macOS and Windows — including the
27% of merged pull requests that changed no code at all (measured over the last 100 merges). A
change that only adds a bug report under specs/ or fixes a sentence in the user guide cannot
affect what the compiler or the test suite sees, so it no longer pays for three platforms’ worth of
Rust compilation.
This page explains how that decision is made, what still runs, and what to do when the pipeline surprises you.
The two paths
Every run is classified as documentation-only or code-affecting, and takes one of two shapes:
| Job | Code-affecting | Documentation-only |
|---|---|---|
classify change | runs | runs |
fmt + clippy | runs | skipped |
build + test (ubuntu-latest) | runs | skipped |
build + test (macos-latest) | runs | skipped |
build + test (windows-latest) | runs | skipped |
assertion freeze (advisory) | runs | skipped |
sandbox against a real runtime (linux) | runs | skipped |
docs check | runs | runs |
ci complete | runs | runs |
Three jobs on a documentation-only run, all on Linux. No macOS or Windows runner is started.
sandbox against a real runtime is Linux-only because Docker Desktop is not available on GitHub’s
macOS and Windows runners, so the alternative is no real-runtime job at all. It builds the :dev
sandbox image from the branch and then runs the sandbox_real_* tests in two steps, one per
crate — micold-core for the adapter layer, micold-daemon for what the isolation is for. The
second step is not optional tidiness: it was missing until feature 027’s T147, and its eleven tests
carry the sandbox’s headline claims. A -p that names one crate does not report the other as
skipped; it produces no output about it at all, which is why the job looked complete for the whole
of the feature it was written for.
docs check is deliberately unconditional: it is the one gate a documentation-only change can
actually break, so skipping the build must not skip it. Delete a required document and the run
fails, exactly as before.
For the same reason it is where the documentation site’s pre-merge checks live — as steps in this
job, never as a job of their own, which a documentation-only change would skip. Besides the
required-document lists, docs check runs:
site/checks/page-set.sh— every page underdocs/is listed inSUMMARY.mdand every entry names a file that exists, and the site stylesheet is still drawn from the design tokens rather than from literals;site/checks/media-references.sh— every media directive in a page names a manifest entry, and every manifest entry names a scene that exists and is referenced by some page;site/checks/links.sh --sources— every internal link and heading fragment in the sources resolves, without fetching a single external URL.
They fail on the pull request, where the page is still in front of its author, rather than at render time on the release branch after the tag. The rest of a publication — the capture, the render, and the checks that need a built site — is described in The documentation site.
What counts as documentation
The declaration lives in .gitattributes, as the attribute micold-docs:
docs/** micold-docs
specs/** micold-docs
/*.md micold-docs
/LICENSE micold-docs
/*.png micold-docs
.claude/skills/** micold-docs
/CHANGELOG.md -micold-docs
That file is the single source of truth. Two consumers read it, both through the same matcher
(git check-attr micold-docs), so they cannot drift apart:
scripts/classify-change.sh— which paths did this change touch?crates/micold-core/tests/documentation_is_not_read.rs— which paths may no test read?
Anything not listed is code. Source, manifests, Cargo.lock, toolchain and tool
configuration, scripts/, packaging/, assets/, and the workflows themselves are all
code-affecting — even when only their comments change.
Only skills/ under .claude/ is documentation. Those files instruct the coding agent and
nothing in the build, the suite or the package opens them. The rest of that directory is the app’s
own runtime state — worktrees/, local settings — which is untracked, so it stays code by default
rather than being declared prose it is not.
CHANGELOG.md is a build input, not documentation. micold-core’s metadata.rs does
include_str!("../../../CHANGELOG.md") so the app can show a “what’s new” view offline, which
means changing the changelog changes the binary. It takes the full pipeline.
To check any path yourself:
git check-attr micold-docs -- path/to/file
set means documentation. unset and unspecified both mean code.
How the classification is made
scripts/classify-change.sh diffs against the merge base (base...head, three dots), so the
verdict comes from everything the pull request changes, not from whichever commit happened to be
last. A pull request that changes a Rust file in its first commit and only prose afterwards is
still code-affecting.
Two rules govern it:
- A change is documentation-only only when every touched path is declared documentation. One path outside the set makes the whole change code-affecting.
- Every failure path lands on code-affecting. A base ref that cannot be resolved, an unrelated history, a diff that fails — all fall back to running everything. There is no input for which “something went wrong” means “skip the build”.
The merge gate
The default branch requires exactly one status check: ci complete. It summarises the run
rather than enumerating the pipeline, and that is what makes the skipping possible at all —
requiring individual job names welds the merge gate to the pipeline’s internal shape, so no job can
be skipped without leaving a required check unreported and the pull request unmergeable forever.
ci complete fails if any job it covers failed or was cancelled. A skipped job satisfies it: a
job that did not need to run is not a failure.
Two properties are load-bearing, and both are asserted by
crates/micold-core/tests/ci_gate_covers_every_job.rs rather than left to review:
if: always(). Without it the implicitsuccess()applies, so the gate would be skipped whenever a covered job failed — and a skipped check reports success. The gate would go green exactly when the run went red.- Every job appears in its
needs:. A job added later and forgotten is a job whose failures stop blocking merges, silently. Nothing in GitHub Actions notices; the workflow stays valid.
Renaming the ci complete job breaks the merge gate for every pull request, and no pull request
can fix it — the ruleset lives in repository settings, not in the repo.
The user-guide gate
Constitution Principle VII says a user-facing feature is not done until its documentation exists,
and the Documentation gate requires the guide to be updated in the same pull request. Half of that
was already mechanical — the docs build runs in CI — and half was not: whether the guide had in
fact been updated was left to review, which is the half a reviewer skips when the diff is long and
the feature works. scripts/check-user-guide-updated.sh is the other half.
It blocks a pull request when all of these hold:
- Its title is a conventional-commit feature —
feat:,feat(scope):, either with a!. - It touches a path declared
micold-user-facingin.gitattributes— the client’s screens (crates/micold-client/src/ui/**) and the behaviours they trigger (crates/micold-client/src/features/**). - It touches no page declared
micold-user-guide— that is, nothing underdocs/user-guide/.
A developer doc does not satisfy it. The reader Principle VII protects is the person using the application, not the person building it.
Why features and not every edit
The word in the principle is “feature”, and holding the gate to it is what makes it worth having.
Replayed over the sixty merges before the gate existed, firing on any edit under the declared
paths would have blocked ten changes — eight of them bug fixes whose guide page says nothing
different. A label applied that often stops being read. Firing on feat alone would have blocked
two, and both were real omissions: naming the AI CLIs the service cannot find, and the Settings
icon rail.
Nothing lints the title prefix today, so writing fix gets past this. It is a guard against
forgetting, not against intent — and the convention is already load-bearing, since release-please
reads the same prefixes to decide version numbers.
The component library underneath the screens (ui/material/**, ui/cdk/**) is deliberately
excluded: Principle VII is satisfied for it by
the component library guide, and a ripple timing or a token tweak changes
pixels without changing anything the user guide says.
Getting past it
Either update the page in docs/user-guide/ that the change makes wrong, or label the pull
request docs-not-needed. Like full-ci, applying a label starts a fresh run, so the waiver
takes effect without a push.
The label is a decision somebody signs, not a way to make the check quiet. If you find yourself
applying it to most features, the declared path set is wrong — widen or narrow .gitattributes
rather than training everyone to reach for the label.
The gate runs on pull requests only; a push to main has already been through it. Its own cases are
in scripts/tests/check-user-guide-updated.test.sh and run in the same job, before it.
The macOS packaging step
The macOS .app bundle is composed and launched inside the test job’s macOS leg, as a step —
not as a job of its own. Look for “Package and launch the macOS bundle” in a run; it is guarded by
if: runner.os == 'macOS', so the Linux and Windows legs skip it.
That placement is deliberate and it is about the section above. A new job would have to be added to
ci complete’s needs:, and ci_gate_covers_every_job.rs would fail until it was — which is the
gate working, but it means the packaging check and the merge gate have to be changed together
forever after. As a step it rides a job that is already covered, already has the checkout, and has
already built the binaries the bundle is composed from. No second compile, and no edit to the merge
gate (research R12).
What the step actually proves is worth being precise about, because “the bundle was created” is the weaker half:
scripts/macos-bundle.shcomposes the bundle fromtarget/debugand ad-hoc signs it — the same scriptmise run appruns, so a contributor reproduces the CI artifact exactly.- The bundled executable is launched, and the step waits for the session daemon’s endpoint
(
~/.micold/run/d.sock) to appear.
Step 2 is the one that catches the failure that matters. The client resolves the daemon as a sibling
of its own current_exe(), so a bundle that puts the two binaries anywhere but together looks
perfectly healthy — correct plist, valid signature, right layout by eye — and can never start a
session. Waiting for the endpoint is that constraint asserting itself.
The drawing-surface escape hatch
A GitHub runner may not be able to give iced a drawing surface at all. That failure says nothing about packaging — the bundle loaded, dyld resolved every library, the plist parsed — so the step reports it and passes.
The hatch is matched against the launch log, not the exit status, because iced reports a missing surface and a missing library through the same code path. It is deliberately narrow: every other way of dying still fails the step, and a missing library, a rejected signature and a bundle whose daemon is not beside its client all look nothing like a surface failure. If you see that message in a run, the packaging half was still checked.
specs/028-macos-package/quickstart.md §B states what this step does, and
crates/micold-core/tests/macos_package_gate.rs holds that statement and the workflow together, so
the description cannot drift from the step.
Reading a run
The classify change job writes its verdict to the run summary, so the top of any run says which
path it took and why. On a code-affecting run it also lists the paths that decided it — the first
place to look when a change you expected to be cheap ran the full build.
ci complete writes a table of every covered job’s result, including whether each one was gated.
A green ci complete on a documentation-only run means nothing needed building — not that the
build passed. The skipped jobs are shown as skipped precisely so the two are never confused.
Forcing the full pipeline
Label the pull request full-ci. Applying it starts a fresh run that ignores the
classification and runs everything.
It has to be a fresh run: re-running an existing one replays the original event payload, which would not contain a label added afterwards. Removing the label is deliberately not a trigger — the next push reclassifies anyway.
Use it when you are changing the pipeline itself, when you suspect the classification is wrong, or when something moved underneath the branch.
Why skipping is safe
The whole scheme rests on one condition: no test or build step reads the contents of the documentation set. If one did, the change that broke it would be exactly the change CI declined to run.
That condition is not a promise in a document. documentation_is_not_read scans every Rust source
under crates/ for a string literal that resolves to a real path carrying micold-docs, and fails
the build if it finds one. A handful of literals resolve to documentation paths while reading
nothing — a fixture branch name that happens to look like a directory, a filename created inside a
temporary repository — and those are allowlisted individually with a written reason. A stale
allowlist entry fails too.
The constitution’s Development Workflow & Quality Gates section names this exemption and points at that check (v1.6.0).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| A change you expected to be cheap ran the full build | Something it touched is not declared documentation | Read the offending paths in the classify change summary. This is usually correct behaviour — widen .gitattributes only if the path genuinely cannot affect a build |
The classifier reports base ref unavailable | The base ref was not fetched. actions/checkout with fetch-depth: 0 does not create origin/<base> on a pull request | The classify job fetches it explicitly; if that step was changed, restore it. Symptom: everything looks fine and the full pipeline always runs |
ci complete is pending forever | The ruleset requires a context nothing produced — usually the gate job was renamed | Restore the job name, or update the ruleset. Only the latter is possible from repository settings |
ci complete is green but a job failed | The job is missing from the gate’s needs:, or if: always() was dropped | Fix both, and check why ci_gate_covers_every_job did not catch it |
documentation_is_not_read fails on a literal that reads nothing | A fixture string that happens to name a real documentation path | Add it to the test’s ALLOWED list with a reason |
A job fails in Install … system dependencies with apt-get update exit 100 and a 403 or timeout from a host this project does not use | A vendor repository the runner image preconfigures (Microsoft, Google Chrome) had an outage. apt-get update fails if any configured repository fails | Already handled: the step empties /etc/apt/sources.list.d except ubuntu.sources before updating, so only the Ubuntu archive is consulted. If this reappears, the archive itself is down — re-run |