ci(checks): a change that touches no code does not run the code checks #527

Merged
mph merged 1 commit from skip-code-checks-for-document-changes into main 2026-09-04 21:59:36 +00:00
Owner

Why

An intake commit that writes only an analysis paid for a format check over 529
files, a core compile, scalafix, both unit suites, and a compile of all 34
command scripts. That took more than ten minutes and proved nothing. The
unattended intake loop writes such a commit for every issue it processes.

The rule

scripts/change-scope.sh holds one rule and prints one word:

scripts/change-scope.sh                 # one path per line on stdin
scripts/change-scope.sh origin/main     # the paths a branch adds to a base

A path is a document when it sits under project-management/ or docs/, or
when it is a markdown file in the root. Everything else is code, including
.iw/commands/, test/, .git-hooks/ and .github/.

It is an allowlist, so a directory nobody has classified yet is code, not a
document. Anything the script cannot work out is code as well — an unresolvable
base, a call from outside a repository, an empty path list. A wrong documents
hides a broken build; a wrong code costs only time.

Three callers, one rule

  • .git-hooks/pre-commit reads the staged paths.
  • .git-hooks/pre-push reads each pushed ref against origin/main.
  • A changes job in CI reads the pull request against its base, and the other
    six jobs gate on its output.

The scope is measured from the merge base, never from the remote branch. A
branch that touched code in an earlier commit still runs the checks on every
later push, and a commit that lands on main after the branch starts does not
count.

Why if: and not paths-ignore:

paths-ignore: on the trigger starts no run at all, so every required check
goes missing rather than green. That is the state phase-pr reads as "no CI"
(#485). A job skipped by if: still reports its state, and the changes job
itself always runs — so a document-only pull request always carries at least one
real check.

Verification

28 new tests, all green:

  • test/change-scope.bats — 21 tests over the path rule and the merge-base range.
  • test/git-hooks-scope.bats — 7 tests over both hooks, every tool stubbed, so a
    skipped check is distinguishable from a check that passed.

Mutating the rule (*/* returning "document" instead of "code") turned 10 of
them red, so they are fallible.

Measured on real branches: IW-513 (an intake analysis) reads documents; this
branch reads code and paid the full pre-push cost.

Notes for the reviewer

The rule is a shell script rather than a function in core/. Both git hooks and
the CI changes job need an answer before paying for a JVM start, and calling
./iw from the changes job would compile core — the exact cost this removes.
The rule stays in one tested place.

Two pre-existing defects met while landing this, neither touched here:

  • The push was blocked twice by the Mill daemon dying. The daemon log records
    ujson.ParseException: expected json value got "G" at index 0 arriving with a
    second connection — an HTTP GET on the daemon's socket. docs/testing.md says
    the cause is not established; this narrows it. Belongs to #494.
  • IssuePollerTest.a read that throws leaves the poller able to read again
    failed once on main and passed on re-run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NhP5pWJetsgNK6SqmQdz7F

## Why An intake commit that writes only an analysis paid for a format check over 529 files, a core compile, scalafix, both unit suites, and a compile of all 34 command scripts. That took more than ten minutes and proved nothing. The unattended intake loop writes such a commit for every issue it processes. ## The rule `scripts/change-scope.sh` holds one rule and prints one word: ```bash scripts/change-scope.sh # one path per line on stdin scripts/change-scope.sh origin/main # the paths a branch adds to a base ``` A path is a document when it sits under `project-management/` or `docs/`, or when it is a markdown file in the root. Everything else is code, including `.iw/commands/`, `test/`, `.git-hooks/` and `.github/`. It is an **allowlist**, so a directory nobody has classified yet is code, not a document. Anything the script cannot work out is code as well — an unresolvable base, a call from outside a repository, an empty path list. A wrong `documents` hides a broken build; a wrong `code` costs only time. ## Three callers, one rule - `.git-hooks/pre-commit` reads the staged paths. - `.git-hooks/pre-push` reads each pushed ref against `origin/main`. - A `changes` job in CI reads the pull request against its base, and the other six jobs gate on its output. The scope is measured from the **merge base**, never from the remote branch. A branch that touched code in an earlier commit still runs the checks on every later push, and a commit that lands on `main` after the branch starts does not count. ## Why `if:` and not `paths-ignore:` `paths-ignore:` on the trigger starts no run at all, so every required check goes *missing* rather than green. That is the state `phase-pr` reads as "no CI" (#485). A job skipped by `if:` still reports its state, and the `changes` job itself always runs — so a document-only pull request always carries at least one real check. ## Verification 28 new tests, all green: - `test/change-scope.bats` — 21 tests over the path rule and the merge-base range. - `test/git-hooks-scope.bats` — 7 tests over both hooks, every tool stubbed, so a skipped check is distinguishable from a check that passed. Mutating the rule (`*/*` returning "document" instead of "code") turned 10 of them red, so they are fallible. Measured on real branches: `IW-513` (an intake analysis) reads `documents`; this branch reads `code` and paid the full pre-push cost. ## Notes for the reviewer The rule is a shell script rather than a function in `core/`. Both git hooks and the CI `changes` job need an answer *before* paying for a JVM start, and calling `./iw` from the `changes` job would compile core — the exact cost this removes. The rule stays in one tested place. Two pre-existing defects met while landing this, neither touched here: - The push was blocked twice by the Mill daemon dying. The daemon log records `ujson.ParseException: expected json value got "G" at index 0` arriving with a second connection — an HTTP GET on the daemon's socket. `docs/testing.md` says the cause is not established; this narrows it. Belongs to #494. - `IssuePollerTest.a read that throws leaves the poller able to read again` failed once on `main` and passed on re-run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NhP5pWJetsgNK6SqmQdz7F
ci(checks): a change that touches no code does not run the code checks
All checks were successful
CI / changes (pull_request) Successful in 3s
CI / contract (pull_request) Has been skipped
CI / lint (pull_request) Successful in 9s
CI / compile (pull_request) Successful in 1m32s
CI / test (pull_request) Successful in 10m33s
CI / format (pull_request) Successful in 9s
CI / dashboard-build (pull_request) Successful in 1m22s
5be8a7f3b3
An intake commit that writes only an analysis paid for a format check over 529
files, a core compile, scalafix, the unit suites and a compile of all 34 command
scripts. That cost more than ten minutes and proved nothing.

scripts/change-scope.sh holds one rule and prints "code" or "documents". A
document is a file under project-management/ or docs/, or a markdown file in the
root. The list is an allowlist, so a directory nobody has classified yet is
code. Anything the script cannot work out is code as well: a wrong "documents"
hides a broken build, a wrong "code" costs only time.

Three callers apply it. Both git hooks skip their checks on a document-only
change, and a "changes" job in CI publishes the scope for the other jobs to gate
on. The scope is measured from the merge base, so a branch that touched code in
an earlier commit still runs the checks, and a commit that lands on main after
the branch starts does not count.

CI gates with "if:", not with "paths-ignore:" on the trigger. A job skipped by
"if:" still reports its state, while "paths-ignore:" starts no run and leaves
every required check missing, which is the state phase-pr reads as "no CI"
(#485). The "changes" job always runs, so a document-only pull request always
carries at least one check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhP5pWJetsgNK6SqmQdz7F
mph merged commit 05b55b8b86 into main 2026-09-04 21:59:36 +00:00
mph deleted branch skip-code-checks-for-document-changes 2026-09-04 21:59:36 +00:00
Sign in to join this conversation.
No description provided.