name: Pre-commit checks on: push: branches: [main] pull_request: branches: [main] # Manual runs: this job's cost is dominated by whether the pre-commit hook # environments are cached, and without a dispatch there is no way to # re-run it on an unchanged tree to confirm a cache actually helps. workflow_dispatch: {} jobs: pre-commit: runs-on: ubuntu-latest env: # The md-naming / file-length hooks are thin shims that exec the shared # gate out of the utils repo. A runner has no ~/utils, so without this # checkout they fail with "shared gate not found" -- which is exactly # how this workflow went red on 2026-08-24. UTILS_ROOT: ${{ github.workspace }}/.utils steps: - uses: actions/checkout@v4 - name: Check out the shared gates uses: actions/checkout@v4 with: repository: kuhyx/utils path: .utils - uses: actions/setup-python@v5 with: python-version: "3.12" # See the note in python-tests.yml: caches the pip download cache, # keyed on the real meta/ path rather than the root symlink. cache: pip cache-dependency-path: meta/requirements.txt # zsh backs the zsh-syntax hook. apt does not let a patch release be # selected, so this is pinned only to what the runner image provides; # the version is echoed so a jump shows up in the log rather than as a # mystery parse difference. Local machines compare on major.minor only. - name: Install system dependencies run: | set -euo pipefail sudo apt-get update && sudo apt-get install -y zsh librsvg2-bin zsh --version rsvg-convert --version # Pin shellcheck instead of inheriting whatever ubuntu-latest ships. # # The runner image carried 0.9.0 while developer machines have 0.11.0 # from pacman, and the shellcheck hook is `language: system` -- so it # used whichever binary happened to be on PATH. The two versions # disagree (0.11.0 demoted SC2002 and several SC2015/SC2119 cases as # noise), which meant a commit could pass every local check and still # fail CI on findings the developer could not reproduce. That is exactly # what kept this workflow red from b83ce5d onward. # # Keep SHELLCHECK_VERSION in step with the version developers install; # the verification below fails loudly rather than silently drifting. - name: Install shellcheck (pinned) env: SHELLCHECK_VERSION: v0.11.0 SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 run: | set -euo pipefail tarball="shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" curl -fsSL -o "$tarball" \ "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/${tarball}" echo "${SHELLCHECK_SHA256} ${tarball}" | sha256sum --check --strict tar -xJf "$tarball" sudo install -m 0755 "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck # Fail here, not 40 hooks later, if PATH still resolves to another copy. installed="$(shellcheck --version | awk '/^version:/ { print $2 }')" expected="${SHELLCHECK_VERSION#v}" if [ "$installed" != "$expected" ]; then echo "::error::shellcheck on PATH is ${installed}, expected ${expected}" >&2 command -v -a shellcheck >&2 exit 1 fi echo "shellcheck ${installed} pinned and on PATH" # prettier and shfmt do NOT run in this job -- prettier is a pre-push # hook and shfmt runs from the local pre-commit git hook. They are # pinned here anyway so this file stays the single place that records # the expected version of every `language: system` tool, and so a future # workflow that does run them inherits the pin instead of re-deriving # it. Their real divergence risk is between developers: both REWRITE # files, so two versions reformat the repo back and forth. - name: Install prettier and shfmt (pinned) env: PRETTIER_VERSION: 3.9.5 SHFMT_VERSION: v3.13.1 SHFMT_SHA256: fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1 run: | set -euo pipefail npm install --global --no-fund --no-audit "prettier@${PRETTIER_VERSION}" curl -fsSL -o shfmt \ "https://github.com/mvdan/sh/releases/download/${SHFMT_VERSION}/shfmt_${SHFMT_VERSION}_linux_amd64" echo "${SHFMT_SHA256} shfmt" | sha256sum --check --strict sudo install -m 0755 shfmt /usr/local/bin/shfmt # Assert what PATH actually resolves to, not what was just written: # a copy earlier on PATH would otherwise win silently. prettier_installed="$(prettier --version)" shfmt_installed="$(shfmt --version)" if [ "$prettier_installed" != "$PRETTIER_VERSION" ]; then echo "::error::prettier on PATH is ${prettier_installed}, expected ${PRETTIER_VERSION}" >&2 exit 1 fi if [ "$shfmt_installed" != "$SHFMT_VERSION" ]; then echo "::error::shfmt on PATH is ${shfmt_installed}, expected ${SHFMT_VERSION}" >&2 exit 1 fi echo "prettier ${prettier_installed} and shfmt ${shfmt_installed} pinned and on PATH" # pre-commit builds a virtualenv per hook repo on first use, which is # slower than the pip install itself. Keyed on the hook definitions, so # it invalidates exactly when a hook's repo or rev changes. - uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit-${{ runner.os }}-py3.12-${{ hashFiles('.pre-commit-config.yaml') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install pre-commit - name: Run pre-commit hooks # SKIP=pytest-coverage: the Python tests workflow already runs the # whole suite with the same 100% coverage gate, so running it again # here doubled the cost of this job for no extra signal. It is the # only hook skipped, and it is skipped because it is DUPLICATED, not # because it is slow -- if that workflow is ever removed, drop this. run: SKIP=pytest-coverage pre-commit run --all-files --show-diff-on-failure