From ba55d1d5529f0f6184038028deaff04d4648d027 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:39:02 +0000 Subject: [PATCH 1/4] feat: declare the release door path --- .github/scripts/release-path.sh | 15 ++++ test/release-path.test.sh | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100755 .github/scripts/release-path.sh create mode 100755 test/release-path.test.sh diff --git a/.github/scripts/release-path.sh b/.github/scripts/release-path.sh new file mode 100755 index 0000000..0068f62 --- /dev/null +++ b/.github/scripts/release-path.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# The release doors' executable path (discussion #217; issue #237). The +# 0.5.0 record had to explain why lib/ruling.sh changed without changing a +# door. Keep this list executable so a doors-unchanged record measures the +# workflow and only the scripts it actually runs, while the test catches a +# new or removed dependency before a record can silently omit it. +set -euo pipefail + +printf '%s\n' \ + .github/workflows/release.yml \ + bin/ \ + lib/version.sh \ + lib/decide.sh \ + lib/facts.sh \ + lib/changelog.sh diff --git a/test/release-path.test.sh b/test/release-path.test.sh new file mode 100755 index 0000000..9fa5db2 --- /dev/null +++ b/test/release-path.test.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Contract tests for the release-door path manifest (issue #237). The list +# is evidence for skipping a live drill, so drift in either direction must +# fail before a release record can make an incomplete claim. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +PATH_SCRIPT="$ROOT/.github/scripts/release-path.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# derive_path — print the workflow, bin/ when a bin command sources a +# door library, and the workflow's direct + transitive lib dependencies. +derive_path() { + local tree="$1" workflow="$tree/.github/workflows/release.yml" + local pending seen=" " lib file refs ref bin_uses_lib=no + + printf '%s\n' .github/workflows/release.yml + pending="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$workflow" | sort -u)" + + while [ -n "$pending" ]; do + lib="$(printf '%s\n' "$pending" | sed -n '1p')" + pending="$(printf '%s\n' "$pending" | sed '1d')" + case "$seen" in + *" $lib "*) continue ;; + esac + seen="$seen$lib " + printf '%s\n' "$lib" + file="$tree/$lib" + [ -f "$file" ] || continue + refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file" | sort -u)" + if [ -n "$refs" ]; then + pending="$(printf '%s\n%s\n' "$pending" "$refs" | sed '/^$/d' | sort -u)" + fi + done + + if [ -d "$tree/bin" ]; then + for file in "$tree"/bin/*; do + [ -f "$file" ] || continue + refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file")" + for ref in $refs; do + case "$seen" in + *" $ref "*) bin_uses_lib=yes ;; + esac + done + done + fi + [ "$bin_uses_lib" = no ] || printf '%s\n' bin/ +} + +declared_path() { + bash "$1/.github/scripts/release-path.sh" +} + +path_check() { + local tree="$1" declared derived missing extra + declared="$(declared_path "$tree" | sort -u)" + derived="$(derive_path "$tree" | sort -u)" + missing="$(comm -13 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))" + extra="$(comm -23 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))" + if [ -n "$missing" ]; then + printf 'release-path: missing dependency: %s\n' "$missing" >&2 + fi + if [ -n "$extra" ]; then + printf 'release-path: stale path: %s\n' "$extra" >&2 + fi + [ -z "$missing" ] && [ -z "$extra" ] +} + +fixture() { + local name="$1" tree + tree="$TMP/$name" + mkdir -p "$tree/.github/scripts" "$tree/.github/workflows" "$tree/lib" "$tree/bin" + cp "$PATH_SCRIPT" "$tree/.github/scripts/release-path.sh" + printf '#!/usr/bin/env bash\n. "$ROOT/lib/changelog.sh"\n' >"$tree/bin/assemble" + printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" + printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" + printf '#!/usr/bin/env bash\n. "$ROOT/lib/version.sh"\n' >"$tree/lib/facts.sh" + printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh" + printf '%s\n' "$tree" +} + +# Exact output is the record author's copy-paste source. +check "manifest prints the specified ordered release path" 0 \ + $'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh' \ + bash "$PATH_SCRIPT" +check "real workflow and transitive dependencies match the manifest" 0 "" \ + path_check "$ROOT" + +# A door growing a dependency must name the missing path (#237 D7). +tree="$(fixture missing)" +printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\nrun: . "$CEREMONY_DIR/lib/version.sh"\nrun: . "$CEREMONY_DIR/lib/ruling.sh"\n' \ + >"$tree/.github/workflows/release.yml" +printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" +check "a new workflow library fails with its missing path" 1 \ + "missing dependency: lib/ruling.sh" path_check "$tree" + +# A manifest may not rot into a safe-looking superset. +tree="$(fixture extra)" +printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ + >"$tree/.github/workflows/release.yml" +sed -i 's| lib/changelog.sh$| lib/changelog.sh \\|' \ + "$tree/.github/scripts/release-path.sh" +printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh" +printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" +check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \ + path_check "$tree" + +# Transitive sourcing is part of the derivation, not decoration. +tree="$(fixture transitive)" +printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ + >"$tree/.github/workflows/release.yml" +: >"$tree/lib/facts.sh" +check "removing facts' version source fails as a stale path" 1 \ + "stale path: lib/version.sh" path_check "$tree" + +summary From 75d85df20c7c51a6cb2aaf9b12f2f1db1cf4e131 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:41:34 +0000 Subject: [PATCH 2/4] docs: define doors-unchanged drill records --- changelog.d/237.md | 5 +++++ drills/README.md | 40 +++++++++++++++++++++++++++++++++++++++ test/release-path.test.sh | 22 +++++++++++++++------ 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 changelog.d/237.md diff --git a/changelog.d/237.md b/changelog.d/237.md new file mode 100644 index 0000000..bfe9cd3 --- /dev/null +++ b/changelog.d/237.md @@ -0,0 +1,5 @@ +### Changed + +- Define the doors-unchanged drill record and an executable release-path list, + so a release may reuse live evidence only when its door bytes are unchanged + since the last rehearsed tag. (#237) diff --git a/drills/README.md b/drills/README.md index f59e961..e25f97f 100644 --- a/drills/README.md +++ b/drills/README.md @@ -66,6 +66,46 @@ record is the only thing that survives the drill, and 0.2.0's record shipped its first draft asserting a cleanup that had not happened (#135) — false evidence in the one file whose job is to be evidence. +A record has one of three shapes. A **rehearsal** records the disposable-repo +run above. **Doors unchanged** records the mechanically checked claim below +when a new rehearsal would execute the same bytes as the last one. **WAIVED** +records a maintainer's judgement under the standing paragraph below. If the +doors-unchanged conditions do not all hold, the release owes a rehearsal or a +waiver; the narrower shape is never a substitute for either. + +## Doors unchanged + +The builder may assert that no disposable-repo rehearsal is owed only when +all three conditions below hold at the candidate head. The release PR's panel +verifies the claim like any other evidence, and if any reviewer rules a full +drill owed, that verdict wins. + +1. `git diff ..HEAD -- ` contains no change + except the `CEREMONY_SELF_REF` pin line in + `.github/workflows/release.yml`. +2. The release path is exactly the output of + `.github/scripts/release-path.sh`: `.github/workflows/release.yml`, `bin/`, + `lib/version.sh`, `lib/decide.sh`, `lib/facts.sh`, and + `lib/changelog.sh`. The script is the record author's copy-paste source; + its contract test keeps this inline list and the workflow's direct and + transitive dependencies in agreement. +3. The last rehearsed tag's own record is a full rehearsal, its release is + published, and `main` was re-armed to `-dev` after it. + +The baseline is the last **rehearsed** tag, never merely the previous tag. A +previous-tag baseline could chain one doors-unchanged assertion from another +while the doors drift a small diff at a time; the last-rehearsed anchor makes +any accumulated release-path change force a new rehearsal. + +The record carries all three measurements as observed at its candidate head, +never copied from an earlier record. `drills/0.4.1.md` and +`drills/0.5.0.md` are the worked examples; the latter's amendment from a +predicted empty `lib/` diff to the observed `lib/ruling.sh` delta is why each +candidate is measured afresh (#233). Re-running its stricter baseline now is +also the path-enumeration proof: `git diff 0.4.0 0.5.0 -- ` is +only the `CEREMONY_SELF_REF` pin, while adding `lib/ruling.sh` makes the diff +non-empty even though neither release door reads that file (#217, #237). + `actions/drill-recorded` refuses any bare-version tree whose record is missing or blank. A waived drill is still a record: the file says WAIVED and why — a maintainer's call, visible and reviewable in the release PR's diff, diff --git a/test/release-path.test.sh b/test/release-path.test.sh index 9fa5db2..99bee54 100755 --- a/test/release-path.test.sh +++ b/test/release-path.test.sh @@ -15,8 +15,9 @@ trap 'rm -rf "$TMP"' EXIT # derive_path — print the workflow, bin/ when a bin command sources a # door library, and the workflow's direct + transitive lib dependencies. derive_path() { - local tree="$1" workflow="$tree/.github/workflows/release.yml" + local tree="$1" workflow local pending seen=" " lib file refs ref bin_uses_lib=no + workflow="$tree/.github/workflows/release.yml" printf '%s\n' .github/workflows/release.yml pending="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$workflow" | sort -u)" @@ -75,10 +76,12 @@ fixture() { tree="$TMP/$name" mkdir -p "$tree/.github/scripts" "$tree/.github/workflows" "$tree/lib" "$tree/bin" cp "$PATH_SCRIPT" "$tree/.github/scripts/release-path.sh" - printf '#!/usr/bin/env bash\n. "$ROOT/lib/changelog.sh"\n' >"$tree/bin/assemble" + printf '#!/usr/bin/env bash\n. "%s"\n' \ + "\$ROOT/lib/changelog.sh" >"$tree/bin/assemble" printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" - printf '#!/usr/bin/env bash\n. "$ROOT/lib/version.sh"\n' >"$tree/lib/facts.sh" + printf '#!/usr/bin/env bash\n. "%s"\n' \ + "\$ROOT/lib/version.sh" >"$tree/lib/facts.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh" printf '%s\n' "$tree" } @@ -92,7 +95,10 @@ check "real workflow and transitive dependencies match the manifest" 0 "" \ # A door growing a dependency must name the missing path (#237 D7). tree="$(fixture missing)" -printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\nrun: . "$CEREMONY_DIR/lib/version.sh"\nrun: . "$CEREMONY_DIR/lib/ruling.sh"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \ + "\$CEREMONY_DIR/lib/ruling.sh" \ >"$tree/.github/workflows/release.yml" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" check "a new workflow library fails with its missing path" 1 \ @@ -100,7 +106,9 @@ check "a new workflow library fails with its missing path" 1 \ # A manifest may not rot into a safe-looking superset. tree="$(fixture extra)" -printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" \ >"$tree/.github/workflows/release.yml" sed -i 's| lib/changelog.sh$| lib/changelog.sh \\|' \ "$tree/.github/scripts/release-path.sh" @@ -111,7 +119,9 @@ check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \ # Transitive sourcing is part of the derivation, not decoration. tree="$(fixture transitive)" -printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" \ >"$tree/.github/workflows/release.yml" : >"$tree/lib/facts.sh" check "removing facts' version source fails as a stale path" 1 \ From e5a87201f8fa5d4c9304716972cc7952417b1b1d Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:43:23 +0000 Subject: [PATCH 3/4] test: derive release path from executable lines --- test/release-path.test.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/release-path.test.sh b/test/release-path.test.sh index 99bee54..6d030c4 100755 --- a/test/release-path.test.sh +++ b/test/release-path.test.sh @@ -12,6 +12,19 @@ PATH_SCRIPT="$ROOT/.github/scripts/release-path.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT +library_refs() { + awk ' + /^[[:space:]]*#/ { next } + { + line = $0 + while (match(line, /lib\/[[:alnum:]_.-]+\.sh/)) { + print substr(line, RSTART, RLENGTH) + line = substr(line, RSTART + RLENGTH) + } + } + ' "$1" +} + # derive_path — print the workflow, bin/ when a bin command sources a # door library, and the workflow's direct + transitive lib dependencies. derive_path() { @@ -20,7 +33,7 @@ derive_path() { workflow="$tree/.github/workflows/release.yml" printf '%s\n' .github/workflows/release.yml - pending="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$workflow" | sort -u)" + pending="$(library_refs "$workflow" | sort -u)" while [ -n "$pending" ]; do lib="$(printf '%s\n' "$pending" | sed -n '1p')" @@ -32,7 +45,7 @@ derive_path() { printf '%s\n' "$lib" file="$tree/$lib" [ -f "$file" ] || continue - refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file" | sort -u)" + refs="$(library_refs "$file" | sort -u)" if [ -n "$refs" ]; then pending="$(printf '%s\n%s\n' "$pending" "$refs" | sed '/^$/d' | sort -u)" fi @@ -41,7 +54,7 @@ derive_path() { if [ -d "$tree/bin" ]; then for file in "$tree"/bin/*; do [ -f "$file" ] || continue - refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file")" + refs="$(library_refs "$file")" for ref in $refs; do case "$seen" in *" $ref "*) bin_uses_lib=yes ;; From e728612ea1b54c13ac706e125444cbbbfeb7f68e Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:12:49 +0000 Subject: [PATCH 4/4] test: resolve sibling release dependencies --- test/release-path.test.sh | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/test/release-path.test.sh b/test/release-path.test.sh index 6d030c4..572574c 100755 --- a/test/release-path.test.sh +++ b/test/release-path.test.sh @@ -13,7 +13,11 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT library_refs() { - awk ' + local sibling_refs=no + case "$1" in + */lib/*.sh) sibling_refs=yes ;; + esac + awk -v sibling_refs="$sibling_refs" ' /^[[:space:]]*#/ { next } { line = $0 @@ -21,6 +25,15 @@ library_refs() { print substr(line, RSTART, RLENGTH) line = substr(line, RSTART + RLENGTH) } + # Door libraries source siblings through their own BASH_SOURCE dirname, + # so the executable line ends in /name.sh without a literal lib/ (#237). + if (sibling_refs == "yes" && $0 ~ /^[[:space:]]*(\.|source)[[:space:]]/) { + line = $0 + while (match(line, /\/[[:alnum:]_.-]+\.sh/)) { + print "lib" substr(line, RSTART, RLENGTH) + line = substr(line, RSTART + RLENGTH) + } + } } ' "$1" } @@ -93,8 +106,9 @@ fixture() { "\$ROOT/lib/changelog.sh" >"$tree/bin/assemble" printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" - printf '#!/usr/bin/env bash\n. "%s"\n' \ - "\$ROOT/lib/version.sh" >"$tree/lib/facts.sh" + printf '#!/usr/bin/env bash\n# shellcheck source=lib/version.sh\n. "%s"\n' \ + "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/version.sh" \ + >"$tree/lib/facts.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh" printf '%s\n' "$tree" } @@ -117,6 +131,20 @@ printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" check "a new workflow library fails with its missing path" 1 \ "missing dependency: lib/ruling.sh" path_check "$tree" +# A library growing a sibling dependency in the production idiom must also +# name the missing path; a literal lib/ marker in a comment is not evidence. +tree="$(fixture missing-transitive)" +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" \ + >"$tree/.github/workflows/release.yml" +printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \ + "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \ + >>"$tree/lib/facts.sh" +printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" +check "a new sibling library fails with its missing path" 1 \ + "missing dependency: lib/ruling.sh" path_check "$tree" + # A manifest may not rot into a safe-looking superset. tree="$(fixture extra)" printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \