Skip to content

Workflow-hardening invariants

Hardening invariants for this repo's workflows, scripts and test harnesses. Most rules are locked in by a script lint, run as a member check of a batched lint group job (lint-workflow-security, lint-script-hygiene, or lint-doc-invariants) and as a pre-commit hook. The exceptions:

  • setup-nix-required is enforced by a standalone CI job.
  • Some rules (test-reachable, tool-guarded and guard-exit-code among the script-hygiene ones, workflow-ascii-space among the workflow-security ones, and the others the enforcement matrix's pre-commit column marks hook-less) are CI-only with no hook.
  • One rule runs under the doc-freshness CI job instead of a lint group: harness subject declaration.
  • One rule is convention-only with no automated enforcer: lean lint-shell routing.

See the enforcement matrix for the authoritative per-rule mapping.

See workflow-scanner division of labor for how these in-tree lints fit the broader layered scanning model (pre-commit → PR/push → weekly sweep → watchdog) alongside the external scanners.

job-timeout-minutes

Every job declares an explicit timeout-minutes as a positive integer.

GitHub Actions defaults a job timeout to 6 hours. A hung job at that ceiling burns the runner budget and holds a required context pending for six hours, so gh pr merge --auto never fires and the unattended bump and lock-refresh pipelines stall behind it. Requiring an explicit per-job value bounds the blast radius of any wedge and forces a deliberate choice when a job is added.

Reusable-workflow callers (jobs that carry a job-level uses:) are exempt because timeout-minutes is not valid on that shape; the timeout belongs in the called workflow's jobs.

What the lint reads as a positive integer, as a reusable-workflow call and as a job id is stated in the header of scripts/check-job-timeout-minutes.sh. It compares the value as text and never evaluates it.

Enforced by scripts/check-job-timeout-minutes.sh. Wired as the lint-workflow-security CI job (member check job-timeout-minutes) and as a pre-commit hook.

workflow-concurrency

Every workflow declares a top-level concurrency: block with a non-empty group:.

Without a concurrency group, cron pile-ups and back-to-back PR pushes can spawn parallel runs on the same ref. Beyond burning runner minutes on superseded work, parallel runs can race steps that touch shared remote state (gh release create, tag pushes, image manifest writes). Forcing every workflow to declare a group keeps each ref serialized to one in-flight run by default.

cancel-in-progress is not required by this lint; the group alone is the load-bearing setting. Pipelines that must run to completion once started (e.g., release-on-bump.yml) deliberately set cancel-in-progress: false so back-to-back triggers queue instead of cancelling.

The lint reads concurrency: and its group: by their YAML kind, reads a root key written as an alias of concurrency as that key and a file giving it twice, plain and as an alias, as one that does not parse, and reports a file yq reads as several YAML documents (GitHub Actions refuses a workflow file holding several) without reading it further. The script header states which shapes are read and which are reported as an unexpected shape.

Enforced by scripts/check-workflow-concurrency.sh. Wired as the lint-workflow-security CI job (member check workflow-concurrency) and as a pre-commit hook.

workflow-ascii-space

Workflow and composite-action YAML (.github/workflows/*.yml, *.yaml and .github/actions/*/action.yml, action.yaml) holds no Unicode space or line separator in any position: U+0085, U+00A0, U+1680, U+2000 to U+200B, U+2028, U+2029, U+202F, U+205F, U+3000.

The lints that scan these files by text read blankness with [[:space:]], which matches most of them (not U+0085, U+00A0, U+2007, U+200B or U+202F) in every UTF-8 locale, while YAML separates tokens with space and tab only. A character after a key's colon, in an indent or before a # could make a text scanner and the consumer read one line two ways, and which reading GitHub Actions takes cannot be checked offline. Refusing the character keeps the scanners and the consumers on one reading. A character wanted as data is written as an escape in a double-quoted scalar ("\u2003").

The rule is by position-free byte match, not by structure: it flags the character inside a run: block or a string as well, since telling those apart needs a YAML parse the scanners it protects do not have. How the rest of the locale-bound classes are settled is in linting.md, Locale-bound character classes.

Enforced by scripts/check-workflow-ascii-space.sh. Wired as the lint-workflow-security CI job (member check workflow-ascii-space).

checkout-persist-credentials

Every actions/checkout step sets with.persist-credentials: false (boolean, not string). The lint selects a step by a uses: that starts with actions/checkout@, its ASCII letters in either case, and a finding names the owner and repository as the step spells them.

Without it, actions/checkout writes GITHUB_TOKEN into .git/config and leaves it on disk for the remainder of the job. Any later step in the same job — a third-party action, a misbehaving binary, a shell injection in a run: block — can read the token from the working tree and use its scopes. persist-credentials: false drops the credential after the initial clone/fetch, narrowing the blast radius of a compromised later step.

Boolean false is required rather than the string "false". Actions serializes every with: value to a string before the action runs, so both spellings reach actions/checkout identically — only the YAML node type distinguishes a deliberate setting from a copy-pasted string, and the lint keys on that tag to keep the spelling unambiguous across the workflow set.

How the lint reads with: and the value (by kind, through aliases, compared whole) is stated in the header of scripts/check-checkout-persist-credentials.sh.

Enforced by scripts/check-checkout-persist-credentials.sh. Wired as the lint-workflow-security CI job (member check checkout-persist-credentials) and as a pre-commit hook.

upload-artifact-strict

Every actions/upload-artifact step sets with.if-no-files-found: error. The lint selects a step by a uses: that starts with actions/upload-artifact@, its ASCII letters in either case, and a finding names the owner and repository as the step spells them.

The action's default is warn, which emits a warning and uploads nothing when the path: glob matches nothing. That hides build-output drift: a broken path produces a green job with no artifact, and the consumer side only notices when something downstream goes missing — sometimes many runs later. error turns the path-mismatch into a hard upload failure, surfacing the bug at its source.

How the lint reads with: and the value (by kind, through aliases, compared whole) is stated in the header of scripts/check-upload-artifact-strict.sh.

Enforced by scripts/check-upload-artifact-strict.sh. Wired as the lint-workflow-security CI job (member check upload-artifact-strict) and as a pre-commit hook.

workflow-on-branches

Every workflow that declares on.pull_request: or on.push: sets branches: [main] exactly under that trigger. No wildcards, no implicit all-branches, no other branch names.

Without the allowlist, Actions fires the workflow on every branch — burning runner minutes on stale topic branches and attaching surprising status checks to refs nobody is watching. Workflows that only run on schedule:, workflow_dispatch:, or workflow_call: are unaffected; pull_request_target: is handled by a separate lint that forbids it outright.

The lint reads on: after passing it through yq's explode a fixed number of times, so a trigger, its value or its branches: list written through an anchor is read as what it stands for. It refuses a workflow whose on: still holds an alias after the passes, or whose root names on more than once. The script header states the mechanism and how a merge key is read.

on: is read by its YAML kind, so a trigger given by name or in a list of names is read as well as one in a map, and a file yq reads as several YAML documents (GitHub Actions refuses a workflow file holding several) is reported without being read further. The script header states which shapes of on: are read and which are reported as an unexpected shape.

Enforced by scripts/check-workflow-on-branches.sh. Wired as the lint-workflow-security CI job (member check workflow-on-branches) and as a pre-commit hook.

pull-request-target-absent

No workflow uses the pull_request_target trigger.

pull_request_target runs the base ref's workflow definition with the full secret scope of the base repo. If the workflow then checks out the PR head (the common reason to use this trigger), an attacker's fork PR can introduce malicious code that the base-ref workflow runs with secret access — the canonical Actions privilege-escalation footgun.

This repo has no use for the trigger. The lint hard-fails any workflow that adopts it. Removing the ban requires deleting this script, its pull-request-target-absent member entry under lint-workflow-security in .github/lint-groups.yml, and the pre-commit hook.

The lint reads on: after passing it through yq's explode a fixed number of times, so the trigger written through an anchor is read as the trigger. It refuses a workflow whose on: still holds an alias after the passes, or whose root names on more than once. The script header states the mechanism, which shapes of on: are read, and which are reported as an unexpected shape.

Enforced by scripts/check-pull-request-target-absent.sh. Wired as the lint-workflow-security CI job (member check pull-request-target-absent) and as a pre-commit hook.

auto-merge-decline-gate

Every workflow run-block that calls gh pr merge with --auto also carries the decline gate: a --json state query and a CLOSED|MERGED arm carrying a literal exit 1, all within the same run-block.

An auto-merging update workflow recreates a per-period branch and merges its PR unattended. Without inspecting PR state first, a re-run in the same period overwrites a PR the maintainer explicitly closed (declined) or one already merged — silently reversing a human decision in a no-review flow. The gate aborts non-zero on CLOSED|MERGED so the run fails and a failure issue is filed instead.

This protects only an explicitly closed or merged PR; routine PRs are unaffected, so the fully-automated update flow is preserved.

Enforced by scripts/check-auto-merge-decline-gate.sh. Wired as the lint-workflow-security CI job (member check auto-merge-decline-gate) and as a pre-commit hook.

verify-reason-ladder

The attribute failure reason step of verify-latest-release.yml covers every id:-carrying step of the verify job other than itself, reads every env var it declares, documents every reason token it emits, and walks the ladder in step-execution order — four assertions binding four hand-synced copies of the same mapping.

The escape hatch is a # reason-ladder-exempt: <reason> comment on the same line as a step's id: key, for a step that legitimately carries an id without being a verification outcome; the rationale must be non-empty.

A marker excuses nothing in two shapes, both reported as drift. On a step id the attribution env already references via steps.<id>.outcome, coverage would have passed that step whether or not the marker existed. On the attribution step's own id, coverage skips it unconditionally, so a marker there changes nothing it could exempt. This marker always sits on a real step's id: line — unlike enumerate-helper-required's and guard-exit-code's markers, it can never be attached to no site at all — so an exemption that protects nothing here is unearned rather than orphaned. Neither shape adds a field to the clean summary line.

Enforced by scripts/check-verify-reason-ladder.sh. Wired as the lint-workflow-security CI job (member check verify-reason-ladder) and as a pre-commit hook. Full rationale: Verification → Ladder coverage is linted.

script-shebang-pipefail

Every executable under scripts/ starts with #!/usr/bin/env bash (exact first line) and carries set -Eeuo pipefail as its own line.

A script that silently swallows a failure can corrupt linpeas-pin.json, skip a security check, or leave a stale build artifact behind. set -Eeuo pipefail plus a portable shebang are the hardening minimum: -e aborts on any command failure, -E propagates ERR traps into subshells, -u rejects unset variables, -o pipefail makes a pipeline fail when any stage fails (not just the last).

The lint accepts a trailing addition on that line (e.g. set -Eeuo pipefail -x); a reordered or indented spelling is not accepted, because the match is anchored to the start of the line.

The scan recurses, and a file under a lib/ component is held to the inverse rule instead. A sourced library runs inside whichever shell sources it, so the executable rule is not merely unnecessary there — it is wrong. A library that sets set -Eeuo pipefail itself overrides whatever the caller chose, and a shebang advertises a file meant to be run rather than sourced. So a library must carry a shellcheck shell= directive (with no shebang, nothing else states the dialect), must not open with a shebang, and must not carry a shell-option line of its own. Each of the three failures prints its own message naming the half that broke.

Classification is by path, not by content: "no shebang means library" would excuse exactly the executable that forgot one, which is half of what this lint exists to catch.

Recursion matters because the shared libraries under scripts/lib/ are where a single defect reaches every caller at once — enumerate_into alone decides whether a failed enumeration becomes exit 2 for every lint that uses it.

Enforced by scripts/check-script-shebang-pipefail.sh. Wired as the lint-script-hygiene CI job (member check script-shebang-pipefail) and as a pre-commit hook.

harness-preamble

Every test harness matching tests/*.test.sh opens with the canonical preamble documented in tests/README.md: #!/usr/bin/env bash as the exact first line, set -Eeuo pipefail, the IFS=$'\n\t' field-separator line, and a readonly REPO_ROOT derived from git rev-parse --show-toplevel.

A harness that drops a piece of the preamble fails differently from its siblings: without pipefail a failing script-under-test can score as a pass, without the strict IFS a fixture path containing a space word-splits mid-scenario, and without a readonly REPO_ROOT derived from the repo top-level a harness run from another directory resolves the wrong tree. The lint makes the documented convention true by construction rather than by review.

The REPO_ROOT rule accepts both documented spellings — the direct form and the two-step lowercase form — by asserting the readonly line and the rev-parse derivation separately rather than one exact shape. The scan is non-recursive on purpose: fixture harnesses under tests/fixtures/ exist to violate rules and stay out of scope.

Enforced by scripts/check-harness-preamble.sh. Wired as the lint-script-hygiene CI job (member check harness-preamble).

lib-source-tool-free

A script under scripts/, sourced libraries included, never resolves a source/. library path through a command substitution — whether the substitution names BASH_SOURCE or invokes some other tool entirely — and BASH_SOURCE never appears inside a command substitution anywhere else in the file either.

BASH_SOURCE[0] is how a script locates its own directory to source a shared library. Feeding it through a command substitution such as $(dirname "$(readlink -f "${BASH_SOURCE[0]}")") needs readlink and/or dirname on PATH, and runs above the guard whose whole job is naming a missing tool. A script whose PATH lacks either tool dies at exit 127 naming readlink — a could-not-run reported under neither the exit code this repo reserves for it (2) nor a diagnostic naming what was actually absent. That holds wherever the substitution sits: directly on the source line (source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/lib/log.sh"), or one line earlier into a variable the source line only reads (_lib_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" followed by source "${_lib_dir}/lib/log.sh") — the second placement dies exactly the same way under a stripped PATH, so this half of the rule scans every line of every file rather than source lines alone.

A source/. line can also shell out to build its path without ever naming BASH_SOURCE — source "$(git rev-parse --show-toplevel)/scripts/lib/log.sh" dies exactly the same way under a stripped PATH, at whatever tool the substitution invokes. This half of the rule is scoped to library source lines: a path under a lib/ directory, or any source line inside a file that is itself under a lib/ directory (a library resolving a sibling). Both are structural tests on the line and the file rather than a list of library basenames, so a library added later stays covered without the lint itself needing an update.

${BASH_SOURCE[0]%/*} needs nothing on PATH: the shell performs the trim itself, with a . fallback for a bare-filename invocation where the expansion strips nothing. A bare ${BASH_SOURCE[0]%/*} with no bare-filename fallback stays legal: it is tool-free, and the case it misses is an invocation no caller in this repo makes. A library that sources a sibling resolves its own directory into _lib_self_dir rather than _lib_dir, because source runs in the caller's scope and reusing the caller's variable name would clobber it partway through the caller's own subsequent source lines — that shape stays clean too. A comment line may still name either banned shape without tripping the check on its own documentation.

The two halves report distinct diagnostics — one names BASH_SOURCE explicitly, the other names a library source path — so an operator can tell which rule fired without reading the line itself.

Breadth is asserted on the same count the source-path half of the rule scans: a clean run reports how many library source .../lib/*.sh lines it read, and reading zero is scored as a could-not-run rather than a clean tree, whether the scan root holds no shell script at all or holds scripts that never source a library. LINT_ALLOW_EMPTY_SCAN=1 suppresses that guard for a scan root that deliberately holds none.

Enforced by scripts/check-lib-source-tool-free.sh. Wired as the lint-script-hygiene CI job (member check lib-source-tool-free).

no-opaque-procsub

No script anywhere under scripts/, sourced libraries included, feeds a redirection from a process substitution — done < <(yq eval '.x' "$f"), mapfile -t rules < <(jq --raw-output '.rules[].type' <<<"${json}"), done < <(find . -name '*.sh'), done < <(parse_blocks) and every variant of the shape. There is no exemption marker and no allowlist.

A process substitution's exit status is invisible to set -Eeuo pipefail: the substitution runs in its own subshell, and the shell only ever sees the exit status of the command the redirection feeds (here, the while/done loop or mapfile), not the producer's. When the producer fails, the substitution produces empty output and the consumer scores that emptiness as data. Which way that lands depends on what the consumer does with an empty result, and both landings are wrong:

  • A scan loop exits 0 as if it found nothing to flag — a fail-open masquerading as a clean pass.
  • An assertion loop reports the substantive violation that an empty result implies — a missing ruleset rule, a dead dependency marker — sending the operator to fix input that was never wrong, while the producer fault itself is reported only as stray stderr.

The rule does not ask what the producer is, because the property does not depend on it: a parser, a find, a git ls-files, or a helper function the same file defines all lose their status the same way. Judging the shape rather than the producer keeps the rule decidable in a single textual pass and keeps it true when a helper grows a git, find, or nix call.

The sanctioned idioms make the failure abort loudly instead: capture the producer's output into a variable first (hits="$(yq eval '.x' "$f")", then iterate with <<<"${hits}") so set -e catches a non-zero exit before the loop ever runs; or, for NUL-delimited output that can't round-trip through "$(...)" (command substitution strips embedded NUL bytes), write to a temp file and iterate with < "${tmp}". A capture that is legitimately empty needs an explicit [[ -n … ]] guard, since a bare here-string feeds one empty line rather than zero.

A tooling failure caught this way exits 2, keeping exit 1 for the drift the check exists to report.

The ban covers redirections only. diff <(jq …) <(jq …) and diff <(expected_keys) <(actual_keys) stay legal: diff consumes both substitutions as file arguments and its own status is what the script acts on.

The lint skips comment lines (lines whose first non-whitespace character is #) so a script is free to document the banned idiom by name — e.g. explaining why it uses the capture idiom instead — without tripping the check on its own documentation.

Enforced by scripts/check-no-opaque-procsub.sh. Wired as the lint-script-hygiene CI job (member check no-opaque-procsub) and as a pre-commit hook.

guard-exit-code

No script anywhere under scripts/, sourced libraries included, exits 1 out of a guard whose test is only an availability check. The three exit codes separate what the operator has to do about a run:

  • 2 — the check could not run: a required tool is absent, or an input is missing, unreadable or malformed. Nothing was inspected, so there is no verdict about the repo.
  • 1 — the check ran and found a violation. The repo needs fixing.
  • 0 — clean.

Reporting an absent tool as 1 sends the operator hunting for drift in content the check never read, and a hook or job that reports both the same way makes a broken environment indistinguishable from a broken repo. Exit 2 still fails the hook and the job, so the verdict is unchanged and only the diagnosis improves.

A hit is a conditional whose test is purely an availability predicate and whose branch body exits 1 — if ! command -v X, if ! require_tool X, if [[ ! -f|-r|-e|-d|-s|-x PATH ]], or the brace-group form [[ -f PATH ]] || { … }.

Matching is branch-scoped rather than proximity-based. The lint walks the branch body from its opening keyword to the matching fi or closing brace, so an exit that merely sits a few lines below an availability test is not attributed to it. That distinction carries the rule: several checks here read a marker or a field out of a file that exists and report its absence as the finding — a doc that lost its managed BEGIN/END markers, a flake.nix missing its pinned hooks SHA URL, a cliff.toml missing tag_pattern. Those exits belong to the search that came back empty, not to the guard above them, and a line-window scan attributes exactly those to the wrong guard.

A test that mixes an availability predicate with another predicate under || — [[ ! -f ${out} ]] || ! cmp --silent -- "${out}" "${tmp}" — is not a hit. That branch is reachable without anything being absent, so absence there is half of a content verdict rather than a could-not-run. An && chain carrying an availability predicate is a hit, because the whole chain then requires the thing to be absent.

The escape hatch is # exit-code-exempt: <rationale> on the exit line, for a guard whose missing input genuinely is the finding. The marker has to open the comment — matching drops everything up to and including the line's first # and requires the remainder to begin with the marker word, so prose naming it exempts nothing — and the rationale has to be non-empty — a marker with an empty rationale is its own diagnostic rather than a silent pass. Any earlier # on the same line, whatever produced it — inside a string, a ${x#y} expansion, or anything else — is read as that first #, so a genuine trailing marker can be missed; the miss reports the site as a violation rather than silently excusing it, which is the direction this lint is safe to fail in. A clean run prints the exemption count, so the exempt set is stated rather than open-ended.

Whole-line comments are blanked before matching, so a script may document the banned shape by name without tripping the check on its own documentation.

Bare temp-file creation

The same lint bans a second shape, which lands on the wrong side of the same convention one level down: a bare mktemp anywhere under scripts/.

An unwritable or absent TMPDIR makes mktemp exit 1. Under set -Eeuo pipefail an unguarded tmp="$(mktemp)" propagates that status, so the script dies with 1 — the code reserved for "this repo carries a violation". A pre-commit hook or CI job reads that as a stale tree and sends the operator to edit content the check never read, when what actually happened is that the machine could not hand the script a scratch file.

Every creation therefore routes through make_temp (scripts/lib/temp.sh), which passes its arguments to mktemp verbatim, prints the created path, and on failure prints <script>: cannot create a temp file (TMPDIR=<dir>) and exits 2. Exiting from inside the command substitution propagates through the enclosing assignment, so a call site needs no guard of its own.

A hit is the command in command position — after line start, $(, a pipe, a ;, a !, a {, an &, or a then/do/else keyword, with an optional command prefix. A bare ( is deliberately not an introducer, so a parenthetical such as "atomic (mktemp + mv) pin write" is not a hit; neither is a whole-line comment naming the command, nor a trailing comment or string operand that names it outside a command position. One that puts it after an introducer (# see $(mktemp)) is a hit, since the matcher reads neither quotes nor trailing comments. The rule is switched off for scripts/lib/temp.sh alone, matched on the whole repo-relative path so that a second file of that name elsewhere under the scan root is an ordinary script and its bare call is a violation — that library holds the one sanctioned invocation, and every other rule in this lint still reads it. The clean summary line carries the count of sanctioned sites the run actually reached, so an exemption that stopped firing is visible rather than indistinguishable from a tree that holds no helper.

The escape hatch is the same # exit-code-exempt: <rationale> marker, on the creation line, for a call whose failure genuinely is the finding. The exit-code rules that take the marker (the exit 1 guard, the bare mktemp, the bare repository-root lookup, a repo_toplevel call in a declaration's arguments and the ${var:?} expansion below) share one marker implementation, so an empty rationale is a diagnostic on any of them rather than a silent pass, and each feeds the same exemption tally a clean run prints.

They also share one census of the marker itself, run after classification finishes with the file: every line whose comment opens with exit-code-exempt: is recorded as the file is read, marked consumed when a hit of one of those rules is actually routed through it, and any recorded line left unconsumed once the file is done is reported as its own finding and fails the run. A marker still reads as a decision someone made about the guard or the creation beneath it, and keeps asserting that decision after the site was rewritten into a compliant shape, moved, or left the file. Classification and the census share one predicate rather than two, so a marker an earlier # hides from classification is hidden from the census identically, and is never both missed as an exemption and reported as unconsumed. The clean summary line reports this count too, as a third field alongside scripts scanned and exemptions applied.

Enforced by scripts/check-guard-exit-code.sh. Wired as the lint-script-hygiene CI job (member check guard-exit-code).

Bare repository-root lookup

The same lint bans a third shape: a bare git rev-parse --show-toplevel anywhere under scripts/.

Run outside a git work tree (no repository, a bare repository, inside a .git directory, or a work tree git refuses to open), the lookup exits 128. Under set -Eeuo pipefail an unguarded root="$(git rev-parse --show-toplevel)" ends the script with that 128, a status no caller reads as either a finding or a could-not-run, while the script's header promises 2. A fallback such as || echo . is worse: the script reads the current directory as the repository and reports a missing input, or a clean scan of a tree it never found.

Every lookup therefore routes through repo_toplevel (scripts/lib/repo.sh), which prints the top level, or prints <script>: cannot resolve the git work tree (cwd: <dir>) under git's own diagnostic and exits 2; it also exits 2 when git is not on PATH. Like make_temp, it exits inside the command substitution, so the enclosing assignment carries the 2 and the call site needs no guard. A call inside the arguments of local, readonly, declare, export or typeset loses the 2, because the builtin returns its own status. For local or declare, declare the name first and assign on its own line; for readonly or export, assign first and then mark the name. The lint reports a call in a declaration's arguments, including inside a default expansion, which shellcheck does not flag; that arm reads neither quotes nor heredocs and does not see a call in backticks. The no-guard property holds for an assignment only: a call in : "${R:=$(repo_toplevel)}" or as a command argument (cd "$(repo_toplevel)") loses the 2 too, and nothing reports those shapes. check-doc-anchors.sh and check-ephemeral-refs.sh call the helper only when their root override is unset (${ROOT_OVERRIDE:-$(repo_toplevel)}), so with it set they run outside a work tree.

A hit is the command in the temp-file rule's command positions, with any words between git and rev-parse (so git -C <dir> counts, a $(…) in <dir> with no ;, | or & inside it included) and --show-toplevel later in the same command. Matching is per physical line: a lookup split by a backslash continuation is not read, and neither is one in a position the temp-file rule does not list (a subshell, env git, a path to git) or --show-cdup. The matcher reads neither quotes nor trailing comments, so a string operand or trailing comment holding $(git rev-parse --show-toplevel) is a hit; such a literal takes the # exit-code-exempt: <rationale> marker. The rule is switched off for scripts/lib/repo.sh alone, keyed on the whole repo-relative path as for temp.sh, and that file adds to the sanctioned-site count. Test harnesses under tests/ are out of scope: the harness preamble requires the bare lookup there.

Enforced by scripts/check-guard-exit-code.sh; tests/repo-root-guard.test.sh runs every script that calls the helper from outside a work tree and requires exit 2 with the helper's diagnostic.

Required-parameter expansions

No script takes a required value through a ${var:?message} expansion.

The shape is another route to the same wrong answer, and the least visible of them because it looks like a guard. There is no branch to assign a code to: the expansion fails with the shell default status of 1, so an operator who typed an incomplete command line is told the check ran and found a violation in the repo. The split is not a considered distinction — it is the shell default leaking through an expansion nobody chose for its exit code, and it is easy to write one beside an unrecognized-argument branch that exits 2 without noticing that the two operator mistakes now report different codes.

Replace it with an explicit test that logs and exits 2. The same # exit-code-exempt: <rationale> marker applies, for the case where an unset value there really is a programming error rather than an operator typo.

exit-contract-documented

Every script directly under scripts/ that can reach exit 2 says so in its header.

guard-exit-code settles which code a script reports; this rule settles whether a reader can find that out without reading the implementation. The header is the first thing consulted before running a probe by hand or wiring it into a new caller, and a header promising only 0 and 1 states that a could-not-run cannot happen. A caller written against that promise treats one as a finding and reports a violation nobody observed — the same collapse of 2 into 1 that guard-exit-code bans in code, arriving instead through the documentation.

A script can reach exit 2 two ways, and both count: a literal exit 2 or return 2 on a line that is not a comment, or a direct call to a library helper that exits 2 in the caller's shell — require_tool, enumerate_into, glob_into, filter_into, require_json_payload, payload_source_into, read_json_payload_into, make_temp, repo_toplevel. Detection is textual and direct-call-only: a helper reached through another helper is already covered by that helper's own call site, and chasing the source graph would report a script for code it never runs.

The header is every line above the first that is neither blank nor a comment, and it is unwrapped before matching. These contracts routinely wrap mid-sentence — one enumerates its exit-1 causes at length before reaching its exit-2 clause — and a line-oriented match cannot see a clause split across two lines.

Four shapes count as documenting exit 2, which is every shape the tree uses:

  • a dedicated sentence — Exits 2 when …, or prose naming exit 2
  • a comma-separated list — Exits 0 on full coverage, 1 on any drift, 2 on tooling error.
  • the same list in any order — Exit: 0 mapped, 3 no input maps to this title, 2 usage error.
  • an enumerated block — an Exit codes: heading over a 2 item line

The list forms match within one sentence only, so a 2 in unrelated prose later in the header excuses nothing. The item forms require the 2 to stand alone as a token rather than open a word: 2FA and v2 are prose, not exit codes, and one of them appears in a header this rule covers.

Scope is scripts/*.sh. Libraries under scripts/lib/ exit in their caller's shell and carry no standalone contract of their own — documenting that exit is the obligation of the callers this rule reads, which is why this scan does not recurse where guard-exit-code's does.

There is no exemption marker. Every script can describe its own exit codes, so a hit is always fixed by writing the sentence rather than by excusing the script.

Enforced by scripts/check-exit-contract-documented.sh. Wired as the lint-script-hygiene CI job (member check exit-contract-documented).

tool-guarded

Every third-party tool a script under scripts/ invokes is guarded somewhere in that same script. Scope is the top-level scripts/*.sh only — scripts/lib/ is out of scope, unlike the recursive sweeps of no-opaque-procsub and guard-exit-code.

guard-exit-code settles which code a guard reports and exit-contract-documented settles whether the header admits it. This rule settles the case neither reaches: a tool with no guard at all. There is no branch to assign a code to, so the absent tool becomes whatever the surrounding code does with a non-zero status, and each of those readings is a different wrong answer.

A shape probe written as <tool> ... || die reports the payload as malformed, and the operator opens a file that is intact looking for a field that is present. A guard that treats success as the violation — if <tool> ...; then report — scores every input clean, because an absent tool cannot succeed; that run exits 0 having examined nothing, which is the only failure here that no caller can see. An enumeration ending in || true comes back empty, and a lint asserting over an empty set asserts nothing. An unchecked command substitution ends the run under the tool's own status, 127 for an absent one, which the convention does not catalogue.

A guard is require_tool <tool>, a command -v <tool> availability test, or a library helper that guards the tool for its caller. require_json_payload is the only such helper: it calls require_tool jq before its first read, so demanding a second guard at each call site would make the shared helper's own guarantee look untrusted.

Scope is tools a shell can genuinely lack — actionlint, check-jsonschema, cosign, curl, docker, gh, jq, just, nix, shellcheck, shfmt, treefmt, yq. POSIX utilities and coreutils staples are assumed present: guarding grep in every script that greps would cost a hundred lines describing an environment that does not occur, and a rule nobody believes is a rule that gets exempted.

Detection reads command words from the shfmt parse tree rather than matching text. A tool name appears in comments, in message strings, and in @description prose, and none of those is an invocation; a || inside a sed or awk program is program text that a line-oriented scan reads as shell control flow. The parser knows which words are commands.

The rule is presence, not position. A parse tree reports where a word was written, which in shell is not when it runs — nearly every script here defines its functions above the main that calls them, so a tool invoked inside a function routinely executes after a guard written hundreds of lines below it. Ordering those correctly needs a call graph; comparing line numbers instead reports the tree's ordinary layout as a defect.

There is no exemption marker. A hit is fixed by adding the guard.

Enforced by scripts/check-tool-guarded.sh. Wired as the lint-script-hygiene CI job (member check tool-guarded).

regex-range-ascii

A bash [[ … =~ … ]] regex, a GNU grep pattern or a GNU sed script, in a script under scripts/ (its libraries included) or a harness directly under tests/ holds no range between ASCII letters or digits unless the test or call runs under a C locale. For grep and sed a positive bracket of digit ranges only ([0-9]) is not reported, since GNU grep and sed keep it ASCII in every locale. The rule and how to meet it are in linting.md, Locale-bound ranges in bash regexes, grep and sed: a range follows the locale's collation, so under en_US.UTF-8 a validator accepts characters the CI runner's C.UTF-8 refuses, and CI cannot see it.

What counts as a C locale, how a regex held in a variable is read, and what the lint does not read are stated in the header of scripts/check-regex-range-ascii.sh.

A =~ hit is fixed by spelling the class out or matching through ascii_match; a grep or sed hit by running the call under LC_ALL=C. A grep or sed call that is locale-bound on purpose carries the reasoned marker, which the script header describes.

Enforced by scripts/check-regex-range-ascii.sh. Wired as the lint-script-hygiene CI job (member check regex-range-ascii).

path-hygiene

No path git reports for the tree — tracked, or untracked and not ignored — may carry a byte in the 0x01-0x1F control range or DEL (0x7F).

A path is the one shell datum whose byte space includes the newline delimiter. A filename carrying one reads as two records to any line-oriented consumer: git ls-files (without -z) C-quotes it onto a single output line, find (without -print0) splits it across two, and either way a downstream [[ -f ]] gate can skip a file that exists while the scan that produced the list still reports a plausible count. scripts/lib/enumerate.sh closes this for every converted producer/consumer pair by reading NUL-delimited output; this lint closes it for every consumer the conversion cannot reach, by refusing to let a name in that class exist in the tree at all.

Tab (0x09) is in scope for the same reason, one level down: several of this repo's own inventories — the harness discrimination census, the enforcement-matrix table — are tab-separated, so a tab inside a path corrupts those exactly as a newline corrupts a line-oriented handoff. Both bytes fall inside the 0x01-0x1F range the lint bans, so no special-casing is needed to cover either one.

The scan covers git ls-files --cached --others --exclude-standard: tracked paths plus untracked-but-not-ignored ones. The untracked half matters for the reason check-ephemeral-refs.sh scans the same way — a file a commit is about to introduce is gated by the same run that introduces it, not by whichever run happens to follow the commit that adds it.

A hit is reported with the offending path rendered through ${path@Q}: the path is otherwise attacker-controlled text passed straight to this lint's own diagnostic output, and @Q renders every control byte in it as a $'...' escape sequence rather than printing it raw, so a crafted name cannot forge a fake report line into the lint's own output.

Enforced by scripts/check-path-hygiene.sh. Wired as the lint-script-hygiene CI job (member check path-hygiene).

awk-operand-explicit

Every awk invocation in a script under scripts/ — the scripts/*.sh git pathspec crosses /, so scripts/lib/ is in scope — that carries a file operand spells that operand "$(awk_path "${var}")".

awk reads a command-line operand shaped name=value as a variable assignment rather than a filename. Given one, it finds no file operand, reads stdin instead, and exits 0 having scanned nothing — so a relative path whose first path component contains = (e.g. a=b/file.sh) silently scores as an empty file rather than failing loud. -- is no defense: POSIX makes operand-assignment parsing independent of it, and gawk treats a -- placed after the program as a filename rather than an end-of-options marker. scripts/lib/awk-path.sh's awk_path() neutralizes the hazard by prefixing a relative path with ./ (conditionally — an absolute path is returned unchanged, since a ./ ahead of one resolves as a different, relative path); this lint is what keeps every future awk call wrapped, rather than trusting a one-time sweep of the existing call sites to hold forever.

Detection parses each script's shell syntax tree via shfmt --to-json — the same mvdan.cc/sh parser shfmt and treefmt already run over this repo — rather than matching text, because a textual rule drifts the moment a script's formatting moves an operand onto another line, inside a multi-line awk program, or beside a sibling operand. A call is recognized as an awk invocation whether it spells the command word awk, gawk, mawk, or nawk; via a path ending in one of those basenames (/usr/bin/awk); via a command-prefixed form (command awk); or via a statically-quoted literal ("awk") — not only the bare awk literal.

For every such call, the argument list is walked following awk's own flags-then-program-then-operands grammar: -v, -F, -f/--file, --field-separator, --assign, --source, and their attached forms (-F'\t', --field-separator=x, -fprog.awk, --file=prog.awk) are all recognized as flags while the program is still pending, and -f/--file, --field-separator, --assign, and --source stay flags even after a program has been supplied, so a legally-repeated -f a.awk -f b.awk is not misread as carrying operands on -f or b.awk. -f, --file, and --source each supply program text (a repeated one concatenates, which is legal), so any of them marks the program as already established; --assign does not, since it never supplies one. Only the first non-flag word establishes the inline program, and only when none of -f/--file/--source already has; every non-flag word after that is an operand. --, -v, and -F are flag-shaped, but their meaning depends on what came before them: while the program is still pending, -- ends option parsing as usual and the word right after it becomes the program, and -v/-F take their values; once the program is already established, gawk (measured directly) instead reads a further --, -v, or -F as an ordinary filename, so the walk folds any of the three back into "this is an operand" rather than treating it as a flag. -f/--file's own value and the program text itself are excluded from the operand list, since neither was ever a file operand. Every operand that survives must be exactly one double-quoted word wrapping a single command substitution that calls awk_path; anything else is a violation.

Not operands, and therefore never reach the walk: stdin redirections (awk 'prog' <"${f}") and here-strings (awk 'prog' <<<"${v}") — a redirection attaches to the enclosing statement, not the command's own argument list, so a call fed one has zero operand arguments to inspect — and pipeline input (producer | awk 'prog'), for the same reason.

The total operand count checked across a run is itself asserted nonzero, unless LINT_ALLOW_EMPTY_SCAN=1 — the same variable check-doc-anchors.sh already uses to gate its own "0 anchor links found" case, reused here rather than a dedicated flag because the shape is identical: enumeration succeeded, but the count of the thing being verified came back zero, and that has to be stated as deliberate rather than inferred. Without this assertion, a classifier regression that silently drops every operand from its accounting would still print a clean "0 violations" summary and exit 0 — the same failure mode scripts/lib/enumerate.sh exists to close at the file-enumeration level, one level further in, at the level of what each enumerated file was found to contain.

Enforced by scripts/check-awk-operand-explicit.sh. Wired as the lint-script-hygiene CI job (member check awk-operand-explicit).

enumerate-helper-required

Every filesystem enumeration in a repo script or test harness runs through enumerate_into (scripts/lib/enumerate.sh). A producer — find, git ls-files, git ls-tree — may appear in exactly three positions: as an argument to the helper, inside a function the helper is handed by name, or behind a # enumerate-exempt: <rationale> marker — on the site's own line or anywhere in the contiguous comment block directly above it — whose rationale is non-empty (an empty one is drift, not an exemption, exactly as the sibling exit-code and patch-tag markers treat it).

A producer word copied to a variable is banned at the assignment itself, a fourth failure mode alongside those three positions: the copy leaves the use site's command word a value, and whether that value is ever invoked as a command is not decidable in one pass, so the copy is flagged regardless of whether or how many times the copied name is later read. The predicate is the command basename find or git rather than the producer set: a bare git counts with no subcommand present, since the subcommand is written at the use site the copy has made unreadable. Both a scalar assignment (finder=find) and an array assignment (lister=(git ls-files -z)) are covered, but the array form is read only at its element 0: a command array is invoked as "${arr[@]}", so its command head is always the first element, and a producer word sitting at any other index is data the same way the label text an enumerate_into call passes as its own argument is data. Stated as the boundary it is: a producer word planted at a non-head index and later spliced into command position by index arithmetic still evades, and is outside what this pass decides.

Every glob-driven scan fills its array through glob_into, the same library's glob analogue. The rule classifies four positions in the tree. One is compliant and counted rather than banned — a glob_into call itself, so the classified-site tally below covers this rule even in a tree that has converted every scan. The other three are violations: a for loop expanding a pattern among its own iteration words, an array assignment expanding one in its element list, and an unquoted read, at either of those two places, of a variable this same file assigns a glob metacharacter. Each of the three takes a # glob-exempt: <rationale> marker (same placement as enumerate-exempt: the site's line or the comment block above it) saying an empty match set is that site's normal state; the rationale is held to the same non-empty rule. Those are the positions a single pass over the syntax tree decides, and the rule is stated no wider than that: the assignment a laundered read is judged against has to sit in the same file, the value read is never traced back to the assignment that produced it — a name given a pattern on one branch and a data list on another is reported — and a pattern that reaches a scan through a command substitution or an indirect expansion is not read at all.

All three rules — producer, glob and filter — are held over one scan set: scripts/*.sh plus tests/*.test.sh, less tests/fixtures/. Each pathspec crosses /, so scripts/lib/ and a harness at any depth are both covered. Harnesses are in scope because they scan the tree the same way the scripts they drive do, and a harness whose own enumeration comes back empty asserts nothing about the script under test while still exiting 0 — the failure this rule exists to end, sitting one level up from where it is usually looked for. tests/fixtures/ is held out because that tree holds files written to be violations: the lint's own harness feeds them in through PATHS_OVERRIDE and asserts the diagnostics they produce, so gating the fixture tree would make the lint flag its own test inputs, and marking them exempt would destroy what they test. The hold-out is a pathspec exclusion rather than a name filter because fixture files are named after the harness shapes they imitate, .test.sh included.

The property being protected is scan breadth, not producer status. A producer that fails is the easy half; the hard half is a producer that succeeds and enumerates nothing. GIT_INDEX_FILE=/nonexistent git ls-files exits 0 and prints not one path — which every status check reads as a clean tree. A lint handed an empty scan set finds no violations and exits 0: off, and green. So breadth has to be asserted rather than inferred, and the helper is where that assertion lives; routing every enumeration through it makes the assertion structural instead of something each call site has to remember.

The glob scan fails the same way from the other end. Under nullglob a pattern that matches nothing expands to nothing: the loop body never runs, the array comes back empty, no violation is found and the run exits 0 — so a scan root that exists and holds nothing scores as a clean tree, and the operator override that points the scan somewhere useless reads exactly like a repo with nothing to fix. glob_into carries that assertion: it expands the patterns itself, fills the named array, and refuses an empty match set unless LINT_ALLOW_EMPTY_SCAN=1. Whatever reads the array afterwards walks an ordinary list.

That is also what makes all three rules decidable in one pass. Associating a scan with a cardinality test written an arbitrary distance later is not something a textual rule can do. Asking whether a producer is an argument to the helper is local to a single call expression, so is asking whether a for loop or an array assignment expands a pattern in its own words, and so is asking whether a filter-named read falls inside a loop's own extent or outside every filter_into call in the file. Asking whether the name a for loop or an array assignment reads is a name this file gives a pattern reaches past the single expression, but no further than the same tree: the assignments are collected in the same walk, which is what keeps the laundered read a file question rather than a dataflow one. The glob rule rests on one further structural property: a pattern handed to glob_into is an argument of a CallExpr — never a WordIter item of a ForClause, never an element of an ArrayExpr — and it arrives quoted, so a compliant call site cannot false-hit however many metacharacters its arguments carry, and a pattern-bearing variable handed to the helper arrives quoted for the same reason.

Detection parses each script's syntax tree via shfmt --to-json rather than matching text, because three shapes name these commands without running them and a textual rule would need a special case for each: the lint's own prose, the label string every compliant call site passes (enumerate_into paths 'git ls-files' git ls-files -z …), and heredocs documenting the idiom. None is a command node, so none is a hit. A git invocation's subcommand is found by walking past the global flags (-C <dir>, -c <k>=<v>), not by reading the word straight after git.

The glob rule reads the same tree. A ForClause whose WordIter items carry *, ? or [ in a bare Lit part is a violation, and so is an assignment whose Array elements carry one; an assignment node has no type tag of its own, so it is recognized by the array it holds, and a scalar assignment holds none. Both are read one level in as well as at the word itself: a metacharacter in the Lit parts of an expansion's Exp.Word — the alternate word of ${y+*.yml}, the default word of ${y:-*.yml} — is written literally at that site and expands there exactly as a bare one does. The laundered read is a third shape: a ParamExp that is a direct part of a WordIter item or of an array element, whose Param.Value names a variable this same file assigns a metacharacter in static text. Static text means a word's bare Lit values, its SglQuoted values, and the Lit values inside a direct DblQuoted, read from a scalar Value and from an Array's elements alike — so pats=('*.sh'), a quoted metacharacter the array position does not classify, is a source like any other. That walk stops at a ParamExp rather than recursing into one: the pattern in ${text%%[![:space:]]*} is a parameter-expansion pattern, not a filesystem glob, and counting it would make every line of string surgery in the tree a glob source.

Three arms keep the read off shapes where the breadth question is already settled or where no pattern can expand. A name is excluded when every glob-bearing assignment to it is itself a classified array assignment: that site expands the pattern and either asserts its breadth or carries the marker saying an empty match set is normal there, so reporting the loop that walks the resulting paths as well would demand a second marker on a compliant loop and cost the exemption count its meaning. scripts/refresh-test-harnesses.sh writes that shape: fixture_dirs=("${fixtures_root}"/*/) is a classified array assignment carrying the marker, and the loop below it walks the paths that assignment filled. A name with one classified assignment and one unclassified one stays in the set; the test is that no unclassified glob-bearing assignment exists, not that some classified one does. A read under the + or :+ operator is skipped, because neither operator ever emits the parameter's own value: ${pats+"${pats[@]}"}, this repo's idiom for reading a possibly-empty array, emits only the quoted alternate word, so no pattern the variable holds can expand there, and whatever that alternate word writes literally is already read by the two positions above. And a site the loop or array position has classified is not reported twice: for f in ${pat-*.yml} writes a literal pattern in its default word beside a name that holds one, and one scan whose breadth goes unasserted earns one record, one diagnostic and one exemption — the relation between a site and a record that every other position here keeps.

