diff --git a/lib/decide.sh b/lib/decide.sh new file mode 100755 index 0000000..1e9b649 --- /dev/null +++ b/lib/decide.sh @@ -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 1–2: 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 3–4: 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 5–6: 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 diff --git a/test/decide.test.sh b/test/decide.test.sh new file mode 100644 index 0000000..6ab4c1a --- /dev/null +++ b/test/decide.test.sh @@ -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 — 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 — 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 — 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