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

The six-row decision table from issue #8 as a pure script: the workflow
gathers facts (VER, BASE_VER, RELEASED, LABELED), this script decides.
Notices/refusals ported near-verbatim from box's decide step, de-repo-ified;
design lineage box#96 / rig#47 / cast#111.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-22 18:59:29 +00:00
parent 3d252d2abd
commit 87dc9d7728
2 changed files with 259 additions and 0 deletions

153
lib/decide.sh Executable file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
# lib/decide.sh — the merge door's decision, pure and exhaustively tested
# (issue #8; design lineage: box#96 / rig#47 / cast#111).
#
# 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.
#
# Pure: no git, no gh, no network. The caller (the release workflow, #9)
# establishes four facts and passes them as environment variables:
#
# VER the version at the pushed head
# BASE_VER the version at the base (the head's first parent /
# event.before — the caller's job, including the
# all-zeros-event.before fallback)
# RELEASED "yes"|"no" — does a 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 (this IS the spec — issue #8):
#
# | # | 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 matches 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 be empty in the states that don't use them — the workflow is
# free to skip API calls it doesn't need — but a state that DOES need one
# refuses when it is empty: a fact-gathering bug upstream must never fall
# through to "no".
#
# 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.
# Consumers that bootstrap at X.Y.Z-dev (the docs/CONSUMERS.md guide says
# to) never hit it.
set -euo pipefail
# shellcheck source=lib/version.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh"
refuse() {
printf '%s\n' "$@" >&2
exit 1
}
notice() {
printf 'NOTICE: %s\n' "$1"
}
# Before the table: a missing fact is a fact-gathering bug upstream and must
# not fall through to "no"; a malformed fact is the same bug wearing a
# different hat.
if [ -z "${VER:-}" ]; then
refuse "VER is empty — the caller failed to establish the version at the pushed head. Refusing to decide — creating nothing."
fi
if [ -z "${BASE_VER:-}" ]; then
refuse "BASE_VER is empty — the caller failed to establish the version at the base. Refusing to decide — creating nothing."
fi
case "${RELEASED:-}" in
yes | no | '') ;;
*) refuse "RELEASED='${RELEASED}' — expected yes, no, or empty. Refusing to decide — creating nothing." ;;
esac
case "${LABELED:-}" in
yes | no | '') ;;
*) refuse "LABELED='${LABELED}' — expected yes, no, or empty. Refusing to decide — creating nothing." ;;
esac
# Rows 12: a -dev tree decides on VER and BASE_VER alone. Only -dev is
# special-cased (version_is_dev): an rc is a pre-release, and an rc
# transition with a label is a shippable ceremony.
if version_is_dev "$VER"; then
if [ "$BASE_VER" = "$VER" ]; then
notice "the version '$VER' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish."
else
notice "the version changed ('$BASE_VER' -> '$VER') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish."
fi
echo "ceremony=no"
exit 0
fi
# Rows 34: bare and unchanged — RELEASED tells the post-release window
# apart from a label with no minted version. LABELED is not consulted.
if [ "$BASE_VER" = "$VER" ]; then
case "${RELEASED:-}" in
yes)
notice "the version '$VER' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish."
echo "ceremony=no"
exit 0
;;
no)
refuse \
"the version '$VER' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." \
"(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. A first release whose version never carried -dev ships by the tag door — the known first-release edge.)"
;;
*)
refuse "the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing."
;;
esac
fi
# Rows 56: a bare transition — now the LABEL, the operator's declared
# intent. No merged, release-labeled PR behind this commit = a transition
# nobody declared: refuse.
case "${LABELED:-}" in
yes)
echo "ceremony=yes"
;;
no)
refuse "the version transitioned ('$BASE_VER' -> '$VER') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing."
;;
*)
refuse "the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing."
;;
esac

