From 7477d3ec951b5f915c941de8bbbc90d7ab5d4119 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 12:14:07 +0000 Subject: [PATCH 1/3] fix: facts.sh reads a parentless head as base_ver=(none) (#134) A repository's first push to main is a branch-create push whose head is a root commit: event.before is all-zeros and MERGE_SHA^1 does not exist, so the fallback died at exit 128 before establishing a fact. The parent count is now read via rev-list --parents (a fact, not an inferred failure), the no-base path skips the belt-and-braces fetch and the base git show, and an unresolvable MERGE_SHA still fails loudly. Co-Authored-By: Claude Fable 5 --- lib/facts.sh | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/facts.sh b/lib/facts.sh index e667524..add259b 100644 --- a/lib/facts.sh +++ b/lib/facts.sh @@ -44,31 +44,50 @@ ver="$(version_read "$VERSION_SOURCE")" # event.before is all-zeros on a branch-create push, and absent outside push # events; the pushed head's first parent is main the instant before, either # way (#1 constraint 10; cast's `*[!0]*` test — "contains a non-zero char"). +# One branch-create has no instant before: the repository's FIRST push to +# main, whose head is a root commit — both 0.2.0 drills died here at exit +# 128, the first release-flow event either scratch consumer ever saw (#134). +# The parent count is read as a fact (`rev-list --parents` prints the head +# alone for a root commit) rather than inferred from a failed rev-parse, so +# an unresolvable MERGE_SHA still dies loudly instead of masquerading as +# "(none)". base_sha="${EVENT_BEFORE:-}" case "$base_sha" in *[!0]*) ;; - *) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;; + *) + parents="$(git rev-list --parents -n 1 "$MERGE_SHA")" + case "$parents" in + *" "*) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;; + *) base_sha="" ;; # a root commit: no base tree exists at all + esac + ;; esac # Belt-and-braces (cast's precedent): the workflow's fetch-depth: 2 resolves # the first parent, but event.before can predate it when pushes raced. If # the fetch still cannot produce it, the git show below is the loud failure. -git cat-file -e "$base_sha" 2>/dev/null \ - || git fetch --depth=1 origin "$base_sha" >&2 \ - || true +# Skipped entirely when there is no base: with an empty rev the fetch is +# meaningless and `git show ":$src"` would read the INDEX — reporting the +# head's own version as the base, a wrong fact worse than any crash (#134). +if [ -n "$base_sha" ]; then + git cat-file -e "$base_sha" 2>/dev/null \ + || git fetch --depth=1 origin "$base_sha" >&2 \ + || true +fi base_dir="$(mktemp -d)" trap 'rm -rf "$base_dir"' EXIT -if git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then +if [ -n "$base_sha" ] && git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")" else - # The base tree has no version source at all: the merge that ADDS the - # version machinery (a consumer's adoption PR, a greenfield repo's first - # caller). "(none)" is not a version, so decide sees a changed version - # and the table still governs: a -dev head is work (row 2, the guided - # bootstrap path), a bare head still demands the merged release label - # (rows 5–6). Nothing releases silently either way. + # No base tree (a root commit — the repository's first push, the 0.2.0 + # drills' wall, #134), or a base tree with no version source in it: the + # merge that ADDS the version machinery (a consumer's adoption PR, a + # greenfield repo's first caller). "(none)" is not a version, so decide + # sees a changed version and the table still governs: a -dev head is work + # (row 2, the guided bootstrap path), a bare head still demands the + # merged release label (rows 5–6). Nothing releases silently either way. base_ver="(none)" fi -- 2.45.2 From a17e49737fe28f4c99ef0a9f763a81e6ac0fe2b0 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 12:16:04 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test:=20root-commit=20fixtures=20=E2=80=94?= =?UTF-8?q?=20(none)=20base,=20D2=20loud-death=20pin,=20e2e=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit facts.test.sh: greenfield fixtures for all-zeros and empty event.before, the bare root establishing labeled=no, and the D2 pin (an unresolvable MERGE_SHA exits 128 and never reports base_ver=(none) — the test that || true would fail). release-chain.test.sh: chain() gains optional repo/stub args; a -dev root commit is a green NOTICE ceremony=no, a bare unlabeled root still refuses. Plus changelog.d/134.md. Co-Authored-By: Claude Fable 5 --- changelog.d/134.md | 1 + test/facts.test.sh | 36 ++++++++++++++++++++++++ test/release-chain.test.sh | 57 +++++++++++++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 changelog.d/134.md diff --git a/changelog.d/134.md b/changelog.d/134.md new file mode 100644 index 0000000..d9d334c --- /dev/null +++ b/changelog.d/134.md @@ -0,0 +1 @@ +- `lib/facts.sh` — a repository's first push to `main` (a root commit with no first parent) now reads `base_ver=(none)` and lets decide's table govern, instead of dying at exit 128 before establishing a fact; the no-base path skips the base fetch and `git show`, and an unresolvable head still fails loudly (#134). diff --git a/test/facts.test.sh b/test/facts.test.sh index 83cc16d..32bea4a 100644 --- a/test/facts.test.sh +++ b/test/facts.test.sh @@ -146,6 +146,42 @@ adoptb_head="$(commit adoption-bare VERSION 0.1.0)" check "absent-at-base with a bare head still asks for the label" 0 "labeled=yes" \ facts_in adoption-bare VERSION_SOURCE=file MERGE_SHA="$adoptb_head" EVENT_BEFORE="$adoptb_base" GH_STUB=labeled-yes +# --- a root commit: no base tree at all (the repository's first push) -------- + +# The first push to main IS a branch-create push (event.before all-zeros) +# whose head has no first parent — there is no base, and the honest fact is +# "(none)", not an exit-128 death at rev-parse. Both 0.2.0 drills hit the +# death independently (#134). The -dev row consults no API (stub default), +# which also asserts the no-base path runs no base fetch/show at all. +repo greenfield +green_head="$(commit greenfield VERSION 0.1.0-dev)" + +check "root commit, all-zeros event.before: base_ver=(none)" 0 "base_ver=(none)" \ + facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$green_head" EVENT_BEFORE="$ZEROS" +check "root commit, empty event.before: base_ver=(none)" 0 "base_ver=(none)" \ + facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$green_head" EVENT_BEFORE= + +repo greenfield-bare +greenb_head="$(commit greenfield-bare VERSION 0.1.0)" +check "bare root commit still establishes labeled, so decide can refuse" 0 "labeled=no" \ + facts_in greenfield-bare VERSION_SOURCE=file MERGE_SHA="$greenb_head" EVENT_BEFORE="$ZEROS" GH_STUB=labeled-no + +# The D2 pin: "no first parent" is detected, never inferred from a failed +# command — an unresolvable MERGE_SHA is a loud death, not "(none)". A +# `|| true` around the fallback would pass every case above and fail here. +BAD_SHA="1111111111111111111111111111111111111111" +check "an unresolvable MERGE_SHA still dies loudly" 128 "bad object" \ + facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$BAD_SHA" EVENT_BEFORE="$ZEROS" +bad_out="$(facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$BAD_SHA" EVENT_BEFORE="$ZEROS" 2>&1)" +if printf '%s' "$bad_out" | grep -qF "base_ver=(none)"; then + echo "FAIL: an unresolvable MERGE_SHA must not be reported as base_ver=(none)" + printf '%s\n' "$bad_out" | sed 's/^/ /' + FAIL=$((FAIL + 1)) +else + echo "ok: an unresolvable MERGE_SHA is not reported as base_ver=(none)" + PASS=$((PASS + 1)) +fi + # --- the package-json backend ------------------------------------------------ repo pkg diff --git a/test/release-chain.test.sh b/test/release-chain.test.sh index a83348b..8733425 100644 --- a/test/release-chain.test.sh +++ b/test/release-chain.test.sh @@ -69,12 +69,14 @@ 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 — facts, then decide fed from facts' -# output lines, then the notes extraction, printing each stage's result. +# chain [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 "$TMP/repo" || exit 1 - facts_out="$(env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture \ + cd "${3:-$TMP/repo}" || exit 1 + facts_out="$(env PATH="${4:-$TMP/stub}:$PATH" GITHUB_REPOSITORY=fixture/fixture \ GH_TOKEN=stub VERSION_SOURCE=file MERGE_SHA="$1" EVENT_BEFORE="$2" \ bash "$FACTS")" || exit 1 printf '%s\n' "$facts_out" @@ -150,4 +152,51 @@ check "the post-release bump decides ceremony=no" 0 "ceremony=no" \ 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 -- 2.45.2 From fa768510bff3624f40716b53c115968271015ae1 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 12:19:27 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20retrigger=20checks=20=E2=80=94=20q?= =?UTF-8?q?ueued=20reconcile=20duplicate=20was=20cancelled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The labels/reconcile job on a17e497 was a queued duplicate cancelled by its repo-global concurrency group (the sweep it duplicated passed seconds earlier); GitHub refuses to rerun it, and the reconciler read the cancellation as ci-red. Nothing in the tree changes. Co-Authored-By: Claude Fable 5 -- 2.45.2