What
On a pull request that changes no code, skip Image build + Trivy scan and Web typecheck, test, and build. Leave Build and test running. Split out of the "skip CI on documentation PRs" idea after reading what actually runs on a docs-only PR here.
Measured, 2026-09-12
7 of the last 40 merged PRs changed nothing under src/, web/, tests/, tools/, deploy/, .github/, Directory.* or the solution file (#701, #718, #731, #744, #754, #757, #768). About 17%.
Per-job wall clock from #775's measurements on a green main run: Build and test 568s, Web typecheck, test, and build 213s, Image build + Trivy scan 129s. So the two jobs proposed here are roughly 340s of the ~900s a docs-only PR spends today, and they are the portion that cannot be affected by prose.
Why Build and test must keep running on a docs-only PR
This is the part that makes the obvious version of the idea wrong. In this repository "documentation" is not outside the test suite's scope — it is something the suite polices. Four gates are load-bearing on a markdown-only change:
Skipping Build and test on docs PRs would stop all four running on exactly the changes they were written to catch.
The hazard that decides the implementation
publish declares needs: [build-and-test, web, image]. A job whose needs dependency was skipped is itself skipped. So any gating must be scoped to github.event_name == 'pull_request' and must not apply on push to main or on workflow_dispatch.
Get that wrong and a merge to main skips image, which skips publish, which means no ghcr.io/<owner>/<repo>:sha-<commit> for that commit. Per #351 a release drafted at a commit with no image can never be promoted, and the release stays a draft with no git tag. ci.yml's own header comment describes that exact repair scenario as the thing the workflow_dispatch path exists to recover from. This issue must not manufacture a second route into it.
image also feeds publish the image_id output that proves the artifact handoff carried the scanned bytes. That contract is unchanged on main and must stay unchanged.
Mechanism: an if: gate, not on.pull_request.paths
Two ways to skip a job, and only one is safe here.
on.pull_request.paths is the trap. It gates the whole workflow, so it cannot skip two jobs and keep a third. Worse, when a required status check lives in a workflow that never ran, GitHub leaves that check in Expected and the PR can never merge. The usual workaround is a second shim workflow declaring a job of the same name on the inverse filter, which is a maintenance burden and a silent-drift risk.
A job skipped by if: reports as skipped, which satisfies a required check. So add one cheap gate job that computes whether the PR touched code, and put if: on web and image referencing its output.
Compute the changed paths with plain git diff --name-only against the merge base. Do not add tj-actions/changed-files or dorny/paths-filter for this — AGENTS.md names the 2025-03 tj-actions/changed-files compromise as the reason third-party actions are SHA-pinned, and a three-line git diff needs no dependency at all.
Decide before writing code
1 — establish which checks are actually required. gh api repos/mforce/cluckwork/branches/main/protection returns 403 for a personal access token, so the required-check list could not be read while writing this. If Web typecheck, test, and build or Image build + Trivy scan is required, the if: approach above is not merely preferable, it is the only one that works. Confirm before implementing.
2 — define "touched code" once, and in one place. The gate's path expression is a second copy of a fact that e2e-smoke.yml already encodes in its own paths: filter (src/**, web/**, tools/simulation/**, deploy/**). Two lists that are supposed to mean the same thing will drift. Decide whether the gate reuses that set, deliberately differs from it, and say which.
3 — web and image do not have the same trigger set. Web typecheck, test, and build should plainly run whenever web/** changes. Image build + Trivy scan also hosts the --locked-mode drift guard, so it must run on any change to Directory.Packages.props, a .csproj, a packages.lock.json or the Dockerfile, not only on src/**. Deriving one shared condition for both jobs would be the easy mistake.
4 — decide what happens on a mixed PR. A PR touching both docs and code runs everything. That is obvious, and it is also the case the gate must not get subtly wrong by testing "are any docs changed" rather than "are no code files changed".
Non-goals
Do not touch Build and test, for the reasons above. Do not touch any security gate (#146). Do not touch e2e-smoke.yml, which already path-filters and already skips docs-only PRs, so that lever is spent. This does not reduce the number of tests anywhere; #775 covers the wall clock of the suite itself, and this is only about not running two jobs that cannot be affected by the change under review.
Relationship to #775
#775 is about where Build and test's 568s goes. This is a smaller, independent saving that does not require answering that question first, and it removes nothing from the critical path #775 is trying to shorten. It can land before or after #775 without interacting with it.
What
On a pull request that changes no code, skip
Image build + Trivy scanandWeb typecheck, test, and build. LeaveBuild and testrunning. Split out of the "skip CI on documentation PRs" idea after reading what actually runs on a docs-only PR here.Measured, 2026-09-12
7 of the last 40 merged PRs changed nothing under
src/,web/,tests/,tools/,deploy/,.github/,Directory.*or the solution file (#701, #718, #731, #744, #754, #757, #768). About 17%.Per-job wall clock from #775's measurements on a green
mainrun:Build and test568s,Web typecheck, test, and build213s,Image build + Trivy scan129s. So the two jobs proposed here are roughly 340s of the ~900s a docs-only PR spends today, and they are the portion that cannot be affected by prose.Why
Build and testmust keep running on a docs-only PRThis is the part that makes the obvious version of the idea wrong. In this repository "documentation" is not outside the test suite's scope — it is something the suite polices. Four gates are load-bearing on a markdown-only change:
SchemaDocsTests.PostgresImagePin_IsOneIdenticalStringAcrossEveryTrackedFileand its Redis twin enumerategit ls-files -zwith no extension filter, so every tracked.mdis in scope.AGENTS.mdalready records this guard going red on a plan document and costing a full implementer stop one increment from the finish line (fix(api): order same-instant audit events by a durable monotonic key, not a random Guid #508). That is not a hypothetical.TenancyDocsFreshnessTests.NoTrackedFileDescribesTenancyAsDormantruns the same tracked-file sweep.tools/schema-docs/generate.sh --checkexists precisely becausedocs/schema/is generated and must never be hand-edited (chore(schema): generate PostgreSQL schema documentation #417).Skipping
Build and teston docs PRs would stop all four running on exactly the changes they were written to catch.The hazard that decides the implementation
publishdeclaresneeds: [build-and-test, web, image]. A job whoseneedsdependency was skipped is itself skipped. So any gating must be scoped togithub.event_name == 'pull_request'and must not apply on push tomainor onworkflow_dispatch.Get that wrong and a merge to
mainskipsimage, which skipspublish, which means noghcr.io/<owner>/<repo>:sha-<commit>for that commit. Per #351 a release drafted at a commit with no image can never be promoted, and the release stays a draft with no git tag.ci.yml's own header comment describes that exact repair scenario as the thing theworkflow_dispatchpath exists to recover from. This issue must not manufacture a second route into it.imagealso feedspublishtheimage_idoutput that proves the artifact handoff carried the scanned bytes. That contract is unchanged onmainand must stay unchanged.Mechanism: an
if:gate, noton.pull_request.pathsTwo ways to skip a job, and only one is safe here.
on.pull_request.pathsis the trap. It gates the whole workflow, so it cannot skip two jobs and keep a third. Worse, when a required status check lives in a workflow that never ran, GitHub leaves that check inExpectedand the PR can never merge. The usual workaround is a second shim workflow declaring a job of the same name on the inverse filter, which is a maintenance burden and a silent-drift risk.A job skipped by
if:reports as skipped, which satisfies a required check. So add one cheap gate job that computes whether the PR touched code, and putif:onwebandimagereferencing its output.Compute the changed paths with plain
git diff --name-onlyagainst the merge base. Do not addtj-actions/changed-filesordorny/paths-filterfor this —AGENTS.mdnames the 2025-03tj-actions/changed-filescompromise as the reason third-party actions are SHA-pinned, and a three-linegit diffneeds no dependency at all.Decide before writing code
1 — establish which checks are actually required.
gh api repos/mforce/cluckwork/branches/main/protectionreturns 403 for a personal access token, so the required-check list could not be read while writing this. IfWeb typecheck, test, and buildorImage build + Trivy scanis required, theif:approach above is not merely preferable, it is the only one that works. Confirm before implementing.2 — define "touched code" once, and in one place. The gate's path expression is a second copy of a fact that
e2e-smoke.ymlalready encodes in its ownpaths:filter (src/**,web/**,tools/simulation/**,deploy/**). Two lists that are supposed to mean the same thing will drift. Decide whether the gate reuses that set, deliberately differs from it, and say which.3 —
webandimagedo not have the same trigger set.Web typecheck, test, and buildshould plainly run wheneverweb/**changes.Image build + Trivy scanalso hosts the--locked-modedrift guard, so it must run on any change toDirectory.Packages.props, a.csproj, apackages.lock.jsonor the Dockerfile, not only onsrc/**. Deriving one shared condition for both jobs would be the easy mistake.4 — decide what happens on a mixed PR. A PR touching both docs and code runs everything. That is obvious, and it is also the case the gate must not get subtly wrong by testing "are any docs changed" rather than "are no code files changed".
Non-goals
Do not touch
Build and test, for the reasons above. Do not touch any security gate (#146). Do not touche2e-smoke.yml, which already path-filters and already skips docs-only PRs, so that lever is spent. This does not reduce the number of tests anywhere; #775 covers the wall clock of the suite itself, and this is only about not running two jobs that cannot be affected by the change under review.Relationship to #775
#775 is about where
Build and test's 568s goes. This is a smaller, independent saving that does not require answering that question first, and it removes nothing from the critical path #775 is trying to shorten. It can land before or after #775 without interacting with it.