lib/decide.sh — the merge door's decision, pure and exhaustively tested #8

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

Part of #1. Blocked by #3. Blocks #9.

Goal

lib/decide.sh — the merge door's decision, extracted into a pure, exhaustively-tested script. This is the subtlest logic in the whole ceremony; in the source repos it lives inline in the workflow (box release.yml L85–L122, rig L123–L163, cast L110–L157) where it can only be proven by releasing. Here, the workflow (#9) gathers facts and this script makes the decision, so every state is contract-tested offline.

Why the decision is subtle (keep this in the header)

The merge-door job runs on every push to main, and the release label carries two legitimate meanings (per each repo's LABELS.md: "release flow and version/packaging work"): the ceremony PR that ships a version, and ordinary work on the release machinery — the PR that adds the ceremony to a repo included. The version transition tells them apart. Every ambiguous middle state is a half-ceremony and must die loudly, creating nothing; every legitimate non-ceremony state must be a green notice no-op, not a red run on main per infra PR.

Interface

Pure: no git, no gh, no network. The caller (workflow #9) establishes four facts and passes them as environment variables:

VER       # the version at the pushed head (via version_read, #3)
BASE_VER  # the version at the base (head's first parent / event.before — caller's job,
          # including the all-zeros-event.before fallback; see #9)
RELEASED  # "yes"|"no" — does a GitHub release for VER already exist?
LABELED   # "yes"|"no" — is a merged, release-labeled PR behind this commit?

Output: ceremony=yes or ceremony=no on stdout (the workflow appends it to $GITHUB_OUTPUT), notices to stdout, refusals to stderr, exit 1 on refusal.

The decision table (exact — this IS the spec)

# VER vs BASE_VER RELEASED LABELED result
1 -dev unchanged ceremony=no, NOTICE: work under the label / ordinary merge — nothing to publish
2 -dev changed ceremony=no, NOTICE: still a dev tree — the post-release bump or a renumber; "a dev tree is by definition not a release"
3 bare unchanged yes ceremony=no, NOTICE: post-release window (ceremony landed, -dev bump hasn't) — nothing to publish
4 bare unchanged no REFUSE (exit 1): "the label says ship but this PR did not mint the version. Refusing to guess — creating nothing."
5 bare changed no REFUSE (exit 1): "version transitioned but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing."
6 bare changed yes ceremony=yes

Ordering matters and must match the sources: the -dev cases never consult RELEASED or LABELED; the bare-unchanged cases never consult LABELED; LABELED is only read after a bare transition is established. (RELEASED and LABELED may therefore be passed as empty in the states that don't use them — the script must not require them there; this keeps the workflow free to skip API calls it doesn't need.)

Also refuse, before the table: empty/missing VER or BASE_VER (a fact-gathering bug upstream must not fall through to "no"), and RELEASED/LABELED values other than yes/no/empty.

State 4 doubles as the known first-release edge (cast#111): a repo whose first version never carried -dev ships its first release by the tag door. Keep cast's parenthetical in the refusal ("if this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump"). Consumers that bootstrap at X.Y.Z-dev (the #12 guide says to) never hit this.

Message text

Port the notices/refusals from box's decide step near-verbatim (they are the operator's debugging surface — each one names the state AND the way out), de-repo-ified (no VERSION-the-filename assumptions; say "the version"; no issue numbers of a single repo — cite box#96 / rig#47 / cast#111 as the design lineage once, in the header).

Tests (test/decide.test.sh)

  • All six table rows, asserting: exit code, ceremony= line, and a distinguishing message substring per row.
  • Empty VER / empty BASE_VER / garbage RELEASED → refuse.
  • Rows 1–2 with RELEASED/LABELED unset (must not be required).
  • -rc1 version behaves as bare (not -dev) — an rc transition with a label is a shippable ceremony (this matches the sources, where only *-dev is special-cased).

Acceptance criteria

  • Script + tests land; CI green; shellcheck-clean.
  • The table in this issue appears as the script's header comment (it is the doc future debuggers will read at 2am).
  • No git/gh/network anywhere in the script — grep proves it.
Part of #1. Blocked by #3. Blocks #9. ## Goal `lib/decide.sh` — the merge door's decision, extracted into a pure, exhaustively-tested script. This is the subtlest logic in the whole ceremony; in the source repos it lives inline in the workflow ([box `release.yml` L85–L122](https://github.com/heavy-duty/box/blob/a17903f07c83aa18c0f009565e1a5442da6d0827/.github/workflows/release.yml#L85-L122), [rig L123–L163](https://github.com/heavy-duty/rig/blob/7f8a0e08852837475505f404985a1251a2c3a8a1/.github/workflows/release.yml#L123-L163), [cast L110–L157](https://github.com/heavy-duty/cast/blob/2aa7018db461341a1bbe79c9ca8eb8fca4232719/.github/workflows/release.yml#L110-L157)) where it can only be proven by releasing. Here, the workflow (#9) gathers **facts** and this script makes the **decision**, so every state is contract-tested offline. ## Why the decision is subtle (keep this in the header) The merge-door job runs on **every** push to main, and the `release` label carries **two** legitimate meanings (per each repo's LABELS.md: "release flow and version/packaging work"): the ceremony PR that ships a version, and ordinary work *on* the release machinery — the PR that adds the ceremony to a repo included. The version transition tells them apart. Every ambiguous middle state is a half-ceremony and must die loudly, creating nothing; every legitimate non-ceremony state must be a **green notice no-op**, not a red run on main per infra PR. ## Interface Pure: no git, no `gh`, no network. The caller (workflow #9) establishes four facts and passes them as environment variables: ``` VER # the version at the pushed head (via version_read, #3) BASE_VER # the version at the base (head's first parent / event.before — caller's job, # including the all-zeros-event.before fallback; see #9) RELEASED # "yes"|"no" — does a GitHub release for VER already exist? LABELED # "yes"|"no" — is a merged, release-labeled PR behind this commit? ``` Output: `ceremony=yes` or `ceremony=no` on stdout (the workflow appends it to `$GITHUB_OUTPUT`), notices to stdout, refusals to stderr, exit 1 on refusal. ## The decision table (exact — this IS the spec) | # | VER | vs BASE_VER | RELEASED | LABELED | result | |---|-----|-------------|----------|---------|--------| | 1 | `-dev` | unchanged | — | — | `ceremony=no`, NOTICE: work under the label / ordinary merge — nothing to publish | | 2 | `-dev` | changed | — | — | `ceremony=no`, NOTICE: still a dev tree — the post-release bump or a renumber; "a dev tree is by definition not a release" | | 3 | bare | unchanged | yes | — | `ceremony=no`, NOTICE: post-release window (ceremony landed, `-dev` bump hasn't) — nothing to publish | | 4 | bare | unchanged | no | — | **REFUSE** (exit 1): "the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." | | 5 | bare | changed | — | no | **REFUSE** (exit 1): "version transitioned but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing." | | 6 | bare | changed | — | yes | `ceremony=yes` | Ordering matters and must match the sources: the `-dev` cases never consult RELEASED or LABELED; the bare-unchanged cases never consult LABELED; LABELED is only read after a bare transition is established. (RELEASED and LABELED may therefore be passed as empty in the states that don't use them — the script must not require them there; this keeps the workflow free to skip API calls it doesn't need.) Also refuse, before the table: empty/missing VER or BASE_VER (a fact-gathering bug upstream must not fall through to "no"), and RELEASED/LABELED values other than yes/no/empty. State 4 doubles as the **known first-release edge** (cast#111): a repo whose first version never carried `-dev` ships its first release by the tag door. Keep cast's parenthetical in the refusal ("if this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump"). Consumers that bootstrap at `X.Y.Z-dev` (the #12 guide says to) never hit this. ## Message text Port the notices/refusals from box's decide step near-verbatim (they are the operator's debugging surface — each one names the state AND the way out), de-repo-ified (no `VERSION`-the-filename assumptions; say "the version"; no issue numbers of a single repo — cite `box#96 / rig#47 / cast#111` as the design lineage once, in the header). ## Tests (`test/decide.test.sh`) - All six table rows, asserting: exit code, `ceremony=` line, and a distinguishing message substring per row. - Empty VER / empty BASE_VER / garbage RELEASED → refuse. - Rows 1–2 with RELEASED/LABELED unset (must not be required). - `-rc1` version behaves as bare (not `-dev`) — an rc transition with a label is a shippable ceremony (this matches the sources, where only `*-dev` is special-cased). ## Acceptance criteria - [ ] Script + tests land; CI green; shellcheck-clean. - [ ] The table in this issue appears as the script's header comment (it is the doc future debuggers will read at 2am). - [ ] No git/gh/network anywhere in the script — `grep` proves it.
dan-claude-bot commented 2026-07-22 18:52:06 +00:00 (Migrated from github.com)

Triage: the only named blocker, #3, landed (PR #28 merged) — flipping blockedready. Spec unchanged; the decide table in the body is the contract.

Triage: the only named blocker, #3, landed (PR #28 merged) — flipping `blocked` → `ready`. Spec unchanged; the decide table in the body is the contract.
claude-bot-andresmgsl commented 2026-07-22 18:55:47 +00:00 (Migrated from github.com)

Claiming — starting on lib/decide.sh + test/decide.test.sh per the decision table in the body. Draft PR soon.

Claiming — starting on `lib/decide.sh` + `test/decide.test.sh` per the decision table in the body. Draft PR soon.
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#8
No description provided.