Audit: Workflow standards¶
What we check¶
These are the standards every workflow file is held to. Several checks measure parts of them, and each is a column in this criterion's section of the compliance page; the rest are not read by any check. Each rule below says which it is:
- Measured names the check, and what it reads.
- Reviewed, not measured is a rule no check reads. It holds as far as review of the change holds it, and a repository breaking it passes this criterion regardless.
A rule is not measured because it is written here, so the labels are the place to look before relying on a green cell.
Workflow permissions¶
Measured by workflow-permissions. Every workflow needs a
top-level permissions: block restricting the default GITHUB_TOKEN
scope; GitHub Advanced Security flags its absence. The check reads
each workflow for a line beginning permissions: at column zero.
Reviewed, not measured: what the block grants. Read-only
workflows use permissions: contents: read; mixed-need workflows use
permissions: {} at the top with per-job overrides. The check passes
any top-level block, whatever its scopes.
Workflow and job naming¶
Reviewed, not measured. Display names are English sentences with correct capitalisation, not kebab case. Job ids may be machine-friendly.
Runner preferences¶
Measured by self-hosted-runners, except the last bullet.
- Use
self-hostedrunners. GitHub-provided minutes are limited per month, so jobs must not leak onto GitHub-hosted runners without a documented reason. - Legitimate exceptions -- Windows and macOS builds where we own no
suitable hardware, as in ryll -- take an
audit-ok: github-hosted-runnercomment on, or immediately above, the line naming the label, ideally with a reason:
- The check flags any GitHub-hosted runner label (
ubuntu-latest,windows-2022,macos-15, ...) without a marker, including matrix values feedingruns-on: ${{ matrix.os }}. It only counts labels where a runner can actually be named -- afterruns-on:, inside a[...]list, or as a-matrix item -- so the same text in an image label, a job name or an ssh command is not a finding. - Reviewed, not measured: Claude Code automation jobs run on
claude-coderunners only. No check reads which pool a Claude Code job asks for.
Static runners for small, non-mutating jobs¶
- Reviewed, not measured: jobs that do not change the state of
the runner -- linting, metadata checks, audits, anything that
installs no OS packages -- should use
runs-on: [self-hosted, static]. Static runners are a pre-provisioned, long-lived pool and are much cheaper to start than a fresh VM. Reservevmrunners for jobs that genuinely need a throwaway machine. - Measured by
static-runner-tags: a static runner advertises exactlyself-hostedandstatic, so astaticjob must request those two labels only. Adding a size (s,l),vm, or an OS (debian-12) asks for a runner that does not exist, and the job waits forever. The check flags anyruns-on:combiningstaticwith additional labels.
VM runners must name a size¶
Measured by vm-runner-size, including the bound-runner form at
the end of the list.
This is the exact complement of the rule above, and the two are easy to
confuse: a static job must name no size because the static pool
advertises none, and a vm job must name one because the conductor
otherwise picks for it.
- Sizes are
xs,s,m,l,xl, them-bigdisk/xl-bigdiskvariants, and thes-cloud/s-clustervariants.s-cloudands-clusterhave the same footprint ass(2 vCPU, 4096 MB) and are for jobs which build a nested test cloud from a small runner:s-cloudfor a cloud on one node,s-clusterfor a multi-node cluster. Everyvmruns-onmust name one. - The conductor matches the size out of the labels, and falls back to
the first entry in its
CI_SIZEStable when it finds none --xs, one vCPU and 2048 MB. An omitted size is therefore a silent downgrade to the smallest runner, not a job left free to take any runner. xscounts as naming a size. The rule is that the size is a decision, not that it is a large one, so a job which genuinely wants the smallest runner says so and passes.- The check reads the labels which are statically known, so a
runs-on:pairing a matrix expression with a literal size passes. A job whose size could only arrive through an expression is a finding: the fleet writes the size literally even when the operating system comes from the matrix. - A job which genuinely cannot name a size marks the line
audit-ok: vm-runner-size, on theruns-on:itself or the line above it, with the reason. This is the fleet's usual escape hatch and it is here for consistency with the other runner checks, not because a case is known: writingxscosts one word and states the same decision honestly, so a marker whose reason amounts to "we did not choose" is the wrong answer. -
Only the inline-list (
[self-hosted, vm, l]) and scalar forms ofruns-on:are examined. A block sequence spread over the following lines is not matched, so a job written that way is neither passed nor flagged -- it is simply not seen. No repository in scope writes one today, and the sibling runner checks share the blind spot; this is recorded so the coverage claim is honest rather than assumed. -
A job may instead bind itself to a runner started for it alone:
[self-hosted, bound-debian-13, bound-s, "bind-${{ github.run_id }}-${{ github.run_attempt }}-<slug>"]. This form names novm, plain image or plain size, so that no unbound job can take the bound runner. It satisfies this rule when it namesself-hosted, exactly onebind-element, exactly onebound-<image>and exactly onebound-<size>with a size from the list above, and nothing else. Thebind-element is always an expression including${{ github.run_id }}and${{ github.run_attempt }}, so that every run and every re-run binds its own runners, and needs no marker. Abound-${{ ... }}element beside a literalbound-<size>is read as the image; abound-<size>that is itself an expression is treated like any other expression size, and is the only bound finding the marker excuses. Anybound-orbind-element without the full form is a finding:bound-with nobind-,bind-with nobound-labels, a missing image or size, two sizes, an unknown bound size, or any other label alongside them --vm, a plain image or size, or an expression that could evaluate to one. See "Binding a job to its runner" in shakenfist/private-cidocs/capacity-claims.md.
The two failures look nothing alike, which is why this needs its own
check. An over-labelled static job is never scheduled and someone
notices within one run. An under-labelled vm job runs perfectly well
on a machine nobody chose -- until it does not.
shakenfist/shakenfist#3696
is the case that prompted this: seven CI jobs building wheels and
driving ansible deploys, all on a 2048 MB runner with roughly 110 MB
free and no swap device, for months. The conductor's own sizing
recommender could not see it either, because both its upsize triggers
are swap-based and the image has no swap.
Job timeouts¶
Reviewed, not measured. Every job must set timeout-minutes.
GitHub's default is 360 minutes, which is never right on a self-hosted
pool: a wedged job holds a runner nothing else can use until it
expires. This matters most on the claude-code pool, where runners
are few and an unattended Claude Code run has no natural upper bound
-- --max-turns caps turns, not wall clock.
Values used in the templates:
- Trigger / dispatch jobs making a few
ghAPI calls (pr-retest.yml, thetrigger-*job inpr-fix-tests.yml): 10 minutes. - Claude Code triage (
issue-fix.yml'striage): 30 minutes. - Claude Code review (
pr-re-review.yml): 60 minutes. Real re-reviews finish in three to six minutes, so this is ten times headroom. - Claude Code fix runs that build and test (
issue-fix.yml'sfix,test-drift-fix.yml): 120 minutes.
Functional test workflow naming¶
Reviewed, not measured. Functional testing lives in
functional-tests.yml, and must include workflow_dispatch as a
trigger -- pr-retest.yml dispatches it. A repository whose test
workflow has another name sets the RETEST_WORKFLOW repository
variable instead; see templates/ci-review-automation/README.md.
PIPESTATUS for piped commands¶
Reviewed, not measured. When piping through tee in a run:
step, capture the upstream exit code with ${PIPESTATUS[0]}:
- name: Run something
run: |
set +e
make something 2>&1 | tee output.txt
exit_code=${PIPESTATUS[0]}
set -e
if [ ${exit_code} -ne 0 ]; then
echo "Command failed with exit code ${exit_code}"
exit ${exit_code}
fi
$? captures tee, which always succeeds, and set -eo pipefail
alone is unreliable on self-hosted runners. Exceptions: a pipeline
whose failure is deliberately ignored (command | tee log.txt || true)
and one whose upstream cannot fail (echo ... | tee).
flake8wrap.sh correctness¶
Measured by flake8wrap, in part, where tools/flake8wrap.sh
exists. The script runs flake8 over the files changed in the current
commit (via tox -eflake8 -- -HEAD), building a space-separated list
in filtered_files. That variable must not be quoted on the
diff/flake8 line: quoting makes the whole list one filename, which
breaks as soon as more than one Python file changed.
# Correct -- the word splitting is deliberate.
# shellcheck disable=SC2086
diff -u --from-file /dev/null ${filtered_files} | $FLAKE_COMMAND ${filtered_files}
# Wrong -- one argument named "a.py b.py".
diff -u --from-file /dev/null "${filtered_files}" | $FLAKE_COMMAND "${filtered_files}"
The check reads the script for two things: that it names SC2086
somewhere, and that no line mentioning both filtered_files and
diff or flake8 quotes "${filtered_files}".
Reviewed, not measured: that the directive's comment says the
splitting is intentional, and that the script filters to .py
files, skips _pb2 generated files, and handles paths in the diff
that no longer exist on disk.
CI linting¶
actionlint, shellcheck, and a .pre-commit-config.yaml that runs
them; kerbside and kerbside-patches are the worked examples. Helper
shell scripts should have shellcheck pre-commit hooks.
Measured by pre-commit-config, and only as far as the file
existing: the check passes any .pre-commit-config.yaml. Reviewed,
not measured: which hooks it runs, including whether actionlint and
shellcheck are among them.
Review marks excluded from pre-commit¶
Measured by review-marks-pre-commit. Repositories with human
review tracking deployed (those carrying .vscode/review-scope.toml)
must exempt the weAudit state files from any pre-commit hook that
rewrites the files it is given:
Those files are generated without a trailing newline, so
end-of-file-fixer rewrites them on every pre-commit run --all-files
and reports a failure nobody can fix. Scope the exclude to the
rewriting hooks rather than the whole file --
docs/code-review-tracking.md explains why
a blanket exclude is worse than none.
The check applies only where a rewriting hook (end-of-file-fixer,
trailing-whitespace, mixed-line-ending, pretty-format-json,
file-contents-sorter) is configured. It tries each exclude: value
as the regex pre-commit would apply and passes if any one matches both
.vscode/<user>.weaudit and .vscode/<user>.weaudit-shas.json, so a
top-level exclude still counts. A value that does not compile, or
whose search does not finish within a few seconds, counts as no
exclude: pre-commit would fail or hang on it too. Repositories without the tooling,
without a .pre-commit-config.yaml, or running no rewriting hook are
not applicable.
PyPI caching¶
Reviewed, not measured: self-hosted runners should use the devpi
PyPI cache at http://192.168.1.15:3141. No check asks whether a job
that installs packages uses it.
Measured by devpi-fallback: any job pointing pip at the cache
with PIP_INDEX_URL must set a pypi fallback in the same env
block:
env:
PIP_INDEX_URL: http://192.168.1.15:3141/root/pypi/+simple/
PIP_EXTRA_INDEX_URL: https://pypi.org/simple/
PIP_TRUSTED_HOST: 192.168.1.15
devpi's root/pypi mirror is lazy: the first request for a package it
has never cached returns an empty index if the upstream fetch misses,
and pip reports Could not find a version that satisfies the
requirement X (from versions: none). PIP_INDEX_URL replaces pypi
entirely, so without PIP_EXTRA_INDEX_URL there is no fallback. The
check flags any devpi-backed env block missing it.
Retired devpi address¶
Measured by devpi-stale-ip. The cache moved from 192.168.1.4
to 192.168.1.15. The old address no longer resolves to a running
server, so a job still pointing at it -- in PIP_INDEX_URL,
PIP_TRUSTED_HOST, or anywhere else -- fails every install. The check
flags any workflow line referencing 192.168.1.4.
Template¶
No single template -- these are standards applied across all workflow
files. See templates/ci-review-automation/ for examples of correctly
structured workflows.
Projects¶
Per-project compliance for this criterion is regenerated on every run of the consistency audit: see the compliance page.