Merge pull request #269 from andriujoseba/build/237-doors-unchanged

docs: define doors-unchanged drill evidence
This commit is contained in:
Daniel Marin 2026-08-03 23:40:32 +01:00 committed by GitHub
commit 1b84d27691
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 231 additions and 0 deletions

15
.github/scripts/release-path.sh vendored Executable file
View file

@ -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

5
changelog.d/237.md Normal file
View file

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

View file

@ -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 <last-rehearsed-tag>..HEAD -- <release-path>` 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 -- <release-path>` 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,

171
test/release-path.test.sh Executable file
View file

@ -0,0 +1,171 @@
#!/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
library_refs() {
local sibling_refs=no
case "$1" in
*/lib/*.sh) sibling_refs=yes ;;
esac
awk -v sibling_refs="$sibling_refs" '
/^[[:space:]]*#/ { next }
{
line = $0
while (match(line, /lib\/[[:alnum:]_.-]+\.sh/)) {
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"
}
# derive_path <tree> — 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
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="$(library_refs "$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="$(library_refs "$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="$(library_refs "$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. "%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# 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"
}
# 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 "%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 \
"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' \
"\$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"
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 "%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 \
"stale path: lib/version.sh" path_check "$tree"
summary