Gate The Commit
Question
This is looking really nice now, but I am noticing that sometimes the agent doesn't actually run the linters. What gives?
Solution
We can fix that. Let's do some pre-commit git hooks and CI jobs!
Explanation
ci/scripts/ is a flat, numbered list of symlinks, one per check, each
one pointing at the real script living inside its own skill:
ci/scripts/
├── 00-shfmt-wrapper
├── 01-shellcheck-wrapper
├── 02-shdoc-wrapper
├── 03-page-check
├── 04-mkdocs-build
├── 05-skill-structure-check
├── 06-pages-preview
├── 07-pages-prod
├── 08-ci-style-check
├── 09-yamllint-wrapper
└── 10-appctl-tester
Source: ci/scripts/
Every one of those is a symlink to the real script, living one level down inside its own skill - the full list, not just the two or three that happen to fit in a slide-sized excerpt:
# relative to .agents/skills/
00-shfmt-wrapper -> shell-fmt/scripts/shfmt-wrapper
01-shellcheck-wrapper -> shell-lint/scripts/shellcheck-wrapper
02-shdoc-wrapper -> shdoc/scripts/shdoc-wrapper
03-page-check -> mkdocs-page-check/scripts/page-check
04-mkdocs-build -> mkdocs-build/scripts/mkdocs-build
05-skill-structure-check -> skill-lint/scripts/skill-structure-check
06-pages-preview -> mkdocs-pages-preview/scripts/pages-preview
07-pages-prod -> mkdocs-pages-preview/scripts/pages-prod
08-ci-style-check -> ci-style-check/scripts/ci-style-check
09-yamllint-wrapper -> yaml-lint/scripts/yamllint-wrapper
10-appctl-tester -> appctl-tester/scripts/appctl-tester
Source: ci/scripts/
.githooks/pre-commit is installed once, per clone, with
git config core.hooksPath .githooks - then it scans ci/scripts/
itself, in that numbered order, and runs whatever it finds there, with
no hardcoded list of its own to fall out of date. The real loop, every
line commented in place of a separate explanation (one real branch -
resolving whether this entry's own skill needs its tests run first -
is elided below; it is not needed to see where this is headed):
# Walk ci/scripts/ itself, in the sorted order its NN-name prefixes
# already encode - nothing here is a hand-maintained list that could
# fall behind as scripts are added.
for AILMPC_SCRIPT_PATH in "${AILMPC_ROOT}"/ci/scripts/*; do
# A real file or a symlink only - never a stray directory the glob
# above might otherwise match.
[[ -f "${AILMPC_SCRIPT_PATH}" || -L "${AILMPC_SCRIPT_PATH}" ]] || continue
# The path this script is known by, relative to the repo root - what
# every message below prints, and what gets compared against the
# staged file list.
AILMPC_GITHOOK_SCRIPT="ci/scripts/${AILMPC_SCRIPT_PATH##*/}"
# [...] resolves the symlink and, if its own skill has test cases
# staged too, runs those first - see the real file for that part.
# Run, time, and report the check itself - always, unconditionally,
# with --githook-precommit appended; whether anything relevant is
# actually staged is that script's own decision, never this hook's.
hookRun "${AILMPC_GITHOOK_SCRIPT##*/}" "${AILMPC_CEILING_GITHOOK}" \
"${AILMPC_ROOT}/${AILMPC_GITHOOK_SCRIPT}" --githook-precommit
done
Source: .githooks/pre-commit
Dropping a new symlink into ci/scripts/ is the entire installation
step for a new gate - this loop finds it, times it (hookRun's own
AILMPC_CEILING_GITHOOK ceiling), and calls it the exact same way as
every other entry, always with --githook-precommit appended. (The
elided branch does the same thing one more time, scoped to a staged
script's own test suite, before trusting it to gate anything.)
hookRun itself - every check's name, ceiling, and the command to run
it, with a local-scoped timer around the one line that can fail:
# @description Run one check, time it, and print a pass/FAIL/warn line.
# Failure is deferred rather than exiting immediately, so one run surfaces
# every failing check instead of just the first.
# @arg $1 string The check's display name.
# @arg $2 string Its runtime ceiling, in seconds.
# @arg $3 string The command to run, plus any of its own arguments.
# @set AILMPC_DEFERRED_FAIL int Set to 1 if the check failed.
hookRun() {
# $1/$2 named up front; everything shift leaves in "$@" is the
# actual command to run and time.
local name="$1" ceiling="$2"
shift 2
local start elapsed rc=0
# $SECONDS is a plain bash builtin counter, not a GNU-only `date`
# trick - no extra command needed just to time something.
start="${SECONDS}"
# Never let the checked command's own failure kill this hook via
# errexit - rc captures it instead, so every other check still gets
# to run and report.
"$@" || rc=$?
elapsed=$((SECONDS - start))
Source: .githooks/pre-commit
...and what it prints, depending on how that went:
if ((rc == 0)); then
# Green, left-padded to 18 columns so every line's timing lines up
# regardless of the check's own name length.
printf ' \033[32mpass\033[0m %-18s %ss (ceiling %ss)\n' \
"${name}" "${elapsed}" "${ceiling}"
else
# Red, and deferred rather than fatal: record the failure and let
# the loop that called this keep going, so every other check
# still runs instead of stopping at the first one.
printf ' \033[31mFAIL\033[0m %-18s %ss rc=%s\n' \
"${name}" "${elapsed}" "${rc}"
AILMPC_DEFERRED_FAIL=1
fi
# A slow check that still passed is still worth flagging - seen the
# day it regresses, not absorbed later as "commits feel slow".
if ((elapsed > ceiling)); then
printf ' \033[33mwarn\033[0m %-18s over its %ss ceiling\n' \
"${name}" "${ceiling}"
fi
}
Source: .githooks/pre-commit
Here is what all of that actually prints - a real run, committing
only this page itself (a couple of the longest "nothing to ..."
messages are cut short with ... to fit; nothing else is edited):
pre-commit gates
shfmt-wrapper: no staged shell file to format-check
pass 00-shfmt-wrapper 3s (ceiling 10s)
shellcheck-wrapper: no staged shell file to lint
pass 01-shellcheck-wrapper 4s (ceiling 10s)
shdoc-wrapper: no staged shell file to check
pass 02-shdoc-wrapper 3s (ceiling 10s)
pass 03-page-check 1s (ceiling 10s)
pass 04-mkdocs-build 2s (ceiling 10s)
skill-structure-check: no staged file under .agents/skills/; ...
pass 05-skill-structure-check 1s (ceiling 10s)
pages-preview: confirmed serving on http://localhost:8000/
pass 06-pages-preview 4s (ceiling 10s)
pages-prod: confirmed serving on http://localhost:8001/
pass 07-pages-prod 3s (ceiling 10s)
ci-style-check: neither .gitlab-ci.yml nor ci/compose.yml is staged; ...
pass 08-ci-style-check 1s (ceiling 10s)
yamllint-wrapper: no staged file matches ci/yamllint-wrapper.conf; ...
pass 09-yamllint-wrapper 1s (ceiling 10s)
[atMain] no apps in scope - nothing to do
pass 10-appctl-tester 2s (ceiling 10s)
all gates passed
Most of those checks say so and skip almost instantly, because nothing
they each care about is staged. Four do not: page-check and
mkdocs-build check the very page this talk is walking through right
now, and pages-preview/pages-prod then rebuild and actually serve
it, confirming over real HTTP that it renders - all because one
Markdown file under mkdocs/ is staged. That is the SCOPED rule from
the hook's own header comment, seen actually happening rather than
just asserted.
The other half of --githook-precommit lives inside each script, not
in the hook: every script checks git diff --cached itself, and does
nothing if none of the files it cares about are actually staged.
Scoping stays with the check that owns it - the hook's only job is
"run every script in ci/scripts/," every time, unconditionally.
Same Job, Twice
ci/compose.yml and .gitlab-ci.yml are a job-for-job match by
design: the same name, the same pinned image, calling the same script.
Take shfmt-wrapper - here is its real CI job:
ailm-00-shfmt-wrapper-job:
stage: ailm-test-stage
image: docker.io/library/alpine:3.20
script:
- apk add --no-cache shfmt
- sh ci/scripts/00-shfmt-wrapper
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
Source: .gitlab-ci.yml
And its local ci/compose.yml emulation of that same job (the
apk add/su-exec plumbing is elided for space - see the real file
for the whole thing):
ailm-00-shfmt-wrapper-job:
image: docker.io/library/alpine:3.20
entrypoint:
[
"/bin/sh",
"-c",
"apk add ... && exec su-exec ... \"$@\"",
"--",
]
working_dir: /workspace
volumes:
- ../:/workspace
Source: ci/compose.yml
Same docker.io/library/alpine:3.20 image either way. CI's own
script: calls ci/scripts/00-shfmt-wrapper directly, already running
inside the right container by virtue of the job's own image:. The
compose service never names that script at all - its entrypoint just
execs whatever "$@" it was handed, and it is shfmt-wrapper's own
ciDispatch (bare-metal only) that decides to hand it
ci/scripts/00-shfmt-wrapper in the first place, via
docker compose run --rm ailm-00-shfmt-wrapper-job <path>.
That is the fork this talk keeps coming back to, shown here in the exact function that makes it real:
ciInContainer() {
{ [ -f /.dockerenv ] || [ -n "${CI:-}" ]; } && [ -z "${AILM_DOOD:-}" ]
}
Source: .agents/skills/shell-fmt/scripts/shfmt-wrapper
Already inside a container - a real CI job, or ciDispatch's own
re-launch - this returns true and the script just runs. Bare-metal, it
returns false and the script re-execs itself through docker compose
first. That decision lives in the script, never in
ci/compose.yml or .gitlab-ci.yml - both of those stay a plain list
of images and jobs, with zero "if local do X, if CI do Y" logic to
keep in sync between two different config languages. (The
AILM_DOOD half of that check exists for a narrower reason: so a
script invoked from inside appctl-tester's own container still
dispatches for real into its own pinned image, instead of wrongly
assuming any container it finds itself in is already the right one.)