lib/facts.sh — a repository's first commit has no first parent; the merge door dies at exit 128 instead of reading (none) #134

Closed
opened 2026-07-24 12:00:40 +00:00 by dan-claude-bot · 2 comments
dan-claude-bot commented 2026-07-24 12:00:40 +00:00 (Migrated from github.com)

Context

A repository's first push to main runs the merge door red. lib/facts.sh
dies at exit 128 before establishing a single fact, so decide never runs and
the doctrine's promise — "every legitimate non-ceremony state must be a green
NOTICE no-op, not a red run on main"
(lib/decide.sh L5–L12)
— is broken at exactly the moment a consumer adopts the ceremony.

Found during the 0.2.0 drill and filed as
discussion #132 by
claude-bot-andresmgsl, per scope discipline (the release PR does not fix
drive-by findings).

Two independent drills hit it, which is why this is filed as a fact rather
than a report. #132 cites run 30089682128 on
claude-bot-andresmgsl/ceremony-drill-0.2.0; #128's own record — drills/0.2.0.md,
section "Failures and setup corrections" — hit it separately on a different
scratch repo: "The scratch repository's root commit triggered run 30089943081
before a parent commit existed, so fact gathering could not read a base
version."
Both scratch repos are now gone or unreadable (one deleted, one
archived under a builder's account), so neither run log is retrievable. The
local repro below is the durable evidence, and it is one command.

The cause

lib/facts.sh L44–L51:

base_sha="${EVENT_BEFORE:-}"
case "$base_sha" in
  *[!0]*) ;;
  *) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;;
esac

event.before is all-zeros on a branch-create push, and a repository's first
push to main is a branch-create push. For every later branch-create the
head has a first parent and this fallback is correct (#1 constraint 10, cast's
*[!0]* test). A root commit has no parent: rev-parse fails, set -e kills
the step.

Reproduced locally against a602fd0:

$ CEREMONY=$PWD && mkdir /tmp/fx && cd /tmp/fx && git init -q .
$ echo 0.1.0-dev > VERSION && git add VERSION && git commit -qm root
$ VERSION_SOURCE=file MERGE_SHA=$(git rev-parse HEAD) \
    EVENT_BEFORE=0000000000000000000000000000000000000000 \
    bash "$CEREMONY/lib/facts.sh"
fatal: ambiguous argument 'f4c1300…^1': unknown revision or path not in the working tree.
$ echo $?
128

The same fixture with EVENT_BEFORE set to a real parent sha never reaches
that line, which is why every existing test passes.

lib/facts.sh is unchanged since 0.1.0 — this is not a 0.2.0 regression, it is
a pre-existing edge the fragment-shaped drill happened to step on. Severity is
cosmetic-but-confusing, not dangerous: nothing is created, the refusal shape
holds, and the failure never recurs on that repo. What it costs is the first
observable release-flow event a new consumer ever sees, on the caller-first
adoption path docs/CONSUMERS.md
itself recommends.

The spec

The honest fact about a root commit is "there is no base tree" — which
facts.sh already knows how to say. L63–L73
sets base_ver="(none)" for the adjacent case (a base tree with no version
source — the adoption PR), and its comment already names "a greenfield repo's
first caller"
as one of the shapes it serves. The root commit never reaches
that branch because it dies four lines earlier.

D1 — a parentless head reads as base_ver="(none)", not as a failure.
Decided, not optional: decide's table then governs, unchanged. Verified
against lib/decide.sh at a602fd0:

head VERSION BASE_VER result
0.1.0-dev (none) row 2 — ceremony=no, green NOTICE no-op, exit 0
0.1.0 (unlabeled) (none) row 5 — REFUSE, exit 1, creating nothing

The greenfield adoption path (-dev in the first commit, which is what
CONSUMERS.md tells a new repo to do) goes green. A bare first commit still
refuses, so nothing releases silently. No behavior other than the crash
changes.

D2 — "no first parent" is detected, never inferred from a failed command.
|| true around the rev-parse would swallow a genuinely unresolvable
MERGE_SHA too, and "a missing fact must never fall through" is the rule this
file exists to keep. The head's parent count is a fact to read:

git rev-list --parents -n 1 "$MERGE_SHA"

prints <sha> alone for a root commit and <sha> <parent>… otherwise, and
still fails loudly if MERGE_SHA does not resolve. Only the zero-parent
reading takes the (none) path; every other rev-parse/rev-list failure
keeps killing the step exactly as today.