Only a bare literal counts at the two literal positions, and that restriction is what the whole rule rests on. A metacharacter inside quotes is not a pattern: for x in "*" iterates one literal asterisk and x=("*") assigns one, neither asserting anything about any tree. More to the point, a pattern travels to glob_into as a quoted string — patterns+=("${d}/*.yml") — to be expanded inside the helper where its match set is asserted, so counting a quoted metacharacter would make every compliant call site a violation of the rule it satisfies. A C-style for ((;;)) has no items at all and never reaches the test.

The same distinction carries the laundered read at no extra cost, one level up: a ParamExp inside a DblQuoted is a part of the quoted string rather than of the word, so for f in "${pat}" iterates one literal string and stays silent where for f in ${pat} does not. At an assignment, by contrast, the quoting decides nothing — pat='*.sh' and pat=*.sh leave the same text in the variable — which is why the source walk reads quoted text where the read walk does not, and why this is the one position whose verdict lands at the read. The two aliasing rules fire at the copy because the value they follow is identified by name or by literal wherever it lands; a metacharacter is identified by whatever expands it, and the same assignment is legal, and written at call sites throughout this repo, when the value travels quoted to glob_into. Flagging the assignment would make the repo a violation of the rule it satisfies.

Each rule owns its own marker word, keyed off the kind of site the record names. A # enumerate-exempt: rationale does not excuse a glob scan and a # glob-exempt: rationale does not excuse a producer, because a reason for running a producer outside the enumeration helper is not a statement about whether a match set may come back empty. One shared word would opt a site out of both rules on the strength of whichever one its author happened to think about. Every glob shape is one rule and therefore shares one word, and the lookup is by rule rather than a test against a single shape, so a shape the glob rule grows cannot quietly start answering to the producer marker. A copied producer name answers to the enumerate marker and a copied filter value to the filter marker — each aliasing site takes the word of the rule whose name pattern it violates, not a marker of its own.

