Scripts reference¶
Auto-generated from in-script @description / @arg / @option /
@example / @exitcode / @stdout annotations by
scripts/refresh-scripts-reference.sh (run via just show-scripts).
scripts/*.sh entry points render their header annotations, grouped by
basename prefix into the Check scripts, Refresh scripts and Other
sections; the sourced libraries under scripts/lib/ render one entry
per library — its file header, then one sub-entry per annotated
function — in the Libraries section. The underscore-prefixed helpers —
the scripts/*.awk parsers and the round-trip checker's Python — are not
rendered here; they are documented from the entry points that invoke
them. Do not edit between the markers.
scripts/check-scripts-reference-roundtrip.sh checks that every header
reaches this page as written; see
linting.md.
Check scripts¶
scripts/check-actionlint-pyflakes-active.sh¶
Canary: assert actionlint's embedded pyflakes
integration is wired. Runs the (wrapper-pinned) actionlint
binary against a fixture workflow containing a planted unused
import; fails if a pyflakes finding does not appear in output.
pyflakes emits no numeric codes of its own (F401 and friends are
flake8's numbering), so the assertion is on actionlint's own
[pyflakes] linter tag, which is present exactly when pyflakes ran
and reported.
If this script fails, the actionlint hook has silently stopped
invoking pyflakes on python run: blocks. See
docs/actionlint-embedded-linters.md.
Env overrides (test-only):
Exits 0 on clean, 1 when the canary fires (pyflakes no longer
reaches python run: blocks), 2 when the canary could not run at all:
the fixture is missing or actionlint is absent from PATH. A canary that
never ran says nothing about the integration, so it must not borrow
the failure code — that sends a maintainer after a wiring regression
that has not happened.
scripts/check-actionlint-shellcheck-active.sh¶
Canary: assert actionlint's embedded shellcheck integration is wired. Runs the (wrapper-pinned) actionlint binary against a fixture workflow containing a planted SC2086 violation; fails if the SC2086 code does not appear in output.
If this script fails, the actionlint hook has silently stopped
invoking shellcheck on run: blocks. See
docs/actionlint-embedded-linters.md.
Env overrides (test-only):
Exits 0 on clean, 1 when the canary fires (shellcheck no longer
reaches run: blocks), 2 when the canary could not run at all: the
fixture is missing or actionlint is absent from PATH. A canary that
never ran says nothing about the integration, so it must not borrow
the failure code — that sends a maintainer after a wiring regression
that has not happened.
scripts/check-auto-merge-decline-gate.sh¶
Lint: every workflow run-block that calls gh pr merge
with --auto must also carry the decline gate (a gh pr view --json state query plus a CLOSED|MERGED arm that exits non-zero), so a
maintainer-closed (declined) or already-merged PR is never silently
resurrected by an auto-merging update workflow.
scripts/check-awk-operand-explicit.sh¶
Lint: every awk invocation in a script under
scripts/ that carries a file operand must spell that operand
"$(awk_path "${var}")". awk reads an operand shaped
name=value as a variable assignment rather than a filename; it
then finds no file operand, reads stdin, and exits 0 having scanned
nothing — so a relative path whose first component contains =
scores as an empty file instead of failing loud. The lint scans
every script the scripts/*.sh git pathspec matches — the pathspec
crosses /, so sourced libraries under scripts/lib/ are in scope.
scripts/lib/awk-path.sh's awk_path() closes that for a wrapped
operand; this lint is the backstop that keeps every future awk
call wrapped too, rather than trusting a one-time sweep 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: a textual
rule would drift the moment a script's formatting moves an operand
onto another line, inside a multi-line awk program, or next to a
sibling operand. The scan also recognizes gawk/mawk/nawk, an
absolute or relative path ending in one of those basenames (e.g.
/usr/bin/awk), a command-prefixed invocation, and a
statically-quoted "awk"/'awk' word — not just the bare awk
literal — since a future call site is under no obligation to spell
the command the same way every existing one does.
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
no program has been supplied yet. -f, --file, and --source
supply the program text (a repeated one concatenates, which is
legal), so any of them marks the program as already established
without ending flag recognition — a legally-repeated
-f a.awk -f b.awk is not misread as two operands; --field-separator
and --assign keep being read as flags too once a program is
established. -v, -F, and -- behave differently once a program
is already established: gawk (measured directly) then reads any of
the three as an ordinary filename rather than as a flag, bare or
attached, so the walk folds a further -v/-F/-- back into "this
is an operand" instead of treating it as one. Only the first
non-flag word establishes the inline program (when none of
-f/--file/--source already has); every non-flag word after
that is an operand. -f/--file's own value and the program text
itself are excluded from the operand list, since neither was ever a
file operand. Each surviving operand 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 above: stdin
redirections and here-strings (a redirection attaches to the
enclosing statement, not the command's argument list, so an awk
call fed one has zero operand arguments to inspect) and pipeline
input (same — the producer feeds stdin, not an argument).
The total operand count checked across the run is itself asserted nonzero (unless LINT_ALLOW_EMPTY_SCAN=1): a parser regression that silently drops every operand from its accounting — e.g. an attached-flag word reclassified as a no-op instead of as the operand-bearing word it actually is — would otherwise still print a clean "0 violations" and exit 0. See the assertion below for why LINT_ALLOW_EMPTY_SCAN, rather than a dedicated variable, is the right opt-out.
Honors PATHS_OVERRIDE (newline-separated file list) for fixtures, and LINT_ALLOW_EMPTY_SCAN=1 to accept a run whose operand tally (or whose enumerated file count) comes back zero. Exit 0 clean, 1 on any unwrapped operand, 2 when a required tool is absent, the scan set could not be enumerated (or came back with a zero operand tally), a named path does not exist, or a file could not be parsed as shell.
scripts/check-bump-script-integrity.sh¶
Lint: scripts/bump-linpeas.sh retains its three
supply-chain integrity guards — asset-URL prefix, .digest
cross-check, and atomic (make_temp + mv) pin write.
scripts/check-changelog-fresh.sh¶
Guard that CHANGELOG.md's released sections match a fresh git-cliff regeneration, so a release that ships without its changelog update landing — or a manual edit to a released section — is caught rather than accruing silently.
Only the released portion (from the first ## [<tag>] header onward) is
compared. The ## Unreleased section legitimately changes with every merged
commit, so comparing it would force a changelog regen on every PR — the
staleness this guards is released sections lagging the release tags.
A release tag is created before the changelog commit that describes it, so between the two there is a window in which a released section cannot yet exist in the committed file. Comparing it there reports staleness that no commit could fix: main goes red, and because the enforcing CI job is a required check, every open PR based before the changelog commit is blocked. A tag is therefore compared only when its commit is an ancestor of the most recent commit that touched CHANGELOG.md — only when the changelog was written at a point where that tag already existed. Newer tags are excluded from both sides of the diff. The condition is history-relative rather than time-based: a wall-clock grace period would either mask a genuinely dropped changelog or still flake, depending on how it was tuned.
Blind spot this accepts: only tags predating the last CHANGELOG.md commit are guarded. A release whose changelog never lands stays unguarded until some later changelog commit exists, and two releases stacking up inside the window would exclude both. That is tolerable because a dropped changelog job fails loudly through the release-on-bump notify path — this check is the backstop, not the primary signal.
git-cliff is invoked only via the flake-pinned .#git-cliff output, per the
nix-run-pinned invariant. Offline and deterministic: git-cliff parses the PR
number from the (#N) subject suffix, so no GitHub token is required. Needs
full history + tags (fetch-depth: 0) so every release tag is visible.
Exits 0 when released sections are fresh, 1 when stale, 2 when the check could not run: nix absent from PATH, the pinned git-cliff exiting non-zero, or an input file (CHANGELOG.md, cliff.toml) missing. A comparison that never happened says nothing about whether the committed changelog is fresh, so it must not borrow the staleness code — that sends a maintainer to regenerate a changelog that was never the problem. git-cliff's own stderr is passed through rather than silenced, so the reason is visible in the job log.
Env overrides (test-only):
CHANGELOG_OVERRIDE — committed changelog path (default CHANGELOG.md)
CLIFF_TOML_OVERRIDE — cliff config path (default cliff.toml)
REGEN_OVERRIDE — pre-generated regen file; when set, the git-cliff
call is skipped so the comparison logic can be tested without nix
scripts/check-changelog-links.sh¶
Refuse to build if the regenerated changelog contains duplicate identical PR links or loses the scorecard-count preprocessor. Validates git-cliff OUTPUT, not the committed CHANGELOG.md formatting. CHANGELOG.md is generator-owned and excluded from treefmt + markdownlint, so no other check guards a malformed regeneration.
Offline and deterministic: git-cliff parses the PR number from the (#N)
subject suffix, so no GitHub token is required.
git-cliff is invoked only via the flake-pinned .#git-cliff output, never
an unpinned nix run nixpkgs#git-cliff, per the nix-run-pinned invariant.
Exits 0 when both invariants hold. Exits 1 on any violation (duplicate links, lost preprocessor). Exits 2 when the check could not run: nix absent from PATH, cliff.toml missing, or the pinned git-cliff exiting non-zero. A generator that cannot run produces no output to validate, so it must not borrow the violation code — that reads as a malformed regeneration and sends a maintainer after a cliff.toml assertion that never fired. git-cliff's own stderr is passed through rather than silenced, so the reason is visible in the job log.
Env overrides (test-only):
scripts/check-checkout-persist-credentials.sh¶
Lint: every actions/checkout step in every workflow
sets with.persist-credentials: false so the GITHUB_TOKEN is not
left in .git/config for subsequent steps to read.
scripts/check-ci-job-in-summary.sh¶
Lint: cross-check .github/workflows/ci.yml jobs
against docs/_data/ci-check-categories.yml in both directions,
with a self-policed EXEMPT list for auxiliary (non-required) jobs.
Options:
--print-exempt— print the EXEMPT job list, one name per line, and exit 0 without linting
scripts/check-cliff-tag-pattern.sh¶
Refuse to build if cliff.toml's tag_pattern drifts from the canonical pin-shape regex. One member of the cross-layer parity set; docs/architecture/auto-update.md lists every file that carries the regex, derived from the tree rather than restated here.
Exits 0 when tag_pattern exactly matches the canonical value. Exits 1 on drift (missing key, wrong value). Exits 2 when the check could not run: yq absent from PATH, or cliff.toml missing. A config that was never read cannot have drifted, so it must not borrow the drift code.
Env overrides (test-only):
scripts/check-commitlint-config-explicit.sh¶
Lint: every wagoid/commitlint-github-action step pins a
non-empty configFile: that resolves to a file on disk, and the merge
ruleset differs from the strict one only by the two zeroed length rules.
scripts/check-cosign-identity-pinned.sh¶
Lint: every cosign verify* invocation (verify,
verify-blob, verify-attestation, verify-blob-attestation) pins
both --certificate-identity (or -regexp) and
--certificate-oidc-issuer so verification is bound to a specific
signer.
scripts/check-cron-table.sh¶
Lint: cron schedule table in docs/architecture/ci.md matches cron triggers in .github/workflows/*.yml (and *.yaml) — set parity and cron string accuracy — and the daily arrow list matches the table's own daily rows, in strictly increasing UTC order.
Exit codes:
0 all checks passed
1 drift detected (details printed to stderr)
2 missing input files / parse error / workflow declares >1 cron line
scripts/check-doc-anchors.sh¶
Lint: every markdown #anchor link pointing at an
in-tree .md (or same-file fragment) must match a heading slug or an
explicit <a name> anchor in the target file; an anchor link whose
target .md is missing, and a non-ASCII heading with no explicit
anchor, are reported too.
scripts/check-doc-cron-restatement.sh¶
Lint: ban restating a workflow's cron schedule in docs.
A line that names a workflow (backticked bare name NAME or a
NAME.yml/NAME.yaml token) AND carries either a clock time (HH:MM) or a
numeric cadence (every N minutes/hours/days) restates the single source
of truth, the schedule table in docs/architecture/ci.md. Such lines must
live only in that table; this lint flags them everywhere else
(README.md + docs/**, excluding ci.md itself).
Bare daily, weekly, and Friday are deliberately out of reach: the
sanctioned form is prose like "runs on a daily cron (see the schedule
table)", and a pattern that flagged those would report the very phrasing
this lint exists to encourage.
Exit codes:
0 no restatements found
1 restatement(s) found (details printed to stderr)
2 the check could not run: missing/empty .github/workflows directory,
or a producer that lists or reads the doc files failed
scripts/check-dockerhub-token-scope-split.sh¶
Lint: enforce the DOCKERHUB_TOKEN RW/DELETE scope split.
The delete-scoped PAT (secrets.DOCKERHUB_TOKEN_DELETE) belongs to
dockerhub-sync.yml (peter-evans/dockerhub-description needs Delete
scope to PATCH repo metadata; a Read/Write-only PAT returns 403), and the
write-scoped PAT (secrets.DOCKERHUB_TOKEN_RW) to release-on-bump.yml.
The lint requires each token in its own workflow, refuses the
delete-scoped token in release-on-bump.yml and in the
anonymous/read-only verify-latest-release.yml, and refuses the
write-scoped token in dockerhub-sync.yml and verify-latest-release.yml.
It does not check other workflows for either token. In every workflow, a
secrets.DOCKERHUB_TOKEN reference without the _RW or _DELETE suffix is
refused. GitHub Actions reads a secret's name whatever its case, so each
reference is matched with the ASCII letters of secrets and of the name
in either case: secrets.dockerhub_token_rw is the write-scoped token.
It reads the index form as the dot form: secrets['DOCKERHUB_TOKEN_RW']
is the write-scoped token too. In every workflow, any other use of the
secrets context in an expression is refused, since no name in the text
says which token it reads: the whole context (toJSON(secrets)), a name
the expression computes (secrets[format(...)], secrets[env.NAME]),
the object filter (secrets.*), a double-quoted index, and the word
secrets inside a string literal. So is a ${{ not closed on its line,
which the lint reads one line at a time (see scripts/lib/secret-uses.sh).
So is a job's secrets: inherit in every workflow, which hands every
secret to the reusable workflow the job calls without naming one; it is
read through yq, so a quoted, tagged or aliased value is read too. An
explicit secrets: map is read through the expressions in its values.
GitHub Actions evaluates an expression in a scalar as YAML decodes it,
and in a double-quoted scalar an escape (s\x65crets, \x24{{) can hide
a reference from the file text, so each rule also reads the decoded text
of every double-quoted scalar, tagged or not, key or value.
The same split binds every manual recovery snippet in the docs. A
shell-fenced Markdown block that performs a tag delete
(--request DELETE / -X DELETE) against Docker Hub must name
DOCKERHUB_TOKEN_DELETE and must not name DOCKERHUB_TOKEN_RW: the
write-scoped PAT returns 403 (access denied: insufficient scope) on a
tag delete, so a snippet pasting it strands the operator part-way
through a recovery the runbook presented as working.
A fence counts as a Docker Hub delete when it does a DELETE and either
addresses hub.docker.com or names a DOCKERHUB_TOKEN — a DELETE against
some other API is not this rule's business, and scoping on the host
alone would exempt a snippet that spells the host through a variable.
Token names are matched over the whole fence, not the delete line: a
real snippet assigns its credential many lines above the request.
In Markdown, fences are read by scripts/lib/md-fence.sh: a run of three or more backticks or tildes opens a fence after any indentation, blockquote markers and list marker, and a run of the same character at least as long, at the same blockquote depth, closes it. A fence is read when the first word of its info string, lower-cased and without attribute braces or a leading dot, is empty or one of sh/bash/shell/console/text. The reader's limits are stated in that file.
Honors WORKFLOWS_DIR_OVERRIDE (defaults to .github/workflows) so the test harness can point at a temp dir, PATHS_OVERRIDE (newline-separated file list) for the Markdown scan set, and LINT_ALLOW_EMPTY_SCAN=1 to accept a workflows dir holding no YAML or an empty Markdown scan set. Exits 0 if the split holds, 1 on a violation, 2 when the lint could not run: yq is not on PATH, the workflows dir is missing or holds no workflow, a workflow or a doc could not be read, or the Markdown scan set is empty or could not be enumerated, for example.
scripts/check-egress-allowlist.sh¶
Lint: every job's harden-runner allowed-endpoints list
carries the hosts its tool inventory actually reaches, carries the ghcr
blob host alongside ghcr.io, carries a complete Docker Hub pull host set
(and the push host too, if it logs in or pushes) if it carries any Docker
Hub registry host at all, carries a complete sigstore host set if it
carries any sigstore host at all, matches the declared notify egress set
if the job runs the notify-workflow-result composite, and carries no
denylisted host.
scripts/check-enumerate-helper-required.sh¶
Lint: every filesystem scan in a repo script or test
harness asserts its own breadth. The scan set is scripts/*.sh plus
tests/*.test.sh, less tests/fixtures/ — a tree whose files exist to
be violations of this lint, driven through PATHS_OVERRIDE by the
harness that asserts the diagnostics they produce.
An enumeration runs through enumerate_into
(scripts/lib/enumerate.sh) — a producer (find, git ls-files, git ls-tree) may appear only as an argument to the helper, inside a
function the helper is handed by name, or behind an inline
# enumerate-exempt: <rationale> marker. Copying a producer word to a
variable is banned at that same assignment, since the use site's
command word is then a value no single pass can resolve; the array
form is read only at element 0, its command head, so a producer word
planted at a non-head index and later spliced into command position by
index arithmetic still evades. A glob-driven scan fills its
array through glob_into — neither a for loop at its own head nor an
array assignment in its element list may expand a pattern, whether the
metacharacter is written bare in the word itself, sits one level in
among the Lit parts of the word any expansion there carries (its
alternate, default, or pattern-operand word alike), or is
held in a variable this same file assigns a pattern and read unquoted
at either place, unless an inline # glob-exempt: <rationale> marker
says an empty match set is that site's normal state. A read under a +
or :+ operator is not such a read: those emit only the alternate word
and never the value the variable holds, so no pattern it holds can
expand there. That last shape is decided at the read rather than at the
assignment, and only within one file: a name a sourced library assigns
is not seen, 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 reaching a loop head or an array
element through a command substitution or an indirect expansion is not
read at all. A site both the literal and the laundered shape match
earns one record, one diagnostic and one exemption, as every other
position here does. A filter-driven scan narrows an
already-enumerated set through filter_into — a variable named FILTER
or ending _FILTER may be read at file scope to reach that call, but
not again inside a for or while loop over the narrowed selection,
nor in a function that loop calls — one hop out; a function called only
by that function still evades and stays outside what this pass decides
— and not at all in a file that never calls filter_into, unless an
inline # filter-exempt: <rationale> marker says the direct read is
deliberate. Copying a filter value into a target whose own name does not
match that same pattern is a violation at the assignment, wherever the
copy is later read, because every one of the checks above keys on the
name of the variable being read and a value under a fresh name would
otherwise satisfy the letter of all three while re-introducing the
defect. Those are the positions a single pass over the tree
decides; a pattern that reaches a scan by any other route is outside
what this lint sees, and the rule is stated no wider than that.
Every marker must open its comment: the comment's text is read from
the syntax tree, not the raw line, and the marker word has to be the
first thing in it — which is what makes the match immune to a #
inside a string or an expansion earlier on the line.
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 in
the world reads as a clean tree. A lint that scans an empty set finds
no violations and exits 0 — off, and green. So breadth has to be
asserted rather than inferred, and enumerate_into is where that
assertion lives: routing every enumeration through it makes the
assertion structural instead of something each call site has to
remember.
A glob scan fails the same way from the other end. Under nullglob a
pattern matching nothing expands to nothing: a loop body never runs, an
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.
glob_into is the same assertion for both shapes: it expands the
patterns, fills the array and refuses an empty match set, and whatever
reads that array afterwards walks an ordinary list.
A pattern held in a variable fails that same way, and the quoting at
the read is what decides whether it does: pat='scripts/*.sh' expands
nowhere until something reads it, so for f in ${pat} runs exactly the
unasserted scan a bare pattern runs, while for f in "${pat}" iterates
one literal string and glob_into files 'shell sources' "${pat}"
asserts the match set. The verdict therefore lands at the read, unlike
the two copying rules, which land at the assignment because the value
they follow is identified by name or by literal wherever it goes.
A filter-driven scan fails a third way, one layer past enumeration and
globbing: filter_into narrows a set the other two helpers already
proved non-empty, and it is the one place that narrowing's own
cardinality is asserted. A loop that reads the raw filter variable
again — directly in its own body, or one hop out in a function the loop
calls by name — instead of trusting the selection filter_into 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 the helper exists to catch: a filter
matching nothing selects no path, the loop body never fires, and the
run exits 0. 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.
A filter-driven scan fails a fourth way, sideways rather than past any
of the first three: copying the filter value into a variable whose own
name is not FILTER or *_FILTER 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 three checks were built to avoid.
That is 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 one 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.
Whether a name read at one of those two glob positions is one this file
gives a pattern is a question about the same tree, answered by
collecting that file's assignments in the same walk, which is what
keeps the laundered read a file question rather than a dataflow one.
Patterns handed to glob_into are arguments of a CallExpr — never
WordIter items, never array elements — and reach it quoted, so a
compliant call site cannot false-hit the glob rule however many
metacharacters it carries; a pattern-bearing name reaches it quoted
too, and a quoted read is a part of the quoted string rather than of
the word, so it is not a read the rule counts either. A filter-named
read handed to filter_into
as its own argument is excluded from the filter rule the same way, and
the sanctioned readonly FILE_FILTER="${WORKFLOW_FILE_FILTER:-}" shape
stays legal under the alias check for the same reason: its own target is
itself a filter name, so a compliant call site cannot false-hit either
rule it satisfies.
Detection parses each script's syntax tree via shfmt --to-json (the
mvdan.cc/sh parser shfmt and treefmt already run over this repo)
rather than matching text, because three shapes here name the banned
commands without running them and a textual rule would need a
special case for each: this file'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 of them is a
command node, so none of them is a hit.
A git invocation's subcommand is found by walking past the global
flags (-C <dir>, -c <k>=<v>, --git-dir=…) rather than by reading
the word right after git: the one hand-rolled enumeration this lint
was written against spelled it git -C "${ROOT}" ls-files.
The count of scan sites classified — producer calls plus a producer
name copied to a variable, plus glob_into call sites plus glob loops
plus glob array assignments plus an unquoted read of a pattern-bearing
name at either, plus filter_into call sites, plus filter reads inside
a loop and inside a function one hop out, plus the single read reported
in a file that calls the helper nowhere, plus a filter value copied to
an unwatched name — is itself asserted nonzero (unless
LINT_ALLOW_EMPTY_SCAN=1). A grammar that
silently recognized nothing would report "0 violations" and exit 0 —
the same clean 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.
Each rule owns its own marker word, and a marker excuses only the kind of site it names: a rationale for running a producer outside the enumeration helper says nothing about whether a glob's match set may come back empty or a loop may read a filter variable directly, and the reverse holds in every direction.
A site a marker was written for can still be rewritten into a
compliant shape, moved, or dropped from the file entirely while the
comment above it stays behind, so every marker word this lint owns is
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 of the rules above are decided by reading an operator out of the
syntax tree — a pipeline stage feeding a loop, and a read under a plus
operator — and a select handed an encoding this file was not taught
matches nothing rather than failing. So the encodings are established
as a precondition: a fixed probe exercising all eight operators is
parsed before any file is scanned, and each construct is asserted both
to match the branch that must select it and to be absent from the
branch that must not.
Honors PATHS_OVERRIDE (newline-separated file list) for fixtures,
SHFMT_OVERRIDE (default shfmt) to point the parse at a stub emitting
a tree the jq program was not written for, and LINT_ALLOW_EMPTY_SCAN=1
to accept a run whose scan-site tally (or whose enumerated file count)
comes back zero.
Exit 0 clean, 1 on a producer outside enumerate_into, a producer name
copied to a variable, a for loop expanding a glob at its own head, an
array assignment expanding one in its element list, a for loop or an
array assignment expanding a variable this file assigns a glob pattern,
a loop reading a filter variable directly, a filter read in a function
that loop calls, a script reading a filter variable without ever
calling filter_into,
an assignment copying a filter value to a name outside the filter
pattern, an exemption marker with no rationale, or an exemption marker
that excuses no site this pass classified, 2 when a required tool is
absent, the parser emitted an operator encoding this lint does not
recognize, the scan set could not be enumerated (or classified
nothing), a named path does not exist, or a file could not be parsed
as shell.
scripts/check-ephemeral-refs.sh¶
Lint: every Markdown file, shell script, Nix source and
YAML source in the repo must carry no ephemeral references — PR/issue
refs, prose dates, planning/review-pass labels, or literal .claude/
paths.
Markdown is read as prose; shell is read as comments only, lifted from
the shfmt syntax tree; Nix is read as the comments that start their
own line, both # line comments and /* */ block comments; YAML is
read as the # comments that start their own line, block scalars
included.
Only the sources whose raw text carries a candidate token are
extracted; the rest are set aside and reported as such.
Default mode blocks (exit 1); --advisory mode
suppresses findings, not defects: it warns on fuzzy causal-history
phrases and exits 0 on those, but a could-not-run (unterminated
fence/generated block/Nix block comment, unparsable shell, a shell
scan, a Nix scan or a YAML scan that extracted no comments, a failed candidate
scan, a structural pass that read fewer sources than were set aside
for it, a class regex that fails its own canary, failed source
enumeration) still exits non-zero the same as the default pass.
Options:
--advisory— suppress findings, not defects: warn on fuzzy causal-history phrases and exit 0 for those, but still exit 1 on an unterminated fence/generated block/Nix block comment and 2 on a failed source enumeration, a failed candidate scan, a class regex that fails its canary, a structural pass that read fewer sources than were set aside, an unparsable shell source, a shell scan that extracted no comments, a Nix scan that extracted no comments, or a YAML scan that extracted no comments
scripts/check-exit-contract-documented.sh¶
Lint: every script directly under scripts/ that can reach
exit 2 says so in its header. Exit 2 means the check could not run — a
required tool absent, an input missing or malformed — and exit 1 means
it ran and found a violation. A header promising only 0 and 1 tells a
reader, and anyone wiring the script into a new caller, that a
could-not-run cannot happen; a caller written against that promise
treats one as a finding and reports a violation nobody observed.
A script can reach exit 2 two ways, and both count:
- a literal `exit 2` / `return 2` on a line that is not a comment
- a 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 line that is neither blank nor a comment. It is unwrapped before matching, because these contracts routinely wrap mid-sentence and a line-oriented match cannot see a clause split across two lines.
Four contract shapes count as documenting exit 2, which is every shape the tree uses:
Exits 2 when … (a dedicated sentence)
Exits 0 on …, 1 on …, 2 on … (a comma-separated list)
Exit: 0 …, 3 …, 2 usage error. (the same list, any order)
Exit codes: … a `2` item line … (an enumerated block)
The list forms match only within one sentence, so a 2 in unrelated
prose later in the header does not excuse a missing contract. The item
form requires the 2 to stand alone as a token: 2FA and v2 are prose,
not exit codes, and one of them appears in a header this rule covers.
Scope is scripts/*.sh only. Libraries under scripts/lib/ exit in
their caller's shell and have no standalone contract of their own —
documenting that exit is the obligation of the callers this rule reads.
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.
Honors SCRIPTS_DIR_OVERRIDE (default: scripts) and LINT_ALLOW_EMPTY_SCAN=1 for fixtures.
Exits 0 when every script that can reach exit 2 documents it, 1 on any script that can reach exit 2 without documenting it. Exits 2 when the check cannot run: the scan set matches no script, which is a could-not-run rather than a clean tree.
scripts/check-flake-lock-provenance.sh¶
Lint: a flake.lock bump that flake.nix does not
account for may only move rev/narHash/lastModified, and a moved
rev must continue the history it claims to continue. Fails when a
top-level input is added, removed, or repointed, or when any node
present in both base and head has its source identity
(owner/repo/type/url/ref/flake/...) changed, unless flake.nix itself
declares a different url for that input between base and head; and
fails when an undeclared rev move on a GitHub-hosted node is not a
fast-forward of the old rev per the GitHub compare API. Gates the
auto-merged weekly flake.lock update so neither a source-level repoint
nor a rewritten upstream history can slip into the build/dev closure
undeclared.
scripts/check-flake-lock-staleness.sh¶
Lint: every top-level flake.lock input was refreshed
recently enough that the mechanism responsible for refreshing it is
demonstrably still running. Fails when an input's locked.lastModified
is older than the threshold declared for it.
scripts/check-flake-systems-eval.sh¶
Assert every system declared in flake.lib.systems
evaluates, forcing each package's derivation (not just the attribute
names) so a package whose value throws is caught. Fails naming the
offending system + the real nix error, so a platform drop in a nixpkgs
bump is diagnosable at a glance.
Exits 0 when every declared system evaluates, 1 when one or more fail
to evaluate or when the flake declares no systems at all. Exits 2 when
the check cannot run: an unrecognized argument, --flake given with no
directory, a flake whose lib.systems cannot be read, nix or jq
absent from PATH, or a temp file that cannot be created.
Options:
--flake—<dir>flake to check (default: repo root)
scripts/check-fork-guard-release.sh¶
Lint: every workflow job holding a guard-required write
scope (contents/packages/id-token/attestations/actions: write) carries
a fork-guard if: pinning execution to the canonical repo.
scripts/check-freshness-hook-watches-modules.sh¶
Lint: every pre-commit hook whose entry runs a generator
that evaluates devTooling.<system>.<attr> names, in its files
regex, every nix module that attribute is defined or transposed by.
scripts/check-gh-api-version-header.sh¶
Lint: every gh api invocation and every api.github.com
request in scripts/*.sh passes an explicit
X-GitHub-Api-Version: <date> header.
scripts/check-gh-attestation-repo.sh¶
Lint: every gh attestation verify invocation across
workflows, composite actions, scripts, nix modules, the justfile, and
docs passes --repo rvenutolo/linPEAS-flake so verification is bound
to this repository. Parsing lives in
scripts/_attestation_invocations.awk; this script owns path
selection and reporting.
scripts/check-guard-exit-code.sh¶
Lint: no script anywhere under scripts/ may exit 1 out
of a guard whose test is only an availability check, none may create
a temp file with a bare mktemp, none may resolve the repository root
with a bare git rev-parse --show-toplevel or lose the status of the
guarded repo_toplevel to a declaration builtin on the same line, and
none may take a required value through a ${var:?} expansion. The exit codes separate what the
operator has to do about a run: 2 means the check could not run (a
required tool is absent, an input is missing, unreadable or
malformed), 1 means it ran and found a violation, 0 means clean. An
absent tool reported 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.
A hit is a conditional whose test is PURELY an availability predicate and whose branch body exits 1:
Matching is branch-scoped rather than proximity-based: the branch body
is walked 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. Many checks here read a marker or a
field out of a file that exists and report its absence as the finding
— 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 || is not a hit: that branch is reachable without the input
being absent, so absence there is half of a content verdict rather
than a could-not-run.
The second rule covers the same class one level down. An unwritable
TMPDIR makes mktemp exit 1, and an unguarded x="$(mktemp)" under
set -e kills the caller with that same 1 — so a machine that cannot
hand the script a scratch file reports as a repo carrying a violation.
Every creation therefore routes through make_temp
(scripts/lib/temp.sh), which reports the failure as exit 2; that
library holds the one sanctioned bare invocation and is the only file
this rule skips. Matching is by command position — line start, a
command substitution, a pipe, a separator, a negation, a group
opening, or one of the keywords then, do and else — so prose
naming the command, including a parenthetical, is not a hit.
The third rule is the same class again. Outside a git work tree, git rev-parse --show-toplevel exits 128, and an unguarded
x="$(git rev-parse --show-toplevel)" under set -e ends the caller
with that status, which no caller reads as could-not-run; a fallback
such as || echo . reads the wrong tree instead. Every lookup therefore
routes through repo_toplevel (scripts/lib/repo.sh), which reports a
missing work tree as exit 2; that library holds the one sanctioned
lookup and is the only file this rule skips. Matching uses the same
command positions as the temp-file rule, with any words between git
and rev-parse (so git -C <dir> is read too, a $(…) in <dir>
with no ;, | or & inside it included), and --show-toplevel later in the same command. Matching is
per physical line, so a lookup split by a backslash continuation is not
read, and neither is a lookup in a position the temp-file rule does not
list (a subshell, env git, a path to git) or --show-cdup. A string
operand or trailing comment holding $(git rev-parse --show-toplevel)
does match, since the matcher reads neither quotes nor trailing
comments; such a literal takes the marker. A repo_toplevel call inside
the arguments of local, readonly, declare, export or typeset is
its own hit: the builtin returns its own status, so the 2 is lost. That
arm reads neither quotes nor heredocs either, and does not see a call in
backticks.
Escape hatch: # exit-code-exempt: <rationale>, on the exit line of a
guard whose missing input genuinely IS the finding, or on the line of a
bare temp-file creation or repository-root lookup whose failure 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. Any earlier # on the same line, whatever
produced it — a ${x#y} expansion, a # inside a string, or
anything else (printf 'a#b\n'; exit 1 # exit-code-exempt: <why>) —
is read as that first #, so a genuine trailing marker can be
missed; the miss reports the site as a hit 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.
The same predicate that opens a marker's comment for classification
also opens it for a census that runs after every rule's check: a line
whose comment opens with exit-code-exempt: is recorded as the file is
read, marked consumed only when a site some rule matched 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
beneath it, and keeps asserting that decision after the guard was
rewritten into a compliant shape, moved, or left the file.
Classification and the census share this 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.
The scan recurses. The shared libraries under scripts/lib/ decide
which exit code their callers report — enumerate_into is where a
could-not-run enumeration becomes exit 2 for every lint that uses it —
so a scan stopping at the top level would vouch for the code that
settles the very convention this lint enforces.
Honors SCRIPTS_DIR_OVERRIDE (default: scripts) for fixtures. Exit 0 clean, 1 on any hit or an exemption marker that excuses no guard this pass classified, 2 on operational error.
scripts/check-harden-runner-block.sh¶
Lint: every step-security/harden-runner step uses egress-policy: block with a non-empty allowed-endpoints list, preventing network-level egress to unlisted hosts.
scripts/check-harden-runner-first.sh¶
Lint: every job in .github/workflows/*.yml (or .yaml) begins
with step-security/harden-runner@<sha> as its first step, so the
eBPF monitor installs before any I/O.
scripts/check-harness-assert-wired.sh¶
Lint: every output-asserting test harness under
tests/*.test.sh is wired to the cross-scenario discrimination gate
in scripts/lib/harness-assert.sh, no harness registers a
discrimination exemption, and every parity exemption comes from an
allowlisted harness.
scripts/check-harness-preamble.sh¶
Lint: every test harness matching tests/*.test.sh
opens with the canonical preamble — #!/usr/bin/env bash as the
exact first line, set -Eeuo pipefail, the tab/newline IFS line,
and a readonly REPO_ROOT derived from
git rev-parse --show-toplevel.
scripts/check-job-timeout-minutes.sh¶
Lint: every job under .github/workflows/*.yml
declares an explicit timeout-minutes, bounding blast radius
from hung jobs. Reusable-workflow jobs are exempt.
scripts/check-jsonschema.sh¶
Validate repo config files (renovate.json, workflow
YAML, composite-action YAML, .markdownlint.json) against pinned
JSON Schemas using check-jsonschema.
scripts/check-lib-source-tool-free.sh¶
Lint: a script under scripts/ — sourced libraries
included — never resolves a source/. library path through a
command substitution, whether the substitution names BASH_SOURCE or
something else 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 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 is true 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 then reads (_lib_dir=" $(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" followed by source "${_lib_dir}/lib/log.sh"), 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 comment line
may still name either banned shape without tripping the check on its
own documentation.
Breadth is asserted on the same count the source-path half of the rule
scans: the run reports how many library source .../lib/*.sh lines it
read, and reading none is a broken scan reported 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.
Honors SCRIPTS_DIR_OVERRIDE for fixtures. Exits 0 clean, 1 on any violation, 2 if the scan set is empty or unreadable.
scripts/check-lint-shell-tools.sh¶
Assert every tool the batched .#lint-hosted invariant-lint
groups (lint-workflow-security, lint-script-hygiene) rely on is present on
PATH. These groups run inside devShells.lint in CI; this guard turns a
dropped tool into a named failure instead of a cryptic mid-check error.
Keep EXPECTED in sync with the lintTools list in nix/devshell-lint.nix.
scripts/check-lock-derived-docs.sh¶
Lint: every workflow that writes a flake.lock runs a
generator for each freshness hook that declares flake.lock a
trigger, and may commit exactly the lock plus the outputs those
generators declare with @generates / @generates-block.
scripts/check-manifest-digest-pinned.sh¶
Lint: every multi-arch manifest-creating docker command references its SOURCE images by immutable digest, never by a mutable tag.
scripts/check-manifest-hook-watches-nix.sh¶
Lint: every pre-commit hook that reaches the flake hook
manifest — by running a manifest-reading script, or by building a flake
attribute a manifest-reading module assigns — includes nix/hooks in
its files regex.
scripts/check-min-permissions.sh¶
Strict least-privilege lint for GitHub Actions
GITHUB_TOKEN scopes: top-level permissions: {} and an explicit
per-job permissions: block in every workflow.
scripts/check-nix-run-pinned.sh¶
Lint: ban any nix invocation against the bare
nixpkgs registry ref across workflows, composite actions, scripts,
and shell-fenced markdown. Allowed alternatives use the repo's own
flake or an explicit commit pin.
scripts/check-no-opaque-procsub.sh¶
Lint: no script anywhere under scripts/ feeds a
redirection from a process substitution. A substitution runs in its own subshell, so set -Eeuo pipefail only ever sees the status of the command the redirection
feeds — never the producer's. A failed producer hands the consumer
empty output to score as data: either a clean pass that flags nothing
(fail-open) or a substantive violation the input never showed (a
tooling fault misdiagnosed as drift). That property holds for whatever
the producer is, so the rule does not ask: every < <(...) is a hit,
with no exemption.
diff <(...) <(...) stays legal: diff consumes both substitutions as
file arguments and its own exit status is what the caller acts on, so
no status is lost.
Use the capture-into-variable idiom (or a temp file for NUL-delimited output) so a producer failure aborts loudly.
The scan recurses. A shared library under scripts/lib/ runs inside
whichever caller sources it, so a lost producer status there is lost
for every script in the tree at once — the widest blast radius the
banned shape has, and the one a top-level-only glob never reads.
Honors SCRIPTS_DIR_OVERRIDE (default: scripts) for fixtures. Exit 0 clean, 1 on any hit, 2 on operational error.
scripts/check-notify-arms.sh¶
Lint: every stated arm list of a scanner notify job must
equal the arms its workflow actually files on. An arm is a result of the
watched job that makes the notify job open or update its deduped issue.
The arms are derived from the workflow, never written down here: the
notify job's if: gate is evaluated over every combination of the
watched job's result, its has-finding output and the triggering event,
and the result: it hands the notify-workflow-result composite decides
which of those combinations file an issue.
A prose site declares the arm list it restates with a marker comment in the same paragraph, after text on the same line:
A failure with no finding, 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, in any order:
finding the watched job failed and its `has-finding` output is 'true'
failure the watched job failed with no such finding (or declares no
`has-finding` output at all)
cancelled the watched job was cancelled, and the composite files it
(as an infrastructure failure when handed the raw result)
success the watched job succeeded, yet the notify job files an issue
skipped the watched job was skipped, yet the notify job files an issue
non-pr the workflow runs on pull_request, and no arm files there
Every notify job in the scanner workflows (scorecard-drift-check,
zizmor-drift-check, octoscan, codeql and image-cve-scan) must carry one
marker in its own issue body:, where an HTML comment does not render,
and at least one in the Markdown docs. A marker naming any other
workflow's notify job is checked the same way. A docs marker that
declares cancelled must share its paragraph with the word "cancel" as
a word in page text (cancel, cancels, canceled, cancelled, canceling,
cancelling or cancellation, but not inside a hyphenated compound such as
cancel-in-progress, and not in a link destination, image alt text or an
HTML tag), because the cancelled arm is the one prose kept dropping. The body is exempt from
that word, because the composite prefixes a cancelled run's issue with
its own notice, and a body is read as plain prose, so a marker on a line
of its own counts there.
Docs are read as paragraphs, split at blank lines, list-item starts, thematic breaks and changes of quote depth; a heading and each table row (after a delimiter row, with or without edge pipes) are blocks of their own. A docs marker cannot sit on a line an HTML block holds: a comment that opens a line, at any indent, starts one, which cuts the marker off from the paragraph it describes (and, in a list item, can swallow the item after it); so does the rest of the line a comment block closes on, and so does every line from a block-level tag to the next blank line. Fenced blocks and inline code spans are skipped, which is how a document shows the marker without declaring anything; a fence line carrying an info string never closes a fence.
The gate grammar is the one these workflows use: always(), the
operators ==, !=, ! (on one operand), &&, ||, parentheses,
quoted strings, github.event_name, and the watched job's result and
outputs.has-finding, with context names and string comparison
case-insensitive. A gate without always() carries GitHub's implicit
success(). A declared has-finding output is tried as 'true', 'false'
and empty; the events tried are the ones the workflow's on: names:
what yq prints for a scalar carrying the string tag, for the items of
a list or for the keys of a map, whatever tag the list or map carries,
split at spaces, tabs and newlines. on: is read from ON_NODE, so an
alias in it is read through. A job of a workflow whose on: is any
other shape, or prints no event, or whose file yq reads as several
YAML documents, is refused when it has to be derived, and so is one
whose needs: is not one job named by a scalar carrying the string
tag (read through an alias, including an alias of a list whose one
item is an alias of the job's name). So is
a job whose notify step has an
if: of its own, follows a step other than step-security/harden-runner
or actions/checkout (each owner and repository read with its ASCII
letters in either case), runs the composite twice, or reaches it by any
other uses: than ./.github/actions/notify-workflow-result. The
composite's own result handling (failure and cancelled file, success
closes, skipped does nothing) is taken as fixed: its text from runs:
through the success branch is pinned by hash, and a gated step is
refused, so a change there stops this lint rather than silently changing
what an arm means.
A workflow whose jobs: holds a key GitHub Actions refuses (an empty
one, one holding a tab, a line break, a carriage return or a NUL, a
non-scalar one, or a merge key) is a finding and its jobs are not read: the key would be listed as
other names or as none, hiding a notify job. Every yq read of a
workflow passes YQ_MERGE_SPEC, so a merge list (<<: [*a, *b]) inside a
job is read first mapping wins, as the YAML merge specification says.
Such a workflow is the one finding: it is not also reported as a scanner
workflow without a notify job, and a marker naming one of its jobs is not
judged against jobs that were never read.
Exit codes: 0 every marker matches its job's derived arms and every
scanner notify job carries its markers, 1 a marker disagrees with the
derived arms, names a job that is not a notify job, is malformed, sits on
a line an HTML block holds, sits in the wrong body, or a docs marker
declaring cancelled has no cancel word beside it, or a scanner notify job
is missing a marker or files on no arm (details printed to stderr), 2
the check could not run: a required tool is missing, the composite or a
scanner workflow is missing or has changed, a yq read of the composite
or of a workflow fails, as it does on a file it cannot parse (the line
names what was being read and the status yq exited with), a job it
must derive has a gate, needs:, result:, notify step or on: outside
what it models, a scanner workflow has no notify job, the scan set could
not be listed or is empty, or a scanned file leaves a code fence or HTML
comment open
scripts/check-notify-label-descriptions.sh¶
Lint: every label-description handed to the
notify-workflow-result composite fits GitHub's 100-character label
description cap, and no label name carries two different descriptions
across the workflows that file it.
scripts/check-orphan-invariants.sh¶
Lint: docs/invariant-index.md and docs/**/*.md stay in lockstep — every index pointer resolves to a real file, and every non-EXEMPT docs file has an index entry.
scripts/check-patch-tag-pins.sh¶
Lint: every SHA-pinned uses: in workflow / composite
action files carries an exact patch-tag comment — present, and shaped
as # v<major>.<minor>[.<patch>] with at least two numeric components
(e.g. # v1.2.3). A missing comment, a comment naming no version
(e.g. # main), and a floating major-tag comment (e.g. # v1) are
all violations. The only escape is an inline
# patch-tag-exception: <reason> marker on the same line.
scripts/check-path-hygiene.sh¶
Lint: no path tracked in this repo, nor any untracked path a commit is about to introduce, may contain a byte in the 0x01-0x1F control range or DEL (0x7F). A newline defeats every line-oriented path handoff in this repo's own tooling, and a tab corrupts the tab-separated inventories those handoffs produce just as surely, so both stay in scope alongside the rest of the control range.
scripts/check-payload-shape-scenario.sh¶
Lint: every script that reads an externally-supplied
payload carries a harness scenario feeding it a malformed payload and
asserting exit 2. 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. This lint therefore gates the scenario's
existence, not the gate's source text: grepping a script for
require_json_payload would pass a script that calls it on a path a
scenario never exercises, and would fail a script whose gate is
hand-rolled (die_op) but genuinely covered.
scripts/check-payload-source-helper.sh¶
Lint: no shell file names a payload's source by hand, and
no shell file under <scripts> (excluding lib/) reads a JSON payload
by hand either.
payload_source_into (scripts/lib/payload.sh) fills a caller variable
with the override variable's name when a fixture supplies the payload
and with the API route or config filename otherwise, so an assignment
that sets a variable to a bare *_OVERRIDE variable name is a copy of
a rule the library already owns. Ten such copies existed across nine
scripts, in two spellings that had already drifted apart from each
other, which is what a rule with no enforcer costs: the shape gate they
all feed reads the source name verbatim into every diagnostic, so a
copy that resolves the override differently from the reader beside it
names a source the run never used.
--- Why an AST, not a grep ----------------------------------------------
The banned shape is a text-shaped shortcut, and a gate whose purpose is
rejecting one must not accept one itself. A textual matcher keyed on
=('|")?[A-Z_]*_OVERRIDE reads the override name out of prose, out of
a heredoc, and out of this file's own diagnostics; it also misses the
same assignment written unquoted. shfmt --to-json answers the only
question that matters — is this a variable assignment whose entire
value is one literal word spelling an override variable's name — and
answers it identically for all three spellings the parser distinguishes:
a single-quoted word, a bare literal word, and a double-quoted word
wrapping one literal part.
Both CallExpr assignments (src='X_OVERRIDE') and DeclClause
arguments (local src='X_OVERRIDE', readonly, declare, export)
are read. The parser files those under different node types, and the
two scripts that named a source inside a function body would have had
the declared form available to them, so a scan of bare assignments
alone leaves the shape most likely to be written next unreachable.
--- Exemption -----------------------------------------------------------
An assignment 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 — is excused by an inline
# payload-source-exempt: <rationale> marker anywhere in the file,
matching this repo's existing 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. A marker with an empty rationale excuses nothing
— 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, and that report is produced before any violation is,
so a stale marker surfaces on a tree where the rule is otherwise
obeyed everywhere.
--- The read rule ---------------------------------------------------------
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. A cat -- <path> command
skips every one of those guards, so a hand-rolled read that later
feeds require_json_payload reproduces the helper's job with none of
its could-not-run handling — the same cost the source-naming rule
above exists to stop, one level earlier in the same read.
The rule is broader than "feeds require_json_payload": every
cat -- <path> command under <scripts> outside <scripts>/lib is a
violation, whether or not this run can prove its output later reaches
the shape gate. A predicate that only fires when it can trace the read
into require_json_payload by variable name misses a read wrapped in
its own fetch function — the read and the gate then share no variable
name for a dataflow check to follow — so the rule does not attempt
that trace at all.
Capturing the output is likewise not part of the predicate. A read
whose bytes go straight to stdout skips the same three guards a
captured one does, and it is the shape a fetch helper writes when its
caller does the capturing, so scoping the rule to x="$(cat -- ...)"
would exempt the reads most likely to be written next.
Two shapes read a temp file without hand-rolling anything: a read
whose path traces back to a make_temp (or mktemp) result in the
same file is exempted automatically, and a # payload-read-exempt: <rationale> marker excuses the rest, matching this file's
payload-source-exempt marker in every other respect — a marker with
no rationale excuses nothing, and a marker on a file holding no
cat -- command the rule matches is itself reported, before any
violation is.
What the rule keys on also bounds what it reaches: a payload read
written as $(<file), mapfile, a while read redirection, or a
file operand handed to jq/yq is not a cat -- command and no part
of this scan sees it.
--- Breadth -------------------------------------------------------------
The scan set is <scripts>/*.sh, <scripts>/lib/*.sh and
<tests>/*.sh. The library arm is not optional: the helper itself
lives there, its neighbors are the files most likely to copy it, and
the older shell-hygiene lints in this repo stop at the top level. The
read rule narrows to <scripts>/*.sh alone — a hand-rolled read in a
library or a harness is not the shape this rule polices, since neither
one is a caller deciding how to read its own payload. The clean
verdict reports files scanned, assignments examined, and reads
examined rather than a bare "ok", because a detector that stopped
reaching either one and a tree with nothing to report emit the same
exit code otherwise.
Measured, not assumed: this file's own prose does not self-match. The finished lint run against a scan root holding only this script reports zero violations across every assignment it examines there — each mention of the banned shape here is a comment or a format string, neither of which the parser files as an assignment — so this file needs no exemption marker of its own. The count itself is deliberately not quoted: it moves with every edit to this file, and a quoted tally that has drifted reads as a measurement nobody re-took.
Honors SCRIPTS_DIR_OVERRIDE (default: scripts), TESTS_DIR_OVERRIDE (default: tests), and LINT_ALLOW_EMPTY_SCAN for a scan root that deliberately holds no assignment and no read at all. Exit 0 clean, 1 on a hand-named source, a hand-rolled read, or a stale exemption marker, 2 when the scan set cannot be enumerated, holds no assignment or no read, a required tool is missing, or a file cannot be parsed as shell.
scripts/check-permission-scopes.sh¶
Per-job GITHUB_TOKEN write-scope allowlist lint for GitHub Actions. Fails when a job grants a write scope absent from .github/permission-scopes.yml, when an allowlist entry is stale, or when an allowlist scope list is not sorted.
scripts/check-pin-diff-isolated.sh¶
Lint: exactly one script under scripts/ writes to
linpeas-pin.json (bump-linpeas.sh), so the
release-on-bump.yml path-filter trigger contract is
self-enforcing.
scripts/check-pin-digest-provenance.sh¶
Lint: a pin digest may not move under an unchanged
version label. Diffs action pins (uses: <path>@<sha> # <version>)
in workflows/composite actions and the octoscan container digest
pair against the base ref; a changed SHA/digest whose version
comment did not change is a repointed released tag (the
digest-repoint supply-chain class) and fails. Floating-major pins
(# vN) legitimately retarget across patch releases, so instead of
a hard fail their new commit must be reachable from the upstream
default branch — a force-pushed dangling commit fails.
Both sides of a moved SHA are first dereferenced through the annotated-tag-object API, so a pin naming a tag object and a pin naming that tag's commit compare equal instead of reading as a repoint. This does not soften the gate: a tag object's hash covers the commit it tags, so retagging a released version moves the tag object too and the resolved commits still differ. Container digests name no git object and resolve to themselves.
scripts/check-pr-workflows-no-secrets.sh¶
Lint: no workflow triggered by pull_request /
pull_request_target references any secrets.* other than
secrets.GITHUB_TOKEN.
scripts/check-pre-commit-hooks-sha-parity.sh¶
Lint: the SHA embedded in flake.nix's
pre-commit-hooks input URL matches flake.lock's pinned
pre-commit-hooks.locked.rev.
scripts/check-prose-ci-names.sh¶
Lint: every CI job or required check named in prose must resolve to something that really runs. Prose that puts a backticked name immediately against the claim noun — either order, for both "job" and "required check" — is asserting that the name is a unit CI schedules. Two ways that assertion goes wrong, both invisible to a freshness gate because nothing generates the sentence:
ghost the name exists in no workflow, no lint group and no harness
roster, so the sentence points at nothing.
mislabel the name resolves, but not to the kind of thing the sentence
calls it. A lint-group member or harness-roster entry runs
inside a batched group job, so it is never a standalone job.
A whole workflow is a CI unit, so calling it a job is loose
rather than wrong — but a required-check context always names
a job, so a workflow under a check noun is still a mislabel.
The valid-name set is derived, never listed: every workflow's jobs:
keys, every workflow's own bare basename, every lint-group name and
member in .github/lint-groups.yml, and the first pipe-delimited field of
every entry in the harness 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 merely near the word
"job" is usually a different noun entirely — one naming a list, or an
artifact — so a proximity window flags those at a rate that makes the
lint unusable. A comma-and-and list stands wherever a single name may,
on either side of the noun, and the copula forms cover "is a job" and
"are required checks". Two adjacency shapes are still not claims and are
dropped: a filename-shaped name, which names the file a job lives in,
and an adjectival use (job output, job inputs), which describes a job's
field rather than naming a job, whether one name holds that position or
several coordinated ones do.
Fenced lines are skipped outright — they leave the prose tally as well as the scan — because a fence quoting workflow YAML is showing a name rather than claiming one. A fence closes only on a marker of the same character that is at least as long as the one that opened it, so a shorter or different marker quoted inside is content; an equal-or-longer one closes it even when it carries an info string, which this does not model. Blockquoted fences count. Indentation is uncapped, because this repo's list-item fences are indented past the CommonMark limit, so a document displaying a marker inside an indented block opens a fence the lint believes is real. A file ending with a fence still open is therefore a precondition failure rather than a clean file. Inline code spans are kept: they are what the lint reads. A name in bold or italic with no code span is not read, and neither is a code span wrapped in its own emphasis markers, which stand between the span and the noun.
A jobs: key GitHub Actions refuses (empty, holding a line break or a
tab, not a scalar, or a merge key) is a finding naming the workflow and
the key. Such a key is never a job name: a line-break key does not
resolve as the two names it spells, and the jobs a merge key brings in
resolve as the jobs they are. The refusal is the only finding for that
file: the names it can still resolve are listed, so a sentence naming one
of them is not reported as a ghost.
Exit codes:
0 every name claimed in prose resolves to something the sentence's own
claim noun admits
1 ghost or mislabel name(s) found, or a workflow with a job key
GitHub Actions refuses (details printed to stderr)
2 the check could not run: a required tool is missing, a temp file
cannot be created, a missing or empty name source, a producer that
lists or reads the scanned files failed, an empty scan set, or a
scanned file that leaves a code fence open, among others
scripts/check-pull-request-target-absent.sh¶
Lint: hard-fail if any workflow under
.github/workflows/*.yml uses the pull_request_target trigger,
foreclosing the canonical Actions privilege-escalation footgun.
scripts/check-ratchet-pin-audit.sh¶
Lint: the ratchet-pin-audit workflow keeps its hardened shape — empty top-level permissions, harden-runner first, per-job permissions and timeouts, typed reason tokens in the notify body, ratchet in the nix/devshell.nix devShell, and a documented ratchet version matching the one the devShell ships, among others — so future edits cannot silently weaken it.
scripts/check-regex-range-ascii.sh¶
Lint: a bash [[ … =~ … ]] regex, a GNU grep regex
pattern, or a GNU sed script, in scripts/*.sh, scripts/lib/*.sh
or tests/*.sh must not hold a locale-bound range between ASCII
letters or digits unless it is matched under a C locale. A range
follows the locale's collation: under en_US.UTF-8 [0-9] in a bash
regex also matches digits such as ٣ and 5, and [a-f] matches
é, while the CI runner's C.UTF-8 matches ASCII only. A validator
written with a range therefore passes on a developer's machine a value
CI refuses, and the difference is invisible to CI.
In bash, every such range is reported. GNU grep keeps a positive bracket
whose ranges all run between digits ([0-9], Q[1-4], [0-9.]) ASCII
in every locale, so only a bracket that holds a letter endpoint
([a-z], [0-9a-f], [A-Za-z0-9._/-]) or is negated ([^0-9]: under
en_US.UTF-8 ٣ falls outside it) is reported for grep. GNU sed follows
the same bracket rules, so a sed script is judged the same way.
A test is matched under a C locale when it follows a
local LC_ALL=C or local LC_ALL=C.UTF-8 that is a direct statement
of its own function (ascii_match in scripts/lib/ascii-match.sh is
one), or follows a top-level export LC_ALL=C or
export LC_ALL=C.UTF-8; the value is one word, unquoted or in single
quotes, the last LC_ALL word on its line. A later assignment,
declare, local or export (also behind builtin or command, and
with the whole assignment quoted) that sets anything but one lone C or
C.UTF-8 word, or a later unset LC_ALL, wait -p LC_ALL, read,
mapfile, getopts, printf -v, a let, (( )) or $(( )) that
holds a literal LC_ALL, read or written, a C-style for whose header
or body holds one (echo LC_ALL counts; $LC_ALL is not a literal),
exec {LC_ALL}> or for LC_ALL in, in the same function or outside
any function, ends that; an element name (LC_ALL[0]) counts as
LC_ALL, except inside a let, (( )), $(( )) or C-style for
header. A write through eval or a nameref is not read, nor is the
order in which functions are called. A declaration
inside a subshell, a command substitution, a conditional or a
background statement does not start a C locale; an unset or write in
one still ends it, as do unset -f, unset -n and
command -v unset LC_ALL, so a range after them is reported although
the shell would match it as C. A backslash-newline inside an operator or
a regex is joined before it is read. Otherwise the class is spelled out
([0123456789]), or the text is matched through ascii_match.
The =~ tests are read from the shfmt --tojson parse tree, and the
operator from the source text between the two operands, since shfmt
releases encode the operator differently. A file is parsed when it
holds ~, holds the marker text, or holds a grep, egrep, fgrep
or sed word together with range-shaped text (a bracket-aware match:
classes, equivalence classes, collating symbols, and a leading ] or
^]); any other file is not parsed. That match reads line by line, so
a grep or sed range split by a backslash-newline inside its bracket,
in a file with no other sign, is not seen. A regex held in a variable
is read through the assignments to that variable and the items of a
for loop over it in the same file, following variables they name,
three levels deep. A quoted operand (=~ "[0-9]") is a literal match,
but is read like an unquoted one and reported; so is a function whose
local LC_ALL value is in double quotes. Unquote it or spell the
class out.
A grep call is a command whose word, after leading command or
builtin words and their options and env with its options and
NAME=value words, is grep, egrep or fgrep; command -v grep is
a lookup, not a call. env options are skipped, and -u/--unset
and -C/--chdir consume their argument; env -S is not read. A
call's patterns are its -e/--regexp operands, or, when there are
none and no -f/--file, its first operand; options are read
wherever they stand, up to --, and an option's own argument (-m 1)
is never a pattern. The grep options read as taking an argument are
-e, -f, -m, -A, -B, -C, -d and -D, and the long
options --regexp, --file, --max-count, --after-context,
--before-context, --context, --label, --include, --exclude,
--exclude-from, --exclude-dir, --binary-files, --devices and
--directories. A first operand before any -- that is a plain
variable ("$opt", ${opt}) with a same-file value starting with -
is an option, so the call is unresolved. A call with
-F/--fixed-strings holds no regex and one with
-P/--perl-regexp matches by code point; neither is read. A call
runs under a C locale when an LC_ALL=C or LC_ALL=C.UTF-8
assignment prefixes it (also through env), or when it stands in a
scope that sets a C locale as described above; a prefix with any other
LC_ALL value overrides that scope, and so does env -u LC_ALL (or
--unset) unless a later env word sets LC_ALL=C again. Only
LC_ALL is read. A pattern held in a variable is followed as a =~
regex is. Inside a pattern, a $name or a backtick in single quotes
is still read as an expansion, as for =~: a single-quoted $name is
followed, and a backtick counts the call as unresolved. That also
reads a sed $ address followed by a command letter ('$d') as the
variable d, so such a call can be reported through a same-file d
value.
A sed call is found as a grep call is: the same leading command,
builtin and env handling, and the same prefix and scope C-locale
rules. Its scripts are its -e/--expression operands, or, when there
are none and no -f/--file, its first operand; -f/--file makes the
call unresolved. -l/--line-length take an argument, and -i takes
the rest of its word as a backup suffix. The whole script text is read,
so a range in replacement text or in a y command is reported too; mark
such a call. A sed script held in a variable is followed as a grep
pattern is, and a first operand that is a variable holding an option
makes the call unresolved.
A grep or sed call that is locale-bound on purpose carries the marker
# regex-range-ascii: locale-bound: <reason> on the line directly
above the line its command starts on (inside a pipeline too); the word
is exactly locale-bound, so locale-boundary is not a marker. A marker
with no reason, or one that exempts no reported call, is itself
reported. The marker never exempts a =~ test. A marker-shaped line
inside a heredoc is read as a marker.
Findings print sorted by line within a file, ties broken by message
text. The final line is the summary: regex-range-ascii: ok — scanned N file(s), N =~ test(s), N with a regex in a variable, N under a C locale; N grep call(s), N not regex (-F/-P), N under a C locale, N unresolved, N marked; N sed call(s), N under a C locale, N unresolved, N marked.
Not read: a regex or pattern that reaches the test or call as a
function argument, from a function's output or a command substitution,
from an array assigned as a list (re=(…)), from a sourced file, or
from a -f file or a sed -f script file; a call whose options come
from an array or $@ expansion; an abbreviated long option
(--regex=) or a separate-argument long option outside the set the
grep-call paragraph lists (--group-separator SEP); a regex inside
eval or bash -c text; grep or sed in workflow and composite-action
run: blocks, which run only on the runner, whose locale is
C.UTF-8; grep run through a wrapper command (xargs, find -exec,
timeout, nice, sudo and the like), which is neither read nor
counted, and so is sed run through one; and awk, jq and yq
patterns.
The summary counts each grep or sed call once, in the first bucket it
fits: not regex (-F/-P, grep only), then under a C locale, then
reported or marked when a range is found, then unresolved when a
pattern or option cannot be read.
Honors SCRIPTS_DIR_OVERRIDE (default: scripts) and TESTS_DIR_OVERRIDE (default: tests), and LINT_ALLOW_EMPTY_SCAN=1 for fixtures.
Exits 0 when no such range is found, 1 when one is. Exits 2 when the
check cannot run: shfmt or jq absent from PATH, a file in the scan
set it cannot read, a file in the parse set (described above) that
shfmt cannot parse, or a scan set matching no file.
scripts/check-renovate-config-validator.sh¶
Validate renovate.json against the upstream Renovate
config schema using renovate-config-validator --strict --no-global.
Catches typoed keys, wrong-type values, and unknown options that
per-tool linters miss. Complements scripts/check-renovate-invariants.sh,
which asserts repo-policy invariants on top of a valid schema.
Honors RENOVATE_JSON_OVERRIDE for fixture testing. Exits 0 on a valid config, 1 on any validation error, 2 when the check cannot run — the config is absent, unreadable, not a regular file, or the validator itself is not on PATH. None of those says anything about the config's validity, so none may borrow the rejection code.
scripts/check-renovate-invariants.sh¶
Lint: renovate.json carries the security-critical invariants — pinGitHubActionDigests, minimumReleaseAge, no top-level automerge, per-manager pinDigests for github-actions.
scripts/check-renovate-markers-matched.sh¶
Lint: every # renovate: datasource=… marker in the tree is
live — some renovate.json customManager scopes the marker's file (a
managerFilePattern matches the path) and matches a line in it (a matchString
matches). A customManager that matches none of its declarations freezes the
dependency silently outside automation coverage; this check fails CI before
that can happen.
Coverage is file-level, not marker-line-level: marker styles differ (inline,
where value + # renovate: share a line; and above, where the comment sits
on its own line and the matched value is on the next). Asserting the marker's
file is consumed by a live manager handles both without a line-adjacency
heuristic.
Honors RENOVATE_JSON_OVERRIDE (config path) and SCAN_ROOT (tree root) for fixture testing, and LINT_ALLOW_EMPTY_SCAN=1 to accept an empty scan set. Exits 0 when every marker is live, 1 on any dead marker, 2 on a tooling error — the config cannot be read at all (absent, unreadable, or not a regular file), its shape fails validation, the file enumeration failed or came back empty, a scanned file cannot be read, or jq cannot read a customManager's declarations — so no verdict about the markers is available and reporting one would blame a marker for a config-shape problem.
scripts/check-required-check-counts.sh¶
Lint: every declared count of the required-check set, and
every undeclared one in the phrasings the backstop reads, must equal the
number of data rows in the ## Required contexts table of
docs/security/required-checks.md. That table is the canonical set, and
nothing generates the sentences that restate its size, so a context added
to or removed from the ruleset leaves each of them silently wrong.
A count is declared, not guessed. A prose count carries a marker comment directly after its number:
The number immediately before the marker is the claim, so a paragraph that states two numbers stays unambiguous. The marker must follow a run of ASCII digits that stands alone, after whitespace or at the start of the paragraph. A number word, digits glued to anything else, a missing number, an unknown key, or any other HTML comment that names a count is reported rather than skipped, because a marker that resolves to nothing is a count nobody checks.
A backstop catches a count written without a marker: a number of up to three digits or a number word, after whitespace or an opening parenthesis or at the start of a paragraph, followed within two words by "required check(s)", "required status check(s)", "required context(s)" or "required status context(s)". Emphasis markers and HTML comments count as whitespace. The words between may not include "of", so a partitive ("one of the required checks") is not a count, and the bare word "one" reads as an article rather than a count. Anything wider matched unrelated numbers on the real tree (a cron time, a sentence counting something else), so other phrasings of the count are not seen. 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 still one phrase. Fenced blocks, inline code spans and comment blocks (an HTML comment opening a line at up to three spaces of indent and closing on a later one) are skipped, which is how a document shows the marker or the phrase without making a claim. Fences are tracked marker-aware, as in check-prose-ci-names.sh, and a file ending inside a fence or a comment block is a precondition failure.
Exit codes: 0 every declared count matches the table and no undeclared count was found, 1 a count disagrees with the table, is undeclared, or carries a malformed marker (details printed to stderr), 2 the check could not run: a required tool is missing, the table doc is missing, holds no Required contexts section or more than one, holds no table, a table with no separator row, a second table or no data rows there, the scan set could not be listed or is empty, or a scanned file leaves a code fence or a comment block open
scripts/check-required-checks-no-paths.sh¶
Lint: no workflow listed in
docs/security/required-checks.md declares paths: or
paths-ignore: under on.pull_request: — avoiding the auto-merge
path-filter skip trap.
scripts/check-run-block-strict.sh¶
Lint: 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.
scripts/check-scorecard-threshold.sh¶
Reads OSSF Scorecard JSON on stdin; exits 1 if any check scored below 10, exits 2 if stdin cannot be read as scorecard JSON at all. Prints offender names + scores, or the could-not-run diagnostic, to stderr.
scripts/check-script-has-test.sh¶
Lint: every scripts/check-*.sh has a matching
tests/check-*.test.sh and vice versa, modulo an explicit EXEMPT
list.
scripts/check-script-shebang-pipefail.sh¶
Lint: every executable script under scripts/ starts
with #!/usr/bin/env bash (exact first line) and carries
set -Eeuo pipefail as its own line (line-anchored; a trailing
addition such as -x is accepted); every sourced library
under scripts/lib/ satisfies the inverse.
scripts/check-scripts-reference-roundtrip.sh¶
Lint: every piece of script-header text reaches docs/reference/scripts.md as written. The page is generated, and its freshness gate compares the committed page with a fresh render, so a generator that drops or rewrites header text agrees with itself and stays green. This check compares the other two ends instead: what each header says, read by an independent reader, against what the committed page shows once rendered the way the site renders it: python-markdown with the extensions mkdocs.yml loads.
Scope is the generator's own: every scripts/*.sh not starting with an
underscore, read header-only, and every scripts/lib/*.sh, whose
function @description blocks are read as well. The reader splits a
header into units at each tag. A unit's text must appear in that script's
or function's entry: prose with whitespace collapsed and code-span
backticks removed, a table in description text as its cells' words
without pipes or delimiter row, an @arg, @option, @exitcode or
@stdout as one whole item of its list, and an @example as the entry's example block,
line for line. Prose after a blank comment line that closes an @arg, @option,
@exitcode or @stdout is a further description unit. An indented run
whose lead-in line ends in a colon must also stay one preformatted block,
line for line, because collapsing it keeps every word and still loses the
shape. Header text the generator never reads is a finding too: prose
before the first tag, other than the path line, the shebang and a
# shellcheck directive; a rendered tag such as # @arg in a comment
block between the header's first blank line and the first line of code;
and, in a library, a rendered tag outside a function's @description
block, or a @description block that no function line follows.
Text outside a code span is Markdown on the page, so a <placeholder>
reads as an HTML tag, a < in <- shows with a backslash, and a glob's
asterisks can open emphasis. Put such text in backticks. Escaping a <
cannot help: the formatter rewrites the escape into one the site's
renderer does not honor.
This wrapper enumerates the files; the checker itself is scripts/_scripts_reference_roundtrip.py.
Env overrides (test-only):
SCRIPTS_DIR_OVERRIDE — alternate scripts/ root
SCRIPTS_REFERENCE_DOC_OVERRIDE — alternate rendered page
SCRIPTS_REFERENCE_MKDOCS_OVERRIDE — alternate mkdocs.yml, whose
markdown_extensions the page is rendered with
Exit codes:
0 every annotation unit is published intact
1 header text the page drops, alters or collapses (details on stderr)
2 the check could not run: git or python3 is missing, no git work
tree is found while an override is unset, the page, its markers,
mkdocs.yml or the checker is missing, python-markdown or PyYAML is
not importable, mkdocs.yml's markdown extensions cannot be read,
loaded or render the page, the config inherits another, the scripts
or lib glob matches nothing (unless LINT_ALLOW_EMPTY_SCAN is set), a
header is not UTF-8 or cannot be read, no annotation text was found,
the checker raises an uncaught exception, or it exits with any
status other than 0, 2 or 3
scripts/check-setup-nix-required.sh¶
Lint: every workflow installing Nix goes through the
composite ./.github/actions/setup-nix — no vendor Nix-installer
action directly — and passes
github-token: ${{ secrets.GITHUB_TOKEN }}.
scripts/check-size-label-ignores.sh¶
Lint: the size-label action's IGNORED list holds exactly
the files this repo's generators declare they own — every @generates
path is on the list, no @generates-block path is, and every other
entry is one of this lint's declared exemptions.
scripts/check-test-reachable.sh¶
Lint: every test harness is executed by some runner. Two scan
sets: tests/*.test.sh, and the tracked harnesses under .claude/. The
second comes from git ls-files rather than a glob because .claude/ is not
gitignored and is overwhelmingly untracked — a glob would report dozens of
local-only harnesses as violations, and only the committed set is a CI
concern. check-script-has-test guarantees a test FILE exists for each script;
it does not guarantee the test ever RUNS. A harness reachable by no runner is
a coverage no-op — the regressions it would catch pass green while the
pairing guard stays satisfied. Reachability is via one of four runners:
1. the HARNESSES array in scripts/run-harness-group.sh (harness-group job),
2. the tests/refresh-*.test.sh glob in scripts/run-doc-freshness.sh,
3. a .github/lint-groups.yml member -> tests/check-<name>.test.sh
(executed by scripts/run-lint-group.sh), or
4. a direct `tests/<x>.test.sh` invocation in a .github/workflows/*.yml.
A tests/ harness is keyed by basename and a tracked .claude/ one by its
repo-root-relative path, which is exactly how each is spelled in the
HARNESSES array, so the two key spaces cannot collide.
Overridable dirs/paths let the paired test harness point at fixtures.
Exits 0 if every harness is reachable, 1 otherwise. Exits 2 when the
check cannot run: a runner manifest, the lint-group manifest, or a
workflow file cannot be scanned for the harnesses it wires; the
tracked harnesses outside tests/ cannot be enumerated; a temp file
cannot be created; or a scan set comes back empty, which is a
could-not-run rather than a tree with nothing to check.
scripts/check-tool-guarded.sh¶
Lint: every third-party tool a script under scripts/
invokes must be guarded somewhere in that same script. A guard is
require_tool <tool>, a command -v <tool> availability test, or a
library helper that guards the tool on its caller's behalf.
An unguarded tool does not fail loudly. It fails as whatever the surrounding code does with a non-zero status, and every one of those readings is wrong:
* A shape probe written as `<tool> ... || die` reports the payload as
malformed. The operator opens a file that is intact and looks 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. The check exits 0 having read nothing, which is the only
failure mode here that no caller can see.
* An enumeration ending in `|| true` comes back empty, and a lint that
asserts 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 exit-code convention
does not catalogue.
The convention this protects: 2 means the check could not run, 1 means
it ran and found a violation, 0 means it ran and found none. A missing
binary is a could-not-run in every case, and require_tool is what
says so.
Scope is tools a shell can genuinely lack. POSIX utilities and
coreutils staples are assumed present: guarding grep in every script
that greps would cost a hundred lines to describe an environment that
does not occur, and a rule nobody believes is a rule that gets
exempted.
The rule is presence, not position. A parse tree reports where a word
was written, and in shell that is not when it runs: nearly every script
here defines its functions above the main that calls them, so a tool
invoked at line 100 inside a function routinely executes after a guard
written at line 700. Ordering those two correctly needs a call graph,
and comparing the line numbers instead reports the tree's ordinary
layout as a defect. What is checkable without one — and what the nine
faults this rule was written for all violate — is whether the script
guards the tool at all.
Detection reads command words from shfmt --tojson rather than
matching text. A tool name occurs in comments, in message strings, and
in @description prose, and none of those is an invocation; a ||
inside a sed or awk program text reads as shell control flow to a
line-oriented scan. The parser knows which words are commands and a
regex does not.
Honors SCRIPTS_DIR_OVERRIDE (default: scripts) and LINT_ALLOW_EMPTY_SCAN=1 for fixtures.
Exits 0 when every invocation is guarded, 1 when one is not. Exits 2
when the check cannot run: shfmt or jq absent from PATH, a script
shfmt cannot parse, or a scan set matching no script.
scripts/check-upload-artifact-strict.sh¶
Lint: every step whose uses: starts with
actions/upload-artifact@, its ASCII letters in either case, in every
workflow under .github/workflows/*.yml sets
with.if-no-files-found: error so empty-glob bugs hard-fail.
scripts/check-uses-sha-pinned.sh¶
Lint: every uses: in .github/workflows/*.yml (or
.yaml) and .github/actions/**/*.yml (or .yaml) ends with a
full 40-hex SHA, or is a local path-relative reference.
scripts/check-verify-reason-ladder.sh¶
Lint: the attribute failure reason step of
verify-latest-release.yml covers every id-carrying step of the
verify job, reads every env var it declares, documents every reason
token it emits, and walks the ladder in step-execution order.
scripts/check-workflow-ascii-space.sh¶
Lint: workflow and composite-action YAML holds no Unicode
space or line separator (U+0085, U+00A0, U+1680, U+2000 to U+200B,
U+2028, U+2029, U+202F, U+205F, U+3000) in any position. The lints that
scan these files by text read blankness with [[:space:]], which
matches U+1680, U+2000 to U+2006, U+2008 to U+200A, U+2028, U+2029,
U+205F and U+3000 in every UTF-8 locale, C.UTF-8 included, while YAML
separates tokens with space and tab only. U+0085 (next line), the
no-break spaces U+00A0, U+2007 and U+202F, and the zero-width space
U+200B are outside that class; they are in the list because each is a
line break, looks like a space or is invisible in a diff. A character
between a key and its value, in an indent or before a # could make a
lint and the consumer read the same line differently, and which reading
GitHub Actions takes cannot be checked offline. Refusing the character
keeps the text scanners and the consumers on one reading. A character
that is wanted as data is written as an escape inside a double-quoted
scalar ("\u2003"), which holds ASCII only in the file.
The search is grep --fixed-strings --text for each separator's UTF-8
bytes, which matches bytes, so the result does not depend on the locale
the lint runs in.
Scan set: .github/workflows/*.yml and *.yaml, and
.github/actions/*/action.yml and action.yaml. Honors
WORKFLOWS_DIR_OVERRIDE and ACTIONS_DIR_OVERRIDE for fixtures, and
LINT_ALLOW_EMPTY_SCAN=1 for an empty scan set.
Exits 0 when no file holds one, 1 when one does. Exits 2 when the check
cannot run: grep absent from PATH, the scan globs together matching
no regular file, or grep failing on a file.
scripts/check-workflow-concurrency.sh¶
Lint: every workflow under .github/workflows/*.yml
declares a top-level concurrency: block with a non-empty
group: string.
scripts/check-workflow-on-branches.sh¶
Lint: every workflow declaring on.pull_request: or
on.push: explicitly sets branches: [main] under that trigger
— no wildcards, no implicit all-branches.
Refresh scripts¶
scripts/refresh-ci-dag.sh¶
Regenerate the ci-dag managed block in docs/architecture/ci-dag.md from .github/workflows/ci.yml plus the docs/_data/ci-check-categories.yml map.
Options:
--check— exit 1 if the doc would change; exit 2 if an input file is missing, if ci.yml has needs: references to non-existent jobs, if ci.yml holds a job key the job list cannot carry (see scripts/lib/job-keys.sh), if the category map is neither one map nor empty, or if a tool fails to read them
scripts/refresh-ci-summary.sh¶
Regenerate the ci-summary managed block in README.md from required-checks.md plus the ci-check-categories.yml map.
Options:
--check— exit 1 if README.md would change; exit 2 if an input file is missing; do not mutate the working tree
scripts/refresh-enforcement-matrix.sh¶
Regenerate docs/security/enforcement-matrix.md from the inline enforcer annotations on every bullet of docs/invariant-index.md, with bidirectional orphan checks.
Options:
--check— exit 1 if the matrix would change; do not mutate the working tree
scripts/refresh-ephemeral-refs-gap.sh¶
Regenerate the ephemeral-refs-gap managed block in
docs/development/linting.md: the file types no ephemeral-refs extractor
claims and that nonetheless carry # comments. The corpus inverts the
lint's own type filter — same producer, same allowlist, all read from
scripts/lib/ephemeral-refs-scope.sh — so the page cannot describe a
narrower set of types than the ban leaves unread. Files the allowlist
skips sit outside both sets and are accounted for by the page's
allowlist bullet. A blocking-class shape found inside the corpus is
refused
rather than rendered: the prose around the block states the gap is
empty, and rendering a non-zero count would publish a page that
contradicts itself.
Options:
--check— exit 1 if the block would change; exit 2 if the check cannot run (doc missing, marker missing, no unclaimed sources, empty corpus, or a class regex that fails its canary) or if a blocking shape was found; do not mutate the working tree
scripts/refresh-flake-show.sh¶
Regenerate the flake-show managed block in
docs/reference/flake-outputs.md from nix flake show --all-systems.
Options:
--check— exit 1 if the doc would change; exit 2 if the check cannot run (doc missing, or nix flake show fails); do not mutate the working tree
scripts/refresh-just-recipes.sh¶
Regenerate the just-recipes managed block in
README.md and docs/reference/just-recipes.md from the current
just recipe list.
Options:
--check— exit 1 if either doc would change; exit 2 if either doc is missing; do not mutate the working tree
scripts/refresh-pin-parity.sh¶
Regenerate the pin-parity managed block in docs/architecture/auto-update.md: every tracked file carrying the canonical pin-shape literal, grouped by path into enforcement and documentation. The set is read from the tree on every run, so no hand-written list can name a file that has stopped carrying the shape or omit one that gained it. A file under tests/ is excluded; the block says so in prose, because a versioning-scheme migration touches the fixtures too and a silent omission would read as coverage.
Env overrides (test-only):
PIN_PARITY_ROOT_OVERRIDE — alternate REPO_ROOT. Fixture roots sit
inside this repo, so the git producer still runs against them and
reports paths relative to the override — which is also what keeps
the fixtures clear of the tests/ filter that would otherwise drop
every one of them.
PIN_PARITY_DOC_OVERRIDE — alternate output doc path
Options:
--check— exit 1 if the block would change; exit 2 if the check cannot run (doc missing, marker missing, or no tracked file carries the literal at all); do not mutate the working tree
scripts/refresh-precommit-table.sh¶
Regenerate the precommit-table managed block in docs/development/git.md from the current pre-commit hook manifest in the flake.
Options:
--check— exit 1 if the doc would change; exit 2 if the doc is missing; do not mutate the working tree
scripts/refresh-scripts-reference.sh¶
Regenerate the scripts-reference managed block in docs/reference/scripts.md from in-script shdoc-style annotations parsed by scripts/_script_docs.awk. Groups entry-point entries by basename prefix into Check / Refresh / Other sections, then renders each library under scripts/lib/ with one entry per annotated function in a Libraries section.
Options:
--check— exit 1 if drift; exit 2 if the doc or the awk parser is missing; do not mutate the working tree
scripts/refresh-test-harnesses.sh¶
Regenerate docs/reference/test-harnesses.md, the census of
every tests/*.test.sh harness: the file or file set it exercises and the
directories under tests/fixtures/ it reads. A harness names its subject
either by a SCRIPT= assignment pointing into scripts/ or by a
# @subject header annotation, and never by both, so the census can
neither render an unknown subject nor silently follow the stale one of
two disagreeing declarations. A fixture directory no harness names is
refused as well: a census that omitted it would describe a smaller tree
than the one on disk.
Options:
--check— exit 1 if drift; exit 2 if the doc is missing, a harness declares no subject or two, or a fixture directory is named by no harness; do not mutate the working tree
scripts/refresh-treefmt-config.sh¶
Regenerate the treefmt-config managed block in
docs/reference/treefmt-config.md from the enabled-formatter manifest
exposed by nix/treefmt-config.nix as devTooling.<system>.treefmtConfig.
Options:
--check— exit 1 if the doc would change; exit 2 if the check cannot run (doc missing, or nix eval fails); do not mutate the working tree
Other¶
scripts/apply-patch-tag-pin-rewrite.sh¶
Apply the patch-tag pin comment rewrite recorded in an inventory TSV produced by scripts/inventory-action-pin-tags.sh. Refuses to run if any recorded line content no longer matches the inventory (stale inventory protection) — aborts before mutating any file so the rewrite is all-or-nothing across the tree.
OK rows have target_comment populated and are applied in place.
NO_PATCH_TAG rows are skipped with a stderr warning.
Any API_FAILURE row aborts the run before any mutation.
Literal substring splicing via awk index/substr — no regex pitfalls on semver dots or path slashes.
Default inventory path: ${TMPDIR:-/tmp}/action-pin-inventory.tsv Override with --inventory PATH.
Honors LINT_ALLOW_EMPTY_SCAN=1 to accept an inventory carrying no rows.
Exits 0 on a completed run, 1 when the inventory is rejected (API failure row, unknown status, stale line content), 2 when a file the run needs is not there to read — an unknown argument, an inventory file that is absent or unreadable, an inventory with no rows in it, or a recorded target file that is absent. Nothing was inspected in those cases, so the rejection code would misreport an unread file as a rejected one; a stale line, by contrast, is read before it is judged.
scripts/bump-linpeas.sh¶
Bump linpeas-pin.json to the latest peass-ng/PEASS-ng release.
scripts/classify-backfill-image-mode.sh¶
Classify whether a release-on-bump backfill-tag run
should exercise the per-arch image pipeline. Given the presence
(present/absent) of six registry objects in the order
amd64@ghcr amd64@hub arm64@ghcr arm64@hub index@ghcr index@hub,
print full when all six exist, none when all six are absent, and
fail on any partial mix. Pure and side-effect free so the decision is
unit-testable without contacting a registry.
scripts/classify-pin-ref.sh¶
Classify one SHA-pinned action ref for
ratchet-pin-audit: given the pinned SHA and the tag's resolved git
objects, print current, drift, or skip-floating-major. Pure
and side-effect free so the drift decision — including the
attack-detection branch — is unit-testable without contacting the
GitHub API.
scripts/classify-refresh-notify-result.sh¶
Classify what renovate-flake-lock-refresh.yml's
notify job should report. Given the identify, compute-refresh
and push-refresh job results followed by identify's
should_refresh and unmapped outputs, print failure when a
refresh was attempted and did not land (or could not be attempted),
success when one landed or was already in place, and skipped for
the steady state where the ci completion was not a Renovate flake
bump. Pure and side-effect free so the decision is unit-testable
without a workflow run.
scripts/classify-renovate-flake-input.sh¶
Classify one Renovate PR title into the flake input that
PR bumps: prints pre-commit-hooks, nixpkgs-unstable, or
nixpkgs. Drives the identify job of
.github/workflows/renovate-flake-lock-refresh.yml, which runs
nix flake update <input> on the PR branch. Pure and side-effect
free so the mapping is testable without a live Renovate PR.
scripts/classify-renovate-pr-author.sh¶
Classify one PR author login as Renovate or not, printing
the canonical renovate spelling when it is. Drives the identify job
of .github/workflows/renovate-flake-lock-refresh.yml, which refreshes
flake.lock only on a PR that Renovate opened. Pure and side-effect
free so the mapping is testable without a live Renovate PR.
scripts/compare-repro.sh¶
Compare two reproducibility-build hash JSON files. Emits a markdown table to GITHUB_STEP_SUMMARY (or stdout if unset) and exits 0 on full match, 1 on any divergence, 2 on bad input. Bad input includes an absent, null, or malformed hash field: two builds that both measured nothing are not a match.
scripts/docs-audit-pressure.sh¶
Report docs-audit drift pressure since the last audit:
how many commits touched CI structure (.github/workflows, scripts,
.github/lint-groups.yml), and which job ids / lint-group members were
added or removed. Emits a Markdown body for the monthly docs-audit
reminder issue, terminated by a machine-readable PRESSURE=<n> trailer
that the reminder workflow reads and strips before posting.
Freshness gates validate only generated content; hand-written prose about CI drifts past every freshness gate. CI churn is the best cheap proxy for that drift, so it decides whether a semantic audit is worth running this month.
The diff base is the commit recorded in .github/docs-audit-state, which
scripts/mark-docs-audit.sh writes once an audit's fixes have landed. A
fixed-length window would measure churn the maintainer has already read
and audited, so its count could never fall to zero on a repo with a
steady commit rate — and the reminder issue's close condition reads that
count. Measuring from the audit point makes the number mean "CI-structure
commits nobody has audited yet", which is zero right after an audit and
grows only with unreviewed churn.
Body contents are restricted to integers and shape-validated identifiers parsed from YAML — never commit subjects or other free text, which would render as arbitrary markdown in the resulting issue.
A workflow holding a job key the list cannot carry (an empty key, one with a line break, a tab, a carriage return or a NUL, or one that is no scalar) would be misread: omitted, or listed as other names. Such a workflow is left out of the job lists at both refs and counted in one line of the body; the commit count and the exit status are unaffected.
A lint-group member the list cannot carry is not refused and not counted.
The member read splits yq's output on line breaks and drops a NUL, so a
member holding a line break is listed as two names and one holding a NUL
as one name without it; an empty member, or one holding a tab, a carriage
return or any other character outside a job id, is omitted. The list is
advisory and the verdict is the commit count, so a misread member changes
the list alone. check-ci-job-in-summary.sh stops CI on an empty member
and on one that spans lines, so a manifest that reached a ref the audit
measures does not hold either.
Honors LINT_ALLOW_EMPTY_SCAN=1 to accept a ref whose workflows dir holds no YAML.
Exit codes:
0 success (body on stdout, PRESSURE=<n> as the final line)
2 missing inputs / parse error / nothing enumerated to measure,
including an audit-state file that is absent, carries no
LAST_AUDIT_SHA=<40-hex> line, or names a commit this history does
not contain, and a workflow at either ref that git cannot show or
whose job ids or job keys `yq` cannot read
scripts/gen-dashboard-data.sh¶
Generate docs/_data/dashboard.yml for the MkDocs site by aggregating pin metadata and live GitHub REST API data.
scripts/inventory-action-pin-tags.sh¶
Enumerate every SHA-pinned uses: in
.github/workflows/*.yml|*.yaml and .github/actions/**/action.yml
(or action.yaml), resolve each pinned SHA to its exact patch tag via
gh api .../tags, and emit a TSV mapping pin -> patch tag for
downstream rewrite tooling.
scripts/mark-docs-audit.sh¶
Record the current commit as the point the semantic docs
audit was last run against. Writes .github/docs-audit-state, which
scripts/docs-audit-pressure.sh uses as its diff base and the monthly
docs-audit-reminder workflow reads through it.
Run this in the final fix PR of an audit cycle — once the audit's findings are fixed, not when the audit is dispatched. The marker means "everything up to here has been read and its drift resolved", and the reminder issue closes on the count it produces; marking at dispatch time would close the issue over findings still outstanding.
Writes the file only. Staging and committing stay with the caller, so the marker lands in the same reviewed PR as the fixes it vouches for rather than as a side effect of running a script.
Honors DOCS_AUDIT_STATE_OVERRIDE (default .github/docs-audit-state)
and REF_OVERRIDE (default HEAD) for fixtures.
Exits 0 once the marker is written. Exits 2 when it cannot be written: the ref does not resolve to a commit in this history, or the target path is not writable. There is no exit 1 — this script records a fact rather than judging one, so it has no finding to report.
scripts/octoscan-scan.sh¶
Run synacktiv/octoscan against .github/workflows
via the pinned ghcr container image. Single source of truth for
the image digest, the version label tracked by Renovate, the
noise-suppression flags, and the exit-code mapping shared by the
CI workflow and the pre-commit hook.
Usage:
scripts/octoscan-scan.sh # text output to stdout
scripts/octoscan-scan.sh --sarif <path> # SARIF output to <path>
Exit codes:
0 — scan clean
1 — findings present, the scanner ran and errored (image pull
failure, scanner internal error), or the command line is
unusable. A finding is told from a scanner error by the
`has-finding` line printed to stdout (`has-finding=true|false`)
— the same contract the CI workflow exposes via
`$GITHUB_OUTPUT` — and by the `classification=` field beside it.
2 — the scan could not proceed: `docker` is absent, `--sarif` was
given while `jq` is absent, or a per-file SARIF temp file could
not be created. The two tool guards report before any workflow
file is read and print the `infra-failure` classification
themselves; the temp-file failure reports itself, and can land
after part of the directory has already been scanned. Either
way the hook and the job still fail; only the diagnosis
differs.
Per-file iteration: octoscan v0.1.7 directory-target mode silently
returns exit 0 with empty SARIF even when a single-file invocation
against the same workflow flags a finding. Loop over each workflow
yaml, tracking "any file errored" separately from "any file has a
finding" (an error must not be masked by another file's finding), and
merge per-file SARIF runs[0].results into a single SARIF document for
upload only when no file errored.
Suppressions (CLI flags — --config-file is documented but
paths.<glob>.ignore is a no-op in v0.1.7):
--disable-rules local-action : repo intentionally uses
`./.github/actions/*` composite actions (e.g.
notify-workflow-result, setup-nix); every reference is a
false positive.
--disable-rules dangerous-write : every `>> "$GITHUB_OUTPUT"`
and `>> "$GITHUB_ENV"` is flagged regardless of input
trust; the rule has no notion of which writes carry
attacker-controlled data, so it is unworkably noisy here.
--ignore '(needs|steps)\.\*\*\.outputs\.\*\*' : `expression-injection`
fires on every workflow-internal `${{ needs.X.outputs.Y }}`
/ `${{ steps.X.outputs.Y }}` reference; those carry data
set by other jobs/steps in the same workflow, not external
input.
--ignore "actions/checkout' with a custom ref" : same regex
covers the renovate-flake-lock-refresh workflow's
`actions/checkout` with `ref:` set to a bot-controlled
branch — the ref source is internal, not attacker-supplied.
Renovate manages OCTOSCAN_DIGEST + OCTOSCAN_VERSION in lockstep (renovate.json customManager scoped to this file).
scripts/run-doc-freshness.sh¶
Run every doc-freshness regenerate-and-diff harness (tests/refresh-*.test.sh) in one devShell, printing a per-generator pass/fail summary table to stdout and $GITHUB_STEP_SUMMARY. Runs all harnesses even if one fails; exits 1 if any failed, 2 if none found.
scripts/run-harness-group.sh¶
Run every setup-tax failure-mode harness in one devShell, printing a per-harness pass/fail summary table to stdout and $GITHUB_STEP_SUMMARY. Runs all harnesses even if one fails; exits 1 if any failed.
Options:
--print-roster— print the HARNESSES roster, one pipe-delimited entry per line, and exit 0 without running anything
scripts/run-lint-group.sh¶
Run every invariant-lint check in a named group from .github/lint-groups.yml inside one devShell, printing a per-check pass/fail summary table to stdout and $GITHUB_STEP_SUMMARY. Runs all checks even if one fails; exits 1 if any failed, 2 on config error.
Libraries¶
scripts/lib/ascii-match.sh¶
Bash regex matching whose ranges hold ASCII only, a regex
that matches a text with its ASCII letters in either case, and a regex
that matches a secret reference in either form GitHub Actions reads.
Source after set -Eeuo pipefail.
ascii_match()¶
Match text against a bash [[ =~ ]] regex under
C.UTF-8, leaving BASH_REMATCH as the match sets it. A range such as
[0-9] or [a-z] follows the locale's collation, so under en_US.UTF-8
it also matches characters such as ٣, 5 or é; under C.UTF-8 a
range holds ASCII only. Character classes such as [[:space:]] keep the
reading C.UTF-8 gives them, the one the CI runner gives them too. The
locale is local to the call, so the caller's messages and tools keep
theirs.
Args:
$1— text$2— regex
Exit codes:
0— the text matches1— it does not2— the regex does not compile
ascii_fold_regex()¶
Print a regex that matches the given text with each of its
letters in either case, as a bracket of the two ASCII letters ([aA]),
for a bash [[ =~ ]] or a yq test(). A lint uses it to select an
action by its owner and repository however their letters are cased. A
case-insensitive regex flag also folds letters outside ASCII (the
Kelvin sign to k, the long s to s), and a bracket of two letters
does not.
Args:
$1— text of lower-case ASCII letters, digits,/,-and_
Exit codes:
2— the text is empty or holds any other character
Stdout:
- the regex, unanchored
secret_ref_regex()¶
Print a grep -E regex matching a reference to a secret
whose name the given regex matches, in both forms GitHub Actions reads:
secrets.NAME and the index form secrets['NAME'], with spaces inside
the brackets. The ASCII letters of secrets match in either case. Each
quote of the index form may be written twice, as a YAML single-quoted
scalar writes it. A double-quoted index is not matched: GitHub Actions
refuses a double-quoted string as an expression error. Nor is a name
the expression computes (secrets[format(...)]) or the whole context
(toJSON(secrets)). The dot form carries no bound after the name; a
caller matching a whole name adds one.
Args:
$1— regex matching the name, without anchors
Stdout:
- the regex, unanchored
secret_ref_name()¶
Print the secret name a reference matched by secret_ref_regex holds, in either form.
Args:
$1— the reference
Exit codes:
1— the text holds no name
Stdout:
- the name
scripts/lib/awk-path.sh¶
Make a path unambiguous as an awk file operand.
awk reads an operand whose text is name=value as a variable
assignment rather than a filename, then finds no file operand, reads
stdin, and exits 0 having scanned nothing — so a relative path whose
first component contains = scores as an empty file. -- is no help:
POSIX makes operand assignment parsing independent of it, and gawk
treats a -- placed after the program as a filename.
Source after set -Eeuo pipefail.
awk_path()¶
Print a path in a form awk cannot read as an assignment.
The prefix is conditional because ./ ahead of an absolute path
resolves as a relative one.
Args:
$1— path
Stdout:
- the path,
./-prefixed when relative
scripts/lib/enumerate.sh¶
NUL-safe filesystem enumeration with producer-status and
breadth assertions. A path is the one shell datum whose byte space
includes the newline delimiter, so a line-oriented handoff can drop a
file that exists: git ls-files C-quotes such a name onto one line,
find splits it across two, and either way the consumer's [[ -f ]]
gate skips it while the scan still reports a plausible file count.
Source after set -Eeuo pipefail.
enumerate_into()¶
Run a NUL-emitting enumeration into an array, asserting
both that the producer succeeded and that it found something. Breadth
is asserted rather than inferred, because a producer that exits 0 with
empty output is exactly the failure a status check cannot see:
GIT_INDEX_FILE=/nonexistent git ls-files exits 0 and emits nothing,
which reads as a clean tree. The producer writes to a temp file rather
than a process substitution: a procsub discards the status this
function exists to check, and is banned repo-wide for that reason. The
temp file is removed on every return path instead of under a trap,
because traps are global in bash and callers install their own.
The temp file comes from make_temp, not from a bare mktemp:
an unwritable TMPDIR makes mktemp exit non-zero, and an unguarded
failed assignment kills the calling script with mktemp's own exit
status (1) — a could-not-run reported as "ran and found a violation"
inside the one function whose job is making sure that never happens.
The read loop's || [[ -n ${__enum_item} ]] clause keeps a final
record that has no trailing NUL: read -r -d '' reports that record as
a failure even though it populated the variable, so a loop keyed only
on read's exit status silently drops the last path of a truncated or
malformed producer — the exact silent-drop failure this library exists
to end. git ls-files -z and find -print0 both terminate every
record including the last, so this only fires on a broken producer.
Args:
$1— name of the array to fill$2— human-readable label naming the producer, used verbatim in both diagnostics below. Callers supply this rather than it being derived from argv[0]: argv[0] ofgit ls-files -z ...is justgit, and a same-file wrapper function's name is even less informative to an operator reading the message.$@— the producer command and its arguments
Exit codes:
2— the producer failed, or the scan set was empty while LINT_ALLOW_EMPTY_SCAN was unset
glob_into()¶
Fill an array from one or more globs, asserting the match
set is non-empty. The glob analogue of enumerate_into: under
nullglob a pattern that matches nothing expands to nothing, the loop
body never runs, no violation is found and the lint exits 0 — a scan
root that exists and holds nothing is scored a clean tree. Patterns
arrive as strings and are expanded here rather than at the call site,
because a caller that has not set nullglob would otherwise hand this
function the literal unexpanded pattern and it would count as one
match — the helper vouching for exactly the tree it exists to catch.
globstar is left as the caller set it, so a ** pattern keeps the
reach it already had.
Args:
$1— name of the array to fill$2— human-readable label naming the scan set, used in the diagnostic$@— the glob patterns, quoted so they reach this function unexpanded
Exit codes:
2— the match set was empty while LINT_ALLOW_EMPTY_SCAN was unset
filter_into()¶
Narrow an enumerated path list to what a filter selects,
asserting the selection is not empty. enumerate_into and glob_into
assert the breadth of a scan set as it is produced; a filter applied
afterwards can throw all of it away again, and neither helper can see
that happen. A filter naming a file the tree does not hold leaves no
path to walk: the loop body never runs, no violation is found, and the
run exits 0 — the same clean line a genuinely clean tree prints. So the
selection is asserted where it is made. An empty filter value selects
everything, which keeps a caller that may or may not be filtering on
one code path rather than branching around this call.
Args:
$1— name of the array to fill$2— human-readable label naming the scan set, used in the diagnostic$3— the filter value: a basename to select, or empty to select all$@— the paths to select from
Exit codes:
2— the selection was empty while LINT_ALLOW_EMPTY_SCAN was unset
scripts/lib/ephemeral-refs-scope.sh¶
The ephemeral-reference ban's scan scope: which file types
an extractor claims, which paths are skipped outright, and the class
regexes a scan matches against. Shared by the lint that enforces the
ban and by the generator that reports which types the ban leaves
unread, so a class that widens widens for both and the two stay
derived from one record set rather than two lists that drift. Source
after set -Eeuo pipefail.
ephemeral_refs_pathspec_into()¶
Fill an array with the git ls-files pathspec selecting
every claimed file type. Filled through a nameref rather than printed,
because a pathspec is passed to git as separate arguments and a
command substitution would split *.md on the caller's IFS and glob it
against the working directory before git ever saw it.
Args:
$1— name of the array to fill
language_of()¶
Language of one source, by extension. Extension is the
whole classifier: a shell library without a .sh suffix, and shell
embedded in a workflow run: block, are out of scope by construction
rather than by a content sniff that would have to guess.
Args:
$1— src_rel source path relative to REPO_ROOT
Stdout:
- one of
md,sh,nix,yaml,other
is_allowlisted()¶
True when the given source path is on the skip-entirely
file allowlist (CHANGELOG.md, docs/releases.md,
tests/fixtures/**, .claude/**).
Args:
$1— src_rel source path relative to REPO_ROOT
scripts/lib/generates.sh¶
@generates / @generates-block annotation parsing.
Source after set -Eeuo pipefail.
generator_declarations()¶
Read the comment header of each named script and emit one record per output declaration, in file order. Parsing only: the caller chooses which scripts to hand over, and the caller tallies what it needs — duplicates are emitted as separate records because one caller counts declarations while deduplicating paths into a map.
The header ends at the first line that is neither a comment nor blank, so an annotation sitting in the body beside the code is not a declaration; blank lines separate paragraphs of a header rather than ending it. The plain name is a proper prefix of the block name, so the two are kept apart by two independent means at once: each pattern demands whitespace between the annotation name and its path, which is what stops the plain pattern from matching a block line, and the if/elif chain settles the longer name first so the kinds stay right even if that whitespace requirement is ever loosened.
A read fault is reported by the return status rather than left to
errexit, because every call site captures this in a command
substitution inside an if — a context where errexit is suppressed —
so a fault that only set $? would reach the caller as a short record
stream, indistinguishable from scripts that declared nothing, and be
scored as agreement.
Args:
$@— script paths to parse
Exit codes:
0— every named path was read, including when none declared anything1— at least one named path could not be read
Stdout:
- one record per declaration,
<kind>\037<path>\037<script-path>, where<kind>isgeneratesorgenerates-block
scripts/lib/harness-assert.sh¶
Cross-scenario discrimination gate for test harnesses. A
harness asserts behavior by grepping a scenario's captured output for a
substring. If that substring also appears in a sibling scenario's
output, the assertion passes whether or not the asserted behavior exists
— green while verifying nothing. Record each scenario here and call
harness_assert_verify at the end of the run to fail on any such
substring (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), on any asserted substring missing
from its own scenario's output, and on any two scenarios whose recorded
output is the same after the clock normalization in
harness_assert_record — a pair that verifies one thing between them
however each is named. A parity exemption excuses such a pair only when
both assert the same substrings. Source after set -Eeuo pipefail.
harness_assert_exempt()¶
Register a substring as legitimately shared with one named
scenario, or with every scenario when the second argument is *. Use the
wildcard for a global banner a script prints on every run of a whole
outcome class: such a substring still separates that class from its
opposite, which is the axis the assertion is about. Use the named form
when one failure path emits no token another lacks. The rationale is
mandatory so the weakening is reviewable.
Args:
$1— substring$2— other scenario name or*$3— rationale
harness_assert_parity_exempt()¶
Register two scenarios as legitimately recording one output. Two scenarios the gate cannot tell apart verify one thing between them, so the pair needs a reason that a reviewer can check: the scenarios must differ in what they exercise even though nothing they emit says so, and no honest output could separate them. The rationale is mandatory, and the pair matches in either order.
Args:
$1— scenario name$2— other scenario name$3— rationale
harness_assert_record()¶
Record one scenario's asserted substring and the output
stream(s) the harness asserts against. Pass '' as the substring for a
scenario that asserts only an exit code — its output still belongs in
the comparison pool, because that is usually the output a failure-path
substring wrongly matches. A substring holding a newline is refused, for
the reason given on harness_assert_also.
Args:
$1— scenario name$2— asserted substring ('' if none)$@— one or more captured output files
harness_assert_also()¶
Attach another asserted substring to the most recent
record. Use when one invocation asserts several properties of its
output: recording that invocation once per property makes those records
byte-identical siblings, which the pairwise rule cannot separate and
the census scores as collapsed coverage. Each attached substring is
held to the same rules as the record's own: harness_assert_verify
fails when it is missing from this record's output and when it appears
in a sibling's, so attaching a substring asserts it. A substring holding
a newline is refused, because the pool stores one substring per line and
would check each line on its own rather than the whole.
Args:
$1— substring
harness_assert_is_exempt()¶
Return 0 if the substring/other-scenario pair is exempt,
either by an exact pair or by a * wildcard registered for the substring.
Args:
$1— substring$2— other scenario name
harness_assert_parity_is_exempt()¶
Return 0 if the two named scenarios are registered as a parity exemption, in either order.
Args:
$1— scenario name$2— other scenario name
harness_assert_verify()¶
Apply the presence rule, the pairwise rule, the
identical-output rule and the parity rule to everything recorded, print
the census, and drop the pool. Return 1 if any asserted substring is
missing from its own scenario's output, if any asserted substring also
occurs in a sibling scenario's output (skipping three kinds of sibling:
one that asserts the same substring; one whose output is identical after
clock normalization, which the next rule judges; and one that
harness_assert_exempt names for that substring), if two records share
one output while asserting different substrings, if two records share
one output without a parity exemption, or if nothing was recorded at
all. The census names every group of scenarios sharing one output before
reporting the counts.
scripts/lib/job-keys.sh¶
Shared reading rules for the jobs: map of a workflow.
Several lints list the job keys one per line and read each key back,
so a key the line cannot carry (an empty one, one holding a tab, a line
break, a carriage return or a NUL, one that is not a scalar) is read as
other names or as none,
and a merge key (<<) directly under jobs: lists as the key <<
while the jobs it brings in are never read. GitHub Actions refuses a
job id outside [A-Za-z_][A-Za-z0-9_-]*, so no runnable workflow has
such a key; a lint reads files GitHub has not validated, and counts the
key as a finding instead of reading around it.
A merge key given a list (<<: [*P, *Q]) with a conflicting key is
read first mapping wins, as the YAML merge specification says. yq
reads it last mapping wins unless it is given
--yaml-fix-merge-anchor-to-spec, so the scripts that source this
library pass "${YQ_MERGE_SPEC[@]}" on their reads of a workflow that
follow a merge key. The flag also silences the warning yq prints when it is absent.
Source after set -Eeuo pipefail.
first_odd_job_key()¶
Print the first job key the job list cannot carry, as a
JSON string, or nothing when every key is carriable. A key is refused
when it is a merge key, is not a scalar, is empty, or holds a tab, a
line break, a carriage return or a NUL. Each key is resolved through an
alias first. A jobs: that is not a map holds no keys.
Args:
$1— workflow path
Exit codes:
1— yq could not evaluate the file
Stdout:
- the first refused key of the first document holding one, as one line of JSON, or nothing
odd_job_key_message()¶
Print the finding for a refused job key.
Args:
$1— workflow path$2— the key as JSON, fromfirst_odd_job_key
first_unlistable_job_key()¶
Print the first key of the job ids a script lists that the
list cannot carry, as a JSON string, or nothing when every key is
carriable. The keys are those the job-id read returns: jobs: is the
JOBS_NODE, and it and the keys in it are resolved through
RESOLVE_JOB_ALIASES first, so a merge
key brings in the jobs it names, which are checked, and is not itself a
key. A key is refused when it is not a scalar, is empty, or holds a tab,
a line break, a carriage return or a NUL.
Args:
$1— workflow path
Exit codes:
1— yq could not evaluate the file
Stdout:
- the first refused key of the first document holding one, as one line of JSON, or nothing
scripts/lib/locale-gap.sh¶
Locale precondition for harness scenarios that pin a
locale to show a locale-bound regex range in bash, grep or sed. Source
after set -Eeuo pipefail.
require_locale_gap()¶
Fail unless a bash run under LC_ALL=<locale> matches the
fullwidth digit 5 (U+FF15) against ^[0-9]$. A range in a bash
[[ =~ ]] regex follows the locale's collation, so in en_US.UTF-8 it
admits characters a C locale does not. Where the pinned locale is not
installed, bash warns, falls back to C, and a scenario meant to show the
difference passes whether or not the script under test spells its class
out. The probe runs the bash on PATH, the one the scripts run under,
rather than asking locale -a, whose answer comes from the system C
library and can disagree with the one bash is linked against. On failure
it prints one line naming the locale and what the probe printed.
Args:
$1— locale name, such asen_US.UTF-8
Exit codes:
0— the locale shows the gap1— it does not
require_grep_locale_gap()¶
Fail unless the grep on PATH, run under
LC_ALL=<locale>, matches é against ^[a-z]$. GNU grep does not use
the C library's regex engine bash uses, so require_locale_gap says
nothing about it: it keeps a lone [0-9] ASCII in every locale, and
follows the locale's collation for a letter range or a mixed one
([a-z], [0-9a-f]). A scenario whose subject is a grep range calls
this one. command grep skips a shell function named grep, so the
binary the scripts run is the one probed. On failure it prints one line
naming the locale, the engine and what the probe printed.
Args:
$1— locale name, such asen_US.UTF-8
Exit codes:
0— the locale shows the gap in grep1— it does not
require_sed_locale_gap()¶
Fail unless the sed on PATH, run under LC_ALL=<locale>,
matches é against ^[a-z]$. GNU sed follows the same range rules as
GNU grep, but it is a separate binary whose locale support can differ, so
a scenario whose subject is a sed range probes sed itself. On failure it
prints one line naming the locale, the engine and what the probe printed.
Args:
$1— locale name, such asen_US.UTF-8
Exit codes:
0— the locale shows the gap in sed1— it does not
scripts/lib/log.sh¶
Shared logging + ERR-trap helpers for repo bash scripts.
Source after set -Eeuo pipefail. The ERR trap captures the failing
exit code as its first action: it is read into the format string before
any other expansion, so nothing between the failure and the report can
reset $?. Timestamps come from bash's printf time format, so no
helper here needs a tool on PATH.
log()¶
Emit a timestamped level-tagged line to stderr.
The timestamp comes from bash's own printf time format rather than
date, so a diagnostic about a missing tool is not itself preceded by
a date: command not found line and an empty timestamp. The format
string is identical to the one date was given.
Args:
$1— level$2— message
log_info()¶
Emit an INFO line via log.
Args:
$@— message words, joined by spaces
log_err()¶
Emit an ERROR line via log.
Args:
$@— message words, joined by spaces
require_tool()¶
Verify a required CLI tool is on PATH; exit 2 if missing. Exit 2 means "could not run", which is what an absent tool is; exit 1 stays reserved for a violation the caller found. A freshness hook's caller reads exit 1 as "the doc is stale, run the generator and commit", so a missing jq reported as 1 sends the operator to regenerate a doc instead of to install jq.
Args:
$1— tool name
install_err_trap()¶
Install the shared ERR trap in the calling shell. Captures the real failing exit code before any command substitution clobbers $?.
scripts/lib/md-fence.sh¶
One Markdown fence reader for the lints that read shell
commands inside fences, as awk source to place ahead of the caller's
program. Source after set -Eeuo pipefail.
scripts/lib/payload.sh¶
Shape gate for an externally-supplied JSON payload.
Source after set -Eeuo pipefail and after lib/log.sh.
require_json_payload()¶
Reject a payload whose shape the reads below cannot rely on, as a could-not-run rather than as a finding.
A payload arriving from an API response, a tool's JSON output, or a
file written by automation carries no shape guarantee. Reading one
unguarded surfaces a malformed payload two ways, both wrong: jq dies
with a raw diagnostic under an exit code the convention does not
catalogue, or — worse — an absent field reads as an empty string and
the caller reports substantive drift. Exit 1 sends a maintainer after
posture nobody changed, and the exit code alone does not tell the two
apart.
One gate in front of every read, rather than a guard per read: a read added later is then total by construction instead of depending on its author remembering the convention.
The source is named by kind — an override variable name or an API path — never by fixture path. A fixture path in output lets two harness scenarios be told apart by their fixture rather than by their behavior.
An empty or whitespace-only payload also fails the parse check below —
jq --exit-status reports no-output as a failure regardless of why
the input produced none — so this check is not what stands between
such a payload and acceptance. It exists for diagnostic precision: a
producer that wrote nothing and a producer that wrote garbage are
different faults with different operator remedies, and the parse
check's own diagnostic names only the garbage case.
Args:
$1— source kind, used verbatim in every diagnostic$2— the payload$3— optional jq program emitting a message for the first field whose type is wrong, andemptywhen the shape is acceptable$4— optional subject, prefixed to every diagnostic as<subject>:. A source kind does not always identify what could not be read. Several sources in this tree are read by more than one caller: one config file read by three lints, one pin file and one latest-release route read by both the bump script and the dashboard generator, and the nameflake.lockreached by more than one check. In each case the source alone leaves an operator unable to tell which subject's payload was malformed, so a caller sharing a source kind is expected to pass one. Callers whose source kind is unique to them pass nothing and their output is unchanged. Whether a subject is warranted is a property of the whole tree rather than of one script, so a second reader of an existing source is the point at which to give both readers one.
Exit codes:
2— the payload is empty, unparsable, or the shape program named a fault
payload_source_into()¶
Name a payload's source by kind, filling a caller variable.
The rule every caller of require_json_payload needs first: 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 name otherwise — and
never by resolved path. A path in a diagnostic lets two harness scenarios
be told apart by their fixture rather than by their behavior, which is
what the census-parity gate exists to forbid.
The result is written into a caller variable rather than printed, because
a function whose result is read as $(...) cannot fail. In argument
position a command substitution discards its own status: the guard below
would print, the shape gate would receive an empty source name, and the
run would end 0. Filling a named variable keeps this function in the
caller's shell, where exit ends the script that has the problem. This
is the same reasoning the shape check above states for capturing its jq
output instead of reading it through a process substitution.
The override is resolved by indirect expansion rather than by an
environment-only lookup, so that it is seen exactly as its consumer sees it:
every caller reads its override with plain ${VAR:-}, which sees a shell
variable as well as an exported one. A namer blind to a variable its reader
honors would name the fallback for a payload that did come from the
override.
The target is bound with a nameref rather than written with printf -v,
so that a caller naming one of this function's own locals collides in the
open — bash warns about the circular reference on stderr — where
printf -v would write the local without a word and leave the caller's
variable untouched. The locals carry a __psrc_ prefix no call site uses,
which keeps that collision theoretical. scripts/lib/enumerate.sh binds
enumerate_into's output the same way, so the two read alike.
Args:
$1— name of the caller variable to fill$2— name of the override variable, exported or shell-scoped$3— source name used when the override is unset or empty
Exit codes:
2— $2 is not a valid shell identifier
read_json_payload_into()¶
Read a file payload into a caller variable, reporting a payload the caller could not read as a could-not-run rather than as a finding.
The result is filled through a nameref rather than printed, for the
reason payload_source_into states: a reader whose value is taken as
$(...) cannot fail. A read that dies inside a command substitution
leaves the caller assigning an empty string under a status it does not
check, and the run continues into the shape gate, which then reports an
empty payload — or, where the caller's own reads tolerate absence, into
a drift verdict about posture nobody changed. Filling a named variable
keeps exit 2 in the shell that has the problem.
Three conditions, three sentences: a payload that is absent, one whose
permissions forbid the read, and one whose read fails for any other
reason are different faults with different operator remedies. The third
sentence covers two guards rather than one: a directory, a FIFO, or a
device node all pass the existence and readable checks, and none of
them is something cat can be trusted to fail on promptly — a
directory does, but a FIFO with no writer, or a device such as
/dev/random, blocks or streams instead of erroring, which would turn
a could-not-run into a hang. The explicit not-a-regular-file check
below reaches that verdict by a [[ -f ]] test, before any read is
attempted, so the only thing left for the final cat guard to catch is
a regular file whose read still fails for some other reason. Both guards stay
exercisable where mode bits are no lever — none of these path kinds
depend on the permission bits -r already checked.
The source is named by kind, never by resolved path, exactly as
require_json_payload requires; pass the value payload_source_into
filled.
Args:
$1— name of the caller variable to fill$2— path to read$3— source kind, used verbatim in every diagnostic$4— optional subject, prefixed to every diagnostic as<subject>:
Exit codes:
2— the path is absent, unreadable, or the read failed
scripts/lib/repo.sh¶
Guarded repository-root lookup. Source after
set -Eeuo pipefail.
repo_toplevel()¶
Print the top level of the git work tree git resolves for
the current directory, reporting a work tree git cannot resolve as a
could-not-run.
Run outside a work tree (no repository, a bare repository, or inside a
.git directory), git rev-parse --show-toplevel exits 128, and an
unguarded x="$(git rev-parse --show-toplevel)" under set -e ends the
caller with that 128, a status no caller reads as "could not run".
Exiting 2 from inside the command substitution propagates through the
enclosing assignment, so the call site needs no guard of its own; a
local, readonly, declare, export or typeset on the same line
returns its own status and masks it, so assign on a line of its own. git's own diagnostic is left on stderr
above this one, because it names the cause: no repository, no work tree,
or a work tree git refuses to open (a safe.directory refusal). The
lookup follows git's
rules, so a set GIT_DIR or GIT_WORK_TREE decides the answer.
Exit codes:
2— git is not on PATH, or git cannot resolve a work tree for the current directory
Stdout:
- the work tree's top-level path
scripts/lib/scratch-tree.sh¶
Run a harness against a scratch copy of the work tree. A
harness that exercises a generator against the real docs would otherwise
rewrite tracked files in the operator's checkout, replacing an uncommitted
edit inside a generated block, and a run killed between the write and its
restore leaves the doc drifted. The harness calls reexec_in_scratch_tree
right after its preamble: the first run copies the tree, re-runs the same
harness inside the copy with SCRATCH_TREE_ACTIVE set, removes the copy
and exits with the child's status; the child's call returns at once, and
its git rev-parse --show-toplevel then resolves to the copy. Source
after set -Eeuo pipefail.
_scratch_git()¶
Run git with the repository-selecting variables removed. A
hook or a linked worktree exports GIT_DIR, GIT_WORK_TREE or
GIT_INDEX_FILE, and any of them would point git -C <dir> back at the
repository the copy exists to protect.
Args:
$@— git arguments
stage_scratch_tree()¶
Copy the tracked files, as they are on disk, into a new directory and commit them there, so the copy is a work tree of its own. A tracked file deleted in the source is left out of the copy and out of its index. Uncommitted edits to tracked files are carried over; untracked and ignored files are not.
Args:
$1— source work tree$2— destination directory, which must exist and be empty
Exit codes:
2— the enumeration or the commit failed; a tracked file that exists but cannot be read fails the copy with the exit status oftar
_scratch_tree_forward()¶
Signal handler of reexec_in_scratch_tree: send TERM to the
process group of every background job and record that a signal arrived.
The copy run is a session leader, so its group holds everything the
harness started.
reexec_in_scratch_tree()¶
Re-run the calling harness inside a scratch copy of the work tree it was started from, then exit with the copy run's status. Returns without doing anything when the caller is already that copy run. The copy is removed on every exit path of the parent. The copy run is a session leader, and a SIGTERM, SIGINT or SIGHUP to the parent reaches its whole process group, which is waited for before the copy is removed; a signal that arrives while the copy is still being built ends the parent with status 143 once the build step in flight returns. SIGKILL cannot be forwarded and leaves the copy and its run behind. Call it after the harness's own preamble and before any statement that writes.
Args:
$@— the harness's own arguments, passed through unchanged
Exit codes:
2— the harness is not inside the work tree,setsidis missing, or the copy failed
scripts/lib/secret-uses.sh¶
Read the uses of the GitHub Actions secrets context in
workflow text, one line at a time: each secret a ${{ }} expression
names, each expression that uses the context without a literal name,
and each ${{ not closed on its line; and the decoded text of a YAML
file's double-quoted scalars, which a reader of the file text misses;
and the jobs that hand every secret on with secrets: inherit.
Source after set -Eeuo pipefail and after scripts/lib/ascii-match.sh,
and after scripts/lib/job-keys.sh for inherit_secret_jobs.
secret_uses()¶
Print one record per use of the secrets context in the
text on stdin (a workflow file or a part of one), as
LINE<TAB>KIND<TAB>TEXT, in the order of the text. Text outside ${{ }}
is not read. The kinds:
named: a reference with a literal name, in the dot or the single-quoted index form; TEXT is the reference.unnamed: an expression that uses the context in any other way, so no name in the text says which secret it reads: the whole context (toJSON(secrets)), a name the expression computes (secrets[format(...)],secrets[env.NAME]), the object filter (secrets.*), a double-quoted index, or the wordsecretsinside a string literal, which this reader cannot tell from a use. One record per expression, after itsnamedrecords; TEXT is the expression.open: a${{not closed on its line; TEXT is${{. GitHub Actions reads an expression broken across the lines of a block scalar, and this reader reads one line at a time, so whatever the expression reads is unknown.
Exit codes:
0— the text was read, whether it holds a use or not2— grep failed reading it
Stdout:
- the records
double_quoted_scalars()¶
Print the text of each double-quoted scalar in a YAML file
as yq decodes it, keys and values alike, each on its own lines. GitHub
Actions evaluates an expression in the decoded text, and a double-quoted
scalar is the one YAML style whose escapes (\x65, \u0065, \n, an
escaped line break) make that text differ from the file's: an escape can
hide a use's name, its ${{ or its }} from a reader of the file text.
A tagged scalar is printed too, whatever its style: yq reports the
style of a tagged scalar as tagged or <unknown>, never double, so
every scalar is printed but an untagged plain, single-quoted, literal or
folded one, the styles that hold no escape. A NUL is printed as a space, since grep reads text holding a NUL as
binary and prints no match, and a NUL continues no name. A scalar written
once under an anchor is printed once, however many aliases repeat it.
Args:
$1— workflow file path
Exit codes:
0— the file was read, whether it holds such a scalar or not2—yqcould not read the file
Stdout:
- the decoded texts
inherit_secret_jobs()¶
Print, one per line as a JSON string, the key of each job
that hands every secret to the reusable workflow it calls: its
secrets: is inherit, which no ${{ }} holds, so secret_uses never
reads it. GitHub Actions takes a map or inherit there and nothing else,
so any value but a map or null is read as inherit without its text
being compared. The jobs: node and each job are read through their
aliases and merge keys, and a merge key directly under jobs: brings its
jobs in. An explicit secrets: map names what it hands on in its values,
which secret_uses reads, so its job is not printed. Source
scripts/lib/job-keys.sh before this file.
Args:
$1— workflow file path
Exit codes:
0— the file was read, whether a job inherits or not2—yqcould not read the file
Stdout:
- the job keys
scripts/lib/temp.sh¶
Guarded temp-file creation. Source after set -Eeuo pipefail.
make_temp()¶
Create a temp file or directory, reporting a failure as a
could-not-run rather than as a finding. An unwritable TMPDIR makes
mktemp exit 1, and an unguarded x="$(mktemp)" under set -e kills
the caller with that same 1 — the status a lint uses for "this file
carries a violation". A pre-commit hook reads that as "the tree is
stale, fix it and commit", sending the operator to edit content the
check never read. Exiting 2 from inside the command substitution
propagates through the enclosing assignment, so the call site needs no
guard of its own. No label argument: the caller's ERR trap already
prints the failing line and BASH_COMMAND, so the site names itself.
Args:
$@— passed through tomktempverbatim
Exit codes:
2— the temp file or directory could not be created
Stdout:
- the created path