Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/code-hygiene-self-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"actions/formatting-check/**",
"tests/**",
"scripts/deonboard-estate-audit.sh",
"scripts/deonboard-workflow.sh",
".github/workflows/code-hygiene-self-test.yml",
".github/workflows/actions.lock",
# lock-transitive-closure.sh reads the literal cicd-suite SHA pins in
Expand All @@ -40,6 +41,7 @@
"actions/formatting-check/**",
"tests/**",
"scripts/deonboard-estate-audit.sh",
"scripts/deonboard-workflow.sh",
".github/workflows/code-hygiene-self-test.yml",
".github/workflows/actions.lock",
# lock-transitive-closure.sh reads the literal cicd-suite SHA pins in
Expand Down Expand Up @@ -85,10 +87,16 @@
run: "bash tests/lock-transitive-closure.sh",
},
{
# scripts/deonboard-estate-audit.sh against every actions.lock schema it
# claims to support; an untested schema must fail closed.
# scripts/deonboard-workflow.sh, through both entry points, against
# every actions.lock schema it claims to support; an untested schema
# must fail closed.
run: "bash tests/deonboard-lock-schemas.sh",
},
{
# The same script must not prune a dependency still reached by a
# lower-case nested `uses:` edge, through either entry point.
run: "bash tests/deonboard-estate-audit.sh",
},
],
},
test: {
Expand Down
4 changes: 2 additions & 2 deletions ARCHITECTURE.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ actions/<name>-check/action.yml one composite action per gate (26; 24 run)
.github/workflows/main-estate-audit.yml the reusable audit: five component jobs
templates/estate-audit-caller.yml the ten-line caller each repository holds
templates/track-estate-audit.yml weekly bot: moves the caller's pin to HEAD
scripts/deonboard-estate-audit.sh removes the caller from a repo's actions.lock
scripts/deonboard-estate-audit.sh removes the caller from a repo's actions.lock (delegates)
rollout_estate.sh installs the three above in every estate clone
actions/rust-ci-check/ the strict Rust gate (resolve.sh, check.sh, test.sh)
actions/creusot-check/ Creusot: detect · theatre · prove (planted controls)
Expand All @@ -25,7 +25,7 @@ actions/kanren-check/ miniKanren: detect · run · purity
.github/workflows/language-gates-self-test.yml each gate's test.sh, every PR
templates/*-caller.yml the thin callers for the four gates
scripts/verify-suite-checkout.sh proves the self-checkout is the caller's pin
scripts/deonboard-workflow.sh general form of deonboard-estate-audit.sh
scripts/deonboard-workflow.sh the one de-onboard implementation, every caller
rollout_language_gates.sh installs the callers by what a tree contains
....

Expand Down
11 changes: 7 additions & 4 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,13 @@ every consuming repository at once.
. The caller must be *de-onboarded* from the repository's
`+actions.lock+`. Onboarded, a cross-repository reusable-workflow call
startup-fails with zero jobs. `+scripts/deonboard-estate-audit.sh <repo>+`
removes the entry and prunes the dependencies only it reached. It needs
mikefarah `+yq+` v4, and it is idempotent. It refuses any `+actions.lock+`
schema it has not been tested on; `+tests/deonboard-lock-schemas.sh+` holds
one fixture per supported schema (v0.0.2, v0.0.3).
removes the entry and prunes the dependencies only it reached, comparing
owner and repository names case-insensitively as GitHub does. It is the
estate-audit entry point to `+scripts/deonboard-workflow.sh+`, the one
implementation every de-onboard goes through. It needs mikefarah `+yq+` v4,
and it is idempotent. It refuses any `+actions.lock+` schema it has not been
tested on; `+tests/deonboard-lock-schemas.sh+` holds one fixture per
supported schema (v0.0.2, v0.0.3) and runs each through both entry points.
. `+templates/track-estate-audit.yml+` becomes
`+.github/workflows/track-estate-audit.yml+`. Weekly, it opens a PR that
moves the pin to cicd-suite HEAD; that PR's own audit run is the check.
Expand Down
62 changes: 5 additions & 57 deletions scripts/deonboard-estate-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,63 +17,11 @@
#
# Idempotent: a lock that does not list the workflow is left untouched.
# Fails closed (exit 3) on an actions.lock schema it has not been tested on.
#
# The work is done by deonboard-workflow.sh, the one implementation, which
# the language-gate rollout also uses. Two copies drifted once: the schema
# guard and the case-insensitive prune landed here and not there.
set -euo pipefail

repo="${1:?usage: deonboard-estate-audit.sh <repo-dir>}"
lock="$repo/.github/workflows/actions.lock"
wf=".github/workflows/main-estate-audit.yml"

if ! yq --version 2>/dev/null | grep -q mikefarah; then
echo "deonboard-estate-audit.sh needs mikefarah yq v4 (https://github.com/mikefarah/yq)" >&2
exit 2
fi
if [ ! -f "$lock" ]; then
echo "$repo: no actions.lock; nothing to de-onboard"
exit 0
fi
if ! w="$wf" yq -e '.workflows | has(strenv(w))' "$lock" >/dev/null 2>&1; then
echo "$repo: $wf is not onboarded; nothing to do"
exit 0
fi

# Lockfile schemas this script is tested against, one fixture each in
# tests/fixtures/actions-lock/ (tests/deonboard-lock-schemas.sh). An untested
# schema fails closed: add its fixture first, then its version here.
known_versions="v0.0.2 v0.0.3"
ver=$(yq '.version' "$lock")
case " $known_versions " in
*" $ver "*) ;;
*) echo "$repo: actions.lock schema '$ver' is untested by this script (tested: $known_versions); see tests/deonboard-lock-schemas.sh" >&2
exit 3 ;;
esac

w="$wf" yq -i -I4 'del(.workflows[strenv(w)])' "$lock"

# owner/repo@ref -> lower-case owner/repo, ref as written. GitHub owner and
# repository names are case-insensitive, and gh actions-lock writes the
# nested `uses:` of a dependency in lower case while keeping the record key's
# original case: a lock can hold the edge 'swatinem/rust-cache@<sha>' and
# the record 'Swatinem/rust-cache@<sha>' (measured on hyperpolymath/januskey,
# 2026-10-14, under its hyperpolymath/standards record). Compared byte for
# byte, that record looks orphaned, and pruning it leaves a dangling edge,
# which kills the run at startup (standards scripts/close-lock.sh). Refs keep
# their case: git refs are case-sensitive.
lower_repo() { awk '{ i = index($0, "@"); print tolower(substr($0, 1, i - 1)) substr($0, i) }'; }

pruned=()
while :; do
used=$( { yq '.workflows[][]' "$lock"
yq '.dependencies[] | select(has("uses")) | .uses[]' "$lock"
} | lower_repo | sort -u )
unused=$(yq '.dependencies | keys | .[]' "$lock" | while IFS= read -r k; do
printf '%s\n' "$k" | lower_repo | grep -qxF -f <(printf '%s\n' "$used") || printf '%s\n' "$k"
done)
[ -n "$unused" ] || break
while IFS= read -r k; do
k="$k" yq -i -I4 'del(.dependencies[strenv(k)])' "$lock"
pruned+=("$k")
done <<< "$unused"
done

echo "$repo: de-onboarded $wf; pruned ${#pruned[@]} orphaned dependenc(ies):"
if [ "${#pruned[@]}" -gt 0 ]; then printf ' %s\n' "${pruned[@]}"; fi
exec "$(dirname "${BASH_SOURCE[0]}")/deonboard-workflow.sh" "$repo" .github/workflows/main-estate-audit.yml
35 changes: 31 additions & 4 deletions scripts/deonboard-workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
# SPARK and miniKanren gate callers are all of that shape.
#
# Idempotent: a lock that does not list the workflow is left untouched.
# deonboard-estate-audit.sh is the original single-purpose form and now
# delegates here.
# Fails closed (exit 3) on an actions.lock schema it has not been tested on.
# This is the one implementation: deonboard-estate-audit.sh is its
# estate-audit entry point and delegates here, so a fix lands for every
# caller at once (tests/deonboard-estate-audit.sh,
# tests/deonboard-lock-schemas.sh).
set -euo pipefail

repo="${1:?usage: deonboard-workflow.sh <repo-dir> [<workflow-path>]}"
Expand All @@ -42,14 +45,38 @@ if ! w="$wf" yq -e '.workflows | has(strenv(w))' "$lock" >/dev/null 2>&1; then
exit 0
fi

# Lockfile schemas this script is tested against, one fixture each in
# tests/fixtures/actions-lock/ (tests/deonboard-lock-schemas.sh). An untested
# schema fails closed: add its fixture first, then its version here.
known_versions="v0.0.2 v0.0.3"
ver=$(yq '.version' "$lock")
case " $known_versions " in
*" $ver "*) ;;
*) echo "$repo: actions.lock schema '$ver' is untested by this script (tested: $known_versions); see tests/deonboard-lock-schemas.sh" >&2
exit 3 ;;
esac

w="$wf" yq -i -I4 'del(.workflows[strenv(w)])' "$lock"

# owner/repo@ref -> lower-case owner/repo, ref as written. GitHub owner and
# repository names are case-insensitive, and gh actions-lock writes the
# nested `uses:` of a dependency in lower case while keeping the record key's
# original case: a lock can hold the edge 'swatinem/rust-cache@<sha>' and
# the record 'Swatinem/rust-cache@<sha>' (measured on hyperpolymath/januskey,
# 2026-10-14, under its hyperpolymath/standards record). Compared byte for
# byte, that record looks orphaned, and pruning it leaves a dangling edge,
# which kills the run at startup (standards scripts/close-lock.sh). Refs keep
# their case: git refs are case-sensitive.
lower_repo() { awk '{ i = index($0, "@"); print tolower(substr($0, 1, i - 1)) substr($0, i) }'; }

pruned=()
while :; do
used=$( { yq '.workflows[][]' "$lock"
yq '.dependencies[] | select(has("uses")) | .uses[]' "$lock"
} | sort -u )
unused=$(yq '.dependencies | keys | .[]' "$lock" | grep -vxF -f <(printf '%s\n' "$used") || true)
} | lower_repo | sort -u )
unused=$(yq '.dependencies | keys | .[]' "$lock" | while IFS= read -r k; do
printf '%s\n' "$k" | lower_repo | grep -qxF -f <(printf '%s\n' "$used") || printf '%s\n' "$k"
done)
[ -n "$unused" ] || break
while IFS= read -r k; do
k="$k" yq -i -I4 'del(.dependencies[strenv(k)])' "$lock"
Expand Down
106 changes: 66 additions & 40 deletions tests/deonboard-estate-audit.sh
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
#
# scripts/deonboard-estate-audit.sh test.
# De-onboard prune test: scripts/deonboard-workflow.sh and its estate-audit
# entry point, scripts/deonboard-estate-audit.sh.
#
# The script removes main-estate-audit.yml from a consumer's actions.lock and
# prunes the dependencies nothing else reaches. Pruning one that IS still
# reached leaves a dangling edge, and a dangling edge kills the run at startup
# (standards scripts/close-lock.sh). That is the defect this test pins down:
# The script removes a workflow from a consumer's actions.lock and prunes the
# dependencies nothing else reaches. Pruning one that IS still reached leaves
# a dangling edge, and a dangling edge kills the run at startup (standards
# scripts/close-lock.sh). That is the defect this test pins down:
# gh actions-lock writes nested `uses:` in lower case, so a record key
# 'Swatinem/rust-cache@<sha>' is reached by the edge
# 'swatinem/rust-cache@<sha>', and a byte-for-byte compare called it orphaned
# (measured on hyperpolymath/januskey's lock, 2026-10-14).
#
# A mutant with the byte-for-byte compare restored must fail, or this suite
# proves nothing.
# Both entry points are run, because the fix once landed in one copy and not
# the other. A mutant of deonboard-workflow.sh with the byte-for-byte compare
# restored must fail through BOTH: through the wrapper too, or the wrapper is
# not delegating and this suite proves nothing about it.

set -uo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$ROOT/scripts/deonboard-estate-audit.sh"
WF=".github/workflows/main-estate-audit.yml"
RUST_CACHE='Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6'
PASS=0; FAIL=0
ok() { printf ' ok %s\n' "$1"; PASS=$((PASS+1)); }
bad() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL+1)); }
Expand Down Expand Up @@ -74,6 +78,15 @@
EOF
}

# De-onboard main-estate-audit.yml from <fixture> through one entry point of
# the scripts in <scripts-dir>.
deonboard() { # <scripts-dir> <wrapper|generic> <fixture>

Check warning on line 83 in tests/deonboard-estate-audit.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit return statement at the end of the function.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_cicd-suite&issues=AaFUDInNcaN6ZSv-0Cg3&open=AaFUDInNcaN6ZSv-0Cg3&pullRequest=56
case "$2" in

Check failure on line 84 in tests/deonboard-estate-audit.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a default case (*) to handle unexpected values.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_cicd-suite&issues=AaFUDInNcaN6ZSv-0Cgy&open=AaFUDInNcaN6ZSv-0Cgy&pullRequest=56
wrapper) bash "$1/deonboard-estate-audit.sh" "$3" ;;

Check warning on line 85 in tests/deonboard-estate-audit.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_cicd-suite&issues=AaFUDInNcaN6ZSv-0Cgz&open=AaFUDInNcaN6ZSv-0Cgz&pullRequest=56

Check warning on line 85 in tests/deonboard-estate-audit.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_cicd-suite&issues=AaFUDInNcaN6ZSv-0Cg0&open=AaFUDInNcaN6ZSv-0Cg0&pullRequest=56
generic) bash "$1/deonboard-workflow.sh" "$3" "$WF" ;;

Check warning on line 86 in tests/deonboard-estate-audit.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_cicd-suite&issues=AaFUDInNcaN6ZSv-0Cg1&open=AaFUDInNcaN6ZSv-0Cg1&pullRequest=56

Check warning on line 86 in tests/deonboard-estate-audit.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_cicd-suite&issues=AaFUDInNcaN6ZSv-0Cg2&open=AaFUDInNcaN6ZSv-0Cg2&pullRequest=56
esac
}

has_dep() { k="$2" yq -e '.dependencies | has(strenv(k))' "$1/.github/workflows/actions.lock" >/dev/null 2>&1; }

# Every edge (a workflows: ref or a nested uses:) must name a record, owner and
Expand All @@ -85,44 +98,57 @@
| lower | sort -u | grep -vxF -f <(yq '.dependencies | keys | .[]' "$lock" | lower) || true
}

echo "== the fixed script =="
FIX="$WORK/fixed"; make_fixture "$FIX"
if out="$(bash "$SCRIPT" "$FIX" 2>&1)"; then ok "exits 0"; else bad "exits non-zero: $out"; fi
if w='.github/workflows/main-estate-audit.yml' yq -e '.workflows | has(strenv(w))' "$FIX/.github/workflows/actions.lock" >/dev/null 2>&1; then
bad "main-estate-audit.yml is still onboarded"
else
ok "main-estate-audit.yml is de-onboarded"
fi
for k in 'hyperpolymath/cicd-suite@main' 'hyperpolymath/deed-ecosystem@f9d999b60cb5f383679ea19912bcdc49c944973a' 'actions/checkout@v7.0.1'; do
if has_dep "$FIX" "$k"; then bad "kept $k, which nothing reaches any more"; else ok "pruned $k"; fi
for entry in wrapper generic; do
echo "== the fixed script, through the $entry entry point =="
FIX="$WORK/fixed-$entry"; make_fixture "$FIX"
if out="$(deonboard "$ROOT/scripts" "$entry" "$FIX" 2>&1)"; then ok "exits 0"; else bad "exits non-zero: $out"; fi
if w="$WF" yq -e '.workflows | has(strenv(w))' "$FIX/.github/workflows/actions.lock" >/dev/null 2>&1; then
bad "main-estate-audit.yml is still onboarded"
else
ok "main-estate-audit.yml is de-onboarded"
fi
for k in 'hyperpolymath/cicd-suite@main' 'hyperpolymath/deed-ecosystem@f9d999b60cb5f383679ea19912bcdc49c944973a' 'actions/checkout@v7.0.1'; do
if has_dep "$FIX" "$k"; then bad "kept $k, which nothing reaches any more"; else ok "pruned $k"; fi
done
if has_dep "$FIX" "$RUST_CACHE"; then
ok "kept Swatinem/rust-cache, still reached as swatinem/rust-cache"
else
bad "pruned Swatinem/rust-cache, which the standards record still reaches"
fi
d="$(dangling "$FIX")"
if [ -z "$d" ]; then ok "no dangling edge"; else bad "dangling edge(s): $d"; fi
cp "$FIX/.github/workflows/actions.lock" "$WORK/after-first"
if deonboard "$ROOT/scripts" "$entry" "$FIX" >/dev/null 2>&1 && cmp -s "$WORK/after-first" "$FIX/.github/workflows/actions.lock"; then
ok "a second run changes nothing"
else
bad "a second run changed the lock or failed"
fi
done
if has_dep "$FIX" 'Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6'; then
ok "kept Swatinem/rust-cache, still reached as swatinem/rust-cache"
else
bad "pruned Swatinem/rust-cache, which the standards record still reaches"
fi
d="$(dangling "$FIX")"
if [ -z "$d" ]; then ok "no dangling edge"; else bad "dangling edge(s): $d"; fi
cp "$FIX/.github/workflows/actions.lock" "$WORK/after-first"
if bash "$SCRIPT" "$FIX" >/dev/null 2>&1 && cmp -s "$WORK/after-first" "$FIX/.github/workflows/actions.lock"; then
ok "a second run changes nothing"

echo "== both entry points give the same lock =="
if cmp -s "$WORK/fixed-wrapper/.github/workflows/actions.lock" "$WORK/fixed-generic/.github/workflows/actions.lock"; then
ok "identical results"
else
bad "a second run changed the lock or failed"
bad "the entry points disagree: $(diff "$WORK/fixed-wrapper/.github/workflows/actions.lock" "$WORK/fixed-generic/.github/workflows/actions.lock" | tr '\n' ' ')"
fi

echo "== a mutant with the byte-for-byte compare must fail =="
MUT="$WORK/mutant.sh"
sed 's/ | lower_repo//g' "$SCRIPT" > "$MUT"
if cmp -s "$SCRIPT" "$MUT"; then
echo "== a mutant with the byte-for-byte compare must fail through both entry points =="
MUT="$WORK/mutant/scripts"; mkdir -p "$MUT"
cp "$ROOT/scripts/deonboard-estate-audit.sh" "$MUT/"
sed 's/ | lower_repo//g' "$ROOT/scripts/deonboard-workflow.sh" > "$MUT/deonboard-workflow.sh"
chmod +x "$MUT/deonboard-estate-audit.sh" "$MUT/deonboard-workflow.sh"
if cmp -s "$ROOT/scripts/deonboard-workflow.sh" "$MUT/deonboard-workflow.sh"; then
bad "mutant is identical to the script: the case-folding the test exists for is gone"
else
MFIX="$WORK/mutated"; make_fixture "$MFIX"
bash "$MUT" "$MFIX" >/dev/null 2>&1 || true
if has_dep "$MFIX" 'Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6' && [ -z "$(dangling "$MFIX")" ]; then
bad "mutant survived: the checks above do not detect the defect"
else
ok "mutant killed: it prunes Swatinem/rust-cache and leaves a dangling edge"
fi
for entry in wrapper generic; do
MFIX="$WORK/mutated-$entry"; make_fixture "$MFIX"
deonboard "$MUT" "$entry" "$MFIX" >/dev/null 2>&1 || true
if has_dep "$MFIX" "$RUST_CACHE" && [ -z "$(dangling "$MFIX")" ]; then

Check failure on line 146 in tests/deonboard-estate-audit.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_cicd-suite&issues=AaFUDInNcaN6ZSv-0Cg4&open=AaFUDInNcaN6ZSv-0Cg4&pullRequest=56
bad "mutant survived through the $entry entry point: the checks above do not detect the defect"
else
ok "mutant killed through the $entry entry point: it prunes Swatinem/rust-cache and leaves a dangling edge"
fi
done
fi

echo
Expand Down
Loading
Loading