ceremony/test/release-chain.test.sh
cluade-reviewer-andresmgsl 957f72739d
Some checks failed
CI / test (pull_request) Successful in 1m27s
CI / release-exercise (pull_request) Failing after 10s
CI / self-guards (pull_request) Failing after 5s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m24s
fix(forge): the release doors speak the shim, and an unread fact refuses (#191)
The 0.4.1 drill measured both doors dead on Forgejo. lib/facts.sh gathered
`released` with `gh release view` and `labeled` with `gh api .../pulls`, and
release.yml tagged and published with `gh` — none of which exist on the
runner image. The merge door therefore read labeled=no for a correctly
labeled, correctly merged ceremony PR and refused it as "a bare push";
the tag door cleared every gate and died at `gh release create`.

Both are ported onto lib/forge.sh. Two asymmetries were measured against
the live instance and its swagger rather than assumed:

  * GitHub serves an ARRAY of PRs at /commits/{sha}/pulls; Forgejo serves a
    single OBJECT at /commits/{sha}/pull and 404s on the plural. Both verbs
    emit the array shape, so facts.sh carries one jq expression.
  * GitHub creates a tag by POSTing to /git/refs; Forgejo serves that path
    GET-only and creates tags at /tags. A 1:1 port of the gh call would
    have 404'd forever.

The behaviour change is the second half of the bug. Any failure used to
become a definite `no`, which is safe for row 4 and catastrophic for row 5:
it is how a missing binary became "this was not a release ceremony". Now a
completed read that finds nothing is still `no` and still fail-closed, and a
read that did not complete refuses and emits no fact at all.

Four new cases in test/facts.test.sh cover exactly that, and a mutation back
to the old fail-closed-on-error behaviour kills all four and nothing else.
1014 assertions, 22 suites, shellcheck and actionlint clean.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:31:06 +00:00

213 lines
7.8 KiB
Bash

#!/usr/bin/env bash
# The merge door's script chain, composed end-to-end (issue #9): facts →
# decide → notes against a constructed fixture ceremony, exactly the way
# release.yml wires them (facts' $GITHUB_OUTPUT lines become decide's env;
# the notes come from the one canonical extractor). facts.test.sh proves the
# fact rows and decide's own suite proves the table; this file proves the
# HANDOFF between them. Also run by release-exercise.yml on dispatch. set
# -u, not -e: failing commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are simply undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
FACTS="$ROOT/lib/facts.sh"
DECIDE="$ROOT/lib/decide.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A gh stub for the one API fact the ceremony path needs: the merged,
# release-labeled PR behind the commit.
mkdir -p "$TMP/stub"
cat >"$TMP/stub/gh" <<'EOF'
#!/usr/bin/env bash
# The label read is now GET commits/{sha}/pulls, a JSON array (#191).
if [ "$1" = api ]; then
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
exit 0
fi
echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
chmod +x "$TMP/stub/gh"
# The fixture: a base tree at 0.6.9-dev with an armed changelog, then the
# ceremony merge — VERSION bumped bare, Unreleased stamped, re-armed above.
git init -q "$TMP/repo"
git -C "$TMP/repo" config user.email fixture@example.invalid
git -C "$TMP/repo" config user.name fixture
printf '0.6.9-dev\n' >"$TMP/repo/VERSION"
cat >"$TMP/repo/CHANGELOG.md" <<'EOF'
# Changelog
## Unreleased
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git -C "$TMP/repo" add VERSION CHANGELOG.md
git -C "$TMP/repo" commit -qm "base"
BASE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
printf '0.7.0\n' >"$TMP/repo/VERSION"
cat >"$TMP/repo/CHANGELOG.md" <<'EOF'
# Changelog
## Unreleased
## 0.7.0 — 2026-07-21
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git -C "$TMP/repo" add VERSION CHANGELOG.md
git -C "$TMP/repo" commit -qm "release: 0.7.0"
MERGE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
# chain <merge_sha> <event_before> [repo_dir] [stub_dir] — facts, then
# decide fed from facts' output lines, then the notes extraction, printing
# each stage's result. The optional dirs default to the main fixture; the
# greenfield cases below (#134) bring their own repo and stub.
chain() {
(
cd "${3:-$TMP/repo}" || exit 1
facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \
CEREMONY_FORGE=github \
GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \
bash "$FACTS")" || exit 1
printf '%s\n' "$facts_out"
ver="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "ver" { print $2 }')"
base_ver="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "base_ver" { print $2 }')"
released="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "released" { print $2 }')"
labeled="$(printf '%s\n' "$facts_out" | awk -F= '$1 == "labeled" { print $2 }')"
decide_out="$(env VER="$ver" BASE_VER="$base_ver" RELEASED="$released" \
LABELED="$labeled" bash "$DECIDE")" || exit 1
printf '%s\n' "$decide_out"
case "$decide_out" in
*ceremony=yes*)
# shellcheck source=lib/changelog.sh
. "$ROOT/lib/changelog.sh"
diagnosis="$(changelog_section_problem CHANGELOG.md "$ver")" || {
printf 'chain: %s\n' "$diagnosis" >&2
exit 1
}
notes="$(changelog_section CHANGELOG.md "$ver")"
printf 'notes: %s\n' "$notes"
;;
esac
)
}
check "the ceremony merge decides ceremony=yes" 0 "ceremony=yes" \
chain "$MERGE_SHA" "$BASE_SHA"
check "the notes are the stamped section's prose" 0 \
"notes: - The entry this release ships." chain "$MERGE_SHA" "$BASE_SHA"
# The same ceremony facts with an entry-less stamped section must stop at the
# notes door, before any tag or release mutation could run.
git -C "$TMP/repo" reset -q --hard "$BASE_SHA"
printf '0.7.0\n' >"$TMP/repo/VERSION"
cat >"$TMP/repo/CHANGELOG.md" <<'EOF'
# Changelog
## Unreleased
## 0.7.0 — 2026-07-21
### Added
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git -C "$TMP/repo" add VERSION CHANGELOG.md
git -C "$TMP/repo" commit -qm "release: entry-less 0.7.0"
EMPTY_MERGE_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
check "the notes door refuses an entry-less stamped section" 1 \
"section '0.7.0' has no entries" chain "$EMPTY_MERGE_SHA" "$BASE_SHA"
git -C "$TMP/repo" reset -q --hard "$MERGE_SHA"
# The same chain on an ordinary merge: -dev, unchanged — a green NOTICE
# no-op that never consults the API (the stub would refuse a release view).
printf 'ordinary work\n' >"$TMP/repo/notes.txt"
git -C "$TMP/repo" add notes.txt
git -C "$TMP/repo" commit -qm "ordinary work"
WORK_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
printf '0.7.1-dev\n' >"$TMP/repo/VERSION"
git -C "$TMP/repo" add VERSION
git -C "$TMP/repo" commit -qm "chore: bump main to 0.7.1-dev"
BUMP_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
printf 'more ordinary work\n' >"$TMP/repo/notes.txt"
git -C "$TMP/repo" add notes.txt
git -C "$TMP/repo" commit -qm "more ordinary work"
WORK2_SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
check "the post-release bump decides ceremony=no" 0 "ceremony=no" \
chain "$BUMP_SHA" "$WORK_SHA"
check "an ordinary -dev merge decides ceremony=no" 0 "ceremony=no" \
chain "$WORK2_SHA" "$BUMP_SHA"
# --- the repository's first push to main: a root commit, no base (#134) ------
# event.before is all-zeros and the head has no first parent. facts reads
# base_ver=(none) instead of dying at rev-parse, and decide's table governs
# from there: the guided bootstrap (-dev first commit, what CONSUMERS.md
# tells a new repo to write) is a green NOTICE no-op — the doctrine's
# promise held at the exact moment a consumer adopts the ceremony.
ZEROS="0000000000000000000000000000000000000000"
git init -q "$TMP/greenfield"
git -C "$TMP/greenfield" config user.email fixture@example.invalid
git -C "$TMP/greenfield" config user.name fixture
printf '0.1.0-dev\n' >"$TMP/greenfield/VERSION"
git -C "$TMP/greenfield" add VERSION
git -C "$TMP/greenfield" commit -qm "root: adopt the ceremony at 0.1.0-dev"
GREEN_SHA="$(git -C "$TMP/greenfield" rev-parse HEAD)"
check "a greenfield -dev root commit decides ceremony=no, not a red run" 0 "ceremony=no" \
chain "$GREEN_SHA" "$ZEROS" "$TMP/greenfield"
check "the greenfield no-op is a NOTICE" 0 "NOTICE:" \
chain "$GREEN_SHA" "$ZEROS" "$TMP/greenfield"
# A bare first commit with no merged release-labeled PR must still refuse —
# the reason the crash could not be `|| true`-ed away: a greenfield adoption
# must never become a silent release. This stub answers the labeled query
# with false.
mkdir -p "$TMP/stub-unlabeled"
cat >"$TMP/stub-unlabeled/gh" <<'EOF'
#!/usr/bin/env bash
if [ "$1" = api ]; then echo false; exit 0; fi
echo "gh stub: unexpected call: gh $*" >&2
exit 97
EOF
chmod +x "$TMP/stub-unlabeled/gh"
git init -q "$TMP/greenfield-bare"
git -C "$TMP/greenfield-bare" config user.email fixture@example.invalid
git -C "$TMP/greenfield-bare" config user.name fixture
printf '0.1.0\n' >"$TMP/greenfield-bare/VERSION"
git -C "$TMP/greenfield-bare" add VERSION
git -C "$TMP/greenfield-bare" commit -qm "root: bare 0.1.0, nobody declared a release"
GREENB_SHA="$(git -C "$TMP/greenfield-bare" rev-parse HEAD)"
check "a bare unlabeled root commit still refuses, creating nothing" 1 \
"no merged, release-labeled PR" \
chain "$GREENB_SHA" "$ZEROS" "$TMP/greenfield-bare" "$TMP/stub-unlabeled"
summary