From f4ac372b547e0cab9c34f43a27029e5042beb7a2 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:34:01 +0100 Subject: [PATCH] fix(ci): restore the composite-reached lock pins #28 silently dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Gate controls` has been dead since 3b4afaf (#28), failing in `Set up job` with one step and no gate ever executed: ##[error]lockfile missing pin for hyperpolymath/deed-ecosystem@f9d999b60cb5f383679ea19912bcdc49c944973a Mechanism. `uses: $/actions/manifest-check` is a same-repo ref, inherently pinned at the running commit, and needs no lockfile entry of its own. But the runner also validates that composite's INTERNALS against the lock, and it does so against the calling workflow's entry in the `workflows:` map. manifest-check reaches deed-ecosystem and k9-ecosystem at full SHA, so both must be listed under `code-hygiene-self-test.yml` — being present in `dependencies:` is not enough. This restores the exact shape that was green at d6bc01a, keeping the two `[]` keys #28 correctly added. Why it was invisible for a day, and why a comment would not have helped: ecd0240 (#16) added both transitive SHA refs ... survived #17, #18, #25 — `Gate controls` green throughout 3b4afaf (#28) regenerated the lock and dropped both `gh actions-lock`'s extractor reads step-level `uses:` only, so it cannot derive a ref reached through a composite and a regenerate deletes any added by hand. `--verify` then returns rc=0 on the result: #28's message says it "pins the SHA-form transitive deps reached via called reusables" while its diff removes exactly those, and the rc=0 was its cited proof. `--no-fix` still prints "Scanning 4 workflows", rc=0, against the broken file today. The tool cannot see the defect it creates. Three fixes, because the defect had three parts: 1. Restore the two pins. 2. `tests/lock-transitive-closure.sh` — walks each onboarded workflow's `$/` composites to a fixpoint and asserts every remote ref they reach is declared for that workflow, plus that every declared ref resolves in `dependencies:`. Read with yq, never grep (Y-1). Non-vacuity is asserted, not assumed: the checked population must be non-empty (that assertion caught a jq-vs-mikefarah dialect bug on this file's first run, where `--arg` failed as a lexer error and a stray 2>/dev/null turned it into a clean empty pass). Mutants: the real broken lock at fa71ac2 goes red naming both refs, the green-era lock at d6bc01a passes, and a declared-but-undefined ref is caught. 3. `actions.lock` added to both `paths:` filters. #28 was a lock-only commit, so no filter matched and this workflow never ran on the commit that broke it. The guard runs in `Composite shell contract`, not `Gate controls`: a missing composite-reached pin kills `Gate controls` in `Set up job`, so a check living there could never report the defect it tests for. Closes #33. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WRvDivYwLSeVCJUrfjic3f --- .github/workflows/actions.lock | 2 + .github/workflows/code-hygiene-self-test.yml | 6 + tests/lock-transitive-closure.sh | 118 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100755 tests/lock-transitive-closure.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index d8205bf..b061fbf 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -5,6 +5,8 @@ version: 'v0.0.2' workflows: '.github/workflows/code-hygiene-self-test.yml': - 'actions/checkout@v7.0.1' + - 'hyperpolymath/deed-ecosystem@f9d999b60cb5f383679ea19912bcdc49c944973a' + - 'hyperpolymath/k9-ecosystem@2155aa26a21758f2ba119f61bc7e0e1981c106fb' '.github/workflows/label-triage.yml': [] '.github/workflows/labels.yml': [] '.github/workflows/main-estate-audit.yml': diff --git a/.github/workflows/code-hygiene-self-test.yml b/.github/workflows/code-hygiene-self-test.yml index b82a961..18568e2 100644 --- a/.github/workflows/code-hygiene-self-test.yml +++ b/.github/workflows/code-hygiene-self-test.yml @@ -17,6 +17,7 @@ on: - 'actions/spdx-license-check/**' - 'tests/**' - '.github/workflows/code-hygiene-self-test.yml' + - '.github/workflows/actions.lock' pull_request: paths: - 'actions/code-hygiene-check/**' @@ -29,6 +30,7 @@ on: - 'actions/spdx-license-check/**' - 'tests/**' - '.github/workflows/code-hygiene-self-test.yml' + - '.github/workflows/actions.lock' permissions: contents: read @@ -44,6 +46,9 @@ jobs: # those pins goes stale the job dies in `prepare`, before a single step runs — # which is exactly how the defect this suite covers stayed invisible. This job # touches nothing but checkout, so its square always reflects the suite. + # For the same reason it is where the actions.lock closure guard runs: a lock + # that is missing a composite-reached pin kills `Gate controls` in `Set up job`, + # so a check living there could never report the very defect it tests for. shell-contract: name: Composite shell contract runs-on: ubuntu-latest @@ -53,6 +58,7 @@ jobs: with: persist-credentials: false - run: bash tests/composite-shell-contract.sh + - run: bash tests/lock-transitive-closure.sh test: name: Gate controls diff --git a/tests/lock-transitive-closure.sh b/tests/lock-transitive-closure.sh new file mode 100755 index 0000000..7c5a0fa --- /dev/null +++ b/tests/lock-transitive-closure.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# actions.lock transitive-closure test. +# +# An onboarded workflow may invoke a same-repo composite as `uses: $/actions/x`. +# That ref is inherently pinned (it resolves at the running commit) and needs no +# lockfile entry of its own — but the runner ALSO validates that composite's +# internals against the lock, and it does so against the *calling workflow's* +# entry in the `workflows:` map. A remote ref reached only through a composite +# must therefore be listed under every workflow that reaches it, or the run dies +# in `Set up job` with `lockfile missing pin for ...` before a step executes. +# +# Why this test exists rather than a comment in the lockfile: +# +# ecd0240 (#16) added the two transitive SHA refs for code-hygiene-self-test +# ... they survived #17, #18, #25 — `Gate controls` was green +# 3b4afaf (#28) regenerated the lock and SILENTLY DROPPED both +# +# `gh actions-lock`'s extractor reads step-level `uses:` only. It cannot derive +# a ref reached through a composite, so a regenerate deletes any added by hand — +# and `gh actions-lock --verify` then returns rc=0 on the result. #28's own +# commit message says it "pins the SHA-form transitive deps reached via called +# reusables"; its diff removes exactly those, and its cited proof was that same +# rc=0. The tool cannot see the defect it creates, so the lockfile needs a gate +# that is not the tool. `--no-fix` still reports "Scanning 4 workflows", rc=0, +# against the broken file. +# +# Measured 2026-09-22, run 35786094563 (push, main, fa71ac2): +# ##[error]lockfile missing pin for hyperpolymath/deed-ecosystem@f9d999b6... +# Gate controls: failure (1 steps) <- dead before a single step ran +# +# Usage: lock-transitive-closure.sh [lockfile] (a path lets the mutant run) + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOCK="${1:-$ROOT/.github/workflows/actions.lock}" +PASS=0; FAIL=0 +ok() { printf ' ok %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL+1)); } + +command -v yq >/dev/null || { echo "yq is required (Y-1: gates read YAML with yq, never grep)"; exit 2; } +[ -f "$LOCK" ] || { echo "no lockfile at $LOCK"; exit 2; } + +# yq here is mikefarah v4. `--arg` and `// empty` are jq-only and fail as a +# LEXER ERROR, which a stray 2>/dev/null turns into a clean empty result and a +# vacuous pass. Errors stay visible on purpose; the expressions below are the +# mikefarah forms (strenv / select(. != null)). +yqr() { yq -r "$@"; } + +# owner/repo/subpath@ref -> owner/repo@ref, the shape lock keys use. +lock_key() { printf '%s\n' "$1" | sed -E 's#^([^/]+/[^/@]+)(/[^@]*)?@(.*)$#\1@\3#'; } + +# Every remote ref a workflow reaches through same-repo ($/) composites, +# following nested composites to a fixpoint. +closure_of_workflow() { # + local wf="$ROOT/$1" seen=" " queue=() cur act ref + [ -f "$wf" ] || return 0 + while IFS= read -r cur; do + [ -n "$cur" ] && queue+=("${cur#\$/}") + done < <(yqr '.jobs[].steps[].uses | select(. != null)' "$wf" | grep '^\$/' || true) + while [ "${#queue[@]}" -gt 0 ]; do + cur="${queue[0]}"; queue=("${queue[@]:1}") + case "$seen" in *" $cur "*) continue ;; esac + seen="$seen$cur " + act="$ROOT/$cur/action.yml" + [ -f "$act" ] || continue + while IFS= read -r ref; do + [ -n "$ref" ] || continue + case "$ref" in + '$/'*) queue+=("${ref#\$/}") ;; # nested same-repo composite + ./*) ;; # legacy local ref: not lockable + *) lock_key "$ref" ;; + esac + done < <(yqr '.runs.steps[].uses | select(. != null)' "$act") + done +} + +echo "== every ref reached through a \$/ composite is declared for its workflow ==" +checked=0 +while IFS= read -r wf; do + [ -n "$wf" ] || continue + declared="$(w="$wf" yqr '.workflows[strenv(w)][]' "$LOCK" 2>/dev/null || true)" + while IFS= read -r need; do + [ -n "$need" ] || continue + checked=$((checked+1)) + if printf '%s\n' "$declared" | grep -qxF "$need"; then + ok "$wf declares $need" + else + bad "$wf reaches $need through a composite but does not declare it" + echo " this is the shape that kills the job in Set up job, 1 step, no gate run" + fi + done < <(closure_of_workflow "$wf" | sort -u) +done < <(yqr '.workflows | keys | .[]' "$LOCK") + +# A closure test with nothing to check is vacuous, and this repo HAS such edges +# (manifest-check reaches two). Assert the population is non-empty — this very +# assertion caught a jq-vs-mikefarah dialect bug on the first run of this file. +if [ "$checked" -eq 0 ]; then + bad "no composite-reached refs found at all — the test is vacuous" +else + ok "checked $checked composite-reached ref(s) — population is non-empty" +fi + +echo "== every declared ref resolves to a dependencies: entry ==" +while IFS= read -r ref; do + [ -n "$ref" ] || continue + if r="$ref" yq -e '.dependencies[strenv(r)]' "$LOCK" >/dev/null 2>&1; then + ok "dependencies: defines $ref" + else + bad "workflows: names $ref but dependencies: has no such key" + fi +done < <(yqr '.workflows[][]' "$LOCK" | sort -u) + +echo +echo "PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ]