Skip to this page's content

Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

JobCode-affectingDocumentation-only
classify changerunsruns
fmt + clippyrunsskipped
build + test (ubuntu-latest)runsskipped
build + test (macos-latest)runsskipped
build + test (windows-latest)runsskipped
assertion freeze (advisory)runsskipped
sandbox against a real runtime (linux)runsskipped
docs checkrunsruns
ci completerunsruns

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 under docs/ is listed in SUMMARY.md and 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:

  1. A change is documentation-only only when every touched path is declared documentation. One path outside the set makes the whole change code-affecting.
  2. 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 implicit success() 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:

  1. Its title is a conventional-commit feature — feat:, feat(scope):, either with a !.
  2. It touches a path declared micold-user-facing in .gitattributes — the client’s screens (crates/micold-client/src/ui/**) and the behaviours they trigger (crates/micold-client/src/features/**).
  3. It touches no page declared micold-user-guide — that is, nothing under docs/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:

  1. scripts/macos-bundle.sh composes the bundle from target/debug and ad-hoc signs it — the same script mise run app runs, so a contributor reproduces the CI artifact exactly.
  2. 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

SymptomCauseFix
A change you expected to be cheap ran the full buildSomething it touched is not declared documentationRead 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 unavailableThe base ref was not fetched. actions/checkout with fetch-depth: 0 does not create origin/<base> on a pull requestThe 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 foreverThe ruleset requires a context nothing produced — usually the gate job was renamedRestore the job name, or update the ruleset. Only the latter is possible from repository settings
ci complete is green but a job failedThe job is missing from the gate’s needs:, or if: always() was droppedFix both, and check why ci_gate_covers_every_job did not catch it
documentation_is_not_read fails on a literal that reads nothingA fixture string that happens to name a real documentation pathAdd 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 useA vendor repository the runner image preconfigures (Microsoft, Google Chrome) had an outage. apt-get update fails if any configured repository failsAlready 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