The count of scan sites classified — producer calls, glob_into call sites, glob loops, glob array assignments, unquoted reads of a pattern-bearing name at either, filter_into call sites, filter reads inside a loop, filter reads inside a hop-reached function, the single filter read reported in a file that calls the helper nowhere, a filter value copied to another name, and a producer name copied to a variable — is itself asserted nonzero unless LINT_ALLOW_EMPTY_SCAN=1. A grammar that stopped recognizing every subject would report "0 violations" and exit 0 — the same line a genuinely scan-free tree prints — leaving this gate off while green, which is the exact failure it exists to prevent one level down. The tally is a single aggregate across all rules, so it cannot catch one rule's grammar dying while the others still classify sites; zeroing out the filter clauses alone still leaves a live tree's producer and glob counts nonzero and the run green. Counting the sanctioned glob_into calls, not only the loops that violate the rule, is what makes the tally cover the glob rule at all: a tree that had converted every site would otherwise contribute nothing to it.

A filter-driven scan fails a third way, one layer past enumeration and globbing: it narrows a set the two rules above already proved non-empty, and nothing enumerating or globbing that set can see the narrowing throw all of it away again. filter_into (scripts/lib/enumerate.sh) is where that narrowing's own size is asserted. A variable named FILTER or ending _FILTER — this repo spells every fixture-driving filter that way — may be read at file scope to reach that call: readonly FILE_FILTER="${WORKFLOW_FILE_FILTER:-}" followed by filter_into selected_files 'workflow YAML' "${FILE_FILTER}" "${workflow_files[@]}" is the shape every converted script uses.

