From b0d4aff964341001cc32734098c8c1c12a49f069 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <224985780+codex-bot-andresmgsl@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:45:08 +0000 Subject: [PATCH] refactor: retire repository-local release machinery --- .github/labels.conf | 2 +- .github/scripts/changelog-armed.sh | 145 ----- .github/scripts/changelog-monotonic.sh | 225 ------- .github/scripts/drill-recorded.sh | 125 ---- .github/scripts/labels-reconcile.sh | 486 --------------- .github/scripts/release-notes.sh | 30 - CHANGELOG.md | 4 + CONTRIBUTING.md | 352 ++--------- LABELS.md | 181 ------ drills/README.md | 2 +- test/labels-reconcile.sh | 416 ------------- test/release.sh | 828 +------------------------ 12 files changed, 99 insertions(+), 2697 deletions(-) delete mode 100755 .github/scripts/changelog-armed.sh delete mode 100755 .github/scripts/changelog-monotonic.sh delete mode 100755 .github/scripts/drill-recorded.sh delete mode 100644 .github/scripts/labels-reconcile.sh delete mode 100644 .github/scripts/release-notes.sh delete mode 100644 LABELS.md delete mode 100644 test/labels-reconcile.sh diff --git a/.github/labels.conf b/.github/labels.conf index 374f227..dc5432c 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -1,4 +1,4 @@ -panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl +panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl triage-actors=dan-claude-bot scope:cli|C5DEF5|bin/box — the command surface scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall diff --git a/.github/scripts/changelog-armed.sh b/.github/scripts/changelog-armed.sh deleted file mode 100755 index a5bf4c7..0000000 --- a/.github/scripts/changelog-armed.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# changelog-armed.sh [] [] — assert that -# CHANGELOG.md is ARMED: that there is a heading for the next PR's entry to -# land under, and that it is the right one for the state this tree is in. -# -# The failure it exists to catch (#108, heavy-duty/rig#66) leaves no trace: -# the ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and -# nothing puts the heading back. A PR authored BEFORE the release wrote its -# entry under '## Unreleased'; that heading is gone by the time it merges, so -# git lands the entry under whatever heading now occupies that position — the -# just-shipped section — CLEANLY, with no conflict. The one signal an author -# would trust ("git told me to look") is absent exactly when the result is -# wrong, and the drift is only ever discovered by reading the file. -# -# The rule, keyed on VERSION, because the two states are genuinely different: -# -# VERSION ends in -dev -> the top section MUST be '## Unreleased' -# VERSION is bare -> the top section may be '## Unreleased' (armed, -# the ceremony's own re-arm) or the stamped -# section for exactly that VERSION — AND the -# section for that VERSION must exist and carry -# prose, because it is the one about to ship -# -# Keying on VERSION is the whole design, and the reason this is not simply -# "require '## Unreleased'". That unconditional form is what rig#44 and -# heavy-duty/cast#108 had to REVERT: it is false by construction on the -# ceremony PR's own tree, which makes the release unshippable through a green -# CI. Anyone tempted to simplify this back should read those two first. -# -# The consequence worth stating plainly: a ceremony PR that stamps and forgets -# to re-arm still passes here — its VERSION is bare, and a bare tree is -# allowed to be stamped. It goes red the moment the '-dev' bump lands on main, -# which release.yml does automatically in the same job as the publish. So the -# guard does not block the release; it refuses to let main SIT disarmed, which -# is the window a late PR can fall into. -# -# A file of its own (not inlined in ci.yml) so test/release.sh can drive it -# against constructed trees for both states — the same discipline as -# release-notes.sh. - -changelog="${1:-CHANGELOG.md}" -version_file="${2:-VERSION}" - -# release-notes.sh lives beside this script; the bare-VERSION branch runs it -# rather than re-implementing the extraction, so the guard and the publisher -# cannot disagree about what a section is or when one counts as empty. -here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" - -[ -f "$changelog" ] || { echo "changelog-armed: no such file: $changelog" >&2; exit 1; } -[ -f "$version_file" ] || { echo "changelog-armed: no such file: $version_file" >&2; exit 1; } - -ver="$(tr -d '[:space:]' < "$version_file")" -[ -n "$ver" ] || { echo "changelog-armed: $version_file is empty" >&2; exit 1; } - -# The TOP section: the first '## ' heading in the file. Everything above it is -# the changelog's own preamble and belongs to no section. -top="$(grep -m1 '^## ' "$changelog" || true)" -[ -n "$top" ] || { - echo "changelog-armed: $changelog has no '## ' section at all — nothing for a PR entry to land under" >&2 - exit 1 -} - -# '## 0.7.0 — 2026-07-19' -> '0.7.0'. Split on whitespace, same shape -# release-notes.sh matches on, so the two cannot disagree about what a -# section header is. -top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')" - -case "$ver" in - *-dev) - if [ "$top_ver" != "Unreleased" ]; then - cat >&2 <&2 </dev/null 2>&1; then - cat >&2 <] [] — assert that no SHIPPED -# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings -# on HEAD must be a SUPERSET of the set at the merge base. -# -# The failure it exists to catch (#122, caught in review of #118) leaves no -# trace either. An author adding an entry under '## Unreleased' REPLACES the -# line below it instead of inserting above it: -# -# -## 0.8.0 — 2026-07-19 -# +## Unreleased -# + -# +### Fixed -# + -# +- **An entry** -# -# git merges that cleanly — it is a one-line edit inside a file nobody has -# touched concurrently — and the shipped section's whole body is silently -# absorbed into '## Unreleased'. 0.8.0 no longer HAS a section; the notes -# anchor release-notes.sh extracts by is gone, and the next release cut from -# that state republishes 0.8.0's prose as if it were new. -# -# changelog-armed.sh is green on exactly that tree, correctly: it asks only -# whether the TOP section agrees with VERSION, and deleting '## 0.8.0' leaves -# '## Unreleased' on top. It is not wrong, it is narrow — it guards ONE -# heading, the one a PR is about to write under. This guards the REST of the -# file, the part no single tree can be asked about at all, because "a heading -# disappeared" is not a property of a tree — it is a property of a DIFF. -# -# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The -# ceremony (#96) adds one and never removes one; nothing else in the documented -# flow (CONTRIBUTING.md, "Releases") touches them. So SUPERSET is exact — it -# has no legitimate violation to carve an exception for. The stamp is covered -# for free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS X.Y.Z and -# removes no X.Y.Z heading, because 'Unreleased' is not one. '## Unreleased' -# is deliberately NOT in the set this guards — changelog-armed.sh owns that -# heading, keyed on VERSION, and the ceremony legitimately consumes it. -# -# A file of its own, NOT a clause inside changelog-armed.sh, for three -# reasons. Its input is different (a git history, not two files). Its -# degradation is different (no base ref is a SKIP, not a failure). And -# changelog-armed.sh is driven by test/release.sh against constructed -# two-file trees that are not git repos at all — folding a git-dependent -# assert into it would make every one of those cases either skip or lie. -# Same discipline as release-notes.sh: its own file so a test can drive it. - -base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}" -changelog="${2:-CHANGELOG.md}" - -# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local -# degradation becomes a red run there instead. A guard that can silently -# stop guarding is the failure shape this whole family of checks exists to -# refuse, so the skip path is loud and CI refuses to take it at all. -strict="${CHANGELOG_MONOTONIC_STRICT:-0}" - -skip() { - if [ "$strict" = "1" ]; then - echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2 - echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2 - echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2 - echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2 - exit 1 - fi - echo "changelog-monotonic: containment SKIPPED — $*" - echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading" - echo " half needs the history. In CI this same condition is a hard failure.)" - exit 0 -} - -[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; } - -# The set of RELEASE headings: '## ...' where looks like a -# version. Field $2, the same split changelog-armed.sh and release-notes.sh -# use, so the three cannot disagree about what a section header is. -# 'Unreleased' fails the shape and is excluded by construction. -headings_raw() { - awk ' - /^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 } - ' -} -headings() { headings_raw | sort -u; } - -# --- uniqueness on HEAD (the #118 class) ------------------------------------- -# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the -# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to -# extras on the head side — with or without `sort -u`, base {0.8.0} minus head -# {0.8.0, 0.8.0} is empty. Multiset comparison does not close it either, for -# the same reason. The assert that does is uniqueness of version headings ON -# HEAD, kept alongside containment rather than replacing it. -# -# This is the shape #118's bad rebase actually produced: two -# `## 0.8.0 — 2026-07-19` headings with the incoming entry between them. Every -# other guard stayed green — markers absent, changelog-armed.sh happy (the top -# section was still right), tests and shellcheck clean — while -# release-notes.sh re-armed its grab on the second heading and folded post-cut -# prose into the shipped release body. -# -# Nothing legitimate repeats a version heading: the ceremony stamps a NEW -# version, and 'Unreleased' fails the version shape and never reaches here. -dupes="$(headings_raw < "$changelog" | sort | uniq -d)" -if [ -n "$dupes" ]; then - { - echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):" - echo - printf '%s\n' "$dupes" | sed 's/^/ ## /' - echo - cat <&2 - exit 1 -fi - -# --- everything below needs the HISTORY ------------------------------------ -# Uniqueness is settled. What follows is containment, which compares HEAD -# against the merge base and therefore genuinely depends on the base ref, the -# merge base, and the base blob. Each of those can be unavailable for reasons -# that are not the author's fault (a shallow clone, a fork checkout without -# the upstream remote, the commit that first adds the changelog), so each -# degrades rather than failing — which is exactly why the uniqueness half must -# NOT live down here (#143). It asks nothing of the history, and gating it -# behind these conditions let a duplicate exit 0 on a message about deletion. - -git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ - || skip "not inside a git work tree, so there is no history to compare against" - -git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \ - || skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)" - -merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)" -[ -n "$merge_base" ] \ - || skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)" - -# The changelog may not exist at the merge base at all (the commit that adds -# it). Nothing to have deleted, so nothing to assert. -base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)" -[ -n "$base_file" ] || { - echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)." - exit 0 -} - -base_headings="$(printf '%s\n' "$base_file" | headings)" -head_headings="$(headings < "$changelog")" - -# comm -23: lines in the base set that are NOT in the head set — exactly the -# headings this branch removed. -missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))" - -if [ -n "$missing" ]; then - { - echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:" - echo - printf '%s\n' "$missing" | sed 's/^/ ## /' - echo - cat <&2 - exit 1 -fi - -count="$(printf '%s\n' "$base_headings" | grep -c . || true)" -head_count="$(printf '%s\n' "$head_headings" | grep -c . || true)" - -# The success line has two honest forms, because this step now runs on two -# shapes of event. On a push to main the merge base IS HEAD: containment -# compared the file against itself and asserted nothing, and deletion is -# undetectable on that event by construction. Reporting "all N still present" -# there would be the same dishonesty the skip messages were fixed for — a log -# claiming a check that did no work. Uniqueness is the half that actually ran, -# so that is the half the line names. -if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then - echo "changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked $head_count release heading(s)." -else - echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog" -fi diff --git a/.github/scripts/drill-recorded.sh b/.github/scripts/drill-recorded.sh deleted file mode 100755 index 7618662..0000000 --- a/.github/scripts/drill-recorded.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# drill-recorded.sh [] [] — assert that a RELEASE -# tree carries a drill record: that the full real-hardware drill this repo -# says a release rests on was actually run for this version, and written -# down as drills/.md. -# -# CONTRIBUTING.md has said since #96 that "this PR is where the release -# ritual hangs: the full drill on real hardware, recorded". No release ever -# did it. #95, #114 and #148 all shipped as a VERSION bump plus a -# CHANGELOG.md stamp and nothing else. That is three releases through the -# same gap, because the gate was a sentence in a document and the only thing -# standing on it was a reviewer remembering to ask. A reviewer finally did — -# which is the point: the ONE time it was caught is the time somebody -# happened to look, and that is not a gate, it is luck with good manners. -# -# So the rule moves into CI, where it fires on every release PR whether or -# not anyone is paying attention. It is keyed on VERSION for the same reason -# changelog-armed.sh is — the two states are genuinely different: -# -# VERSION ends in -dev -> PASS. A development tree ships nothing, so -# there is nothing for it to have proven. Almost -# every PR in this repo is this case, and a guard -# that nagged all of them would be turned off. -# VERSION is bare -> the ceremony tree, the one about to ship. -# /.md MUST exist and carry -# at least one non-whitespace character. -# -# ONE FILE PER VERSION, and that is the whole design. Records used to be -# sections sharing drill/RUNS.md, and every hard edge this script used to -# have existed only because of that sharing: em-dash field matching, an -# optional ' — DATE' tail, whole-version comparison so '0.9.0-rc1' could not -# satisfy '0.9.0', avoiding '\x' escapes because CI runs mawk not gawk, and a -# non-blank body rule to tell an empty section from a filled one. Two -# separate defects were found in review because of that complexity — a -# `sed '/./,$!d'` whitespace bypass, and heading-grammar drift from the -# sibling repos. Splitting the file makes almost all of it UNREPRESENTABLE: -# '0.9.0.md' and '0.9.0-rc1.md' are simply different files, so whole-version -# matching is free rather than a trap, and there is no grammar left to drift. -# -# The directory is plain 'drills', NOT '.drills'. A dot-directory is -# invisible to a glob without dotglob, which is exactly what caused #116 and -# #118 in this repo; evidence a sweep cannot see is evidence that goes -# missing quietly. -# -# drills/ is RELEASE EVIDENCE, one file per shipped version. It is NOT -# drill/RUNS.md, which stays exactly as it is: the harness's own run log, -# traps table and lore, a different artifact with a different purpose. This -# guard reads drills/ and never looks at drill/RUNS.md. -# -# What this guard asserts is a RECORD, deliberately — not a passing drill. -# CI cannot run the drill: it wants real hardware, a real Incus, and the -# better part of an hour (see ci.yml, which says exactly this about the -# rehearsal job it runs instead). What CI can do is refuse to let a release -# claim a ritual it left no evidence of. That also leaves the maintainer -# waiver intact and honest: a release that must ship without a full drill -# records WHY in its own file, which is a deliberate, reviewable commit in -# the diff — rather than the silent skip that got us here. -# -# A file of its own (not inlined in ci.yml) so test/release.sh can drive it -# against constructed trees for both states — the same discipline as -# changelog-armed.sh and release-notes.sh. - -drills="${1:-drills}" -version_file="${2:-VERSION}" - -# A missing or empty version file is an ERROR, never a silent pass. A guard -# that cannot read the version cannot know whether this tree is its business, -# and "could not tell" must not resolve to "allowed". -[ -f "$version_file" ] || { echo "drill-recorded: no such file: $version_file" >&2; exit 1; } - -ver="$(tr -d '[:space:]' < "$version_file")" -[ -n "$ver" ] || { echo "drill-recorded: $version_file is empty" >&2; exit 1; } - -case "$ver" in - *-dev) - # Nothing to assert, and saying so is the point: the operator reading a - # green log should be able to tell "the guard passed" from "the guard - # decided this tree was not its business". - echo "drill-recorded: VERSION '$ver' is a development tree — nothing to assert; only ceremony trees ship" - exit 0 - ;; -esac - -record="$drills/$ver.md" - -# The one rule that survives the rewrite, and it survives because it was -# never really about heading parsing: a file of only spaces, tabs and -# newlines is NOT a record. The first cut of the old guard extracted with -# `sed '/./,$!d'`, where `.` matches a space — so a record whose body was one -# tab satisfied a guard that promised "at least one non-blank line". An -# evidence-free release for the price of an invisible character, on the one -# check whose entire job is to demand evidence. Existence alone is a weaker -# claim than `touch` can defeat, so existence alone is not the test. -if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then - cat >&2 < successor.startedAt - # is the ordinary case, and `max` dated the dead predecessor newer than the - # live run that replaced it, narrowing both failures above without closing - # them. The list is already in preference order, so `first` IS that rule. - # - # An entry that carries no usable timestamp at all sorts LAST rather than - # first — something we cannot date is most likely the thing just created, - # and treating it as newest keeps an undateable in-flight run from being - # discarded in favour of a stale success. Every ambiguity resolves toward - # "not settled". - | [ (.statusCheckRollup // [])[] - | { ctx: [.workflowName // "", .name // .context // ""], - at: ([.startedAt, .createdAt, .completedAt] - | map(select(type == "string" and . != "" - and (startswith("0001-01-01") | not))) - | first // ""), - outcome: ((.conclusion // .state // "") | ascii_upcase) } ] - | group_by(.ctx) - | map(sort_by([(.at == ""), .at]) | last | .outcome) as $latest - - | if ($latest | length) == 0 then "NONE" - elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE" - elif (($latest - $passing) | length) > 0 then "PENDING" - else "SUCCESS" end - - end' -} - -bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK - local review state commit - review="$(jq -c --arg u "$1" \ - '[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \ - <<<"$REVIEWS_JSON")" - if [ -z "$review" ]; then echo MISSING; return; fi - state="$(jq -r '.state' <<<"$review")" - commit="$(jq -r '.commit_id' <<<"$review")" - case "$state" in - CHANGES_REQUESTED) - # blocks at ANY head — GitHub's own semantic: only a newer review - # from the same reviewer clears it - echo BLOCK ;; - APPROVED) - if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;; - *) - # COMMENTED and anything else: a non-verdict. The machine does not - # read bodies — if the comment is really an agreement, the AUTHOR - # says so by requesting the human's review. - echo FEEDBACK ;; - esac -} - -human_request_needed() { # 0 when needs-human requires a FRESH human request - # already requested → the handoff is live; head-current human approval → - # nothing left to ask. Anything else (never reviewed, an old comment, an - # approval of an older head) stalls the handoff unless we request — - # guarding on "has the human ever reviewed" wedged exactly that way. - if requested "$HUMAN"; then return 1; fi - if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi - return 0 -} - -blockers() { # → the blocker:* labels this PR should carry, one per line - # The second axis. These are FACTS ABOUT THE BRANCH, and they are mutually - # independent — a PR can be conflicted and red and unasked at once — so they - # are a set, not an ordering. That is the whole point of splitting them out - # of state:*: every precedence bug this machine has had (needs-human - # surviving a conflict, MISSING swallowing STALE) came from projecting - # independent facts onto one totally-ordered label. A set has no precedence - # to get wrong. - # - # UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for - # about a minute after every merge while it recomputes, and flapping every - # open PR on each merge would be worse than the bug. Same for a failed read - # of either fact — both default to the "do not know" value, which blocks - # nothing. An unset global (an older fixture, a failed fetch) must never - # invent a verdict it did not read. - case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo blocker:conflict ;; esac - case "${CHECKS:-NONE}" in FAILURE) echo blocker:ci-red ;; esac - - # Nobody is on the hook for a verdict somebody still owes. Distinct from - # bots-reviewing, which says a request is live and an answer is coming: - # here the round is stalled because no one was ever asked, and the board - # said "waiting on the bots" for the 48h it took `stale` to notice. - # A draft is exempt (the bots ignore drafts by design), and so is an - # explicit human request — a maintainer claiming a PR early is deliberate, - # not a dropped ball. - if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then - local b v owed=false any_requested=false - for b in "${BOTS[@]}"; do - requested "$b" && any_requested=true - # MISSING and STALE are both verdicts this head does not have: nobody - # reviewed it, or everybody reviewed something else. The agent owes an - # ask either way — the stale round is if anything the worse of the two, - # since it has approvals on the page that no longer describe the tree. - v="$(bot_verdict "$b")" - case "$v" in MISSING | STALE) owed=true ;; esac - done - if [ "$owed" = true ] && [ "$any_requested" = false ]; then - echo blocker:unrequested - fi - fi -} - -decide_state() { # → the one state:* label this PR should carry - if [ "$DRAFT" = true ]; then echo state:building; return; fi - - local s - s="$(round_state)" - - # The one rule joining the two axes: state:needs-human means a human could - # merge this RIGHT NOW, so it requires a clear branch. Any blocker at all - # means the work is the agent's — whatever the review round says — and the - # blocker label says which work it is. Nothing else in this function reads - # the branch, which is what keeps the ordering below purely about reviews. - if [ "$s" = state:needs-human ] && [ -n "$(blockers)" ]; then - echo state:addressing; return - fi - echo "$s" -} - -round_state() { # → the state the REVIEW ROUND alone implies; knows no branch facts - local b verdicts="" - for b in "${BOTS[@]}"; do - if requested "$b"; then echo state:bots-reviewing; return; fi - done - # Collect the WHOLE round before applying any precedence. Deciding inside - # the loop let BOTS order pick the winner: a MISSING returned immediately, - # so a STALE belonging to a later bot was never even read, and the mixed - # round (one approval staled by a push, another bot yet to review) came out - # needs-human — the #136 headline shape, with zero reviews bound to the head. - for b in "${BOTS[@]}"; do - verdicts="$verdicts $(bot_verdict "$b")" - done - case "$verdicts" in - # STALE = a verdict for an older head. Unlike MISSING, this outranks the - # human request: every approval it covers was invalidated by a push, so - # NOBODY has reviewed this tree. Handing that to the human is the #136 case - # where everything reads green — mergeable, CI passing, "waiting on the - # human" — over code no reviewer has seen. The agent owes a re-request. - # Checked before MISSING because "unfinished" must not swallow "and also - # stale": a round that is both is a push that outran the re-requests, not - # a maintainer deliberately claiming the PR early. - *STALE*) echo state:addressing; return ;; - esac - case "$verdicts" in - # No verdict at all from some bot, and nothing staled. An explicit human - # request still outranks an unfinished round — a maintainer pulling a PR - # to themselves early is a deliberate act, and the original precedence. - # - # Otherwise it is the AGENT's ball, not the bots'. The loop above already - # returned for every live bot request, so reaching here with a MISSING - # means somebody owes a verdict and nobody was asked for one — the round - # is not running. Calling that bots-reviewing was the lie that let a - # forgotten PR read "waiting on the reviewers" for the 48h it took the - # stale sweep to notice. blocker:unrequested says why. - *MISSING*) - if requested "$HUMAN"; then echo state:needs-human; return; fi - echo state:addressing; return ;; - esac - # an explicit human request outranks the remaining bot outcomes — it is the - # final gate, and a maintainer pulling a PR to themselves early counts too - if requested "$HUMAN"; then echo state:needs-human; return; fi - case "$verdicts" in - # FEEDBACK = a comment with no verdict → the agent owes the round-reply. - *BLOCK* | *FEEDBACK*) echo state:addressing; return ;; - esac - # the bots all approve — but if the human's standing word is - # changes-requested (and nobody re-requested them yet), the agent owes - # fixes, not the human a nag - if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then - echo state:addressing - else - echo state:needs-human - fi -} - -# --------------------------------------------------------------------------- -# The sweep: fetch facts, decide, converge. One PR's failure never aborts the -# others — each PR reconciles in a subshell and a failure just logs. -# --------------------------------------------------------------------------- - -bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick - while IFS='|' read -r name color desc; do - [ -n "$name" ] || continue - run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force - done <<'EOF' -state:building|FBCA04|PR is a draft — the coding agent is still building -state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round -state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes -state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer -blocker:conflict|B60205|Does not merge — the branch conflicts and the agent owes a rebase -blocker:ci-red|B60205|A check is failing — the agent owes a fix (not a rebase) -blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked for one -merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here) -stale|B60205|No activity for 48h — needs a poke (sweep-managed) -blocked|6A737D|Waiting on another PR or issue to land first -release|0E8A16|Release flow and version/packaging work -scope:cli|C5DEF5|bin/box — the command surface -scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall -scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack -scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user -scope:templates|C5DEF5|templates/ — the box seeds -scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md -EOF -} - -has_label() { grep -qxF "$1" <<<"$LABELS"; } - -reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch - local n="$1" desired remove s args last_activity age - - desired="$(decide_state)" - - # encode the runbook's last step for the no-judgment case: three formal - # head-current approvals → the human is asked, once. The guard asks whether - # a FRESH human review is needed for THIS head — never "has the human ever - # reviewed", which wedged the handoff after any earlier human comment. - # Idempotent (a live request suppresses it); race-free via the shared - # concurrency group in labels.yml. With a comment-only bot on the panel - # this path stays cold and the AUTHOR requests the human. - if [ "$desired" = state:needs-human ] && human_request_needed; then - run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent - log "#$n: requested $HUMAN (round passed)" - fi - - # ---- converge both axes ---- - # state:* is exclusive (everything but $desired comes off); blocker:* is a - # set (each one on or off on its own); RETIRED always comes off. One edit - # call for all of it, so a PR never flickers through a half-applied board. - local want_blockers add="" - want_blockers="$(blockers)" - - remove="" - for s in "${STATES[@]}"; do - if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi - done - for s in "${RETIRED[@]}"; do - if has_label "$s"; then remove="$remove,$s"; fi - done - for s in "${BLOCKERS[@]}"; do - if grep -qxF "$s" <<<"$want_blockers"; then - has_label "$s" || add="$add,$s" - else - has_label "$s" && remove="$remove,$s" - fi - done - add="${add#,}" - remove="${remove#,}" - - # Never NAME a label the repo does not have. `gh issue edit --add-label` - # rejects the WHOLE call on one unknown name — nothing is applied — so a - # single missing blocker would take the state convergence down with it, on - # exactly the PRs this change exists to fix, surfacing only as a log line. - # Batching state and blockers into one edit for anti-flicker is what widened - # that blast radius; filtering the add side is what closes it again. - # Removals need no filter: they are built from has_label, so the label - # provably exists. REPO_LABELS unreadable means no filtering rather than - # filtering everything out — a failed read must not silently strip the board. - local skip_edit=false - if [ -n "${REPO_LABELS:-}" ]; then - local kept="" missing="" want - for want in ${add//,/ }; do - if grep -qxF "$want" <<<"$REPO_LABELS"; then kept="$kept,$want" - else missing="$missing $want"; fi - done - add="${kept#,}" - # A missing STATE label skips only the EDIT — never the rest of this - # function. Everything below is independent of the state:* taxonomy, and - # returning here stranded it: `merge-next` kept claiming "merge this one - # next" on a PR the board had moved to the agent, and the stale sweep - # stopped running. That is the original false-invitation bug, reintroduced - # in the very fix meant to survive a cold-start repo — and a regression - # against the old behaviour, which failed the edit and fell through. - if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then - log "#$n: WARNING: state label '$desired' does not exist — skipping the label edit; dispatch the workflow to bootstrap" - skip_edit=true - elif [ -n "$missing" ]; then - log "#$n: WARNING: missing label(s)$missing — state still converged; dispatch the workflow to bootstrap" - fi - fi - if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then - args=(--add-label "$desired${add:+,$add}") - [ -n "$remove" ] && args+=(--remove-label "$remove") - if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then - log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" - else - # a deleted label must not wedge the sweep — dispatch heals the taxonomy - log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)" - fi - fi - - # ---- merge-next: cleared, never set ---------------------------------- - # Queue order is INTENT — which PR should land first is a judgement about - # conflicts and dependencies that GitHub knows nothing about, so the - # reconciler must not guess it (LABELS.md's rule for `blocked`/`release`). - # What it CAN do is stop the label going stale the way needs-human did: - # the moment the PR is no longer the thing a human should merge next, the - # claim is removed. Setting it stays with whoever owns the queue. - if has_label merge-next && [ "$desired" != state:needs-human ]; then - run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null - log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" - fi - - # ---- stale: real activity only, and blocked is legitimately quiet ---- - last_activity="$( - { - jq -r '.created_at' <<<"$PR_JSON" - jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" - gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' - } | sort | tail -n1 - )" - age=$((NOW - $(date -d "$last_activity" +%s))) - if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then - if has_label stale; then - run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null - log "#$n: unstale" - fi - elif ! has_label stale; then - run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null - log "#$n: stale ($((age / 3600))h quiet)" - fi -} - -main() { - REPO="${REPO:?set REPO to owner/name}" - NOW="$(date +%s)" - - if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then - log "workflow_dispatch: bootstrapping the taxonomy" - bootstrap_labels - fi - - # The repo's label set, read ONCE per sweep — reconcile_pr filters every - # add against it, because one unknown name fails the whole edit call. - REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")" - [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" - - local n - for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do - ( - PR_JSON="$(gh api "repos/$REPO/pulls/$n")" - DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" - HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" - LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" - REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")" - # PENDING reviews are unsubmitted drafts in someone's browser — not a verdict - REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ - | jq -s '[.[] | select(.state != "PENDING")]')" - # mergeability + the check rollup, the two facts the state machine was - # blind to (#136). `gh pr view` rather than the REST PR object: the API's - # `mergeable` is a tri-state boolean that GitHub computes lazily, while - # this returns the same MERGEABLE/CONFLICTING/UNKNOWN string the UI shows. - # Failure to read them is NOT fatal and NOT treated as broken — an API - # hiccup must never flap every PR into needs-rebase, so both degrade to - # the "do not know" value that triggers nothing. - GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')" - MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" - CHECKS="$(checks_state <<<"$GH_VIEW")" - # Read failed: leave this PR exactly as it is. Recomputing on facts we - # did not read is how an API hiccup turns into a false "merge me" — - # and the next tick is 15 minutes away, not 15 hours. - if [ "$CHECKS" = UNREADABLE ]; then - log "#$n: could not read mergeability/checks — left alone this pass" - exit 0 - fi - reconcile_pr "$n" - ) || log "#$n: reconcile failed — continuing with the remaining PRs" - done - log "reconciled." -} - -# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - main "$@" -fi diff --git a/.github/scripts/release-notes.sh b/.github/scripts/release-notes.sh deleted file mode 100644 index 06bce9b..0000000 --- a/.github/scripts/release-notes.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# release-notes.sh [] — print exactly 's -# section of the changelog: every line between its '## ' -# header and the next '## '. This is what release.yml hands to -# 'gh release create', so the release notes are the curated prose we wrote, -# not the PR list GitHub would generate (#83). Fails loudly when the section -# is missing or empty — a tag without its changelog section is a release -# ritual skipped, and an empty release body would paper over it. -# -# A file of its own (not inlined in release.yml) so test/release.sh drives -# the same extraction against fixtures and the real CHANGELOG.md. - -ver="${1:-}" -changelog="${2:-CHANGELOG.md}" -[ -n "$ver" ] || { echo "usage: release-notes.sh []" >&2; exit 2; } -[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; } - -# $2 of a section header ('## 0.6.0 — 2026-07-18') is the bare version — -# compared WHOLE, so 0.6.0 can never match a 0.6.0-rc1 section (or vice -# versa), and no regex-escaping of dots. sed drops the blank padding under -# the header; the command substitution eats the trailing blanks. -notes="$(awk -v ver="$ver" ' - /^## / { grab = ($2 == ver); next } - grab { print } -' "$changelog" | sed '/./,$!d')" - -[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#83)" >&2; exit 1; } -printf '%s\n' "$notes" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de7b08..2c11272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ which records not just what changed but what each drill run proved. ## Unreleased +### Changed + +- Release and repository governance now use the shared ceremony pinned at `0.1.0` (heavy-duty/ceremony#14) + ### Added - `kimi-box` template — the Moonshot Kimi CLI agent seed (#158; rig#109's tenant) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed9c95e..a1d40b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,313 +1,83 @@ # Contributing -How change lands in this repo. The short version: PRs are born as drafts, -three reviewer bots take the first rounds, a human takes the last word — and -labels tell you where everything is without opening anything. +This repository is governed by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read +[`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first, then the role file it +selects. The files under `.ceremony/` are machine-managed and must never be +edited in place. -## The PR loop +Only triage mints issues. Everyone else opens or extends a discussion when +they find work outside an existing issue contract. Only humans merge. -1. **Fork and branch.** Contributors work from forks; upstream branches are - for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`), - and include a `CHANGELOG.md` entry under `## Unreleased` when the change - deserves one. -2. **Open as a draft** while you build. Drafts are invisible to the reviewer - bots on purpose. -3. **When it's ready**: mark ready-for-review and request all three bots — - `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`. - They poll roughly every 15 minutes. -4. **Rounds are answered whole.** Wait until all three have reviewed, then - answer the entire round in a **single reply**, push the fixes, and - re-request the bots that didn't approve. Prefer verification over - argument: a test settles what a comment thread can't. -5. **Reviews end in a verdict.** A reviewer — bot or human — either - **approves** or **requests changes**, never a bare comment. A - comment-only review is a non-verdict: it doesn't say whether the round - passed, and the state machine (and anyone scanning the board) has to - guess. The verdict carries *blockingness only*, the body carries the - feedback: non-blocking nits ride an **approval** and the author addresses - them at their discretion; anything blocking — including a question that - gates the verdict — is **request changes**, saying what unblocks it. The - reconciler treats a comment-only review as not-approved, so commenting - without a verdict only stalls the PR. The machine never reads review - bodies: when a comment-only reviewer's line is really an agreement, that - judgment belongs to the **author** — escalate by requesting the - maintainer's review (step 6), and the reconciler flips the label on that - request, because an explicit request is a fact it can trust. -6. **When the round passes, the author hands the PR to the maintainer** in - three acts, in this order: post the tagged round summary, request the - maintainer's review, then set `state:needs-human` yourself — removing the - state label it replaces. The review request is what *earns* the label, - provided the PR carries **no `blocker:*` label**. A blocker means the work - is still yours whatever the round said, so on a conflicted or red PR - neither the request nor your own label write will stick — the sweep takes - it straight back off. With three formal head-current approvals the labels - workflow requests the maintainer automatically; when part of the panel is - comment-only, reading their agreement is the author's judgment, so the - author makes the request. +## Review panel - Writing the label by hand is an **optimistic write, not a transfer of - ownership**. The machine stays the authority — but because the workflow - wakes on `labeled`, the author's own write fires the sweep that validates - it, and a handoff that had not earned the label is corrected seconds later. - Forgetting the write is not a failure either; it only means the label waits - for the cron, which is the lag this replaced. -7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally - mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus. +The review panel is: -## Changelog entries +- `claude-bot-andresmgsl` +- `codex-bot-andresmgsl` +- `grok-bot-andresmgsl` +- `kimi-bot-andresmgsl` -Every PR that changes behaviour adds **one line** to `## Unreleased`. One line -is the whole rule — if it wraps more than twice in your editor, cut it down. +Every PR needs a current-head verdict from the whole panel minus its author. +`dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain +invisible to the panel; when ready, request every eligible reviewer. -- **Say what changed, and stop.** Why it was wrong, how it was found, what it - cost, what it implies — that belongs in the PR body and the commit message, - which is where anyone chasing the reasoning already goes. This file answers - one question: what is different in this version. -- **Any word that can be removed, is removed.** -- **Lead with the surface, not the mechanism.** "`state:needs-human` is set at - handoff" beats "the labels workflow now also wakes on `labeled`". -- **Cite the issue or PR** — `(#141)` — and let the reader follow it for the - rest. -- **Mark a breaking change** with a leading `BREAKING:`. -- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`. -- No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays. +## Code and verification -Good: +- Bash executables use `set -euo pipefail`; test harnesses use `set -u` + because they assert failing commands. +- Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`; + CI also runs the Incus multi-user rehearsal. +- Match whole versions: `0.7.0` must never match `0.7.0-rc1`. +- Comments preserve the incident that bought a rule, including its issue + number. -```markdown -- `state:needs-human` is set at handoff, not by the cron (#141) -- An unreadable check rollup no longer reads as "nothing is failing" (#136) -- BREAKING: `--class human|server` is now `--root-door closed|open` (#77) -``` +## Changelog -Not an entry — that is a PR body: - -```markdown -- **`state:needs-human` no longer waits on the cron to become true** (#141) — - the labels workflow now also wakes on `pull_request_target: labeled` and - `unlabeled`, and the author sets it themselves when handing a PR over. A - review landing was never a trigger. There is no `pull_request_review_target`, - and on fork PRs — which is all of them here — ... -``` +Every behavior-changing PR adds one concise line under `## Unreleased`, +above the shipped heading below it. Cite the issue or PR. Never replace or +duplicate a shipped heading; the shared armed and monotonic guards enforce +both halves of this rule. ## Releases -A release is a PR, and merging it ships it -([#96](https://github.com/heavy-duty/box/issues/96), building on -[#83](https://github.com/heavy-duty/box/issues/83)): +The release ceremony, merge and tag doors, version stamps, guard semantics, +and recovery paths are defined by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony/blob/0.1.0/README.md). +Box pins the shared machinery and doctrine at `0.1.0`. -1. **The release PR** — `release: X.Y.Z`, labeled `release` — bumps `VERSION` - from `X.Y.Z-dev` and stamps the `## Unreleased` section with version + - date (feature PRs land their changelog entry as part of the PR, so the - section is already written). +Box uses the `file` version backend and has no artifact hook: for this +pure-Bash tree, GitHub’s source tarball for the tag is the package, and +`install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and +`drills/.md` remain box-owned release inputs. - **Stamping is two edits, not one — the second is re-arming.** After - rewriting `## Unreleased` into `## X.Y.Z — DATE`, put an **empty - `## Unreleased` back at the top**, immediately above the section you just - stamped: +### What a box drill proves - ```markdown - ## Unreleased +The box drill is the 85-probe VM isolation contract: it exercises the trust +boundary on real hardware. The lighter Incus container rehearsal in CI proves +the tier mechanics but cannot substitute for that boundary measurement. The +record format and operating procedure live in [drills/README.md](drills/README.md). - ## 0.7.1 — 2026-07-19 +`drills/.md` and [`drill/RUNS.md`](drill/RUNS.md) are deliberately +different artifacts. The former is per-release evidence read by the release +guard; the latter is the harness’s ongoing run log and lore. Updating one +never satisfies the purpose of the other. - ### Fixed - ... - ``` +The family drills are independent and may run in any order. Each pins the +same fixed candidate refs: rig’s drill uses the candidate box ref, while +box’s drill mints with the candidate rig ref. Static refs dissolve the +box↔rig runtime recursion; no repository needs to release first. - Not cosmetic, and not deferrable to the next PR that happens to need it. - Between the stamp and the next re-creation of that heading, `main` has no - `## Unreleased`. A PR authored *before* the release wrote its entry under - that heading; with the heading gone, git lands the entry under whatever - now occupies the position — **the section that just shipped** — and it - merges **cleanly**, no conflict, no signal. The changelog then credits a - released version with a change it does not contain, and nothing but a - human reading the file will ever say so - ([#108](https://github.com/heavy-duty/box/issues/108); confirmed in the - sibling repo as - [heavy-duty/rig#66](https://github.com/heavy-duty/rig/issues/66)). +A known gap remains from box#81: released box templates still default +`RIG_REF` to `main`, so a later mint may consume a rig revision other than +the one drilled. This conversion does not change that behavior or claim the +gap is closed. - CI enforces the arming rule with - [.github/scripts/changelog-armed.sh](.github/scripts/changelog-armed.sh), - keyed on `VERSION`: a `-dev` tree must carry `## Unreleased` on top; a - bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may - carry either `## Unreleased` or its own stamped section. That is why the - guard cannot simply demand `## Unreleased` unconditionally — the - unconditional form is false on the ceremony PR's own tree and makes the - release unshippable, which is why rig and cast both reverted it. The - practical consequence: forgetting to re-arm does **not** block the release - PR, it turns `main` red on the very next push — the automatic `-dev` bump - the release itself makes. Do it in the ceremony PR and main is never - disarmed at all. +## Scope labels - **Release headings are append-only.** When you add your entry under - `## Unreleased`, *insert above* the heading below it — never type over - that line. Replacing `## 0.8.0 — 2026-07-19` with your own `## Unreleased` - block deletes a shipped section: its prose is absorbed into `Unreleased`, - `release-notes.sh` can no longer find the version it extracts by heading, - and the next release republishes the absorbed prose as if it were new. git - merges that edit cleanly and `changelog-armed.sh` stays green on it — the - top section is still the right one — so - [.github/scripts/changelog-monotonic.sh](.github/scripts/changelog-monotonic.sh) - asserts the other half on every PR: the set of `## X.Y.Z` headings on your - branch must be a **superset** of the set at the merge base - ([#122](https://github.com/heavy-duty/box/issues/122), caught in review of - [#118](https://github.com/heavy-duty/box/pull/118)). The ceremony's own - stamp passes it by construction — rewriting `## Unreleased` into - `## X.Y.Z — DATE` adds a heading and removes none. - - **The drill gates the release, and CI enforces it.** The release PR's - flow is: draft → ready → bot round → **drill** → `state:needs-human` → - maintainer merge (which *is* the release). CI proves the tier's semantics - on every PR; the drill is what proves the VM trust boundary, and it wants - real hardware and the better part of an hour, so it runs on the release - PR's branch and nowhere else. - - Record it in its own file, named exactly for the version: - - ``` - drills/X.Y.Z.md - ``` - - ...with the run in it — what it measured, what it found, what it cost. - See [drills/README.md](drills/README.md) for the shape. - [.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh) - asserts exactly that on every tree with a bare `VERSION`, which is every - `release` PR and the merge that publishes it; a `-dev` tree passes with - nothing to assert. It is **no longer a thing a reviewer has to remember**. - - **`drills/` is release evidence; [`drill/RUNS.md`](drill/RUNS.md) is the - harness's own log.** Two artifacts, two purposes. `drill/RUNS.md` keeps - being what it has always been — every run of the harness, the traps table, - the lore — and is not release-scoped. `drills/X.Y.Z.md` is the per-release - record the gate reads. Appending to `drill/RUNS.md` does not satisfy the - gate, and is not meant to. - - One file per version is also why the guard is short. When records shared a - file it had to match em-dash heading fields, tolerate an optional date - tail, and compare versions *whole* so `0.9.0-rc1` could not satisfy - `0.9.0` — and two separate defects were found in review because of that - complexity. Now `0.9.0.md` and `0.9.0-rc1.md` are simply different files - and none of it is representable. - - It became CI's job because remembering did not work. The sentence this - paragraph replaces described a step **no release had ever performed** — - there was no drill record anywhere in the repo — and #95, #114 and #148 - all shipped through the gap as a `VERSION` bump plus a `CHANGELOG.md` - stamp. The one time it was caught was the one time somebody happened to - look, which is not a gate. - - **The three drills are INDEPENDENT — there is no fixed order.** box, rig - and cast each drill separately, in any order, on any schedule, in separate - sittings if you like. What makes that safe is not sequencing, it is that - every drill **pins the same fixed set of candidate refs**: rig's drill - runs `--host yes` with `BOX_REF=release/`, so it exercises the - box that will actually ship; box's drill mints with - `RIG_REF=release/`, so it exercises the rig that will actually - ship. Both measure the same pair. - - That is what dissolves the box↔rig recursion. box and rig really are - **mutually recursive** — rig sits *below* box as the host-builder - (`rig bootstrap --host yes` installs box and runs `setup-host`) and - *above* it as the guest-converger (a `box new` seed's cloud-init curls - rig's installer and runs `rig bootstrap -box`); box's own source - says as much, the seeds "invert the rig→box install edge (rig#28: rig - installs box on hosts; now box guests install rig)". A cycle at *runtime* - becomes two independent tests against one fixed pair, because the refs are - **static identifiers that exist as soon as the release branches do**, long - before any drill runs. Nothing has to be released, or drilled, first. - - Within a single drill you of course bring the substrate up before probing - it — a host before a guest. That is how you run a drill; it is not an - ordering rule between repos. - - Each repo drills in a **different way** and asserts a different thing: box - asserts the **isolation contract** (the 85-probe VM trust boundary), rig - asserts **convergence** (a machine reaches its role, idempotently), cast - asserts **promotion** (A→B reproduces, the diff is idempotent). Three - different exercises sharing a substrate — not three phases of one script, - which is exactly why the records are per-repo. - - It drills **candidate refs, not released artifacts.** `RIG_REPO` and - `RIG_REF` are mint-time environment variables (defaults - `heavy-duty/rig` and `main`, `bin/box`), so a run pins the exact commits - under test. That dissolves the chicken-and-egg: **no repo has to be - released before another can be drilled.** And drilling the candidate *is* - drilling the release — a release PR's diff is `VERSION` plus - `CHANGELOG.md` and nothing else, so no executable byte differs between - the tree that was drilled and the tree that ships. - - The drills of a release set share **one run ID**. Each repo records its own - legs in its own `drills/X.Y.Z.md`, citing that run ID and the other two - repos' commit SHAs, so the three records reconcile afterwards. The guard - reads only this repo's file — it asserts box's record exists, never the - other two. - - If a defect shows up only in the combination: **patch, re-drill, - re-record.** The three releases converge on a set that holds together; - they are not required to be right in one pass. - - **A known gap, and box is where it belongs.** A *released* box still - defaults `RIG_REF` to `main`, so what a user mints a week after a drill is - not the combination that was drilled — the guest converges against - whatever rig's main has become since. Pinning `RIG_REF` to a released rig - tag in the templates is the outstanding step from - [#81](https://github.com/heavy-duty/box/issues/81) (rig#32 step 5), and it - is what would make a drilled combination reproducible for users. This PR - does not fix it; box's source already says the two directions "track main - unpinned today, said honestly ... until a release flow exists". - - A maintainer **waiver** is possible, and it is still written down. The - guard requires a *record*, not a passing result, so a release that must - ship without a full drill creates `drills/X.Y.Z.md` anyway and says plainly - that the drill was waived and why. Skipping then costs a deliberate, - reviewable file in the diff — which is precisely what the three silent - skips above did not. -2. **The maintainer's merge IS the release.** - [release.yml](.github/workflows/release.yml) fires on the merged, - `release`-labeled PR and asserts before creating anything: `VERSION` at - the merge commit is non-`-dev` **and changed in this PR** (the `-dev` - interlock — a mislabeled ordinary PR fails loudly and creates nothing), - the version's `CHANGELOG.md` section extracts non-empty, and no tag or - release exists for it yet. Then, in the same job, it tags the merge - commit bare `X.Y.Z` (no `v` prefix, the `0.6.0` precedent) and publishes - the GitHub release with that section as the body. No assets — the source - tarball for the tag is the package, and `install.sh` downloads exactly - that. - - *Manual fallback/backfill*: the tag-push path stays. Tagging the merge - commit bare `X.Y.Z` by hand and pushing the tag still publishes the same - way (release.yml asserts the tag names the tree's own `VERSION`) — for - backfills, or the day the merge path is red. -3. **The release re-arms main itself**: the same workflow run bumps - `VERSION` to `X.Y.(Z+1)-dev` and pushes the commit straight to main — - no follow-up PR (it opens one only if branch protection refuses the - direct push, and says so loudly). Not cosmetic — the versioned layout - names install trees after `VERSION`, so a `main` install without the - bump would land in `versions/X.Y.Z` and impersonate the release just - cut. On the *manual* tag path the bump stays yours: open the one-line - PR after publishing. - -## Labels — who sets what - -The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is -who sets each kind — most of it is machinery, and hand-moving a -machine-owned label just gets corrected on the next pass: - -| Labels | Set by | -|---|---| -| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff (step 6) and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* | -| `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. | -| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. | -| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. | -| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. | -| `blocked`, `release` | you — automation never guesses intent. | -| `merge-next` | you or the agent owning the queue. Which PR lands first is a judgement about how they conflict, so the workflow never sets it — it only **clears** it, the moment the PR stops being something a human could merge. | -| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. | - -## Issues - -Give issues the same care as PR titles: say the surface in the title, apply a -`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when -you open one, and `blocked` when it waits on something — that is what keeps -the board navigable as the issue count grows. +- `scope:cli` — `bin/box`, the command surface +- `scope:installer` — `install.sh`, versioned installs, upgrade/uninstall +- `scope:host` — host setup, teardown, firewall, and isolation stack +- `scope:tiers` — grant/revoke and multi-user boundaries +- `scope:templates` — template and profile seeds +- `scope:drill` — rehearsals, doctor, and run evidence diff --git a/LABELS.md b/LABELS.md deleted file mode 100644 index ddfc75f..0000000 --- a/LABELS.md +++ /dev/null @@ -1,181 +0,0 @@ -# Labels - -How this repo uses GitHub labels. The taxonomy is shared across the -heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo, -because it names this repo's actual surfaces. - -## State — who is the ball with? (PRs, exactly one) - -Every open PR carries exactly one `state:` label, and it answers the only -question a board scan actually asks: *who is this PR waiting on?* The states -mirror the review loop this repo runs — PRs open as drafts, three reviewer -bots pick up ready PRs with reviews requested, each round is answered in a -single reply, and a human takes the final review. - -| Label | Color | Waiting on | Enters when | Leaves when | -|---|---|---|---|---| -| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested | -| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round | -| `state:addressing` | `#D93F0B` | the coding agent to reply, fix, or ask | all bots reviewed and not all approved; or nobody was asked; or a blocker is up | the round-reply is posted and fixes pushed — and any blocker named alongside is cleared | -| `state:needs-human` | `#8250DF` | the human reviewer | the PR **could be merged right now**: no blockers, three formal head-current approvals — and the human review is requested | merged — or changes requested, which cycles back to `state:addressing` | - -`bots-reviewing` and `addressing` are deliberately distinct: staleness in the -first means *poke the bots*, staleness in the second means *the agent dropped -the ball*. Collapsing them loses exactly the information a sweep needs. -`bots-reviewing` therefore means strictly *a request is live and an answer is -coming* — a PR nobody was asked to review is the agent's ball, not the bots'. - -## The second axis: `blocker:*` - -State answers *whose ball is it*. Blockers answer *what is in the way*, and -unlike states they are *facts about the branch* — mutually independent, so a -PR carries as many as apply. - -| Label | Color | Means | Clears when | -|---|---|---|---| -| `blocker:conflict` | `#B60205` | GitHub says `CONFLICTING` — the agent owes a **rebase** | it merges cleanly | -| `blocker:ci-red` | `#B60205` | a check failed — the agent owes a **fix**, which a rebase will not provide | checks are green | -| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody — never reviewed, or staled by a push — and **nobody was asked** for one | reviews are requested | -| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no drill record at [`drills/X.Y.Z.md`](drills/README.md) — the ceremony is **correct but unevidenced** | the drill is run and recorded (or a waiver is recorded) at `drills/X.Y.Z.md` | - -`blocker:drill-pending` is the one blocker that says the diff is *fine*. The -version is stamped, the changelog is right, CI's other guards are green — what -is missing is the evidence that the release was proven on real hardware, which -[.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh) refuses -to let a release ship without (CONTRIBUTING.md, "Releases"). Naming it -separately from `blocker:ci-red` matters because the two owe different work: a -red check is a fix in the branch, a pending drill is an hour on a real host. - -It must be **created by a maintainer account** — the bot account gets a `403` -creating labels, so the labels workflow's bootstrap dispatch cannot mint it. -Until it exists in the repo, `blocked` stands in: it is the closest true thing -(the PR is waiting on something outside itself) and, usefully, the staleness -sweep already skips it, so a release parked overnight on a drill does not -collect a `stale`. - -One rule joins the axes: **`state:needs-human` requires zero blockers.** Any -blocker means the work is the agent's, whatever the review round says. - -This split exists because the single-label version kept lying. Independent -facts were projected onto one totally-ordered label, so one always had to win -and the losers vanished off the board: a PR that was *both* conflicted and red -could only say one of them, and `needs-rebase` told an agent to rebase when -what it actually owed was a bug fix. Precedence between two blockers is not a -question a set has to answer, which is why every ordering bug this machine has -had — `needs-human` surviving a conflict, `MISSING` swallowing `STALE` — lived -on the axis that had to be totally ordered. - -`state:needs-rebase` was the first attempt at this and is **retired**; the -reconciler strips it on sight so no PR is left carrying a label nothing -recomputes. - -**`state:needs-human` means one thing: a human could merge this right now.** -The label is the only signal a maintainer scanning the board (or a phone) -actually reads, and one that says "your turn" on an unmergeable PR is worse -than no label at all. So beyond the blockers, one review fact also outranks an -explicit human request: - -- **nobody reviewed *this* head** — every approval staled by a push → `state:addressing`, - because the agent owes a re-request - -That case is more dangerous than any blocker: a blocked PR at least shows an X -or a disabled merge button, while a staled-approval PR reads green, mergeable -and "waiting on the human" over code no reviewer has seen. - -`UNKNOWN` mergeability is deliberately **not** treated as a conflict. GitHub -reports it for about a minute after every merge while it recomputes, and -flapping every open PR through `blocker:conflict` on each merge would be worse -than the bug this fixes. A failed read of either branch fact degrades to the -same "do not know" value, for the same reason. - -An *unfinished* round still yields to an explicit human request — a maintainer -pulling a PR to themselves early is a deliberate act. `MISSING` (nobody has -reviewed yet) and `STALE` (everyone reviewed something else) are different -facts and are treated differently. - -## Cross-cutting (PRs and issues) - -| Label | Color | Meaning | -|---|---|---| -| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. | -| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. | -| `release` | `#0E8A16` | Release flow, versioning, and packaging work. | -| `merge-next` | `#0E8A16` | Head of the merge queue — **merge this one next**. Queue order is *intent* (which PR lands first, given how they conflict), so the reconciler never sets it: you or the agent maintaining the queue do. The reconciler only **clears** it, the moment the PR stops being something a human could merge — so it cannot go stale the way `state:needs-human` did. | - -## Scope — which surface? (PRs and issues, any number) - -All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. - -| Label | Covers | -|---|---| -| `scope:cli` | `bin/box` — the command surface itself | -| `scope:installer` | `install.sh`, the versioned install layout, upgrade/uninstall | -| `scope:host` | `host/` — setup-host, teardown, the firewall and isolation stack | -| `scope:tiers` | the restricted tier — grant/revoke, multi-user semantics | -| `scope:templates` | `templates/` — the box seeds | -| `scope:drill` | `drill/` — the rehearsals, doctor, RUNS.md | - -## Issue types - -`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in -the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label -would just say the same thing twice, drifting apart eventually. - -## Maintenance - -State labels are machine-owned, with exactly one exception. Every state above -is derivable from GitHub's own facts — the draft flag, requested reviewers, -review states, push timestamps — so the labels workflow -([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the -state and reconciles labels statelessly, on PR events (label changes included) -plus a 15-minute cron. A hand-moved label is a lie waiting to happen; the -workflow asserts the effective state instead. - -The exception is `state:needs-human`, which the author sets at handoff -([CONTRIBUTING.md](CONTRIBUTING.md), step 6). That is an optimistic write, not -a transfer of ownership: because `pull_request_target: labeled` wakes the -workflow, the author's own label write fires the sweep that validates it, and -a handoff that had not earned the label is corrected within seconds. - -It exists because the wake signal was missing. There is no -`pull_request_review_target` — on fork PRs, which is all of them here, -`pull_request_review` runs read-only and cannot label anything — so the moment -the label becomes true, the third approval landing, fired nothing at all. What -was left was the `*/15` cron, and GitHub deprioritises short intervals hard -enough that the delivered rate is closer to hourly. The label could therefore -lag the round it described by hours, worst on the quietest repo: every sweep -reconciles the whole board, so a busy repo stays fresh by piggybacking on -unrelated PR events, while a quiet one depends on the cron most and receives -it least. `scope:` labels on PRs are applied from the changed -paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml)); -[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what. - -The same workflow bootstraps the taxonomy: a manual dispatch creates any -missing label idempotently. To create them by hand (needs push access): - -```sh -gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force -gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force -gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force -gh label create "blocker:conflict" --color B60205 --description "Does not merge — the branch conflicts and the agent owes a rebase" --force -gh label create "blocker:ci-red" --color B60205 --description "A check is failing — the agent owes a fix (not a rebase)" --force -gh label create "blocker:unrequested" --color E99695 --description "Somebody still owes a verdict and nobody was asked for one" --force -# retired — the reconciler strips it; delete it once no PR carries it -# gh label delete "state:needs-rebase" -gh label create "state:needs-human" --color 8250DF --description "No blockers, all bots approve — waiting on the human reviewer" --force -gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --force -gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force -gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force -gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force -gh label create "scope:cli" --color C5DEF5 --description "bin/box — the command surface" --force -gh label create "scope:installer" --color C5DEF5 --description "install.sh, versioned installs, upgrade/uninstall" --force -gh label create "scope:host" --color C5DEF5 --description "host/ — setup, teardown, firewall, isolation stack" --force -gh label create "scope:tiers" --color C5DEF5 --description "restricted tier — grant/revoke, multi-user" --force -gh label create "scope:templates" --color C5DEF5 --description "templates/ — the box seeds" --force -gh label create "scope:drill" --color C5DEF5 --description "drill/ — rehearsals, doctor, RUNS.md" --force -# delete is not an upsert: a label that is already gone exits non-zero. Swallow -# that, so this block converges on re-run instead of erroring after first success. -for L in duplicate invalid question wontfix "help wanted" "good first issue"; do - gh label delete "$L" --yes 2>/dev/null || true -done -``` diff --git a/drills/README.md b/drills/README.md index bdb2659..8e18fa5 100644 --- a/drills/README.md +++ b/drills/README.md @@ -9,7 +9,7 @@ drills/0.9.0-rc1.md ``` The name must match the contents of `VERSION` exactly. -[.github/scripts/drill-recorded.sh](../.github/scripts/drill-recorded.sh) +[the pinned ceremony drill-recorded action](https://github.com/heavy-duty/ceremony/tree/0.1.0/actions/drill-recorded) refuses any tree with a bare `VERSION` that has no such file, or whose file is blank. A `-dev` tree passes with nothing to assert. diff --git a/test/labels-reconcile.sh b/test/labels-reconcile.sh deleted file mode 100644 index cce4691..0000000 --- a/test/labels-reconcile.sh +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Fixture tests for the labels-reconcile state machine: a comment is a -# non-verdict whatever its body says (the AUTHOR escalates by requesting the -# human), a stale approval does not promote unreviewed code, and an explicit -# human request outranks everything. -# Dependency-free beyond jq; no network, no daemon — pure decide_state. - -cd "$(dirname "$0")/.." -# shellcheck source=.github/scripts/labels-reconcile.sh -. .github/scripts/labels-reconcile.sh - -# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state -# machine's inputs, consumed inside the sourced decide_state — not unused. -# shellcheck disable=SC2034 -BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}" -pass=0 fail=0 - -expect() { # $1 = description, $2 = want, $3 = got - if [ "$2" = "$3" ]; then - pass=$((pass + 1)) - else - fail=$((fail + 1)) - printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3" - fi -} - -rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object - jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \ - '{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}' -} - -reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array - -# -- drafts are building, whoever is requested -------------------------------- -DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' -expect "draft PR is building" state:building "$(decide_state)" - -# -- fresh ready PR with bots requested --------------------------------------- -DRAFT=false REQUESTED="$BOT1 -$BOT2 -$BOT3" REVIEWS_JSON='[]' -expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)" - -# -- a bot that never reviewed keeps the round open --------------------------- -# With a live request that is the bots' ball; with NO request outstanding it -# is the agent's, because nothing is coming until somebody asks. -REQUESTED="$BOT3" REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)")" -expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing "$(decide_state)" -REQUESTED="" -expect "...but with nobody asked it is the agent's ball" state:addressing "$(decide_state)" -expect "...and the blocker names the stall" blocker:unrequested "$(blockers)" - -# -- a comment is a non-verdict, agreement body or not: the author escalates -- -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)" -# ...and the author's escalation — requesting the human — flips it -REQUESTED="$HUMAN" -expect "author escalation flips to needs-human" state:needs-human "$(decide_state)" -REQUESTED="" - -# -- three formal approvals need no author judgment --------------------------- -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)" - -# -- a comment WITHOUT a verdict parks the PR on the agent -------------------- -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "comment without verdict is addressing" state:addressing "$(decide_state)" - -# -- changes requested blocks, at any head ------------------------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)" - -# -- a stale approval must not promote unreviewed code ------------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED old1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)" - -# -- a re-requested bot reopens the round even with an old approval on file --- -REQUESTED="$BOT1" -expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)" -REQUESTED="" - -# -- only the LATEST review per bot counts ------------------------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \ - "$(rev "$BOT1" APPROVED head1 "" t2)" \ - "$(rev "$BOT2" APPROVED head1 "" t3)" \ - "$(rev "$BOT3" APPROVED head1 "" t4)")" -expect "later approval supersedes earlier block" state:needs-human "$(decide_state)" - -# -- an explicit human request outranks the bot rounds ------------------------ -REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")" -expect "human requested outranks bots" state:needs-human "$(decide_state)" -REQUESTED="" - -# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)" \ - "$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")" -expect "human block with bots approving is addressing" state:addressing "$(decide_state)" -# ...and re-requesting the human hands it back to them -REQUESTED="$HUMAN" -expect "re-requested human is needs-human again" state:needs-human "$(decide_state)" -REQUESTED="" - -# -- an old human comment must not wedge the handoff (codex, #85 round 3) ----- -REVIEWS_JSON="$(reviews \ - "$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)" -expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)" -# ...a stale human APPROVAL likewise needs a re-request for the new head -REVIEWS_JSON="$(reviews \ - "$(rev "$HUMAN" APPROVED old1 "" t0)" \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)" -# ...a HEAD-CURRENT human approval needs nothing more -REVIEWS_JSON="$(reviews \ - "$(rev "$HUMAN" APPROVED head1 "" t0)" \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)" -# ...and a live request suppresses re-requesting -REQUESTED="$HUMAN" -expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)" -REQUESTED="" - -# --------------------------------------------------------------------------- -# #136: state:needs-human must mean "a human could merge this RIGHT NOW". -# Both cases below were observed live in this repo on 2026-07-20, and both -# showed state:needs-human while being unmergeable in different ways. -# --------------------------------------------------------------------------- -ALL_APPROVE="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" - -# -- flavour 1: not mergeable. The merge button is disabled, yet the board -# said "your turn" on #119/#120/#127 for hours. The branch fact now rides -# the blocker axis; the state says whose ball it is, which is the agent's. -DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=CONFLICTING CHECKS=SUCCESS -expect "a CONFLICTING PR is the agent's, not the human's" state:addressing "$(decide_state)" -expect "...and says WHY on the blocker axis" blocker:conflict "$(blockers)" -REQUESTED="$HUMAN" -expect "...even with the human explicitly requested" state:addressing "$(decide_state)" - -# -- red CI is the same claim, but NOT the same work: a rebase does not fix a -# failing test. Collapsing both into one needs-rebase label told the agent -# to do the wrong thing, which is why the axis split exists. -REQUESTED="" MERGEABLE=MERGEABLE CHECKS=FAILURE -expect "a red PR is the agent's" state:addressing "$(decide_state)" -expect "...and is distinguishable from a conflict" blocker:ci-red "$(blockers)" -REQUESTED="$HUMAN" -expect "...and a human request does not override red CI" state:addressing "$(decide_state)" - -# -- both at once. The single-axis design could not say this at all: one label -# had to win, and the loser silently vanished off the board. -REQUESTED="" MERGEABLE=CONFLICTING CHECKS=FAILURE -expect "a conflicted AND red PR reports both blockers" "blocker:conflict -blocker:ci-red" "$(blockers)" -expect "...and is still just the agent's ball" state:addressing "$(decide_state)" - -# -- UNKNOWN is NOT unmergeable. GitHub reports it for ~a minute after every -# merge while it recomputes; treating it as broken would flap every open PR -# on each merge — worse than the bug being fixed. -REQUESTED="" MERGEABLE=UNKNOWN CHECKS=PENDING -expect "UNKNOWN mergeability blocks nothing" state:needs-human "$(decide_state)" -expect "...and raises no blocker" "" "$(blockers)" - -# -- blocker:unrequested — the stalled round. Nobody owes an answer because -# nobody was ever asked, yet the board read "waiting on the bots" until -# `stale` noticed 48h later. -MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]' -expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested "$(blockers)" -# ...the partial case is equally stalled: one verdict in, nobody asked for the rest -REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")" -expect "one bot in, none requested is still unrequested" blocker:unrequested "$(blockers)" -# ...a STALE round with nobody asked is the same debt, and arguably worse: the -# page carries approvals that no longer describe the tree. Guarding on -# MISSING alone let this one through with no blocker at all. -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED oldhead "" t1)" \ - "$(rev "$BOT2" APPROVED oldhead "" t2)" \ - "$(rev "$BOT3" APPROVED oldhead "" t3)")" -expect "a stale round with nobody asked is unrequested too" blocker:unrequested "$(blockers)" -expect "...and is still the agent's ball" state:addressing "$(decide_state)" -# ...but a live request means an answer IS coming -REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")" -REQUESTED="$BOT2" -expect "a live bot request is not a stalled round" "" "$(blockers)" -# ...and a draft is exempt: the bots ignore drafts by design -DRAFT=true REQUESTED="" REVIEWS_JSON='[]' -expect "a draft with nobody asked is not stalled" "" "$(blockers)" -# ...as is an explicit human request — claiming a PR early is deliberate -DRAFT=false REQUESTED="$HUMAN" -expect "an early human claim is not a stalled round" "" "$(blockers)" -REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS - -# -- flavour 2 (the dangerous one): mergeable, green, human requested, and -# NOBODY has reviewed this head. Observed on #119 after a rebase: every -# signal read "merge me" and nothing on the page contradicted it. -MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN" -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED oldhead "" t1)" \ - "$(rev "$BOT2" APPROVED oldhead "" t2)" \ - "$(rev "$BOT3" APPROVED oldhead "" t3)")" -expect "stale approvals outrank the human request (nobody reviewed this tree)" state:addressing "$(decide_state)" - -# -- ...and a round that is BOTH unfinished and staled is still the agent's. -# Deciding inside the bot loop made this depend on BOTS order: the MISSING -# returned before any later bot's STALE was read, so the mixed round came -# out needs-human with nothing bound to the head. Pinned at both ends of -# the array, because the whole failure was one of ordering. -MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN" -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED oldhead "" t1)" \ - "$(rev "$BOT2" APPROVED oldhead "" t2)")" -expect "stale approvals + a bot yet to review is addressing, not needs-human" \ - state:addressing "$(decide_state)" -REVIEWS_JSON="$(reviews "$(rev "$BOT3" APPROVED oldhead "" t3)")" -expect "...and the same when the stale verdict is the LAST bot in BOTS" \ - state:addressing "$(decide_state)" - -# -- but an UNFINISHED round still yields to an explicit human request: a -# maintainer pulling a PR to themselves early is deliberate, and was the -# original precedence. MISSING differs from STALE — nobody has reviewed -# YET, versus everyone reviewed something else. -REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")" -expect "an unfinished round still yields to an explicit human request" state:needs-human "$(decide_state)" -REQUESTED="" -expect "...and without that request the agent owes the ask" state:addressing "$(decide_state)" - -# --------------------------------------------------------------------------- -# checks_state: the rollup classifier. It lived inline in main() for the first -# round of this PR, which is why nothing here caught it calling ERROR, -# CANCELLED and STALE green. Extracted so the enum can be pinned down. -# --------------------------------------------------------------------------- -rollup() { jq -n --argjson c "$1" '{statusCheckRollup: $c}'; } -run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \ - '{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, completedAt:$t}'; } -ctx_() { jq -n --arg n "$1" --arg s "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \ - '{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}'; } - -expect "no checks at all is NONE" NONE "$(rollup '[]' | checks_state)" -# A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty -# ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" — -# the same unknown-certified-as-green shape as #136, in the one place that -# fix did not look. The caller skips an UNREADABLE PR rather than relabelling. -expect "a failed read is UNREADABLE, not NONE" UNREADABLE "$(echo '{}' | checks_state)" -expect "...and a real empty rollup is still NONE" NONE \ - "$(echo '{"mergeable":"MERGEABLE","statusCheckRollup":[]}' | checks_state)" -expect "all green is SUCCESS" SUCCESS \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS)]" | checks_state)" -expect "a queued run is PENDING" PENDING \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b QUEUED)]" | checks_state)" -expect "a plain failure is FAILURE" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b FAILURE)]" | checks_state)" - -# -- the round-1 gap: outcomes that are neither success nor pending, and that -# leave a required check unsatisfied. All three reached the old `else`. -expect "a commit status ERROR blocks" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(ctx_ lint ERROR)]" | checks_state)" -expect "a CANCELLED run blocks" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)" -expect "a STALE run blocks" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b STALE)]" | checks_state)" -expect "an outcome the enum does not know blocks, it does not pass" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b SOME_FUTURE_STATE)]" | checks_state)" - -# -- NEUTRAL and SKIPPED satisfy branch protection; path-filtered jobs skip -# constantly, and calling that red would park every PR on the agent. -expect "NEUTRAL and SKIPPED are not failures" SUCCESS \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b NEUTRAL),$(run_ c SKIPPED)]" | checks_state)" - -# -- latest-wins. The rollup keeps superseded runs, so this PR's own tip -# carried a CANCELLED `scope` beside the SUCCESS `scope` that replaced it. -# Without collapsing, making CANCELLED block would strand it forever. -expect "a re-run supersedes the cancelled original" SUCCESS \ - "$(rollup "[$(run_ scope CANCELLED 2026-07-20T15:19:39Z),\ - $(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)" -expect "...and the reverse order is not a re-run passing, it is one failing" FAILURE \ - "$(rollup "[$(run_ scope SUCCESS 2026-07-20T15:19:39Z),\ - $(run_ scope CANCELLED 2026-07-20T15:19:45Z)]" | checks_state)" -# same job name in a different workflow is a different context, not a re-run -expect "same name in another workflow does not supersede" FAILURE \ - "$(rollup "[$(jq -n '{__typename:"CheckRun",workflowName:"labels",name:"scope",conclusion:"FAILURE",completedAt:"2026-07-20T15:00:00Z"}'),\ - $(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)" - -# -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a -# real completedAt, which is exactly why the supersede rule shipped dating -# runs by completion and nothing caught it. Both spellings of "no -# completion" are pinned, because `gh` emits the zero sentinel (a string, -# which `//` does not fall through) while the API emits null. -inflight_() { jq -n --arg n "$1" --arg t "$2" --arg c "${3:-0001-01-01T00:00:00Z}" \ - '{__typename:"CheckRun", workflowName:"ci", name:$n, status:"IN_PROGRESS", - conclusion:"", startedAt:$t, completedAt:(if $c == "null" then null else $c end)}'; } - -expect "a re-run in flight beats the success it superseded (zero sentinel)" PENDING \ - "$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\ - $(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)" -expect "...and the same when the absent completion is null" PENDING \ - "$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\ - $(inflight_ build 2026-07-20T15:10:00Z null)]" | checks_state)" -expect "a replacement in flight for a CANCELLED run is pending, not failed" PENDING \ - "$(rollup "[$(run_ build CANCELLED 2026-07-20T15:00:00Z),\ - $(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)" -# an entry carrying no usable timestamp is treated as newest, not oldest — -# ambiguity resolves toward "not settled" rather than toward a stale success. -# Guarded by the sort tiebreak rather than the dating expression: reverting -# only `at:` leaves this passing, so the two changes are separately pinned. -expect "an undateable in-flight run is not discarded for a stale success" PENDING \ - "$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\ - $(jq -n '{__typename:"CheckRun",workflowName:"ci",name:"build",conclusion:"",startedAt:null,completedAt:null}')]" \ - | checks_state)" -# ...and the reverse direction, which stops "in flight sorts last" being -# widened into "in flight always wins": a run that FINISHED after an earlier -# in-flight entry is the newer word, and the context is settled. -expect "a finished re-run supersedes an earlier in-flight run" SUCCESS \ - "$(rollup "[$(inflight_ build 2026-07-20T15:19:00Z),\ - $(run_ build SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)" - -# -- the wind-down window. A predecessor cancelled by the concurrency group -# does not stop the instant its replacement starts, so its completion -# routinely lands AFTER the successor's start — on box's aa5a6ba the -# replacement started 15:19:38 and the run it cancelled finished 15:19:51. -# Dating by "newest stamp of any kind" compares the dead run's completion -# against the live run's start, which is not an ordering on runs, and the -# predecessor wins. Every fixture above spaces completion before start, so -# none of them can see it. run_() cannot express the overlap either — it -# carries no startedAt — hence the explicit payloads. -overlap_() { jq -n --arg n "$1" --arg o "$2" --arg s "$3" --arg c "$4" \ - '{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, - startedAt:$s, completedAt:$c}'; } -expect "a predecessor finishing after its replacement started is still older (CANCELLED)" PENDING \ - "$(rollup "[$(overlap_ scope CANCELLED 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\ - $(inflight_ scope 2026-07-20T15:19:38Z)]" | checks_state)" -expect "...and the same when it finished green — mid-flight is not mergeable" PENDING \ - "$(rollup "[$(overlap_ build SUCCESS 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\ - $(inflight_ build 2026-07-20T15:19:38Z)]" | checks_state)" - -# -- the classifier feeds the state machine: a cancelled required check must -# take the PR off the human's plate, which is the whole point of #136. -DRAFT=false HEAD_SHA=head1 REQUESTED="$HUMAN" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE -CHECKS="$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)" -expect "a cancelled check reaches decide_state as the agent's ball" state:addressing "$(decide_state)" -expect "...via blocker:ci-red, not a conflict" blocker:ci-red "$(blockers)" - -# -- the happy path survives all of the above. -REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" -expect "mergeable + green + three head-current approvals is needs-human" state:needs-human "$(decide_state)" -# -- and a draft outranks everything, including a conflict. -DRAFT=true MERGEABLE=CONFLICTING -expect "a draft is building even when conflicted" state:building "$(decide_state)" -DRAFT=false MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]' - -# --------------------------------------------------------------------------- -# reconcile_pr's cold-start path. Everything above tests pure functions, which -# is exactly why a per-PR `return` in the label pre-flight got through review: -# the fixtures could not reach it. A missing state:* label must skip the label -# EDIT only — merge-next clearing and the stale sweep are independent of the -# taxonomy, and stranding them reintroduced the false-invitation bug (a -# `merge-next` claim surviving on a PR the board had moved to the agent). -# --------------------------------------------------------------------------- -reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr emits - ( - REPO_LABELS="$1" REPO=owner/repo NOW="$(date +%s)" - LABELS="merge-next" # the PR carries a queue claim - DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' - MERGEABLE=MERGEABLE CHECKS=SUCCESS - PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' - run() { :; } # swallow mutations - gh() { :; } # no network - reconcile_pr 777 2>&1 - ) -} - -cold="$(reconcile_probe "merge-next")" # state:* labels absent entirely -expect "a cold-start repo still clears merge-next" \ - yes "$(grep -q 'cleared merge-next' <<<"$cold" && echo yes || echo no)" -expect "...and still runs the stale sweep" \ - yes "$(grep -q 'stale (' <<<"$cold" && echo yes || echo no)" -expect "...while warning that the state label is missing" \ - yes "$(grep -q "state label 'state:addressing' does not exist" <<<"$cold" && echo yes || echo no)" - -warm="$(reconcile_probe "$(printf 'state:addressing\nmerge-next\nstale\nblocker:unrequested')")" -expect "a bootstrapped repo converges the state as well" \ - yes "$(grep -q 'state -> state:addressing' <<<"$warm" && echo yes || echo no)" - -printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" -[ "$fail" -eq 0 ] diff --git a/test/release.sh b/test/release.sh index da3df3f..d47372d 100644 --- a/test/release.sh +++ b/test/release.sh @@ -1,22 +1,10 @@ #!/usr/bin/env bash -# The release flow (#83), proven offline. Run: bash test/release.sh -# -# Three surfaces: the changelog-section extraction release.yml publishes -# (.github/scripts/release-notes.sh, driven against fixtures AND the real -# CHANGELOG.md so the header format cannot drift under it), the -# latest-release tag resolution install.sh defaults to (the extracted -# function, driven against a shim curl serving canned redirects), and the -# three install channels — REAL install.sh runs against throwaway roots, -# with the shim curl standing in for GitHub. Nothing here touches the -# network; the same discipline as test/cli.sh. Deliberately no `set -e` — -# the harness asserts on failing commands. +# Box-specific release-channel coverage. Shared release/guard machinery lives +# in heavy-duty/ceremony and is tested there; this file drives real install.sh. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PASS=0 FAIL=0 -# check -# Runs cmd, asserts exit code and (if non-empty) that combined output -# contains want_substr. check() { local desc="$1" want="$2" substr="$3"; shift 3 local out rc @@ -34,741 +22,17 @@ check() { echo "ok: $desc"; PASS=$((PASS + 1)) } -NOTES="$ROOT/.github/scripts/release-notes.sh" WORK="$(mktemp -d)" - -# --------------------------------------------------------------------------- -# release-notes.sh — the extraction, against a fixture changelog that carries -# every boundary: an Unreleased section that must never leak into a release, -# two adjacent versions, a version that prefixes another (0.7.0 vs -# 0.7.0-rc1), and a stamped-but-empty section that must refuse. -# --------------------------------------------------------------------------- -check "release-notes: runnable bash" 0 "" bash -n "$NOTES" - -FIX="$WORK/CHANGELOG.md" -cat > "$FIX" <<'EOF' -# Changelog - -Intro prose that belongs to no section. - -## Unreleased - -- **Not yet released** — must never appear in a release body. - -## 0.7.0 — 2026-07-20 - -### Added - -- **The seven-oh entry** — prose for 0.7.0, and only 0.7.0. - -## 0.7.0-rc1 — 2026-07-19 - -- **The rc entry** — must not ride along with 0.7.0. - -## 0.6.0 — 2026-07-18 - -- **The six-oh entry** — the previous release's prose. - -## 0.5.0 — 2026-07-15 - -EOF - -check "extract: prints the asked-for version's prose" 0 "The seven-oh entry" bash "$NOTES" 0.7.0 "$FIX" -check "extract: keeps the section's own subheaders" 0 "### Added" bash "$NOTES" 0.7.0 "$FIX" -# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design -check "extract: stops at the NEXT section" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "rc entry"' _ "$NOTES" "$FIX" -# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design -check "extract: never leaks Unreleased into a release" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "Not yet released"' _ "$NOTES" "$FIX" -# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design -check "extract: never prints the header itself" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "^## "' _ "$NOTES" "$FIX" -check "extract: the version is matched WHOLE (rc1 is its own section)" \ - 0 "The rc entry" bash "$NOTES" 0.7.0-rc1 "$FIX" -check "extract: an adjacent older version still resolves" 0 "six-oh" bash "$NOTES" 0.6.0 "$FIX" -check "extract: a missing version refuses by name" 1 "no section for '9.9.9'" bash "$NOTES" 9.9.9 "$FIX" -check "extract: ...and names the ritual that was skipped" 1 "#83" bash "$NOTES" 9.9.9 "$FIX" -check "extract: a stamped-but-EMPTY section refuses" 1 "no section for '0.5.0'" bash "$NOTES" 0.5.0 "$FIX" -check "extract: no version argument is a usage error" 2 "usage:" bash "$NOTES" -check "extract: a missing changelog refuses by path" 1 "no such file" bash "$NOTES" 1.0.0 "$WORK/nope.md" - -# The REAL changelog: released sections must keep extracting, or release.yml -# breaks the day it runs — this is the guard against header-format drift. -check "extract: the real 0.6.0 section extracts" 0 "restricted tier" bash "$NOTES" 0.6.0 "$ROOT/CHANGELOG.md" -check "extract: the real 0.5.0 section extracts" 0 "" bash "$NOTES" 0.5.0 "$ROOT/CHANGELOG.md" - -# --------------------------------------------------------------------------- -# release.yml — a daemon-free run cannot push a tag, so the wiring is -# grepped, fail-closed (the house discipline): the VERSION assertion, the -# shared extraction script, and that the tag is verified before creation. -# --------------------------------------------------------------------------- -RY="$ROOT/.github/workflows/release.yml" -check "release.yml: exists" 0 "" test -f "$RY" -# shellcheck disable=SC2016 # the $-string is a literal in the target file -check "release.yml: asserts tag == VERSION before creating anything" 0 "" \ - grep -qF 'GITHUB_REF_NAME" != "$ver"' "$RY" -check "release.yml: the mismatch creates NOTHING (exit 1)" 0 "" \ - grep -qF 'creating nothing' "$RY" -check "release.yml: the body comes from the shared extraction script" 0 "" \ - grep -qF '.github/scripts/release-notes.sh' "$RY" -check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \ - grep -qF -- '--verify-tag' "$RY" - -# --------------------------------------------------------------------------- -# release.yml, the merge door (#96) — merging the release-labeled PR IS the -# release. Same daemon-free discipline: the gate, the four asserts, and the -# same-job tag+publish are grep-pinned, fail-closed. -# --------------------------------------------------------------------------- -check "release.yml: the tag-push trigger is still present (manual fallback)" 0 "" \ - grep -qF 'tags: ["**"]' "$RY" -# The merge door rides pushes to MAIN, not pull_request events: a fork PR -# run gets a read-only GITHUB_TOKEN (permissions: cannot raise it), and -# every ceremony PR this org merges is cross-repo from the bot fork — the -# tag create would 403 after green asserts (#97 round 1). The label — the -# operator's intent — is read via the API off the merge commit's PR. -check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \ - grep -qF 'branches: [main]' "$RY" -check "release.yml: the doors split on the ref — tags to the tag door..." 0 "" \ - grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY" -check "release.yml: ...main to the merge door" 0 "" \ - grep -qF "github.ref == 'refs/heads/main'" "$RY" -# shellcheck disable=SC2016 # the $-string is a literal in the target file -check "release.yml: the release label is read via the API off the merge commit" 0 "" \ - grep -qF 'commits/$GITHUB_SHA/pulls' "$RY" -check "release.yml: a transition without a labeled PR refuses" 0 "" \ - grep -qF "no merged, release-labeled PR is behind this commit" "$RY" -check "release.yml: assert — VERSION at the merge commit is non--dev" 0 "" \ - grep -qF '*-dev)' "$RY" -check "release.yml: assert — VERSION changed IN THIS PR (first parent vs merge)" 0 "" \ - grep -qF 'git show HEAD^1:VERSION' "$RY" -check "release.yml: assert — no existing tag for the version" 0 "" \ - grep -qF 'git/ref/tags/' "$RY" -check "release.yml: assert — no existing release for the version" 0 "" \ - grep -qF 'gh release view' "$RY" -check "release.yml: BOTH doors extract notes via the shared script" 0 "2" \ - grep -cF 'bash .github/scripts/release-notes.sh' "$RY" -check "release.yml: every failing assert creates NOTHING (both doors)" 0 "5" \ - grep -cF 'creating nothing' "$RY" -check "release.yml: the merge door creates the tag ref via the API..." 0 "" \ - grep -qF 'ref=refs/tags/' "$RY" -# shellcheck disable=SC2016 # the $-string is a literal in the target file -check "release.yml: ...at the MERGE commit" 0 "" \ - grep -qF 'sha=$MERGE_SHA' "$RY" -check "release.yml: BOTH doors publish bound to an existing tag (--verify-tag)" 0 "2" \ - grep -cF -- '--verify-tag' "$RY" -check "release.yml: tag + publish share one job (the anti-recursion shape)" 0 "" \ - grep -qF 'anti-recursion' "$RY" -# The decide step tells the label's two meanings apart (LABELS.md gives -# `release` to release-flow WORK as well as to the ceremony PR — the PR -# that added the merge door included): work under the label no-ops GREEN — -# in the -dev steady state and in the post-release window (bare, unchanged, -# already released) — while every half-ceremony refuses. Pin each verdict -# and the gating output. -check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \ - grep -qF "release-flow work under the release label, not a ceremony" "$RY" -check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \ - grep -qF "a dev tree is by definition not a release" "$RY" -check "release.yml: decide — post-release-window work no-ops green" 0 "" \ - grep -qF "release-flow work merged in the post-release window" "$RY" -check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \ - grep -qF "Refusing to guess" "$RY" -check "release.yml: decide gates every later merge-door step on ceremony=yes" 0 "4" \ - grep -cF "if: steps.decide.outputs.ceremony == 'yes'" "$RY" -# The release re-arms main itself: the post-release -dev bump is arithmetic, -# not judgment, so it rides the same job — direct push, PR fallback. -check "release.yml: the release bumps main to the next -dev itself" 0 "" \ - grep -qF "bump main to the next -dev" "$RY" -check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \ - grep -qF "opening the bump PR instead" "$RY" - -# --------------------------------------------------------------------------- -# changelog-armed.sh (#108) — the changelog has a heading for the NEXT entry. -# -# The drift this catches produces no conflict and no error: the ceremony -# stamps '## Unreleased' away, and a PR authored before the release merges its -# entry cleanly into the section that just shipped. box has no top-section -# guard at all today — the two checks above pin only that the 0.6.0 and 0.5.0 -# sections still extract, which a disarmed main passes happily. -# -# BOTH states are constructed as real trees and the real script is run against -# them, because the failure mode of the naive fix is precisely a state -# mismatch: an unconditional '## Unreleased' requirement is green on main and -# false on the ceremony PR's own tree, which is why rig#44 and -# heavy-duty/cast#108 both had to revert one. A test that only drives the -# -dev state would have shipped that bug again. -# --------------------------------------------------------------------------- -ARMED="$ROOT/.github/scripts/changelog-armed.sh" -check "changelog-armed: runnable bash" 0 "" bash -n "$ARMED" - -# tree — a two-file tree to run against -tree() { - local d="$WORK/$1" v="$2"; shift 2 - mkdir -p "$d" - printf '%s\n' "$v" > "$d/VERSION" - { echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md" - echo "$d" -} -armed() { bash "$ARMED" "$1/CHANGELOG.md" "$1/VERSION"; } - -# --- the -dev steady state: armed is the only legal shape ------------------ -T="$(tree dev-armed 0.7.1-dev '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')" -check "armed: a -dev tree with '## Unreleased' on top passes" 0 "agrees" armed "$T" -T="$(tree dev-disarmed 0.7.1-dev '## 0.7.0 — 2026-07-19' '' '- **Shipped**')" -check "armed: a -dev tree WITHOUT it fails — the #108 drift, caught" 1 "MUST carry" armed "$T" -check "armed: ...and the failure says how to fix it (re-arm)" 1 "re-arm" armed "$T" -check "armed: ...naming the issue and its origin" 1 "heavy-duty/rig#66" armed "$T" - -# --- the ceremony PR: bare VERSION, BOTH arrangements legal ---------------- -# This is the pair that the reverted guards got wrong. Neither may fail, or -# the release PR cannot go green and the ceremony is unshippable. -T="$(tree rel-stamped 0.7.1 '## 0.7.1 — 2026-07-19' '' '- **This release**')" -check "armed: a bare VERSION with its OWN stamped section on top passes" 0 "agrees" armed "$T" -T="$(tree rel-rearmed 0.7.1 '## Unreleased' '' '## 0.7.1 — 2026-07-19' '' '- **This release**')" -check "armed: ...and so does the RE-ARMED ceremony tree (the shape #108 asks for)" \ - 0 "agrees" armed "$T" -# The one bare-VERSION arrangement that is wrong: a stamp naming another -# version. release.yml would publish a body that is not this release's. -T="$(tree rel-wrong 0.7.1 '## 0.7.0 — 2026-07-19' '' '- **Some other release**')" -check "armed: a bare VERSION under someone ELSE's stamped section fails" 1 "wrong number" armed "$T" - -# --- the HALF-ceremony: the gap the two bare-VERSION clauses leave --------- -# VERSION bumped to the release, '## Unreleased' still populated on top, and -# the section for that version never stamped at all. The wrong-number test -# above is false on its FIRST clause here and short-circuits, so before -# heavy-duty/rig#67's rule this tree passed the guard and was refused instead -# by release.yml — at publish time, after the merge, on main, with the release -# already half-shipped. Caught here one step earlier, by running the same -# extraction release.yml runs. -T="$(tree rel-half 0.8.0 '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')" -check "armed: a bare VERSION whose section was never stamped fails (half-ceremony)" \ - 1 "no non-empty section" armed "$T" -check "armed: ...and names the stamp as MISSING, not misnumbered" \ - 1 "MISSING, not misnumbered" armed "$T" -# The wording is the whole point of the separate branch: an operator sent to -# fix a version number that is already correct will not find the real problem. -not_wrong_number() { ! armed "$1" 2>&1 | grep -qF 'wrong number'; } -check "armed: ...and not as the wrong-number case, which has a different fix" \ - 0 "" not_wrong_number "$T" -# A section that exists but carries no prose is the same failure: release.yml -# would publish an empty body, which is what release-notes.sh already refuses. -T="$(tree rel-empty 0.7.1 '## 0.7.1 — 2026-07-19' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')" -check "armed: a bare VERSION whose section is stamped but EMPTY fails" \ - 1 "no non-empty section" armed "$T" - -# --- degenerate trees refuse rather than pass by accident ------------------ -T="$(tree no-sections 0.7.1-dev 'Prose and no headings at all.')" -check "armed: a changelog with no '## ' section at all fails" 1 "no '## ' section at all" armed "$T" -check "armed: a missing changelog refuses by path" 1 "no such file" \ - bash "$ARMED" "$WORK/nope.md" "$ROOT/VERSION" -check "armed: a missing VERSION refuses by path" 1 "no such file" \ - bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/nope-version" -mkdir -p "$WORK/empty-ver"; : > "$WORK/empty-ver/VERSION" -check "armed: an empty VERSION refuses" 1 "is empty" \ - bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/empty-ver/VERSION" - -# --- and the tree under test, which is the assertion that actually fires --- -check "armed: THIS tree's VERSION and CHANGELOG.md agree" 0 "agrees" \ - bash "$ARMED" "$ROOT/CHANGELOG.md" "$ROOT/VERSION" - -# The guard is only a guard if CI runs it, and the ceremony is only re-armed -# if the ceremony step says so. Fail-closed pins on both, since a guard nobody -# invokes and a step nobody wrote are the two ways this reverts silently. -check "ci.yml: runs the changelog-armed guard" 0 "" \ - grep -qF 'changelog-armed.sh' "$ROOT/.github/workflows/ci.yml" -check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \ - grep -qF 'Stamping is two edits, not one' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \ - grep -qF 'changelog-armed.sh' "$ROOT/CONTRIBUTING.md" - -# --------------------------------------------------------------------------- -# drill-recorded.sh — a RELEASE tree has a drill record at drills/.md. -# -# The gap this closes is not a drift or a race; it is a rule that was only -# ever a sentence. CONTRIBUTING.md has said since #96 that the release PR is -# where the full real-hardware drill hangs — and #95, #114 and #148 all -# shipped without one, because nothing but a reviewer's memory stood on it. -# -# Both states are constructed as real trees, same discipline as the armed -# block above, and for a sharper reason here: the -dev pass is not a -# convenience, it is what keeps the guard installable. A version of this rule -# that fired on every tree would be red on every ordinary PR in the repo and -# would be switched off inside a day. -# -# Every fixture carries its OWN VERSION and its OWN drills/ dir. Reaching for -# $ROOT/VERSION instead is exactly the coupling #146 had to fix: the suite -# then passes or fails on whichever state the repo happens to be in, so it -# goes red on the ceremony tree — the one tree where the release suite most -# needs to be trustworthy. -# -# Records are now ONE FILE PER VERSION, which is why this block is shorter -# than the one it replaces. The heading-parsing cases are gone because there -# is no heading to parse: em-dash fields, the optional ' — DATE' tail, and -# "an empty section must not borrow a neighbour's prose" were all artifacts -# of records sharing drill/RUNS.md. Deleting a test is only safe when the -# failure it described became unrepresentable, and that is the case here — -# except for the whitespace rule, which was never about headings and is -# pinned harder below. -# --------------------------------------------------------------------------- -DRILLED="$ROOT/.github/scripts/drill-recorded.sh" -check "drill-recorded: runnable bash" 0 "" bash -n "$DRILLED" -check "drill-recorded: executable" 0 "" test -x "$DRILLED" - -# dtree [ ] — a self-contained -# tree: its own VERSION, and its own drills/ dir created only when asked for. -# With 2 args there is NO drills/ dir at all, which is a case in its own right. -dtree() { - local d="$WORK/$1" v="$2"; shift 2 - mkdir -p "$d" - printf '%s\n' "$v" > "$d/VERSION" - if [ "$#" -gt 0 ]; then - local rec="$1"; shift - mkdir -p "$d/drills" - printf '%s\n' "$@" > "$d/drills/$rec.md" - fi - echo "$d" -} -drilled() { bash "$DRILLED" "$1/drills" "$1/VERSION"; } - -# --- the -dev steady state: nothing to assert ------------------------------ -# No record at all, and that must PASS. This is the case that makes the guard -# survivable on an ordinary PR. -T="$(dtree drill-dev 0.8.1-dev)" -check "drill-recorded: a -dev tree with NO drills dir at all passes" 0 "nothing to assert" drilled "$T" -check "drill-recorded: ...and says why it declined to judge" 0 "development tree" drilled "$T" - -# --- the ceremony tree: a record is required and must not be blank --------- -T="$(dtree drill-ok 0.9.0 0.9.0 '# Release drill — 0.9.0' '' '- 47/47 on real hardware')" -check "drill-recorded: a bare VERSION with a non-empty record for it passes" \ - 0 "has a drill record" drilled "$T" - -# No drills/ dir AT ALL is the commonest way to fail this: the release branch -# was cut and nobody ran anything. It must fail loudly, not error out on a -# missing directory in some way that reads like a broken guard. -T="$(dtree drill-nodir 0.9.0)" -check "drill-recorded: a bare VERSION with NO drills dir fails" 1 "no drill record" drilled "$T" -check "drill-recorded: ...and the failure names the version" 1 "VERSION is '0.9.0'" drilled "$T" - -# The dir exists — some other release left its record here — but this version -# has none. A directory listing is not evidence about a particular version. -T="$(dtree drill-otherver 0.9.0 0.8.0 '# Release drill — 0.8.0' '' '- the previous release')" -check "drill-recorded: a drills dir with no file for THIS version fails" \ - 1 "no drill record" drilled "$T" - -check "drill-recorded: ...and names the unblock — run the drill and record it" \ - 1 "RUN THE DRILL" drilled "$T" -check "drill-recorded: ...and names the path it wanted" 1 "drills/0.9.0.md" drilled "$T" -check "drill-recorded: ...and offers the recorded maintainer waiver as the other way out" \ - 1 "WAIVED" drilled "$T" -check "drill-recorded: ...and names the three releases that shipped through the gap" \ - 1 "#95, #114 and #148" drilled "$T" - -# A file that exists and says nothing is the same failure as no file: the -# ceremony created the shape and never wrote the evidence. `touch` must not -# be able to satisfy a gate whose entire job is to demand evidence. -mkdir -p "$WORK/drill-emptyfile/drills" -printf '0.9.0\n' > "$WORK/drill-emptyfile/VERSION" -: > "$WORK/drill-emptyfile/drills/0.9.0.md" -check "drill-recorded: a record file that is present but EMPTY fails" \ - 1 "no drill record" drilled "$WORK/drill-emptyfile" - -# ...and WHITESPACE is not evidence either. This is the ONE rule carried over -# from the heading-parsing guard, because it was never a heading problem. The -# first cut extracted with `sed '/./,$!d'`, where `.` matches a space — so a -# record whose body was one tab passed a guard that promised "at least one -# non-blank line". An evidence-free release for the price of an invisible -# character. All three reviewers on #149 found it independently, which is the -# level of scrutiny it deserved and not a level it should ever need again. -# Splitting the file per version removed the parsing; it did not remove this. -mkdir -p "$WORK/drill-blank/drills" -printf '0.9.0\n' > "$WORK/drill-blank/VERSION" -printf ' \n\t\n\n' > "$WORK/drill-blank/drills/0.9.0.md" -check "drill-recorded: a record of only spaces, tabs and newlines fails (#149)" \ - 1 "no drill record" drilled "$WORK/drill-blank" - -# --- the version is matched WHOLE, both directions ------------------------- -# Under the old scheme this took deliberate field-matching against a heading, -# and getting it wrong would have let a release-candidate drill stand in for -# the release it was a candidate for — the one thing an rc drill by -# definition did not measure. One file per version makes it free: these are -# simply different paths. Pinned anyway, in both directions, so that a future -# rewrite reaching for a prefix or glob match cannot quietly reintroduce it. -T="$(dtree drill-rc-only 0.9.0 0.9.0-rc1 '# Release drill — 0.9.0-rc1' '' '- the rc drill')" -check "drill-recorded: a 0.9.0-rc1.md record does NOT satisfy 0.9.0" 1 "no drill record" drilled "$T" -T="$(dtree drill-rel-only 0.9.0-rc1 0.9.0 '# Release drill — 0.9.0' '' '- the release drill')" -check "drill-recorded: ...and a 0.9.0.md record does NOT satisfy 0.9.0-rc1" 1 "no drill record" drilled "$T" - -# --- degenerate trees refuse rather than pass by accident ------------------ -# A guard that cannot read the version cannot know whether this tree is its -# business, and "could not tell" must never resolve to "allowed". -check "drill-recorded: a missing VERSION refuses by path" 1 "no such file" \ - bash "$DRILLED" "$WORK/drill-ok/drills" "$WORK/nope-version" -mkdir -p "$WORK/drill-empty-ver"; : > "$WORK/drill-empty-ver/VERSION" -check "drill-recorded: an empty VERSION refuses" 1 "is empty" \ - bash "$DRILLED" "$WORK/drill-ok/drills" "$WORK/drill-empty-ver/VERSION" -# The drills dir is only consulted once the tree is a release — a -dev tree -# must not be refused for a directory it has no business reading. -check "drill-recorded: a -dev tree does not even look for the drills dir" 0 "nothing to assert" \ - bash "$DRILLED" "$WORK/nope-drills" "$WORK/drill-dev/VERSION" - -# --- and the tree under test ---------------------------------------------- -# The property is that the guard's VERDICT IS CORRECT FOR THIS TREE — not that -# it always passes. Those come apart on a ceremony tree, and demanding success -# is wrong there in a way that took a red release PR to notice. -# -# A -dev tree is vacuous, so it must pass. A ceremony tree passes only once a -# human has run the drill and written the record — which is the entire point of -# the gate. Asserting exit 0 unconditionally therefore made test/release.sh -# UN-GREENABLE on every release branch before its drill, and reported it as a -# `release-flow tests` failure rather than as the gate doing its job: the same -# noise-and-misattribution shape as #146, where a fixture read the repo's real -# VERSION and only misbehaved on the ceremony tree. -# -# So: assert the verdict that this tree's own state entails. Green in all three -# states — dev, ceremony-without-record, ceremony-with-record — and still red if -# the guard ever disagrees with the tree in front of it. -THIS_VER="$(tr -d '[:space:]' < "$ROOT/VERSION")" -case "$THIS_VER" in - *-dev) - check "drill-recorded: THIS tree is -dev, and the guard is vacuous on it" 0 "nothing to assert" \ - bash "$DRILLED" "$ROOT/drills" "$ROOT/VERSION" ;; - *) - if [ -s "$ROOT/drills/$THIS_VER.md" ]; then - check "drill-recorded: THIS ceremony tree HAS its record, and the guard accepts it" 0 "" \ - bash "$DRILLED" "$ROOT/drills" "$ROOT/VERSION" - else - check "drill-recorded: THIS ceremony tree has NO record yet, and the guard refuses it" 1 "no drill record" \ - bash "$DRILLED" "$ROOT/drills" "$ROOT/VERSION" - fi ;; -esac - -# drills/ is release evidence and drill/RUNS.md is the harness's own log. The -# rewrite is only coherent if both keep existing and the docs say which is -# which; a reader who appends to the wrong one gets a red gate and no clue. -check "drills/: the directory documents itself" 0 "" test -f "$ROOT/drills/README.md" -check "drills/: ...and is plain, not a dot-directory invisible to globs (#116)" 1 "" \ - test -d "$ROOT/.drills" -check "drills/: ...and distinguishes itself from the harness log" 0 "" \ - grep -qF 'drill/RUNS.md' "$ROOT/drills/README.md" -check "drills/: ...and its worked example uses a version that can never ship" 0 "" \ - grep -qF '9.9.9' "$ROOT/drills/README.md" -check "drill/RUNS.md: the harness log is untouched and still present" 0 "" \ - test -f "$ROOT/drill/RUNS.md" -# No real record may be invented to make the suite green. The repo is -# 0.8.1-dev; fabricating drills/0.8.1.md would defeat the whole gate. -check "drills/: no fabricated record for an unshipped version" 1 "" \ - test -f "$ROOT/drills/0.8.1.md" - -# The guard is only a guard if CI runs it, and only a rule if the document -# that used to carry the rule now points at it. -check "ci.yml: runs the drill-recorded guard" 0 "" \ - grep -qF 'drill-recorded.sh' "$ROOT/.github/workflows/ci.yml" -check "CONTRIBUTING: names the guard that enforces the drill" 0 "" \ - grep -qF 'drill-recorded.sh' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: documents the drill record's path" 0 "" \ - grep -qF 'drills/X.Y.Z.md' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: ...and distinguishes it from the harness's own log" 0 "" \ - grep -qF "harness's own log" "$ROOT/CONTRIBUTING.md" -# The three drills are INDEPENDENT. An earlier draft of this section said the -# drill was "ONE orchestrated run over the whole stack", which over-constrains -# it into a fixed sequence the repos cannot satisfy. Pinning the corrected -# claim, and negatively pinning the fixed-order language, because "surely you -# drill rig before box" is exactly the simplification a future editor makes. -check "CONTRIBUTING: the three drills are independent, in any order" 0 "" \ - grep -qF 'The three drills are INDEPENDENT' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: ...and no fixed-order framing survives" 1 "" \ - grep -qF 'ONE orchestrated run' "$ROOT/CONTRIBUTING.md" -# The load-bearing correction: box and rig are mutually recursive, so a -# "release A before you can drill B" rule is not merely bureaucratic, it is -# unsatisfiable in principle. What dissolves it is that every drill pins the -# same fixed candidate refs — static identifiers that exist as soon as the -# release branches do — turning a runtime cycle into two independent tests. -check "CONTRIBUTING: ...drilling candidate refs, not released artifacts" 0 "" \ - grep -qF 'candidate refs, not released artifacts' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: ...and the same fixed refs are what dissolve the recursion" 0 "" \ - grep -qF 'pins the same fixed set of candidate refs' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: ...and each repo asserts a different thing" 0 "" \ - grep -qF 'isolation contract' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: ...and a combination defect means patch, re-drill, re-record" 0 "" \ - grep -qF 're-drill' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING: ...and the RIG_REF gap is stated as outstanding (#81)" 0 "" \ - grep -qF 'RIG_REF' "$ROOT/CONTRIBUTING.md" -check "LABELS: documents blocker:drill-pending" 0 "" \ - grep -qF 'blocker:drill-pending' "$ROOT/LABELS.md" -check "LABELS: ...and points at the per-version record, not the harness log" 0 "" \ - grep -qF 'drills/X.Y.Z.md' "$ROOT/LABELS.md" - -# --------------------------------------------------------------------------- -# changelog-monotonic.sh (#122) — no SHIPPED release heading may be DELETED. -# -# The complement of the guard above, and the reason it is a separate script: -# changelog-armed.sh asks about ONE tree ("does the top section agree with -# VERSION?"), which is exactly why it was green on #118's broken branch — the -# top section was still '## Unreleased'. "A heading disappeared" is not a -# property of a tree at all; it is a property of a DIFF. So these cases are -# driven against real, constructed GIT REPOS with a base commit and a branch -# commit, not the two-file trees above — a fixture without history cannot -# express the failure being guarded. -# -# The #118 shape is reconstructed verbatim as the first case: the line -# '## 0.8.0 — 2026-07-19' replaced by an entry written under '## Unreleased'. -# The ceremony's own stamp is driven right beside it, because a rule that -# fires on the stamp is unshippable for the same reason rig#44 and cast#108 -# were — that pair, not the failing case alone, is what makes this a design. -# --------------------------------------------------------------------------- -MONO="$ROOT/.github/scripts/changelog-monotonic.sh" -check "changelog-monotonic: runnable bash" 0 "" bash -n "$MONO" - -# grepo — a git repo whose `main` carries the -# given changelog, left checked out on a branch `pr` off it. Prints the dir. -# -# It carries its own VERSION, like tree() above, so the checks that run -# changelog-armed.sh against these fixtures read a version that belongs to the -# FIXTURE. Reaching for the repo's real VERSION instead makes those checks pass -# or fail on what box happens to be versioned at today: bare (the release -# ceremony's own tree) sends changelog-armed.sh down its bare branch, where it -# demands a section for THAT version in a fixture changelog that has never -# heard of it. `-dev` here because every one of these fixtures tops out at -# '## Unreleased', which is what a development tree is required to carry. -grepo() { - local d="$WORK/$1"; shift - mkdir -p "$d" - git -C "$d" init -q -b main - git -C "$d" config user.email test@example.invalid - git -C "$d" config user.name test - printf '%s\n' '0.8.1-dev' > "$d/VERSION" - { echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md" - git -C "$d" add CHANGELOG.md VERSION - git -C "$d" commit -qm base - git -C "$d" checkout -q -b pr - echo "$d" -} -# head_changelog — the PR branch's version of the file -head_changelog() { - local d="$1"; shift - { echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md" - git -C "$d" commit -qam head -} -mono() { local d="$1"; shift; ( cd "$d" && bash "$MONO" "$@" ); } -mono_strict() { local d="$1"; shift; ( cd "$d" && CHANGELOG_MONOTONIC_STRICT=1 bash "$MONO" "$@" ); } - -# --- the #118 incident, reconstructed -------------------------------------- -G="$(grepo mono-118 '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')" -head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '### Added' '' '- **Shipped prose**' -check "monotonic: a DELETED release heading fails (the #118 near-miss)" 1 "DELETES release heading" mono "$G" main -check "monotonic: ...and names the heading that vanished" 1 "## 0.8.0" mono "$G" main -check "monotonic: ...and the shape of the mistake (replaced, not inserted)" 1 "instead of being" mono "$G" main -check "monotonic: ...and why nothing else says so (git merges it cleanly)" 1 "git merges that edit cleanly" mono "$G" main -# The whole point of the issue: the OTHER guard is green on this same tree. -# Pinned here so a future 'just widen changelog-armed.sh' cannot quietly -# delete the reason this script exists. -check "monotonic: ...on a tree changelog-armed.sh calls FINE (the #122 gap)" 0 "agrees" \ - bash "$ARMED" "$G/CHANGELOG.md" "$G/VERSION" - -# --- the #118 incident as it ACTUALLY happened: a DUPLICATED heading ------- -# The deletion case above is the near-miss. What the bad rebase really produced -# was two '## 0.8.0 — 2026-07-19' headings with the incoming entry stranded -# between them. Containment cannot see this: the duplicate is head-side -# SURPLUS, and `comm -23` (base minus head) is blind to extras on the head side -# — with or without `sort -u`, and multiset comparison does not close it for -# the same reason. Uniqueness on HEAD is the assert that does. -G="$(grepo mono-dup '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')" -head_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**' -check "monotonic: a DUPLICATED release heading fails (the #118 shape)" 1 "DUPLICATE release heading" mono "$G" main -check "monotonic: ...and names the repeated heading" 1 "## 0.8.0" mono "$G" main -check "monotonic: ...and says what a repeat does to release-notes extraction" 1 "re-arms its extraction" mono "$G" main -# Containment alone is green on this exact tree — nothing was deleted. Pinned -# so a future simplification cannot collapse the two asserts into one. -# shellcheck disable=SC2016 # $1/$2 are the inner shell's positionals, not ours -check "monotonic: ...on a tree where NOTHING was deleted (containment is blind)" 1 "" \ - bash -c 'cd "$1" && bash "$2" main 2>&1 | grep -q "DELETES release heading"' _ "$G" "$MONO" -# And, as with the deletion case, the other guard calls this tree fine. -check "monotonic: ...on a tree changelog-armed.sh calls FINE" 0 "agrees" \ - bash "$ARMED" "$G/CHANGELOG.md" "$G/VERSION" - -# --- the release ceremony's stamp: an ADD, never a removal ----------------- -# '## Unreleased' -> '## 0.8.1 — DATE' adds 0.8.1 and removes no X.Y.Z -# heading, because 'Unreleased' is not one. A false positive here would make -# every release unshippable — the rig#44 / cast#108 failure, one guard over. -G="$(grepo mono-stamp '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" -head_changelog "$G" '## Unreleased' '' '## 0.8.1 — 2026-07-20' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**' -check "monotonic: the ceremony stamp passes (adds a heading, removes none)" 0 "still present" mono "$G" main - -# --- the ordinary entry, done right ---------------------------------------- -G="$(grepo mono-ok '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" -head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**' -check "monotonic: an entry INSERTED above the top section passes" 0 "still present" mono "$G" main -check "monotonic: ...and counts what it actually checked" 0 "all 1 release heading" mono "$G" main - -# --- deletion is caught anywhere in the file, not just at the top ---------- -G="$(grepo mono-mid '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.7.0 — 2026-07-18' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**')" -head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **a**' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**' -check "monotonic: a heading deleted MID-FILE is caught too" 1 "## 0.7.0" mono "$G" main -# ...and only the deleted one is named, so the message points at the edit. -notes_only_070() { ! mono "$1" main 2>&1 | grep -qE '^ ## 0\.(6|8)\.0$'; } -check "monotonic: ...naming only the heading that went missing" 0 "" notes_only_070 "$G" - -# --- a rewritten Unreleased is NOT a violation (changelog-armed owns it) --- -G="$(grepo mono-unrel '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" -head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **Shipped**' -check "monotonic: a deleted '## Unreleased' is NOT this guard's business" 0 "still present" mono "$G" main - -# --- a changelog that did not exist at the base ---------------------------- -G="$(grepo mono-new 'No sections yet.')" -head_changelog "$G" '## 0.1.0 — 2026-07-20' '' '- **First**' -check "monotonic: a base with no release headings passes (nothing to delete)" 0 "all 0 release heading" mono "$G" main - -# --- degradation: no base to compare against ------------------------------- -# A local run may genuinely have no base ref. That must SKIP loudly, not fail -# spuriously (which would make the script un-runnable off CI) and not pass -# silently (which is the failure shape this repo keeps refusing). CI closes -# the hole from the other side with STRICT. -G="$(grepo mono-nobase '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" -check "monotonic: an unresolvable base ref SKIPS containment" 0 "containment SKIPPED" mono "$G" no-such-ref -check "monotonic: ...and says uniqueness already ran, not that nothing did" 0 "already ran" mono "$G" no-such-ref -check "monotonic: ...naming the base ref it could not resolve" 0 "no-such-ref" mono "$G" no-such-ref -check "monotonic: ...and warning that CI treats it as a failure" 0 "hard failure" mono "$G" no-such-ref -check "monotonic: STRICT turns that skip into a red run" 1 "is a FAILURE, not a skip" mono_strict "$G" no-such-ref -check "monotonic: ...and points at the checkout, not the script" 1 "fetch-depth: 0" mono_strict "$G" no-such-ref -# Outside a work tree at all (a tarball, an unpacked release). -mkdir -p "$WORK/mono-nogit" -printf '%s\n' '# Changelog' '' '## 0.8.0 — 2026-07-19' > "$WORK/mono-nogit/CHANGELOG.md" -check "monotonic: outside a git work tree it skips containment, not everything" 0 "containment SKIPPED" \ - mono "$WORK/mono-nogit" main -check "monotonic: a missing changelog refuses by path (never a skip)" 1 "no such file" \ - bash "$MONO" main "$WORK/nope.md" - -# --- #143: uniqueness is a property of HEAD, so nothing base-side may gate it - -# Containment needs the merge base. Uniqueness needs only the file in front of -# it. Before #143 the duplicate check sat downstream of the base-ref, merge-base -# and base-blob conditions, so each of the three degradation paths below exited -# 0 on a tree with a duplicate in plain sight — the base-blob one not even via -# skip(), but a bare `exit 0` that STRICT could not reach. These cases pin the -# ORDER, which is the actual invariant; asserting the exit code alone is what -# let the original ship (the base-absent case below was green before and after). -grepo_nocl() { # a repo whose main has NO changelog at all - local d="$WORK/$1" - mkdir -p "$d" - git -C "$d" init -q -b main - git -C "$d" config user.email test@example.invalid - git -C "$d" config user.name test - echo seed > "$d/README.md" - git -C "$d" add README.md - git -C "$d" commit -qm base - git -C "$d" checkout -q -b pr - echo "$d" -} -add_changelog() { # — the PR introduces the file - local d="$1"; shift - { echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md" - git -C "$d" add CHANGELOG.md - git -C "$d" commit -qm head -} - -# The changelog is absent at the merge base AND the PR introduces a duplicate. -G="$(grepo_nocl mono-143-newdup)" -add_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.8.0 — 2026-07-19' '' '- **stranded**' -check "monotonic: a duplicate introduced where the base had no changelog is CAUGHT (#143)" 1 "DUPLICATE release heading" mono "$G" main -check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 "DUPLICATE release heading" mono_strict "$G" main -# ...and the clean counterpart still exits 0, now saying uniqueness did run. -G="$(grepo_nocl mono-143-newok)" -add_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **a**' -check "monotonic: ...while a CLEAN introduced changelog still passes" 0 "nothing could have been deleted" mono "$G" main -check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 "uniqueness on HEAD already passed" mono "$G" main - -# No git at all: uniqueness still has everything it needs. -mkdir -p "$WORK/mono-143-nogit" -printf '%s\n' '# Changelog' '' '## 0.8.0 — 2026-07-19' '' '## 0.8.0 — 2026-07-19' > "$WORK/mono-143-nogit/CHANGELOG.md" -check "monotonic: a duplicate OUTSIDE a git work tree is caught (#143)" 1 "DUPLICATE release heading" \ - mono "$WORK/mono-143-nogit" main - -# Unresolvable base ref: same — the skip is containment's, not the script's. -G="$(grepo mono-143-nobase '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" -head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.8.0 — 2026-07-19' '' '- **b**' -check "monotonic: a duplicate is caught even when the base ref will not resolve (#143)" 1 "DUPLICATE release heading" mono "$G" no-such-ref - -# --- the push-to-main shape: containment vacuous, uniqueness real ---------- -# With the pull_request gate gone (#143), merge_base == HEAD is a ROUTINE path, -# not a degradation. Containment compares the file against itself and asserts -# nothing, so a line reading "all N still present" would claim a check that did -# no work — the same dishonesty the skip messages were fixed for. The success -# line therefore has two forms, and this pins which one each event gets. -G="$(grepo mono-vacuous '## 0.8.0 — 2026-07-19' '' '- **Shipped**')" -check "monotonic: HEAD as its own base reports containment VACUOUS, not verified" 0 "containment vacuous" mono "$G" HEAD -check "monotonic: ...and names uniqueness as the half that actually ran" 0 "uniqueness on HEAD checked" mono "$G" HEAD -# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design -check "monotonic: ...and does NOT claim headings were still present" 1 "" \ - bash -c 'cd "$1" && bash "$2" HEAD | grep -q "are still present"' _ "$G" "$MONO" -# The PR shape keeps the containment wording — the two must not collapse. -head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **Shipped**' '' '## 0.9.0 — 2026-07-20' '' '- **New**' -check "monotonic: a real base still reports containment, naming the count" 0 "still present" mono "$G" main - -# --- and the real tree, through the real script ---------------------------- -# HEAD as its own base: the merge base is HEAD, so the sets are identical by -# construction. Proves the script runs against the actual CHANGELOG.md and -# parses its real headings, without depending on an `origin/main` that a -# fresh clone or a detached CI checkout may not have. -# HEAD as its own base is now the VACUOUS-containment path (#143), so the -# assertion moved to uniqueness's count — which is the stronger proof of the -# original intent anyway: it says the parser read the REAL CHANGELOG.md and -# found real headings in it, rather than that a self-comparison came out equal. -check "monotonic: THIS tree passes against itself (the parser meets reality)" 0 "uniqueness on HEAD checked" \ - mono "$ROOT" HEAD - -# The guard is only a guard if CI runs it — and only if CI runs it with the -# history it needs. Fail-closed pins on all three, since a depth-1 checkout -# would silently downgrade every run to the SKIP path. -check "ci.yml: runs the changelog-monotonic guard" 0 "" \ - grep -qF 'changelog-monotonic.sh' "$ROOT/.github/workflows/ci.yml" -check "ci.yml: ...with full history, or the merge base is unreachable" 0 "" \ - grep -qF 'fetch-depth: 0' "$ROOT/.github/workflows/ci.yml" -check "ci.yml: ...and STRICT, so a skip is a red run and not a green one" 0 "" \ - grep -qF 'CHANGELOG_MONOTONIC_STRICT' "$ROOT/.github/workflows/ci.yml" -# #143: the step must NOT be pull-request-only — duplication is vacuous on no -# tree — and dropping that gate is only safe with the base-ref fallback, since -# `github.base_ref` is empty on a push and a bare `origin/` under STRICT is a -# hard failure on every push to main. -# Scoped to the step's OWN block, deliberately. A file-wide negative would -# forbid any FUTURE step in ci.yml from being pull_request-gated and would fail -# citing #143 when one legitimately is — #143 constrains this step, not the file. -# Terminates on a new STEP or a new JOB. The job boundary is not optional: the -# monotonic step is the LAST step of `check`, so stopping only at the next -# `- name:` runs the block into the `rehearsal` job below and swallows its -# job-level `if:`. That reintroduces the very bug the scoping fixed — an -# unrelated edit failing while citing #143 — just moved from "any step in the -# file" to "this step plus the head of the next job". -mono_step_block() { - awk '/^ - name: no shipped changelog heading/ {f=1; print; next} - f && (/^ - / || /^ [^ ]/) {exit} - f {print}' "$ROOT/.github/workflows/ci.yml" -} -# Anchored: an `if:` appearing inside a `run:` line is not a step condition. -mono_step_gated() { mono_step_block | grep -q '^ if:'; } -check "ci.yml: the monotonic step itself is not pull_request-gated (#143)" 1 "" mono_step_gated -check "ci.yml: ...and the block was actually found (guards the awk above)" 0 "changelog-monotonic" mono_step_block -check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \ - grep -qF 'github.base_ref || github.ref_name' "$ROOT/.github/workflows/ci.yml" -check "CONTRIBUTING: names the append-only rule for release headings" 0 "" \ - grep -qF 'changelog-monotonic.sh' "$ROOT/CONTRIBUTING.md" - -# --------------------------------------------------------------------------- -# latest_release_tag — extracted from install.sh (the source-the-pure-function -# trick) and driven against a shim curl. The shim serves the ONE seam the -# function uses: -w '%{redirect_url}' on the releases/latest probe. -# --------------------------------------------------------------------------- SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR" cat > "$SHIMDIR/curl" <<'SHIM' #!/usr/bin/env bash -# Fake curl for the release drills: answers the releases/latest probe with -# $FAKE_REDIRECT on stdout (the -w '%{redirect_url}' seam) — or fails with -# $FAKE_CURL_RC (network down) — and serves downloads (-o ) by copying -# $FAKE_TARBALL when the URL is $FAKE_SERVE_URL, else exit 22 (curl's own -# 404-under--f code). Every URL is appended to $FAKE_CURL_LOG so a test can -# assert exactly what was asked for, and in what order. url="" out="" while [ $# -gt 0 ]; do case "$1" in - -o|--output) out="$2"; shift 2 ;; + -o|--output) out="$2"; shift 2 ;; -w|--write-out) shift 2 ;; - -*) shift ;; - *) url="$1"; shift ;; + -*) shift ;; + *) url="$1"; shift ;; esac done [ -n "${FAKE_CURL_LOG:-}" ] && printf '%s\n' "$url" >> "$FAKE_CURL_LOG" @@ -787,105 +51,77 @@ chmod +x "$SHIMDIR/curl" TAGFN="$(mktemp)" awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN" -check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN" -check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN" +check "latest_release_tag: extracted from install.sh" 0 "releases/latest" cat "$TAGFN" +check "latest_release_tag: extracted function is valid bash" 0 "" bash -n "$TAGFN" -ltag() { # ltag [curl_rc] +ltag() { FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \ PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag" } check "resolve: reads the tag off the redirect" 0 "0.6.0" \ ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" -check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \ +check "resolve: a pre-release tag survives verbatim" 0 "0.7.0-rc1" \ ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1" -check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \ +check "resolve: a repo with no releases fails" 1 "" \ ltag "https://github.com/heavy-duty/box/releases" -check "resolve: no redirect at all fails" 1 "" ltag "" -check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \ +check "resolve: no redirect fails" 1 "" ltag "" +check "resolve: a curl failure fails" 1 "" \ ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6 -rm -f "$TAGFN" -# --------------------------------------------------------------------------- -# The three channels, driven through REAL install.sh runs (#83): default = -# latest release, BOX_REF= = pinned, BOX_REF= = dev. The shim -# curl serves a fabricated release tarball shaped exactly like GitHub's (one -# top-level directory), and its log proves WHICH URLs the installer asked -# for. FAKE_TARBALL carries VERSION 9.9.9 so nothing collides with the tree -# under test. -# --------------------------------------------------------------------------- FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin" cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box" -echo "9.9.9" > "$SRC/VERSION" +printf '9.9.9\n' > "$SRC/VERSION" tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9 -ninst() { # ninst [VAR=val ...] — install.sh, shim network +ninst() { local h="$1" b="$2"; shift 2 env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \ BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \ FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh" } -# --- channel 1: the default is the latest RELEASE --------------------------- H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log" -check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \ +check "default channel: installs the latest release" 0 "latest release: 9.9.9" \ ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \ FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \ FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz" -check "default channel: the download is the TAG tarball" 0 "" \ +check "default channel: downloads the tag tarball" 0 "" \ grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1" -check "default channel: it never asked for a branch" 1 "" \ - grep -q "refs/heads" "$L1" -check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \ +check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1" +check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \ cat "$H1/versions/9.9.9/INSTALLED_FROM" -check "default channel: the install answers through the chain" 0 "box 9.9.9" \ +check "default channel: installed binary answers" 0 "box 9.9.9" \ env HOME="$FAKEHOME" "$B1/box" --version -# --- channel 2: BOX_REF= pins a release -------------------------------- H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log" -check "pinned channel: BOX_REF= installs that tag" 0 "done" \ +check "pinned channel: installs the requested tag" 0 "done" \ ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \ FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz" -check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \ +check "pinned channel: skips latest-release resolution" 1 "" \ grep -q "releases/latest" "$L2" -# --- channel 3: BOX_REF= is the dev channel ------------------------- H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log" -check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \ +check "dev channel: falls back from tag to branch" 0 "trying it as a branch" \ ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \ FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz" -check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \ - head -1 "$L3" -check "dev channel: then the branch" 0 "" \ +check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3" +check "dev channel: then downloads the branch" 0 "" \ grep -qF "archive/refs/heads/main.tar.gz" "$L3" -# --- the failure is LOUD, never a silent fall-through to main --------------- H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log" -check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \ +check "resolution failure: names the latest-release probe" 1 "could not resolve the latest release" \ ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4" -check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \ +check "resolution failure: names BOX_REF as the override" 1 "BOX_REF" \ ninst "$H4" "$B4" FAKE_CURL_RC=6 -check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \ - grep -q "archive/" "$L4" -check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions" -check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \ +check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4" +check "resolution failure: installs nothing" 1 "" test -e "$H4/versions" +check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \ ninst "$H4" "$B4" BOX_REF=no-such-ref -# --------------------------------------------------------------------------- -# The -dev convention (#83): main's VERSION carries -dev between releases, so -# a dev install lands beside releases in versions/ instead of impersonating -# one — and the docs keep the promises this PR makes. -# --------------------------------------------------------------------------- -check "CONTRIBUTING documents the post-release -dev bump" 0 "" \ - grep -q -- '-dev' "$ROOT/CONTRIBUTING.md" -check "CONTRIBUTING documents the release ritual (tag == VERSION)" 0 "" \ - grep -qi 'release' "$ROOT/CONTRIBUTING.md" -check "README documents the default (latest release) channel" 0 "" \ - grep -qF 'latest release' "$ROOT/README.md" -check "README documents the pinned channel" 0 "" \ - grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md" -check "README documents the dev channel" 0 "" \ - grep -qF 'BOX_REF=main' "$ROOT/README.md" +check "README documents the latest-release channel" 0 "" grep -qF 'latest release' "$ROOT/README.md" +check "README documents the pinned channel" 0 "" grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md" +check "README documents the dev channel" 0 "" grep -qF 'BOX_REF=main' "$ROOT/README.md" echo "---" echo "$PASS passed, $FAIL failed"