Skip to content

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.)