What is banned is the same read happening again somewhere a filter_into call cannot cover it. A for or while loop that reads the raw filter variable again, instead of trusting the selection filter_into already handed back, re-applies the filter test outside the helper. Whether that second application runs over the narrowed selection — where it is merely redundant, since every path there already matched — or over a set the helper never narrowed is not decidable at the read site, and the second is the empty-root failure filter_into exists to catch: a filter matching nothing selects no path, the loop body never fires, and the run exits 0. The loop's reach follows one function hop: a read inside a function whose name appears as a literal command word within the loop's own extent is treated the same as a read written inline, because the loop consumes it exactly as it would consume a read written directly in its body — a read already inside the loop's own extent is reported as a loop read, not a function read, regardless of whether that same function is also called elsewhere. The hop stops at one level, stated as the boundary rather than implied as total coverage: a function called only by another function the loop calls still sits outside every extent and still evades. A file that reads a filter variable and never calls filter_into at all is the same hole with no call site to point to — nothing anywhere in that file asserts the selection the read implies is non-empty. The loop rule covers a read among a for's own iteration words, a while's own input — its redirect, its herestring, or an upstream pipeline stage feeding it — and one in either loop's body alike: all discard just as much of the narrowed set.

A read outside every loop and every hop-reached function is legal without a marker — the rule tests position, not purpose, not whether the read happens to guard any particular kind of assertion. The two live examples: scripts/check-egress-allowlist.sh reads FILE_FILTER after its own scan loop has finished, to recognize that a fixture harness scanning one workflow at a time is the one legitimate way its notify-job and nix-host-job tallies come back zero — a job-count assertion. scripts/check-permission-scopes.sh reads it to skip a reverse allowlist-staleness pass, recognizing that a run filtered to a single fixture names other fixtures in the allowlist and so cannot judge it stale. Neither read re-derives a selection a loop body is about to trust, so neither carries the cardinality risk the banned shapes do.

The filter rule fails a fourth way, sideways rather than past any of the first three: copying the filter value into a variable whose own name does not match the filter pattern moves every later read of that copy outside all three checks above at once, because each one keys on the name of the variable being read rather than tracing where its value came from. The copy is flagged at the assignment regardless of where or how many times the copied name is later read, which is what keeps the rule decidable in one pass rather than requiring the kind of dataflow tracing the other checks were built to avoid. The sanctioned readonly FILE_FILTER="${WORKFLOW_FILE_FILTER:-}" stays legal under the alias check for the same reason it satisfies the read rule: its own target is itself a filter name, so a compliant call site cannot false-hit either rule it satisfies.

Any of the four banned shapes — the in-loop re-read, the one-hop function read, the missing filter_into call entirely, or the aliasing assignment — takes a # filter-exempt: <rationale> marker (same placement as its siblings), held to the same non-empty-rationale rule as its siblings; the marker word is keyed to this shape, so neither # enumerate-exempt: nor # glob-exempt: excuses it.

Every marker among the three — # enumerate-exempt:, # glob-exempt:, # filter-exempt: — has to open its comment: the comment's text is read from the syntax tree, not the raw line, and the marker word must be the first thing in that text. A sentence that merely names a marker — this lint's own header, or a doc paragraph describing the escape hatch — is prose about the rule rather than a use of it, and excuses nothing. Reading from the tree also makes the anchor immune to a # that appears earlier on the line inside a string or a ${x#y} expansion, unlike guard-exit-code's matcher, which anchors to a line's first # and so can be misled by one earlier on the line.

A site a marker was written for can be rewritten into a compliant shape, moved, or dropped from the file entirely while the comment above it stays behind, so all three marker words are censused independently of whichever rule's sites a file happens to hold today. A # glob-exempt: comment sitting where the glob rule finds nothing to excuse is unconsumed exactly like an # enumerate-exempt: comment would be, because a marker protecting nothing keeps asserting the decision it was written for regardless of which of the three words it is spelled with. A marker classification never consumed while walking a file is reported on its own line, and the clean summary line carries the count as a fourth field alongside files scanned, sites classified and exemptions applied.

Two branches of the scan are decided by an operator the parser names rather than by a node type: BinaryCmd.Op, which tells a | or |& stage feeding a loop — that stage is part of what the loop consumes, so it extends the loop's range — from a && or || guard, which is not; and ParamExp.Exp.Op, which is what makes the read under + or :+ skippable. Both are serialized in either of two encodings depending on the parser, an opaque integer or the operator token itself, and a select handed a third matches nothing rather than failing — the branch stops detecting while the run keeps exiting 0, which is this rule's own empty-scan failure reappearing one level down, inside the lint that exists to end it. So the encodings are established as a precondition instead of assumed: a fixed probe exercising all eight operators is parsed through the same invocation the scan uses, before any file is read, and each construct is asserted twice — that it matches the branch meant to select it, and that it is absent from the branch meant to leave it alone. That second half is what an enumeration of known values would miss, since an encoding spelling && the way this lint spells | reads as the opposite of a silent no-op and is just as wrong. An unrecognized value exits 2 — the code reserved for a precondition the lint could not establish, as distinct from the exit 1 a violation in a scanned file earns — naming the field and the value seen. SHFMT_OVERRIDE points the parse at a stub emitting a doctored tree, which is what lets the harness put that shape under assertion at all: no parser on PATH can be made to produce it.

Enforced by scripts/check-enumerate-helper-required.sh. Wired as the lint-script-hygiene CI job (member check enumerate-helper-required).

payload-shape-scenario

Every script matching a four-arm external-payload predicate — a literal gh api call, a *_JSON_OVERRIDE-shaped variable, a bare stdin slurp (="$(cat)"), or a scoped flake.lock/lock-content read (cat -- ... of a flake.lock or LOCK path, git show ...:flake.lock) — carries a harness scenario that feeds it a malformed payload and asserts exit 2, or a # payload-subject-exempt: <rationale> marker.

A shape gate (require_json_payload, or an equivalent hand-rolled die_op guard) that regresses or was never written is invisible to every other lint in this repo, because none of them runs the scripts under test — only a scenario that actually drives a malformed payload through the gate and checks the exit code proves the gate still fires. The lint therefore gates the scenario's existence, not the gate's source text: it never greps a script for require_json_payload, since a script that forgot the gate would then be undiscoverable by construction.

All four predicate arms are needed. A gh api / *_JSON_OVERRIDE predicate alone misses a script whose payload arrives on stdin with no override variable at all, and a lock-content read scoped only to the read call — not to any mention of the filename — avoids flagging a script that merely names flake.lock in a comment or a watch list. The predicate is textual and declares that plainly: a gh api call or an override variable name built by string interpolation is invisible to it.