106
test/decide.test.sh Normal file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Contract tests for lib/decide.sh (issue #8) — every row of the decision
# table, offline. 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"
DECIDE="$ROOT/lib/decide.sh"
# decide <VER> <BASE_VER> <RELEASED> <LABELED> — run the script with exactly
# these four facts in its environment.
decide() {
VER="$1" BASE_VER="$2" RELEASED="$3" LABELED="$4" bash "$DECIDE"
}
# decide_bare <VER> <BASE_VER> — RELEASED and LABELED genuinely unset, not
# empty: the -dev rows must not require them at all, so the workflow can
# skip API calls it doesn't need.
decide_bare() {
env -u RELEASED -u LABELED VER="$1" BASE_VER="$2" bash "$DECIDE"
}
# refuses_cleanly <cmd...> — exit 1 AND no ceremony= line on stdout: a
# refusal dies creating nothing, not even an output line for the workflow
# to append.
refuses_cleanly() {
local out
out="$("$@" 2>/dev/null)"
[ $? -eq 1 ] && ! printf '%s' "$out" | grep -q "ceremony="
}
# --- the six table rows ------------------------------------------------------
check "row 1: -dev unchanged -> ceremony=no" 0 "ceremony=no" \
decide 1.2.3-dev 1.2.3-dev "" ""
check "row 1: notice names work under the label" 0 "work under the release label" \
decide 1.2.3-dev 1.2.3-dev "" ""
check "row 2: -dev changed -> ceremony=no" 0 "ceremony=no" \
decide 1.2.4-dev 1.2.3 "" ""
check "row 2: notice names the dev tree" 0 "a dev tree is by definition not a release" \
decide 1.2.4-dev 1.2.3 "" ""
check "row 3: bare unchanged, released -> ceremony=no" 0 "ceremony=no" \
decide 1.2.3 1.2.3 yes ""
check "row 3: notice names the post-release window" 0 "post-release window" \
decide 1.2.3 1.2.3 yes ""
check "row 4: bare unchanged, unreleased -> refuse" 1 "did not mint the version" \
decide 1.2.3 1.2.3 no ""
check "row 4: refusal carries the first-release edge parenthetical" 1 "drop the label" \
decide 1.2.3 1.2.3 no ""
check "row 4: refusal creates nothing" 0 "" \
refuses_cleanly decide 1.2.3 1.2.3 no ""
check "row 5: bare transition, unlabeled -> refuse" 1 "not a bare push" \
decide 1.2.3 1.2.2 "" no
check "row 5: refusal creates nothing" 0 "" \
refuses_cleanly decide 1.2.3 1.2.2 "" no
check "row 6: bare transition, labeled -> ceremony=yes" 0 "ceremony=yes" \
decide 1.2.3 1.2.3-dev "" yes
# --- fact validation (before the table) --------------------------------------
check "empty VER refused" 1 "VER is empty" decide "" 1.2.3 yes yes
check "empty BASE_VER refused" 1 "BASE_VER is empty" decide 1.2.3 "" yes yes
check "garbage RELEASED refused" 1 "expected yes, no, or empty" \
decide 1.2.3 1.2.3 maybe ""
check "garbage LABELED refused" 1 "expected yes, no, or empty" \
decide 1.2.3 1.2.2 "" true
# --- facts not consulted must not be required --------------------------------
check "row 1 with RELEASED/LABELED unset" 0 "ceremony=no" \
decide_bare 1.2.3-dev 1.2.3-dev
check "row 2 with RELEASED/LABELED unset" 0 "ceremony=no" \
decide_bare 1.2.4-dev 1.2.3-dev
# --- facts consulted must not fall through to "no" ---------------------------
check "bare unchanged with empty RELEASED refused" 1 "RELEASED is empty" \
decide 1.2.3 1.2.3 "" ""
check "bare transition with empty LABELED refused" 1 "LABELED is empty" \
decide 1.2.3 1.2.2 "" ""
# --- rc versions behave as bare (only -dev is special-cased) -----------------
check "rc transition with a label is a shippable ceremony" 0 "ceremony=yes" \
decide 1.2.3-rc1 1.2.3-dev "" yes
check "rc unchanged and released is the post-release window" 0 "ceremony=no" \
decide 1.2.3-rc1 1.2.3-rc1 yes ""
# --- purity: no git, no gh, no network (issue #8 acceptance) -----------------
# no_tool_calls — outside comments, the script never invokes git, gh, or a
# network client; the decision stays provable offline.
no_tool_calls() {
! grep -v '^[[:space:]]*#' "$DECIDE" | grep -Ewq 'git|gh|curl|wget'
}
check "decide.sh calls no git/gh/network tools" 0 "" no_tool_calls
summary