D3 — the no-base path must not run a rev-scoped git command with an empty
rev.
git show ":VERSION" reads the index, not a tree — an empty
base_sha falling through to L63
would silently read the checkout and report the head version as the base.
That is a wrong fact, which is worse than the crash being fixed. The
belt-and-braces fetch at
L56–L58
is equally meaningless with no base and is skipped as well. Shape is the
builder's call; the invariant is an acceptance criterion below.

D4 — the comment carries the why, per CONTRIBUTING: the war story is the
0.2.0 drill and this issue number.

Tasks

  • In lib/facts.sh, read the head's parent count before falling back to
    MERGE_SHA^1, and take the (none) path when it is zero (D1, D2).
  • Skip the belt-and-braces fetch and the base git show when there is no
    base tree; never invoke either with an empty rev (D3).
  • Extend the L66–L72 comment (or add one beside the new branch) naming the
    root-commit case, the 0.2.0 drill, and this issue (D4).
  • Add the test/facts.test.sh cases below.
  • Write one fragment, changelog.d/134.md — the published
    prose only, nothing else (CONTRIBUTING, #112). Never edit CHANGELOG.md.

Acceptance criteria

  • facts.sh with EVENT_BEFORE all-zeros against a root commit exits
    0 and prints base_ver=(none).
  • Same, with EVENT_BEFORE empty (the non-push caller).
  • A root commit whose VERSION is 0.1.0-dev produces ceremony=no and a
    green NOTICE through the full facts→decide chain — no red run.
  • A root commit whose VERSION is bare 0.1.0 with no merged
    release-labeled PR still refuses (exit 1) and creates nothing.
  • An unresolvable MERGE_SHA still fails loudly (non-zero exit), and is
    not reported as base_ver=(none).
  • No git show, git cat-file or git fetch runs with an empty rev on the
    no-base path.
  • Every existing test/facts.test.sh case passes unchanged — in particular
    L96–L99,
    all-zeros and empty event.before on a head that does have a first
    parent still resolve to it.
  • test/run.sh, shellcheck and actionlint green.

Test plan

New fixtures in test/facts.test.sh, beside the existing
"a base tree with no version source at all" block (L130–L149),
which is the closest precedent and whose comment already claims this case:

fixture input must be
greenfield — one root commit, VERSION=0.1.0-dev EVENT_BEFORE=$ZEROS exit 0, base_ver=(none), gh never called (the stub's default mode asserts this)
same EVENT_BEFORE= (empty) exit 0, base_ver=(none)
greenfield-bare — one root commit, VERSION=0.1.0 EVENT_BEFORE=$ZEROS, GH_STUB=labeled-no exit 0, labeled=no — the fact, so decide can refuse on it

The cases that must fail:

  • MERGE_SHA set to a sha that does not exist in the fixture repo → non-zero
    exit, and base_ver=(none) must not appear in the output. This is the
    test that pins D2; without it, || true passes everything above.
  • End-to-end in test/release-chain.test.sh (which already drives
    facts→decide together at
    L78):
    a root commit with a bare 0.1.0 and no label refuses with exit 1 and
    creates nothing. A greenfield adoption must not become a silent release —
    that is the whole reason the crash cannot simply be || true-ed away.

Dependencies

None. lib/facts.sh is untouched by the changelog.d epic (#112) and by the
0.2.0 release (#118); this lands independently and ships in whatever release
follows.

## Context A repository's **first** push to `main` runs the merge door red. `lib/facts.sh` dies at exit 128 before establishing a single fact, so `decide` never runs and the doctrine's promise — "every legitimate non-ceremony state must be a green NOTICE no-op, not a red run on main" ([lib/decide.sh L5–L12](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/lib/decide.sh#L5-L12)) — is broken at exactly the moment a consumer adopts the ceremony. Found during the 0.2.0 drill and filed as [discussion #132](https://github.com/heavy-duty/ceremony/discussions/132) by `claude-bot-andresmgsl`, per scope discipline (the release PR does not fix drive-by findings). **Two independent drills hit it**, which is why this is filed as a fact rather than a report. #132 cites run `30089682128` on `claude-bot-andresmgsl/ceremony-drill-0.2.0`; #128's own record — `drills/0.2.0.md`, section "Failures and setup corrections" — hit it separately on a different scratch repo: *"The scratch repository's root commit triggered run 30089943081 before a parent commit existed, so fact gathering could not read a base version."* Both scratch repos are now gone or unreadable (one deleted, one archived under a builder's account), so **neither run log is retrievable**. The local repro below is the durable evidence, and it is one command. ### The cause [`lib/facts.sh` L44–L51](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/lib/facts.sh#L44-L51): ```sh base_sha="${EVENT_BEFORE:-}" case "$base_sha" in *[!0]*) ;; *) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;; esac ``` `event.before` is all-zeros on a branch-create push, and a repository's first push to `main` **is** a branch-create push. For every *later* branch-create the head has a first parent and this fallback is correct (#1 constraint 10, cast's `*[!0]*` test). A root commit has no parent: `rev-parse` fails, `set -e` kills the step. Reproduced locally against `a602fd0`: ```console $ CEREMONY=$PWD && mkdir /tmp/fx && cd /tmp/fx && git init -q . $ echo 0.1.0-dev > VERSION && git add VERSION && git commit -qm root $ VERSION_SOURCE=file MERGE_SHA=$(git rev-parse HEAD) \ EVENT_BEFORE=0000000000000000000000000000000000000000 \ bash "$CEREMONY/lib/facts.sh" fatal: ambiguous argument 'f4c1300…^1': unknown revision or path not in the working tree. $ echo $? 128 ``` The same fixture with `EVENT_BEFORE` set to a real parent sha never reaches that line, which is why every existing test passes. `lib/facts.sh` is unchanged since 0.1.0 — this is not a 0.2.0 regression, it is a pre-existing edge the fragment-shaped drill happened to step on. Severity is **cosmetic-but-confusing, not dangerous**: nothing is created, the refusal shape holds, and the failure never recurs on that repo. What it costs is the first observable release-flow event a new consumer ever sees, on the caller-first adoption path [docs/CONSUMERS.md](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/docs/CONSUMERS.md) itself recommends. ## The spec The honest fact about a root commit is *"there is no base tree"* — which `facts.sh` already knows how to say. [L63–L73](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/lib/facts.sh#L63-L73) sets `base_ver="(none)"` for the adjacent case (a base tree with no version source — the adoption PR), and its comment already names *"a greenfield repo's first caller"* as one of the shapes it serves. The root commit never reaches that branch because it dies four lines earlier. **D1 — a parentless head reads as `base_ver="(none)"`, not as a failure.** Decided, not optional: `decide`'s table then governs, unchanged. Verified against `lib/decide.sh` at `a602fd0`: | head `VERSION` | `BASE_VER` | result | |---|---|---| | `0.1.0-dev` | `(none)` | row 2 — `ceremony=no`, green NOTICE no-op, exit 0 | | `0.1.0` (unlabeled) | `(none)` | row 5 — REFUSE, exit 1, creating nothing | The greenfield adoption path (`-dev` in the first commit, which is what CONSUMERS.md tells a new repo to do) goes green. A bare first commit still refuses, so nothing releases silently. **No behavior other than the crash changes.** **D2 — "no first parent" is detected, never inferred from a failed command.** `|| true` around the `rev-parse` would swallow a genuinely unresolvable `MERGE_SHA` too, and "a missing fact must never fall through" is the rule this file exists to keep. The head's parent count is a fact to read: ```sh git rev-list --parents -n 1 "$MERGE_SHA" ``` prints `<sha>` alone for a root commit and `<sha> <parent>…` otherwise, and still fails loudly if `MERGE_SHA` does not resolve. Only the zero-parent reading takes the `(none)` path; every other `rev-parse`/`rev-list` failure keeps killing the step exactly as today. **D3 — the no-base path must not run a rev-scoped git command with an empty rev.** `git show ":VERSION"` reads the *index*, not a tree — an empty `base_sha` falling through to [L63](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/lib/facts.sh#L63) would silently read the checkout and report the *head* version as the base. That is a wrong fact, which is worse than the crash being fixed. The belt-and-braces fetch at [L56–L58](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/lib/facts.sh#L56-L58) is equally meaningless with no base and is skipped as well. Shape is the builder's call; the invariant is an acceptance criterion below. **D4 — the comment carries the why**, per CONTRIBUTING: the war story is the 0.2.0 drill and this issue number. ## Tasks - [ ] In `lib/facts.sh`, read the head's parent count before falling back to `MERGE_SHA^1`, and take the `(none)` path when it is zero (D1, D2). - [ ] Skip the belt-and-braces fetch and the base `git show` when there is no base tree; never invoke either with an empty rev (D3). - [ ] Extend the L66–L72 comment (or add one beside the new branch) naming the root-commit case, the 0.2.0 drill, and this issue (D4). - [ ] Add the `test/facts.test.sh` cases below. - [ ] Write one fragment, `changelog.d/134.md` — the published prose only, nothing else (CONTRIBUTING, #112). Never edit `CHANGELOG.md`. ## Acceptance criteria - [ ] `facts.sh` with `EVENT_BEFORE` all-zeros against a **root commit** exits `0` and prints `base_ver=(none)`. - [ ] Same, with `EVENT_BEFORE` **empty** (the non-push caller). - [ ] A root commit whose `VERSION` is `0.1.0-dev` produces `ceremony=no` and a green NOTICE through the full facts→decide chain — no red run. - [ ] A root commit whose `VERSION` is bare `0.1.0` with no merged release-labeled PR still **refuses** (exit 1) and creates nothing. - [ ] An unresolvable `MERGE_SHA` still fails loudly (non-zero exit), and is **not** reported as `base_ver=(none)`. - [ ] No `git show`, `git cat-file` or `git fetch` runs with an empty rev on the no-base path. - [ ] Every existing `test/facts.test.sh` case passes unchanged — in particular [L96–L99](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/test/facts.test.sh#L96-L99), all-zeros and empty `event.before` on a head that *does* have a first parent still resolve to it. - [ ] `test/run.sh`, shellcheck and actionlint green. ## Test plan New fixtures in `test/facts.test.sh`, beside the existing "a base tree with no version source at all" block ([L130–L149](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/test/facts.test.sh#L130-L149)), which is the closest precedent and whose comment already claims this case: | fixture | input | must be | |---|---|---| | `greenfield` — one root commit, `VERSION=0.1.0-dev` | `EVENT_BEFORE=$ZEROS` | exit 0, `base_ver=(none)`, **gh never called** (the stub's default mode asserts this) | | same | `EVENT_BEFORE=` (empty) | exit 0, `base_ver=(none)` | | `greenfield-bare` — one root commit, `VERSION=0.1.0` | `EVENT_BEFORE=$ZEROS`, `GH_STUB=labeled-no` | exit 0, `labeled=no` — the fact, so decide can refuse on it | **The cases that must fail:** - `MERGE_SHA` set to a sha that does not exist in the fixture repo → non-zero exit, and `base_ver=(none)` must **not** appear in the output. This is the test that pins D2; without it, `|| true` passes everything above. - End-to-end in `test/release-chain.test.sh` (which already drives facts→decide together at [L78](https://github.com/heavy-duty/ceremony/blob/a602fd0a7091f935d73c8a6a1d7275fce8c2f8bd/test/release-chain.test.sh#L78)): a root commit with a **bare** `0.1.0` and no label refuses with exit 1 and creates nothing. A greenfield adoption must not become a silent release — that is the whole reason the crash cannot simply be `|| true`-ed away. ## Dependencies None. `lib/facts.sh` is untouched by the `changelog.d` epic (#112) and by the 0.2.0 release (#118); this lands independently and ships in whatever release follows.
claude-bot-andresmgsl commented 2026-07-24 12:11:35 +00:00 (Migrated from github.com)

Claiming — starting now. Plan: root-commit detection in lib/facts.sh via git rev-list --parents (D2), zero-parent → base_ver=(none) path skipping the base fetch/show (D1, D3), comment carrying the 0.2.0-drill war story (D4), fixtures per the test plan, changelog.d/134.md. Draft PR shortly.

Claiming — starting now. Plan: root-commit detection in `lib/facts.sh` via `git rev-list --parents` (D2), zero-parent → `base_ver=(none)` path skipping the base fetch/show (D1, D3), comment carrying the 0.2.0-drill war story (D4), fixtures per the test plan, `changelog.d/134.md`. Draft PR shortly.
claude-bot-andresmgsl commented 2026-07-24 12:19:54 +00:00 (Migrated from github.com)

Built and in review: PR #136 is ready-for-review with the whole panel requested (codex, grok, kimi). This claim is now parked, shape 2 — the round is awaiting its first verdicts; the next move belongs to the reviewers. All acceptance criteria are demonstrated by tests (test/facts.test.sh greenfield fixtures + D2 pin, test/release-chain.test.sh end-to-end greenfield green / bare-refusal cases); full suite, shellcheck sweep, and actionlint green.

Built and in review: PR #136 is ready-for-review with the whole panel requested (codex, grok, kimi). This claim is now **parked, shape 2** — the round is awaiting its first verdicts; the next move belongs to the reviewers. All acceptance criteria are demonstrated by tests (`test/facts.test.sh` greenfield fixtures + D2 pin, `test/release-chain.test.sh` end-to-end greenfield green / bare-refusal cases); full suite, shellcheck sweep, and actionlint green.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/ceremony#134
No description provided.