The scenario match is not a textual 2 substring search: it is anchored to the scenario call's own bare positional exit-code argument, parsed via shfmt --to-json the same way enumerate-helper-required parses call sites above. Only a 2 that stands alone as one whole, unquoted argument word of some function call counts, so a message string that merely contains the digit 2 — quoted prose such as 'malformed payload: 2 offending fields, exit clean' — is never mistaken for the exit-code argument. Once a genuine bare-2 argument is found, its own line, continuation lines, and the contiguous comment block above it are checked for one of a small vocabulary of malformed-payload words (malformed, garbage, unparsable, payload).

A subject for which a malformed payload is not a could-not-run — a script whose verdict is the payload's validity, so a malformed payload produces a correct exit 1 rather than a could-not-run; a meta-lint that only mentions gh api in the text it scans; a generator whose documented output contract already records an unreachable or malformed API response as a data row rather than a run failure — carries an inline # payload-subject-exempt: <rationale> marker, matching this repo's enumerate-exempt / glob-exempt / exit-code-exempt / reason-ladder-exempt convention: the rationale records what was measured, not what was assumed. A marker on a script the predicate does not match is reported as drift, the same way a stale entry in any of this repo's other exemption lists would be.

Enforced by scripts/check-payload-shape-scenario.sh. Wired as the lint-script-hygiene CI job (member check payload-shape-scenario).

payload-source-helper

Every payload source name is filled by payload_source_into (scripts/lib/payload.sh). No assignment in the scanned shell files sets a variable to a bare *_OVERRIDE variable name, whether written plainly (src='X_JSON_OVERRIDE') or declared (local, readonly, declare, export), unless the file carries an inline # payload-source-exempt: <rationale> marker.

The helper owns one rule: a source is named by kind — the override variable's name when a fixture supplies the payload, the API route or the config's repo-relative filename otherwise — and never by resolved path. The shape gate that consumes the name reads it verbatim into every diagnostic, so a hand-written copy of that rule is a second resolver sitting beside the reader it feeds. When the two disagree about whether the override is set, the diagnostic names a source the run never used, and an operator debugging a malformed payload is sent after the wrong input. Two spellings of the copy are enough for them to drift apart from each other while both keep passing every exit-code assertion, because nothing in a scenario's verdict reads the source name.

The helper fills a caller variable rather than printing its answer, and the rule requires the filling form specifically. A source namer read as $(...) in argument position discards its own status: the guard inside would print its complaint, the shape gate would receive an empty source name, and the run would end 0. Filling a named variable keeps the namer in the caller's shell, where a failed resolution can exit the script that has the problem. enumerate_into binds its output the same way, so the two helpers read alike at their call sites.

Detection parses each file's syntax tree via shfmt --to-json rather than matching text. The banned shape is itself a text-shaped shortcut, and a gate whose purpose is rejecting one must not accept one: a matcher keyed on =('|")?[A-Z_]*_OVERRIDE reads an override name out of prose, out of a heredoc, and out of the lint's own diagnostic strings, while still missing the same assignment written unquoted. The parser answers the only question that decides the rule — is this a named assignment whose entire value is one literal word spelling an override variable's name — and answers it identically for the three spellings it distinguishes: a single-quoted word, a bare literal word, and a double-quoted word wrapping one literal part.

Both CallExpr assignments and DeclClause arguments are read, because the parser files a bare assignment and a declared one under different node types. A scan of bare assignments alone would leave local src='X_JSON_OVERRIDE' unreachable — the shape any source named inside a function body would be written in, and therefore the one most likely to be written next.

The scan set is scripts/*.sh, scripts/lib/*.sh and tests/*.sh. The library arm is not optional: the helper itself lives in scripts/lib/, its neighbors there are the files most likely to copy it, and the older shell-hygiene lints in this repo stop at the top level of scripts/.

The exemption marker excuses a file that spells an override variable's name for some reason other than naming a payload source — an operator message telling a reader which variable to set, say. It follows this repo's payload-subject-exempt / enumerate-exempt / glob-exempt convention: the rationale lives beside the code it excuses rather than in a hand-maintained doc table that drifts silently, and a marker whose rationale is empty excuses nothing, since an exemption nobody has to justify is a way to switch the rule off in place. A marker on a file holding no assignment the rule matches is itself reported, ahead of any violation, so a stale marker surfaces on a tree that is otherwise obeying the rule everywhere.

The clean verdict reports files scanned and assignments examined rather than a bare "ok", and the run exits 2 when it examined none, unless LINT_ALLOW_EMPTY_SCAN=1 says the scan root deliberately holds no assignment. A detector that stopped reaching assignments and a tree with nothing to report otherwise emit the same exit code — the same off-and-green failure the enumeration helper exists to close one level up.

Enforced by scripts/check-payload-source-helper.sh. Wired as the lint-script-hygiene CI job (member check payload-source-helper).

payload-source-helper: the read rule

The same file also enforces a second, narrower rule: every cat -- <path> command under scripts/*.sh — never scripts/lib/*.sh or tests/*.sh, since neither is a caller deciding how to read its own payload — is a violation unless the path traces back to a make_temp or mktemp result created earlier in the same file, or the file carries an inline # payload-read-exempt: <rationale> marker. Whether the command's output is captured is not part of the predicate: a cat -- whose bytes go straight to stdout skips the same guards a captured one does, and it is the shape a fetch helper writes when its caller does the capturing.

read_json_payload_into (scripts/lib/payload.sh) turns a file path into a shape-checked payload while reporting an absent, unreadable, or non-regular-file path as a could-not-run rather than a finding. A cat -- command skips every one of those guards, reproducing the helper's job with none of its could-not-run handling — the read-side half of the cost the source-naming rule above exists to stop on the naming side. The rule does not require proving that the bytes later reach require_json_payload: a fetch wrapped in its own function shares no variable name with the gate it feeds, so a dataflow trace between the two would miss exactly the read most likely to be written next.

The rule keys on the cat -- command itself, which is also the boundary of what it reaches: a payload read spelled $(<file), mapfile, a while read redirection, or a file operand handed to jq/yq is invisible to it. Several scripts carry such reads of override-able payloads — for example scripts/check-egress-allowlist.sh, scripts/refresh-enforcement-matrix.sh (a while read redirection and a yq file operand) and scripts/check-permission-scopes.sh — none of them a JSON payload the helper could read for them; nothing enforces that list, so treat it as illustrative.

A read whose path traces to a temp this same script created is exempt automatically — tearing down a scratch file the script owns is not the shape this rule polices. The path operand is located as the word following -- rather than at a fixed argument position, so an option ahead of the separator neither misnames the path in a report nor loses that temp exemption. The # payload-read-exempt: <rationale> marker excuses the rest, matching the source-naming marker in every other respect: a marker whose rationale is empty excuses nothing, and a marker on a file holding no matching command is itself reported, ahead of any violation.

The clean verdict extends the same tally with a read count — files scanned, assignments examined, violations, exemptions applied, and reads examined — and the run exits 2 when it examined no reads, unless LINT_ALLOW_EMPTY_SCAN=1 says the scan root deliberately holds none. The two breadth floors are checked independently: an assignment count of zero and a read count of zero are different scans stopping short, since the two rules walk disjoint syntax-tree shapes.

script-has-test

Every scripts/check-*.sh has a matching tests/check-*.test.sh, and every tests/check-*.test.sh has a matching scripts/check-*.sh.

The check-lint family is held together by naming convention: each lint script ships next to a fixture-driven test harness that validates the script's spec. Without enforcement, a new lint can land without tests and silently rot. The bidirectional pairing forecloses that.

check-jsonschema is exempt: it's a thin wrapper around the upstream check-jsonschema tool plus a schema bundle, so there's no spec-driven behavior worth unit-testing. New exemptions require updating the EXEMPT list in the script and justifying the entry in its comment.

Enforced by scripts/check-script-has-test.sh. Wired as the lint-script-hygiene CI job (member check script-has-test) and as a pre-commit hook.

test-runner reachability

Every test harness is executed by at least one runner — both the tests/*.test.sh set and the harnesses tracked under .claude/ — so the coverage each represents is real rather than latent.

check-script-has-test guarantees a test file exists for each script; it does not guarantee the test ever runs. A harness wired into no runner is a coverage no-op — a regression it would catch merges green while the pairing guard stays satisfied. Two scan sets are held to this: tests/*.test.sh, and the harnesses tracked under .claude/. The second is enumerated with git ls-files rather than a glob, because .claude/ is not gitignored and is overwhelmingly untracked, so a glob would report local-only harnesses as violations; only the committed set is a CI concern. A tests/ harness is keyed by basename and a tracked .claude/ one by its repo-root-relative path — exactly how each is spelled in the HARNESSES array. Every harness must be reachable via one of four runners: the HARNESSES array in scripts/run-harness-group.sh (the harness-group job), the tests/refresh-*.test.sh glob in scripts/run-doc-freshness.sh (the doc-freshness job), a .github/lint-groups.yml member resolving to tests/check-<name>.test.sh (run by scripts/run-lint-group.sh), or a direct tests/<x>.test.sh invocation in a .github/workflows/*.yml.

The EXEMPT list in the script is empty: every harness must be wired to a runner. An entry is named as its scan set keys it — a tests/ harness by basename, a tracked .claude/ one by its repo-root-relative path. A genuinely manual-only harness would be listed there with a rationale.

Enforced by scripts/check-test-reachable.sh. Wired as the lint-script-hygiene CI job (member check test-reachable).

harness assertion discrimination

Every harness scenario asserts a substring that appears in its own recorded output and in no sibling scenario's output (a sibling asserting the same substring, one whose output is identical after clock normalization, or one that harness_assert_exempt names for that substring is skipped), and every harness asserting against captured scenario output is wired to the gate that checks it.

A harness proves behavior by grepping one scenario's captured output for a substring. When that substring also appears in a sibling scenario's output — a banner the script prints on the nominal path as well as the failure path, say — the grep matches whether or not the asserted behavior exists, so the harness stays green against a script that never implements it and the regression it was written to catch merges unseen. The gate records each scenario's asserted substring alongside its captured output and, after the run, flags any substring missing from its own scenario's output, and any that also occurs in a sibling's output. Scenarios asserting the same substring are mutually exempt, and two records whose normalized output is identical are judged as a group rather than as two scenarios a substring fails to separate — see harness census parity. A harness wired to the gate that records nothing fails closed.

harness_assert_exempt <substring> <other-scenario|*> <rationale> registers a reviewed exception: the named form where one failure path emits no token another lacks, the * form for a banner a script prints across a whole outcome class. The rationale is mandatory so every weakening is reviewable, and the number of live registrations is held at zero — see harness exemption ratchet. A harness that asserts produced artifact content — a rewritten workflow file, a generated doc — rather than captured scenario output is listed on the EXEMPT array in scripts/check-harness-assert-wired.sh with a rationale comment.

Enforced by scripts/lib/harness-assert.sh, which runs inside every wired harness and is therefore reached by whichever CI job runs that harness — any of the four runners in test-runner reachability, standalone jobs such as dashboard-data-tests included, and by scripts/check-harness-assert-wired.sh, which asserts the wiring. Wired as the lint-script-hygiene CI job (member check harness-assert-wired) and as a pre-commit hook.

harness exemption ratchet

No harness registers a discrimination exemption, and a harness counts as wired to the gate only when it calls the verification function rather than naming it.

An exemption is sound at the moment it is written and silent afterwards. The shared banner it excuses gains a distinguishing token, or the sibling scenario it names is deleted, and the registration stays behind — accepting a substring that no longer needs accepting, in the one place built to notice that. Holding the count at zero prices the escape hatch at an edit to the ratchet itself, which is the review moment a weakened assertion deserves. harness_assert_exempt stays in scripts/lib/harness-assert.sh with its spec-test coverage so a genuinely shared banner keeps a relief valve; reaching for it means saying so in the same change.

Wiring is scored on a call because the gate reads harness source text. A header comment naming harness_assert_verify, or a commented-out call left in place, otherwise satisfies the check while nothing runs — the failure mode the gate exists to catch, reproduced inside the gate. Whole-line comments are blanked before matching, and the verify token must open a statement, optionally behind if, ||, or &&. A trailing comment on a code line survives the blanking; the statement-anchored patterns reject it regardless, since the token is not the first word on its line.

Both ratchets cover every harness that is not on the EXEMPT array in scripts/check-harness-assert-wired.sh, including those asserting by other means than the quiet-grep idiom — an exemption registered by a harness the wiring verdict does not score weakens the same library. The EXEMPT entries are skipped before either ratchet runs, so they are out of ratchet scope as well as out of the wiring verdict, and each carries its reason inline. tests/lib-harness-assert.test.sh is the one entry held out for a reason other than artifact-content assertion: the gate library's own spec test must not be gated by the library it tests, and its generated library-driving snippets are indistinguishable from live calls to any textual rule.

Enforced by scripts/check-harness-assert-wired.sh. Wired as the lint-script-hygiene CI job (member check harness-assert-wired) and as a pre-commit hook.

harness census parity

No two scenarios in a harness record the same output. A record is the output files the harness passes to the gate for one run — the exit code, the stdout and the stderr together when it records the whole observable outcome — and the gate fails when two records normalize to the same bytes unless a rationale-bearing exemption covers the pair.

Two scenarios the gate cannot tell apart verify one thing between them: whatever the second is meant to exercise, its entire recorded output is already produced by the first, so deleting either leaves the recorded evidence unchanged and a regression that flips only the second merges green behind its twin. A harness at parity — as many distinct recorded outputs as scenarios — is one where every scenario earns its place. Each pair inside a collapsed group is judged on its own, so excusing one pair never excuses the rest, and the census names every surviving group by scenario instead of reporting a bare count: a named collapse is one a reviewer can act on, where a count is a number that drifts unread. The rule that every member of a group must claim the same substring set still applies to an excused pair — an exemption excuses a shared recorded output, never a mismatched set of assertions.

harness_assert_parity_exempt <scenario> <other> <rationale> registers the exception, and the rationale carries a specific claim: the two scenarios differ in what they exercise, nothing they honestly emit says so, and no output the script could print would separate them without keying on fixture identity rather than on behavior. Unlike the discrimination hatch this one cannot be held at zero — a scenario can exist to refute an implementation that reads an input the real one deliberately never consults, and such a pair admits no separating output by construction. It is held to the PARITY_EXEMPT_ALLOWED list in scripts/check-harness-assert-wired.sh instead, each entry carrying the reason its pair is irreducible, so a second collapse costs an edit to that list under review.

A collapsed group is a measurement fault before it is a coverage fault, and reading it the other way around is the expensive mistake. A scenario whose clean run prints nothing still discriminates, through its exit code: mutate the script under test to drop a code path and that scenario goes red, which is exactly what a scenario verifying nothing could not do. What collapsed was the record, not the coverage — the gate was comparing one stream while the distinction lived in another. Treating such a group as missing coverage leads to inventing per-scenario chatter for the gate's benefit, which verifies nothing and weakens every later assertion in that harness by giving substrings more text to match by accident.

Both moves that reach parity are therefore about what gets recorded. Record the whole observable outcome — exit code, stdout and stderr — so a distinction the script already draws is visible to the gate. Where scenarios still collapse, the script's own summary line widens to state the scope it verified — files scanned, mechanism matched, exemption applied, tags excluded — which is output a maintainer reads on its own merits and which separates the scenarios because it reports something that genuinely differs between them.

Enforced by scripts/lib/harness-assert.sh, which runs inside every wired harness and is therefore reached by whichever CI job runs that harness — any of the four runners in test-runner reachability, standalone jobs such as dashboard-data-tests included, and by scripts/check-harness-assert-wired.sh, which holds registration to the allowlist. Wired as the lint-script-hygiene CI job (member check harness-assert-wired) and as a pre-commit hook.

harness subject declaration

Every tests/*.test.sh harness names the file or file set it exercises, either through a SCRIPT= assignment pointing into scripts/ or through a # @subject header annotation, and never through both.

The census in docs/reference/test-harnesses.md is the one place the tree states what each tests/ harness covers, and it is only as trustworthy as the declarations it reads. A harness that names no subject would render as an unknown — a row that reports the harness exists while saying nothing about what a regression in it would protect, which is the question the census is consulted to answer. A harness that names two would render whichever the generator happened to prefer, so a SCRIPT= assignment retargeted at a different script leaves a stale # @subject line describing coverage nobody has. Refusing both shapes keeps the column a claim someone made deliberately rather than one the generator guessed. The same run refuses a directory under tests/fixtures/ that no harness's fixture set names, since a census that quietly omitted it would describe a smaller tree than the one on disk; a fixture reached only through an override is declared with a # @fixtures annotation.

Enforced by scripts/refresh-test-harnesses.sh, which exits 2 rather than rendering an unknown subject. Wired as the doc-freshness CI job and as the test-harnesses-fresh pre-commit hook.

manifest-reaching hook watches nix/hooks

Every pre-commit hook that reaches the Nix hook manifest (nix eval .#devTooling.<system>.preCommitHooks) includes nix/hooks in its files filter. A hook reaches it either through the script its entry runs, or through a flake attribute its entry evaluates whose assigning module reads the manifest.

A freshness hook regenerates or validates a generated doc from the manifest. When its files filter omits nix/hooks, a commit that edits only a hook definition under nix/hooks/*.nix changes the manifest but does not re-trigger the hook on the per-changed-file git commit path, so a stale generated doc can be committed locally. The --all-files CI mirror still catches the drift, but the local fast-path defense is lost. Tying every manifest-reader's filter to nix/hooks keeps the local and CI paths in agreement.

The guard derives its subjects by content, not from a hardcoded list. A script subject is any scripts/*.sh naming preCommitHooks or PRECOMMIT_HOOK_NAMES; an attribute subject is a hook entry naming a flake attribute, and it qualifies when a module assigning that attribute names either token. Each qualifying hook's files filter must then contain nix/hooks. The guard fails loud when it finds no subjects of either class, and when a hook entry names a flake attribute that subject discovery did not pick up — both mean a parser break from a hook-file reformat rather than a clean tree.

Enforced by scripts/check-manifest-hook-watches-nix.sh. Wired as the lint-script-hygiene CI job (member check manifest-hook-watches-nix) and as a pre-commit hook.

freshness hook watches evaluated sources

Every pre-commit hook whose entry evaluates a flake attribute names, in its files filter, every source that evaluation reads. Two hook shapes evaluate one: a generator script the entry runs, which reads devTooling.<system>.<attr>, and an entry that evaluates an attribute directly with nix build or nix eval, naming no script at all.

A freshness hook regenerates a doc from an evaluated flake attribute and refuses a stale commit. Its files regex decides which changed paths re-trigger it on the per-changed-file git commit path. When the filter misses a module the generator evaluates, a commit touching only that module leaves the doc stale with the guard silent. The --all-files CI mirror still catches the drift, but the local fast-path defense is lost, so the gap surfaces late.

The required source set is derived rather than hardcoded, per shape.

For a generator subject: modules naming the evaluated attribute in non-comment nix source, plus one level of their relative imports, plus modules assigning flake.devTooling — the transposition every generator reads through. The second signal is what makes the derivation structural. A module that merely mentions an attribute in a comment is not thereby required, and a module that performs the transposition is required whether or not it names the attribute at all.

For an attribute subject: flake.nix and flake.lock, since any flake evaluation reads both and a lock bump changes the packages the attribute resolves to; every module assigning the attribute, matched on the assignment shape so that merely naming the leaf does not qualify a module that is not a source of it; and one level of the relative paths those modules reference, of any extension — a module embedding ${../scripts/foo.sh} genuinely depends on that script.

The guard is source-parsed rather than nix eval-ed: files and entry are literal in source, and nix eval is the known local-commit-path long pole. It fails loud if it finds no devTooling-evaluating generator, no hook running one, an attribute with no defining or assigning module, or a hook entry naming a flake attribute that subject discovery did not pick up — each of which means the derivation broke rather than that the tree is clean.

Enforced by scripts/check-freshness-hook-watches-modules.sh. Wired as the lint-script-hygiene CI job (member check freshness-hook-watches-modules) and as a pre-commit hook.

lean lint-shell routing

The batched invariant-lint groups are split across two devShells by closure cost. The light groups — lint-workflow-security and lint-script-hygiene — run in devShells.lint, whose tight closure realizes far faster in CI than the full author shell. lint-doc-invariants must stay on devShells.default: its renovate-config-validator check invokes the renovate-config-validator binary, which ships in the renovate package and pulls the heavy closure that the lean shell deliberately omits. Its scripts-reference round-trip check also runs with the Python devShells.default declares from nix/renderer-python.nix, which lintTools does not carry. Moving lint-doc-invariants to .#lint would break both checks; moving the light groups back to .#default would forfeit the realize saving. A new batched group belongs in .#lint only if every tool it needs is in the lintTools list in nix/devshell-lint.nix; otherwise it stays on .#default.

lint-shell-tools

The lint-workflow-security and lint-script-hygiene invariant-lint groups run inside the lean devShells.lint shell, whose buildInputs are the lintTools list (bash, coreutils, gnugrep, gnused, gawk, findutils, yq-go, jq, gh, git, shellcheck, shfmt, actionlint, check-jsonschema) rather than the full author toolchain. The lean shell trades realize cost for a tighter closure, so a tool dropped from its declared list would surface only as a cryptic mid-check failure deep inside one of the batched groups.

nix/devshell-lint.nix declares that tool set once, as lintTools, and consumes it twice: as the buildInputs of devShells.lint, and as the PATH of the checks.lint-shell-tools derivation. The guard itself is one script asserting every expected tool resolves on the current PATH; two layers run it against two different environments.

  • The declared list. checks.lint-shell-tools runs the guard with PATH set to exactly lintTools and nothing else, so a package dropped from the list fails the build naming that tool. That holds only while every declared package contributes at least one binary to EXPECTED in scripts/check-lint-shell-tools.sh — a package with no probed binary would drop silently. Nothing gates that correspondence: the two lists are hand-synced, with cat, grep, sed, awk, find and yq standing in for coreutils, gnugrep, gnused, gawk, findutils and yq-go. The lint-shell-tools pre-commit hook builds this check, and nix flake check picks it up as a flake check. A hook that instead ran the guard against the committer's ambient PATH is vacuous for exactly the edit it watches: every expected tool is also in devShells.default, so a dropped package still resolves and the guard stays green.
  • The realized shell. The lint-script-hygiene CI job runs the guard from inside devShells.lint, validating the shell that actually hosts the batched groups. What this layer adds is the shell itself: the derivation builds its PATH from lintTools directly, so it stays green if devShells.lint stops exposing that list — its buildInputs edited away from lintTools, or the job entering some other shell — while the CI run fails on the first probed binary that no longer resolves. A declared package whose probed binary is absent or renamed already fails in the derivation, and a same-named binary from another input satisfies command -v in either environment, so shadowing is outside what either layer detects. Run inside devShells.default — by hand, or through just verify, which runs the whole group there — the same script confirms the author shell remains a superset; no CI job or flake check validates that superset.

Both layers are wanted: the derivation checks the declared list, the CI job checks that the shell CI enters still carries it. The expected-tool list lives in scripts/check-lint-shell-tools.sh and must stay in sync with lintTools; a missing tool becomes a named ::error:: line instead of an opaque downstream crash.

Enforced by scripts/check-lint-shell-tools.sh. Wired as the lint-script-hygiene CI job (member check lint-shell-tools) and as a pre-commit hook that builds checks.lint-shell-tools.

ci-job-in-summary

Every jobs.<name>: in .github/workflows/ci.yml either appears as a key in docs/_data/ci-check-categories.yml or is on the lint's EXEMPT list of auxiliary jobs deliberately not exposed as required status checks. EXEMPT is currently empty: every ci.yml job is mapped. Conversely, every key in the category map corresponds to a real jobs.<name>: in some workflow file under .github/workflows/.

refresh-ci-summary.sh already enforces parity between the category map and docs/security/required-checks.md. This lint adds the ci.yml ↔ categories check, so a new required job that ships without a category mapping fails the PR rather than landing and breaking the pre-commit summary regenerator on the next commit.

Adding a new ci.yml job that should be a required status check requires updating the categories map, the required-checks doc, and the protect-main ruleset. Adding an auxiliary job requires only an EXEMPT entry justified in the script comment. The list is self-policed: an entry must name a real ci.yml job that has no category-map key, so it cannot rot into a name that exempts nothing while the lint stays green.

The same lint asserts a third thing, manifest coverage: every check basename in .github/lint-groups.yml resolves to a real scripts/check-<basename>.sh. The grouped lint jobs name their members in .github/lint-groups.yml rather than individually in ci.yml, so without this a check could silently leave the merge gate — its manifest entry orphaned, or its script deleted — while the group job stayed green. The manifest is a precondition: one in any shape other than the one the script header states stops the run as a could-not-run (exit 2) before any check prints.

The EXEMPT list is also the ci-job exemption source for the enforcement matrix: scripts/refresh-enforcement-matrix.sh reads it through this script's --print-exempt mode, so one declaration serves both checks and they cannot disagree about which auxiliary jobs are expected to have no invariant behind them. --print-exempt prints one job name per line and exits 0; an empty list prints nothing, so exit status — not output length — is what says the list is readable. The generator treats any nonzero exit as fatal, so a dropped or renamed mode aborts the refresh instead of quietly widening the orphan-job check to every unmapped job.

Enforced by scripts/check-ci-job-in-summary.sh. Wired as the lint-doc-invariants CI job (member check ci-job-in-summary) and as a pre-commit hook.

prose-ci-names

ci-job-in-summary holds the workflow set against the category map; neither side is prose. Nothing else checks that a CI job or required check named in a sentence exists, and no freshness gate can, because no generator writes the sentence. Two shapes go wrong:

  • ghost — the name resolves to none of the sources listed below, so the sentence points at nothing.
  • mislabel — the name resolves, but not to the kind of thing its claim noun names. A lint-group member or a harness-roster entry runs inside a batched group job, so it is neither a standalone job nor a required status check. A whole workflow named under the required-check noun is a mislabel too, because a required-check context is a job name. A set-membership test alone would pass both.

The valid-name set is derived on every run, never listed, from four sources: every jobs: key of every .github/workflows/*.yml and *.yaml, every workflow's own bare basename, every group name and member in .github/lint-groups.yml, and the first pipe-delimited field of every entry in the harness roster, read through scripts/run-harness-group.sh --print-roster. Job outranks workflow outranks member, so a name carried by more than one source is judged by the strongest.

Only adjacency counts as a claim — a backticked name immediately against the claim noun, in either order, for both nouns, and for a comma-and-and enumeration wherever a single name may stand:

`NAME` job                      job `NAME`
`NAME`, `NAME` and `NAME` jobs  job named `NAME` / job called `NAME`
`NAME` is a job                 `NAME` and `NAME` are jobs
`NAME` required check           required check `NAME`
`NAME` is a required check      required checks `NAME` and `NAME`
`NAME`, a required check        status check `NAME`
`NAME` and `NAME` are required checks

A list may stand wherever a single name may, on either side of the noun, and the copula forms accept the past tense.

Initial letters of the claim nouns match in either case, so a sentence that opens with one is not silently exempt. The bare name-first form stays singular: without a copula, a name standing before a plural check noun is attributive — "the protect-main required status checks" are the checks belonging to that ruleset, not a claim that it is one. The noun-first form stays singular for "job" for the mirror reason, since "the five jobs NAME re-runs" counts jobs rather than naming one.

A proximity window instead of adjacency was measured on this tree and rejected: every site it added was a different noun standing near the word "job" — a name for a list, a name for an artifact. Claim-site and drop counts are printed on every clean run rather than restated here, because a number written into prose is exactly the kind of fact this repo has watched rot.

The shapes above sit in a fenced block for the same reason the lint skips fences: written as prose they are claims about names that deliberately resolve to nothing, and the lint would be right to flag them.

Two adjacency shapes are still not claims and are dropped, each with its own tally in the summary line so a clean run says which exclusion it rested on. A filename-shaped name (`ci.yml` job) names the file a job lives in. An adjectival use (`cancelled` job conclusion, `has-finding` job output) names a job's field, with the job itself unnamed. Fenced blocks are skipped, because a fence quoting workflow YAML shows a name rather than claiming one; inline code spans are kept, since they are what the lint reads.

A name is read as a code span standing directly against the noun, so emphasis decides whether a claim is seen:

**NAME** job     *NAME* job       not read: the name is in no code span
**`NAME`** job   job **`NAME`**   not read: the emphasis markers stand between the span and the noun
**`NAME` job**   *`NAME` job*     read: asterisks wrap the whole phrase
_`NAME` job_                      not read: the closing underscore joins the noun

A bold word with no code span is left unread on purpose: live prose also puts a bold adjective directly before "job", and a matcher cannot tell that from a name. Write a job or check name as a code span.

The scan set is every Markdown and YAML file a commit would carry — tracked files plus not-yet-added ones, minus anything gitignored — so the lint sees a new doc and its fixtures on the commit that adds them. It is not every committed file: claims sitting in Nix and shell comments are outside it. tests/fixtures/ is excluded because it exists to carry deliberate violations, the Claude-behavior tree is excluded because almost all of it is untracked, and CHANGELOG.md and docs/releases.md are excluded as historical records that must keep naming jobs as they stood at the time.

A fence is tracked by its marker character and run length, so a fence closes only on a marker of the same character that is at least as long as the one that opened it — a shorter or different marker quoted inside is content. A same-character marker of equal or greater length does close the fence, including one carrying an info string, which CommonMark treats as an opener rather than a closer; the lint does not model that distinction. Fences inside blockquotes are recognised. A file that ends with a fence still open is a precondition failure, not a clean file: every line after the opener went unread, so there is nothing for a clean verdict to rest on.

Indentation is not capped, because this repo's list-item fences are indented four spaces and capping at the CommonMark limit of three would stop treating them as fences at all. The cost is that a document displaying a fence marker inside an indented block opens a fence the lint believes is real, and an unbalanced one fails the run. That failure is loud and names the file, which is the better trade against silently dropping the remainder of a document.

A harness-roster entry can share its name with a whole workflow. That name is a CI unit of its own, so calling it a job is loose rather than wrong, and it is not reported. Under the job noun, only a name that is a group or roster member and neither a workflow nor a job is a mislabel.

Enforced by scripts/check-prose-ci-names.sh. Wired as the lint-doc-invariants CI job (member check prose-ci-names) and as a pre-commit hook.

required-check-counts

The ## Required contexts table in required-checks.md is the canonical required set. Prose elsewhere restates its size, and nothing generates those sentences, so a context added to or removed from the ruleset leaves each one silently wrong. The lint counts the table's data rows on every run and holds every declared prose count of the set against that number, plus every undeclared one in the phrasings its backstop reads.

A count is declared rather than guessed. The number carries a marker comment directly after it, which GitHub and the site hide:

Every PR must pass NN <!-- count: required-contexts --> required status checks.

The claim is the number immediately before the marker, so a paragraph that also states another count stays unambiguous. The number must be a run of ASCII digits standing alone, after whitespace or at the start of a paragraph. A marker is a finding, not a skip, when it follows anything else (a number word, 1,003, v3), carries a key other than required-contexts, or is misshapen — a comment that opens with a count word or names the key without being a marker, including one whose closer is broken or that spans lines, is reported: a marker that resolves to nothing is a count nobody checks. Keep the marker on the number's line and off the start of a line. Up to three spaces in, a line that opens with an HTML comment starts an HTML block, which ends the paragraph holding the number, and the lint reads it the same way.

A backstop catches a count written without a marker. It reports a number of up to three digits or a number word, standing after whitespace or an opening parenthesis or at the start of a paragraph, followed within two words by one of these phrases:

NN required check(s)
NN required status check(s)
NN required context(s)
NN required status context(s)

Matching is case-insensitive and whole-word at the noun. Emphasis markers and HTML comments read as whitespace, so a bold number or a stray comment does not hide a count. The words in between may not include "of", so a partitive ("one of the required checks") is not reported, while a total written inside one ("any one of NN required checks") still is. The bare word "one" reads as an article and is not a count. A clock time written with a colon or a four-digit year is not read as a count, but a number followed by a unit word ("5 pm required checks") is. Any other phrasing of the count is not seen: "the required set of NN", "NN checks are required", a count after the noun, a number inside a link or glued to punctuation, or one separated from the phrase by a non-breaking space. A wider window was measured on this tree and rejected, because every site it added was a different number standing near the phrase — a cron time, a sentence counting something else. A subset count in the backstop's shape is reported too, since a marker cannot name a subset; rephrase it.

Paragraphs are read with their lines joined, so a count wrapped across a line break is one phrase, and a finding about a count names the line its number sits on. Fenced blocks, inline code spans and comment blocks — a comment opening a line at up to three spaces of indent and closing on a later one, across blank lines — are skipped, which is how a document shows the marker or the phrase without making a claim; a file that ends inside a fence or a comment block is a precondition failure. A backslash-escaped backtick opens no span, and a line that opens with an inline span is prose, since a backtick fence's info string cannot hold a backtick. Otherwise fences are tracked by marker character and length as in prose-ci-names, with the same accepted gaps: indentation is uncapped, and a same-character marker carrying an info string closes a fence. Indented code blocks are read as prose, because this repo's list-item paragraphs are indented the same way.

The table doc must hold exactly one ## Required contexts section, which runs to the next level-one or level-two heading, and it must hold exactly one table with a separator row under its header. Rows are counted as GFM renders them, whether or not a row opens with a pipe, up to the next blank line or the start of another block: a heading, a blockquote, a list item, an HTML line or a fence. A missing section or a duplicated one, a missing table or a second one, a table with no separator row, or one with no data rows is a precondition failure rather than a count of zero. The table doc's own fences and comments are not tracked, so a fenced example holding the section heading or a pipe line in the section fails the run as a precondition failure rather than being skipped.

The scan set is every Markdown file a commit would carry, minus tests/fixtures/ (deliberate violations) and CHANGELOG.md and docs/releases.md (counts as they stood at the time). Only the tracked files of the Claude-behavior tree are read, because the rest of it is untracked scratch a commit cannot fix. Counts in YAML, shell or Nix comments are outside it.

Enforced by scripts/check-required-check-counts.sh. Wired as the lint-doc-invariants CI job (member check required-check-counts) and as a pre-commit hook.

run-block-strict

Every block-scalar or newline-carrying run: block under .github/workflows/*.yml (or .yaml) and .github/actions/**/action.yml (or .yaml) starts with set -Eeuo pipefail as its first non-blank, non-comment line.

Actions runs a workflow run: block as bash -e {0} and a composite shell: bash block as bash --noprofile --norc -eo pipefail {0}. Neither shape enables -u or -E, and a workflow block gets no pipefail, so a failing pipeline stage or an unset-variable expansion produces wrong results without failing the step — in security-critical jobs (release signing, attestation verify, pin write-back) that is a silent bad output. The strict-mode prelude closes that gap uniformly.

Composite actions are covered for the same reason, and the exposure is larger there: a composite runs inside every job that calls it, so one block that swallows a failure swallows it across the whole workflow set. Composite steps hang off runs.steps instead of jobs.<id>.steps, so the lint reads both document shapes and names which one a violation came from (job <id> step[n] versus composite step[n]).

Adding the prelude to an existing block is a behavior change, not a formality: a command whose non-zero exit the block ignores today will abort the step under -e. Blocks that tolerate failure by design — retry loops, optional probes — must keep the tolerated command in a context that consumes its status (an if condition, a || fallback) and say so, rather than relying on -e being absent.

The rule keys off YAML node style (|, >, and their chomping/indent variants) as well as newlines in the evaluated value. Newline presence alone under-detects: a folded scalar (run: >-) spells a ;-separated command sequence across several source lines but folds to one newline-free string, so a block that plainly runs several commands would otherwise slip past the requirement.

Plain single-line run: invocations are exempt — they're already a single shell command whose exit status drives the step directly.

A job key the job list cannot carry, or a merge key directly under jobs:, is a counted finding and the file's jobs are not read; see "Job keys and merge lists in the jobs: reads" in docs/development/linting.md.

Enforced by scripts/check-run-block-strict.sh. Wired as the lint-workflow-security CI job (member check run-block-strict) and as a pre-commit hook.

fork-guard-release

Every workflow job that holds a guard-required write scope includes a fork-guard if: clause containing github.repository == 'rvenutolo/linPEAS-flake'.

Guard-required write scopes are any of: contents: write, packages: write, id-token: write, attestations: write, actions: write. A fork that inherits these workflows can otherwise fire them under its own GITHUB_TOKEN (or repo-scoped secrets, if any were configured) — accidentally cutting a release, pushing to the fork's container registry, minting OIDC tokens, or (for actions: write) deleting the fork's own Actions caches and re-running its failed jobs on triggers the fork owner never asked for. The repository check pins execution to the canonical repo.

A job that mints a GitHub App installation token (via actions/create-github-app-token, or referencing secrets.BUMP_APP_PRIVATE_KEY in the dot or the index form secrets['BUMP_APP_PRIVATE_KEY'], each matched with its ASCII letters in either case, as GitHub Actions reads a secret's name whatever its case) is likewise privileged despite declaring a read-only GITHUB_TOKEN: the App token carries its own write scopes, so the job can commit via the REST contents API, open pull requests, and enable auto-merge — all under the canonical repo's identity. Such a job must carry the same fork guard, otherwise a fork holding the App's private key as a secret could drive those writes against the canonical repo. A job whose expressions use the secrets context without a literal name (toJSON(secrets), secrets[format(...)], secrets[env.NAME], secrets.*, a double-quoted index, the word secrets inside a string literal) or leave an expression unclosed on its line may read that key too, so it needs the guard as well. Every job inherits the workflow-level env:, so any of those uses there puts every job in the file under the guard. env: is the one workflow-level key GitHub Actions lets read the secrets context; run-name, concurrency and defaults cannot. A job calling a reusable workflow with secrets: inherit hands it the key with every other secret, though no expression names one, so it needs the guard too; an explicit secrets: map is read through the expressions in its values.

GitHub Actions if: is job-scoped (no workflow-level syntax), so every guard-required job must carry the guard in its own if: expression. Existing if: clauses are AND-ed with the repository check.

A file yq reads as several YAML documents (GitHub Actions refuses a workflow file holding several) is reported without its jobs being read.

A root env: or jobs: key written as an alias is read as the key it stands for, and a file giving one twice, plain and as an alias, is not passed. A job key the job list cannot carry, or a merge key directly under jobs:, is a counted finding and the file's jobs are not read. The App-token check reads the matched job after explode, so a step reached through a job written as an alias or through a merge key inside the job is found, and a merge list is read first mapping wins. See "Job keys and merge lists in the jobs: reads" in docs/development/linting.md.

Enforced by scripts/check-fork-guard-release.sh. Wired as the lint-workflow-security CI job (member check fork-guard-release) and as a pre-commit hook.

nix-run-pinned

No workflow, script, or documentation fence the lint reads runs any nix subcommand — run, shell, develop, build, or otherwise — against the bare nixpkgs flake reference, with or without intervening flags. Which documentation fences it reads is stated in the header of scripts/check-nix-run-pinned.sh.

At runtime the bare nixpkgs resolves through the user's (or runner's) flake registry — not this repo's flake.lock. A step that calls nix run nixpkgs#cosign therefore pulls whatever nixpkgs commit the runner's registry happens to point at, bypassing the Renovate-pinned nixpkgs input in flake.lock. A malicious or compromised nixpkgs revision could ship a backdoored tool. The registry lookup is what makes the reference unpinned, so the hazard is identical no matter which subcommand consumes it.

Allowed alternatives:

  • nix shell .#<pkg> --command <pkg> <args> — uses this repo's own flake outputs, resolved via flake.lock. Requires the package to be exposed under packages.<pkg> by the flake (see nix/packages.nix).
  • nix run .#<pkg> -- <args> — same.
  • nix run nixpkgs/<rev>#<pkg> — explicit commit-pin (the lint matches a nix command word followed by a bare nixpkgs# token, so a /<rev> between nixpkgs and # passes).

cosign is exposed under packages.cosign so release-on-bump.yml and verify-latest-release.yml can invoke it via the pinned shape. Future tools follow the same pattern.

Enforced by scripts/check-nix-run-pinned.sh. Wired as the lint-workflow-security CI job (member check nix-run-pinned) and as a pre-commit hook. The scan set covers workflows, composite actions under .github/actions/, scripts/*.sh, docs, README, and SECURITY.

notify-label-descriptions

Every label-description a workflow hands to the notify-workflow-result composite fits GitHub's 100-character label-description cap, and no label name is filed with two different descriptions.

The composite writes the description onto the label when a run opens a new issue: it creates the label, or updates the description when the label exists and its live description differs. A run that comments on an open issue or closes one writes no label. The value in the tree therefore reaches the label the next time a run opens an issue, and is what a maintainer triaging an auto-filed issue reads. Two ways that stops being true, both invisible in the tree:

  • Over the cap. The labels API rejects a description longer than 100 characters with a 422. The label keeps whichever wording it was created with, each later run that opens an issue re-attempts the write and warns, and the workflow file goes on looking like the source of truth for text that has never reached the label.
  • Two callers, two descriptions. A label filed by more than one workflow with different wording is rewritten by whichever of them last opened an issue.

Enforced by scripts/check-notify-label-descriptions.sh. Wired as the lint-workflow-security CI job (member check notify-label-descriptions) and as a pre-commit hook. The scan set is .github/workflows/*.yml; a scan that matches workflows but finds no caller of the composite exits 2 rather than reporting a clean tree, because the composite having moved is not the same fact as every description being fine.

notify-arms

Each scanner workflow's notify jobs open a deduped issue on a set of results of the job they watch: a finding, a failure with no finding, a cancelled job. That set is restated in prose in several places, and nothing generated those sentences, so an arm added to or renamed in a workflow left each restatement silently wrong. The lint derives the set from the workflow on every run and holds every declared restatement against it.

The arms come from the workflow, not from a list in the lint. Each notify job's if: gate is evaluated for every combination of the watched job's result (success, failure, cancelled, skipped), its has-finding output ('true', 'false' or empty when the watched job declares that output, and only empty when it does not) and the event (each event the workflow's on: names, and no other, since no other run happens). The result: the job hands the notify-workflow-result composite then decides which admitted combinations file an issue: the composite files failure and cancelled, closes on success and ignores skipped. Those composite rules are taken as fixed. The composite's text from its runs: line through the line that opens its success branch is pinned by a SHA-256 in the lint, and a composite step gated by an if: is refused, so a change there stops the lint instead of quietly changing what an arm means.

A restatement is declared with a marker comment in the paragraph that states the arms, after text on the same line, which GitHub and the site hide:

... or a cancelled job, is paged under `codeql-infra` <!-- notify-arms: codeql.yml/notify-infra = failure cancelled non-pr -->.

The tokens after = are a set:

finding    the watched job failed and its has-finding output is 'true'
failure    it failed with no such finding, or declares no has-finding output
cancelled  it was cancelled, and the composite files it (flagged as infrastructure when handed the raw result)
success    it succeeded, and the notify job still files an issue
skipped    it was skipped, and the notify job still files an issue
non-pr     the workflow runs on pull_request, and no arm files on such a run

A marker fails when its set differs from the derived one, when it names a job that is not a notify job, or when it is misshapen, spans lines or repeats a token; a comment that opens with the marker word after a slip (an extra dash in the opener, punctuation between the two words) is reported as misshapen rather than skipped. A docs marker also fails when it sits on a line an HTML block holds. A comment that opens a line starts an HTML block, which cuts the marker off from its paragraph and, inside a list item, can swallow the item after it; the lint reads any indent as opening the line, because inside a list item CommonMark counts from the item's content column. The rest of the line a comment block closes on belongs to the block as well, and so does every line from a block-level tag such as <div> or <details> (or a lone tag opening a block) to the next blank line.

A docs marker that declares cancelled must share its paragraph with the word "cancel" as a word in page text, because the cancelled arm is the one that restatements kept dropping. Cancel, cancels, canceled, cancelled, canceling, cancelling and cancellation count, in prose or in a code span. A hyphenated compound such as cancel-in-progress does not, since it names a setting, and neither does a link destination, image alt text or an HTML tag with its attributes, none of which a reader sees as text. Paragraphs end at blank lines, list-item starts, thematic breaks and changes of quote depth, and a heading (quoted or not) and each table row (after a delimiter row, with or without edge pipes) are blocks of their own, so the word in one does not speak for a marker in another.

Every notify job in the scanner workflows (codeql.yml, image-cve-scan.yml, octoscan.yml, scorecard-drift-check.yml and zizmor-drift-check.yml) must carry one marker in its own issue body: and at least one in the Markdown docs; the docs markers live in the per-scanner sections of workflow-scanners.md and the OCI image CVE scan sections of verification.md. Other docs point there rather than restating the arms. A body is read as plain prose: the composite prefixes a cancelled run's issue with its own notice, so no cancel word is asked of it, and every comment in an issue is hidden, so a marker on a line of its own counts there. A marker naming another workflow's notify job is checked the same way, but none is required.

The gate grammar is what these workflows use: always(), ==, !=, ! (which binds to one operand, so !x == 'y' compares a boolean and is refused), &&, ||, parentheses, quoted strings, github.event_name, and the watched job's result and outputs.has-finding. Context names and string comparison are case-insensitive, as in GitHub's expressions, and a literal is compared as written, with no escape decoding. A gate with no always() carries GitHub's implicit success(). The lint refuses a job it cannot model when it has to derive it, which it does for every scanner notify job and for any job a marker names. That covers:

  • a gate outside the grammar;
  • a needs: that is not one job named by a string;
  • a result: other than a literal result or the watched job's raw result;
  • a notify step with an if: of its own;
  • a step before the notify step other than step-security/harden-runner or actions/checkout (each owner and repository matched with its ASCII letters in either case), since its failure would skip the composite;
  • a job that runs the composite twice;
  • a job that reaches the composite by any other uses: than ./.github/actions/notify-workflow-result, such as a pinned remote revision the hash does not cover;
  • a workflow whose on: gives no event, or that yq reads as several YAML documents. How the lint reads on: (by kind, through aliases) is stated in the header of scripts/check-notify-arms.sh; a value that prints only spaces, tabs and newlines is refused.

A yq read that fails is not one of these: the lint stops on a line naming what it was reading and the status yq exited with, and says nothing about the workflow.

Known limits:

  • Only the arms the gate defines are checked. The failure cases prose splits out — a scorecard check under 10 against an unreadable payload against a scan that could not run — come from scripts and step order, not from the gate, so they are not read.
  • A marker is the claim. Prose that disagrees with its own marker is not caught, except for the cancel word, and the cancel word is matched as a word, so a negation ("no cancellation") passes.
  • non-pr summarises the whole job. An arm that files only on some events next to one that files on all of them yields no non-pr, and which arm files on which event is not stated or checked.
  • Issue titles, label descriptions and the workflow comments beside a notify job restate the arms too. A title and a label description cannot hide a marker, and the comments sit beside the gate they describe, so none of these is read.
  • Indented code blocks are read as prose, because this repo's list-item paragraphs are indented the same way, so a marker in one counts as a declaration and the cancel word in one counts, though the page shows both as code: a silent pass. Fence indentation is uncapped for the same reason, so a fence marker indented four or more spaces inside a paragraph opens a fence and hides the prose after it up to the next closing fence marker, a silent pass or, with no later closing marker, an unterminated-fence exit 2. This repo writes neither shape.
  • An HTML block opened by <pre>, <script>, <style> or <textarea> is taken to end at a blank line, as the other block-level tags do, rather than at its closing tag.

Docs are read as paragraphs split at blank lines and list-item starts, with fences, inline code spans and comment blocks skipped; a fence marker carrying an info string does not close a fence, and a file that ends inside a fence or a comment block is a precondition failure. The scan set is every Markdown file a commit would carry, minus tests/fixtures/, CHANGELOG.md and docs/releases.md, plus the tracked files of the Claude-behavior tree, and the issue bodies of every notify job in .github/workflows/.

Enforced by scripts/check-notify-arms.sh. Wired as the lint-doc-invariants CI job (member check notify-arms) and as a pre-commit hook.

setup-nix composite required

Every workflow that installs Nix must do so via ./.github/actions/setup-nix, passing github-token: ${{ secrets.GITHUB_TOKEN }}. Direct use of any Nix-installer action from a workflow is forbidden — cachix/install-nix-action, DeterminateSystems/nix-installer-action, DeterminateSystems/determinate-nix-action, and nixbuild/nix-quick-install-action are named explicitly, and the lint also matches unlisted actions of the same family. The token is matched with the ASCII letters of secrets and of GITHUB_TOKEN in either case, as GitHub Actions reads a secret's name whatever its case. It is required in the dot form: the index form secrets['GITHUB_TOKEN'] is refused as the wrong token, and so is any spelling that reads the secrets context without that literal name, since the input must equal the one expression.

Why. Unauthenticated api.github.com tarball fetches are capped at ~60 requests/hour per source IP. GitHub Actions runner IPs are shared across many concurrent jobs; under contention the cap is exhausted and the API returns HTTP 401 Bad credentials, which surfaces from nix build / nix flake as a flake-input fetch failure. Passing access-tokens = github.com=<token> raises the ceiling to ~1000/hour per token and eliminates the class.

Enforcement. scripts/check-setup-nix-required.sh, gated by the setup-nix-required job in .github/workflows/ci.yml.

nix-host-reachability

Every job whose harden-runner allowed-endpoints: carries cache.nixos.org or releases.nixos.org must satisfy one of:

  • a step uses: the ./.github/actions/setup-nix composite — the only nix-installing path anywhere in this tree,
  • a run: block invoking a nix subcommand (nix build, nix develop, nix shell, nix flake, and siblings), matched only when nix is followed by whitespace and a recognized subcommand so nixpkgs-fmt, nixos.org, and a nix path segment inside a URL such as releases.nixos.org/nix/nix-<version>/install do not count. The match is textual over the job's concatenated run: text, not shell-comment-aware, so a nix subcommand written inside a shell comment (e.g. # we do not nix build here anymore) still satisfies this arm — the same shell-comment blind spot the Docker Hub push-branch check documents in scripts/check-egress-allowlist.sh, and not parsed around for the same reason, or
  • an in-job # egress-nix-exempt: <reason> comment with a non-empty reason.

The rules elsewhere in scripts/check-egress-allowlist.sh that involve a tool at all bind a host to a tool that must be present; this binds a host to a tool that must be reachable — the two nix hosts are cheap to leave in an allowlist by copy-paste long after the job that needed them stops installing Nix, and nothing else in this repo's tooling notices.

The reverse direction is bound too: every job with a step whose uses: names the ./.github/actions/setup-nix composite must carry both cache.nixos.org and releases.nixos.org. The composite fetches the installer from one and every substitution from the other, so a job that installs Nix without both allowed fails on its first substituter fetch with a connection refused against a healthy public host — harden-runner's block signature, which reads as an outage rather than as an allowlist gap. Each host is required on its own, so a job carrying one is reported for the other alone rather than blanket-failed.

Both directions share a single matcher for what counts as the composite, bounded so a same-prefixed path such as ./.github/actions/setup-nix-cache-thing is neither credited as reaching nix nor required to carry its hosts. A substring test satisfies both directions wrongly and in opposite directions at once, which is why the boundary is pinned by a fixture rather than by reading.

An empty-reason marker is rejected when the job reaches no nix tooling, which is the only case the marker's reason is read at all. A marker on a job whose allowlist carries neither host is reported as stale — the rule it would exempt does not apply to that job, so the marker's own justification has gone stale along with it. A marker on a job that carries a host and already reaches nix is read by nothing and goes unreported.

Detection deliberately does not follow callees: a job reaching nix indirectly through a scripts/*.sh invocation or a just recipe is invisible to both the uses: and run: arms and needs the marker instead. The reason a reviewer reads is what carries the justification in that case, not an approximate call-graph resolver — the same blind-spot tradeoff the sigstore host-set rule (assertion 4 there) makes for cosign reached through a script.

The marker is a YAML comment, gone once yq has parsed the document, so candidates are found by a raw-text scan bounded to the job's own block — from its key's source line (read via yq's line builtin) to the last line before the first line that is not a comment and is indented no deeper than the key, or to the end of the file, without the trailing comments indented no deeper than the key, then confirmed against yq's comments. A marker comment written at key indentation or shallower, such as just above the next job's key, sits outside the block and exempts nothing in the job above it. A marker indented deeper than the key belongs to the job above it wherever it sits, and one after a shallower comment still inside the job is found.

A candidate counts only when yq also reports it as a head_comment or foot_comment anywhere under the job (for the file's last job, also the document's foot comment), so a line of that shape inside a run: | body or a multi-line quoted scalar, which is value text, exempts nothing. The comparison is by comment text, not position: the trailing comments the block excludes are counted and subtracted from yq's count of the same text, so one cannot vouch for a value-text line, and a real marker whose text a trailing comment repeats is not credited. A marker at an indentation that no node of the job shares, ending the job's block, is a comment yq reports on no node, so it exempts nothing. That is a known limit. A same-line comment is not a marker.

A job key that is empty or null is a finding, and that file is read no further: the job list prints such a key as an empty name and the main loop skips empty names, so that job's allowlist would never be read.

Two job keys that print as the same text (a literal build: twice, or 1 beside "1") are a finding too, naming the key and ending the read of that file: they share one lookup and one line range, so one job would shadow the other.

A file holding a carriage return that does not end a CRLF line break is a finding too: yq counts it as a line break and the newline-separated read does not, so every job line after it is off and a block would be bounded from the wrong line.

yq also reports each key's line without the comment, blank and --- lines before the first content line, so that many lines are added back. The line must then hold the key as a plain, quoted or alias key before its colon. Any other line, as with an anchored, tagged or escaped key or a shift of another cause, is a finding naming the key, since a block bounded from the wrong line can credit one job's marker to another.

Breadth is asserted the same way the notify-composite rule asserts it: the run reports how many jobs carry either host, and finding none on an unfiltered scan is a could-not-run, not a clean tree. WORKFLOW_FILE_FILTER and LINT_ALLOW_EMPTY_SCAN=1 suppress that guard the same way they do for the notify rule.

Enforced by scripts/check-egress-allowlist.sh via the lint-workflow-security CI job (member check egress-allowlist) and a pre-commit hook — the same enforcement path as the tool-inventory rules in trust-model.md.