From 76926785f5587271bc79519b3513a3dd0739fcde Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 13:57:10 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(release):=20consume=20the=20shared=20c?= =?UTF-8?q?eremony=20at=200.1.0=20=E2=80=94=20callers,=20artifact=20hook,?= =?UTF-8?q?=20labels.conf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 250-line release.yml becomes the caller stub (version-source: package-json — the backend's first exercise); the prebuilt-asset build moves into .github/actions/release-artifact/ per ceremony#9's hook contract (setup-node moves into the hook — the shared workflow is node-free). labels.yml becomes its caller; the panel and cast's six scope rows extract into .github/labels.conf (data only — the parser refuses comment lines). ci.yml swaps the guard script steps for the pinned actions and adds changelog-armed (cast regains the guard reverted in cast#108) and docs-sync. The four shared scripts die; test/labels-reconcile.sh dies with the reconciler it sourced (machinery test files go whole — rig #13's rule). Co-Authored-By: Claude Fable 5 --- .github/actions/release-artifact/action.yml | 39 ++ .github/labels.conf | 7 + .github/scripts/changelog-monotonic.sh | 250 ---------- .github/scripts/drill-recorded.sh | 152 ------ .github/scripts/labels-reconcile.sh | 486 -------------------- .github/scripts/release-notes.sh | 31 -- .github/workflows/ci.yml | 88 ++-- .github/workflows/labels.yml | 81 +--- .github/workflows/release.yml | 260 +---------- test/labels-reconcile.sh | 416 ----------------- 10 files changed, 105 insertions(+), 1705 deletions(-) create mode 100644 .github/actions/release-artifact/action.yml create mode 100644 .github/labels.conf 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 test/labels-reconcile.sh diff --git a/.github/actions/release-artifact/action.yml b/.github/actions/release-artifact/action.yml new file mode 100644 index 0000000..3d6bb1b --- /dev/null +++ b/.github/actions/release-artifact/action.yml @@ -0,0 +1,39 @@ +name: release-artifact +description: >- + Build cast's prebuilt release asset — the ceremony's artifact hook + (ceremony#9's contract; ceremony#15 is this conversion). Where cast + differs from its siblings: box and rig are pure bash, so GitHub's source + tarball for the tag IS their package; cast's source tarball is not + runnable — it needs npm ci and tsc first. So the build happens ONCE, + here, and the asset is the runnable tree — bin/, dist/, production + node_modules/, package.json — staged as cast-/ inside + cast-.tgz. That name and layout are the install contract: the + installer's release channels download this exact asset and never run npm + or tsc (test/install-sh.test.ts pins it). The hook owns its own + toolchain (the shared workflow is node-free). +inputs: + version: + description: The release version the asset is named for + required: true +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: build once, stage the runnable tree, drop the tgz + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + # Deliberately no tests/checks here: ci.yml already gated the merge + # commit this release names, and cast's suite needs `age`, which + # the release runner does not install. The staged tree is exactly + # what an install needs to run. + npm ci + npm run build + npm prune --omit=dev + mkdir -p "$RUNNER_TEMP/stage/cast-$VERSION" + cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$VERSION/" + tar -C "$RUNNER_TEMP/stage" -czf "$RELEASE_ASSETS_DIR/cast-$VERSION.tgz" "cast-$VERSION" diff --git a/.github/labels.conf b/.github/labels.conf new file mode 100644 index 0000000..7e28a82 --- /dev/null +++ b/.github/labels.conf @@ -0,0 +1,7 @@ +panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl +scope:capture|C5DEF5|draft/capture — reading the live world into a manifest +scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify +scope:secrets|C5DEF5|secrets, age, the encrypted state repo +scope:fleet|C5DEF5|fleet/inventory/server — placement +scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language +scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh deleted file mode 100755 index 295b570..0000000 --- a/.github/scripts/changelog-monotonic.sh +++ /dev/null @@ -1,250 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# changelog-monotonic.sh [] [] — 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. -# -# Ported from box (heavy-duty/box#122, caught in review of box#118) for #133, -# because cast's release-notes.sh carries the exact awk shape that made box#118 -# dangerous. The failure it exists to catch leaves no trace either. An author -# adding an entry under '## Unreleased' REPLACES the line below it instead of -# inserting above it: -# -# -## 0.1.1 — 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.1.1 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.1.1's prose as if it were new. -# -# The ARMING rule (test/release.test.ts, "the changelog is armed for the next -# entry (rig#66)") is green on exactly that tree, correctly: it asks only -# whether the TOP section agrees with package.json's version, and deleting -# '## 0.1.1' 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 (#111) adds one and never removes one; nothing else in the -# documented flow (CONTRIBUTING.md, "Releasing") 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 — the arming rule -# owns that heading, keyed on package.json's version, and the ceremony -# legitimately consumes it. -# -# A file of its own, NOT a clause inside the arming assertions, 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 the -# arming assertions run against constructed in-memory changelog strings that -# are not git repos at all — folding a git-dependent assert into them would -# make every one of those cases either skip or lie. Same discipline as -# release-notes.sh: its own file so test/release.test.ts 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 the arming rule 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 box#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.1.1} minus head -# {0.1.1, 0.1.1} 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. -# -# cast is the MORE exposed of the two repos here (#133). release-notes.sh -# extracts with: -# -# /^## / { grab = ($2 == ver); next } -# grab { print } -# -# There is no `exit`. `grab` re-arms on every matching '## ' line, so two -# '## 0.1.1' headings make the published body ABSORB whatever sits between the -# copies — and an entry stranded there is dropped from the NEXT release's notes -# as well. (rig's extractor has `if (found) exit`, so it truncates instead of -# absorbing — same class, milder symptom. cast has the absorbing one.) -# -# This is the shape box#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 — the arming rule happy (the top section was still -# right), tests and `bash -n` clean — while release-notes.sh re-armed its grab -# on the second heading and folded post-cut prose into the shipped release -# body. Note the arming rule's "double re-arm" case counts duplicate -# '## Unreleased' headings only; duplicate VERSION headings, the ones that -# reach release-notes.sh, are this script's. -# -# 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 (#133, box#143). It asks nothing of the history, and gating it -# behind these conditions let a duplicate exit 0 on a message about deletion. -# -# That ordering mattered MORE here than anywhere. cast's release-notes.sh has -# no `exit`, so `grab` re-arms on every matching '## ' line and a duplicate -# makes the published body ABSORB whatever sits between the copies — the live -# extraction bug this guard exists for. The half with that bug behind it was -# the half with the most ways to silently not run. - -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 (#133) — -# 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 876e547..0000000 --- a/.github/scripts/drill-recorded.sh +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# drill-recorded.sh [] [] — assert that the version -# this tree claims to ship has a DRILL RECORD at /.md. -# -# CONTRIBUTING says a release carries the full real-hardware drill. Nothing -# checked that, so no release in this family ever carried one: the step lived -# only in a reviewer's memory, and a step that lives in a reviewer's memory is -# performed exactly as often as the reviewer remembers it. A bot finally -# blocked on it, which is the first time the omission was visible at all. So -# the gate moves into CI, where it is asserted on every release PR rather than -# recalled. -# -# ONE FILE PER VERSION — WHY THE PARSER IS GONE -# -# The first cut kept every record in one drill/RUNS.md and asked awk which -# section belonged to this version. That bought a heading grammar: em-dash -# field matching, an optional ' — DATE' tail, a whole-version comparison so -# 0.2.0-rc1 could not satisfy 0.2.0, a '(NF == 5 || $6 == dash)' tail -# constraint to stay in step with box's twin, and a non-blank body rule. -# -# All of it existed ONLY because records shared a file — and in review this -# repo shipped two defects out of that complexity: a `sed '/./,$!d'` -# extraction where `.` matches a space, so a heading followed by one tab -# satisfied the gate; and heading-grammar drift from box's stricter form. -# Two defects, on the one check whose entire job is to demand evidence. -# -# One file per version makes nearly all of it UNREPRESENTABLE. `0.2.0.md` and -# `0.2.0-rc1.md` are simply different files — the whole-version rule is the -# filesystem's, not a comparison anyone can get wrong. There is no heading to -# parse, so there is no grammar to drift from box's. What is left is a -# question a shell can ask directly: does the file exist, and does it say -# anything. -# -# The directory is plain `drills/`, NOT `.drills/`. Dot-prefixed directories -# are invisible to globs without `dotglob`, which is the exact blind spot that -# produced #118, #121 here and box#116 — a sweep that looks green because it -# never descended into the directory holding the thing it was meant to check. -# -# WHAT IT ASSERTS, AND WHAT IT DELIBERATELY DOES NOT -# -# It asserts a RECORD EXISTS — not that the drill passed. That is the whole -# design. A maintainer may ship on a failed or partial drill; what they may not -# do is ship on silence. Requiring a record makes a waiver a deliberate, -# reviewable commit (a file saying who waived it and what is untested) instead -# of the default outcome of forgetting. A guard that demanded a PASS would be -# argued with and eventually bypassed; one that demands EVIDENCE has nothing to -# argue about. -# -# PER-REPO, ON PURPOSE -# -# This reads cast's OWN drills/. It does not reach into box or rig to ask -# whether the family drilled. A cross-repo lookup has a failure mode this repo -# keeps refusing: when the fetch fails — no network, moved file, renamed repo, -# a token without read on the other repo — the honest answers are "unknown" and -# "blocked", but the shape such code actually takes degrades to "pass". Same -# class as the unreadable check rollup that read as "nothing is failing". -# -# There is also nothing to look up. The three repos' drills are INDEPENDENT -# (CONTRIBUTING.md, "Releasing") — run in any order, on any schedule, in -# separate sittings. What makes that safe is that every drill pins the SAME -# FIXED SET OF CANDIDATE REFS (RIG_REPO/RIG_REF at mint time), so each one -# exercises the combination that will ship rather than whatever main happens -# to be that afternoon. -# -# That pinning, not sequencing, is what dissolves the box<->rig recursion. box -# and rig ARE mutually recursive — rig builds the host that runs box, box's -# seed calls rig back to converge the guest — but candidate refs are static -# identifiers that exist as soon as the release branches do, long before any -# drill runs. A cycle at runtime becomes independent tests against one fixed -# pair, and no repo must ship before another can be drilled. The three -# releases are NOT published in a fixed sequence. -# -# Each repo also drills a DIFFERENT thing: box asserts the isolation contract, -# rig asserts convergence, cast asserts promotion. Three different exercises -# over a shared substrate — which is exactly why the records are per-repo. -# Each cites the shared run ID naming the pinned set, plus the other repos' -# SHAs, so three records still reassemble into one picture without any repo -# reading another's file. -# -# A file of its own, not a clause inlined in ci.yml, for the same reason as -# release-notes.sh and changelog-monotonic.sh: test/release.test.ts drives the -# REAL script against fixtures, so what the tests prove is what CI runs. - -drills="${1:-drills}" -version_file="${2:-package.json}" - -[ "$#" -le 2 ] || { echo "usage: drill-recorded.sh [] []" >&2; exit 2; } -[ -f "$version_file" ] || { echo "drill-recorded: no such file: $version_file" >&2; exit 1; } - -# cast's version lives in package.json (there is no VERSION file), so this -# reads JSON — with sed, not node. release-notes.sh takes the version as an -# ARGUMENT and so never had to; this one is invoked by CI with no arguments and -# has to find it itself. sed keeps the script runnable by `bash -n`, shellcheck -# and a bare shell alike, with no dependency on a toolchain being installed -# before the guard can speak. The first "version" key in package.json is the -# package's own by npm's schema; dependency entries are "": "" -# pairs and carry no "version" key to be confused with it. -ver="$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file" | head -1)" -[ -n "$ver" ] || { echo "drill-recorded: no \"version\" key in $version_file" >&2; exit 1; } - -# A -dev tree is main between releases. Nothing ships from it, so there is no -# claim to evidence — and demanding a record here would make every ordinary -# feature PR red until somebody drilled for a version that will never be cut. -# The gate is about the SHIP CLAIM, and `-dev` is the absence of one. -case "$ver" in - *-dev) - echo "drill-recorded: version $ver is a development tree — nothing ships from it, so there is nothing to assert." - exit 0 - ;; -esac - -# A bare version is a release ceremony tree: this is the tree whose merge IS -# the release, so this is where the evidence has to exist. -# -# The record is /.md, and it must contain at least one -# NON-WHITESPACE character. That second clause is the one surviving piece of -# the whitespace defect found in review (#138): a file of only spaces, tabs -# and newlines is a file, and `[ -f ]` is happy with it, but it is not a -# record — an evidence-free release for the price of an invisible character. -# `grep -q '[^[:space:]]'` asks the question the old `sed '/./,$!d'` only -# claimed to: `.` matches a space, a POSIX class does not. -record="$drills/$ver.md" - -if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then - { - echo "drill-recorded: version $ver is a release, but there is no drill record at $record." - echo - echo " A release PR's version must have a NON-EMPTY file named for it:" - echo - echo " $drills/$ver.md" - echo - echo " (A file that exists but holds only whitespace counts as no record." - echo " One file per version, so '$ver-rc1.md' is a different record and" - echo " does not satisfy '$ver', or the other way round.)" - echo - echo " To unblock, either:" - echo " * run the drill and record it — the legs (team, apply, idempotent" - echo " diff, smoke, inventory, emit-draft, fleet, destroy, read-only" - echo " guard), the numbers, and what failed; or" - echo " * record an explicit maintainer WAIVER for this version in that" - echo " file, saying who waived it and what is untested." - echo - echo " The waiver is allowed on purpose: this gate requires a RECORD, not a" - echo " passing result, so shipping without a drill stays possible — and" - echo " stays a deliberate, reviewable commit instead of an oversight." - } >&2 - exit 1 -fi - -echo "drill-recorded: $record carries a drill record for $ver ($(grep -c '' "$record") line(s))" diff --git a/.github/scripts/labels-reconcile.sh b/.github/scripts/labels-reconcile.sh deleted file mode 100644 index 3d40ba1..0000000 --- a/.github/scripts/labels-reconcile.sh +++ /dev/null @@ -1,486 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# labels-reconcile.sh — the automation LABELS.md promises: state labels are -# written by machinery, never by hand. Every run derives each open PR's -# state:* from GitHub's own facts (draft flag, requested reviewers, submitted -# reviews) and converges the labels to it, so a killed run or a hand-moved -# label heals on the next pass. Stale is judged from real activity — commits, -# comments, reviews — never from label churn, or the sweep would un-stale its -# own mark every tick. -# -# The verdict contract (CONTRIBUTING.md): reviews end in approve or -# request-changes. Some live bots are comment-only and post agreement as a -# COMMENTED review — a non-verdict this machine refuses to guess about (body -# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The -# judgment call belongs to the PR AUTHOR, who reads the round and escalates -# by requesting the human's review — an explicit request is a fact, and it is -# the one this machine trusts (see decide_state's top precedence). The -# machine auto-requests the human only in the no-judgment-needed case: three -# formal head-current approvals. Any approval that counts must be bound to -# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a -# stale approval must never promote unreviewed code to the human. -# -# DRY_RUN=1 narrates every mutation instead of performing it (how this script -# is rehearsed against the live repo). A workflow_dispatch run also bootstraps -# the taxonomy (label create --force) — that heal is dispatch-only; the cron -# sweep tolerates a missing label rather than recreating it. -# -# The state machine below is pure (globals in, state out) and covered by -# fixture tests in test/labels-reconcile.sh. - -HUMAN="${HUMAN_REVIEWER:-danmt}" -BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl) -STATES=(state:building state:bots-reviewing state:addressing state:needs-human) -BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested) -# Labels this machine used to own and no longer does. Cleared on sight so a -# retirement heals the board instead of stranding a label nothing recomputes. -RETIRED=(state:needs-rebase) -STALE_AFTER=$((48 * 3600)) - -log() { printf 'labels: %s\n' "$*"; } - -run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing - if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi -} - -# --------------------------------------------------------------------------- -# The state machine. Pure functions over four globals, set per PR: -# DRAFT true|false -# HEAD_SHA the PR's current head commit -# REQUESTED newline-separated logins with a review currently requested -# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews -# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) -# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup) -# --------------------------------------------------------------------------- - -requested() { grep -qxF "$1" <<<"$REQUESTED"; } - -checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE - # UNREADABLE is the absence of the key itself, which is what a failed fetch - # leaves behind — distinct from a present-but-empty rollup, which honestly - # means this PR has no checks. Collapsing the two let an API hiccup present - # as "nothing is failing", i.e. as mergeable-by-a-human: the same - # unknown-certified-as-green shape as the bug this machine exists to stop. - # The caller skips the PR entirely rather than labelling on facts it did not - # read; blocking on it instead would flap the whole board on one bad call. - # The rollup mixes two node types with two different closed enums: CheckRun - # carries `conclusion` (CheckConclusionState), StatusContext carries `state` - # (StatusState). Rather than list the outcomes that block — the version that - # shipped in this PR's first round listed four, and ERROR, CANCELLED and - # STALE fell through its `else` into SUCCESS — this lists the outcomes that - # DON'T, and treats everything else as blocking. - # - # That direction is the point. An outcome we do not recognise is one we - # cannot certify as mergeable, and certifying the unrecognised as green is - # the exact shape of #136. The cost of being wrong is symmetric in form and - # not in consequence: a false FAILURE parks the PR on the agent, who looks; - # a false SUCCESS invites a human to merge a tree that will not merge. - jq -r ' - if (has("statusCheckRollup") | not) then "UNREADABLE" else - - # NEUTRAL and SKIPPED satisfy branch protection — a skipped required check - # is not a failed one, and path-filtered jobs skip constantly here. - ["SUCCESS", "NEUTRAL", "SKIPPED"] as $passing - # "" covers a StatusContext still reported with no state at all. - | ["", "PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", "EXPECTED"] as $waiting - - # A re-run does not evict the run it superseded — the rollup keeps both. - # This PR proved it: its own tip carried a CANCELLED `scope` (15:19:39) - # beside the SUCCESS `scope` (15:19:45) that replaced it, same workflow. - # Once CANCELLED blocks, judging every entry would strand this very PR in - # needs-rebase forever, so collapse each context to its newest entry first. - # Key on workflow + name because a bare job name is only unique within its - # workflow. - # - # Dating a run is the subtle part, and getting it wrong restores the bug. - # A run still in flight has no completion, but `gh` does not omit the - # field: its Go struct marshals the zero time as "0001-01-01T00:00:00Z", - # which is a string, so `//` will not fall through it. Ordering on - # completion therefore sorted the LIVE re-run to the bottom and let `last` - # pick the very run it superseded — reporting the old SUCCESS while a - # replacement was still running, which is #136 again. - # - # So: date a run by when it BEGAN, discarding both spellings of absent - # (null, and the zero sentinel) and falling back only if it never recorded - # a beginning. NOT by the newest stamp of any kind: `max` compares the - # completion of a finished run against the start of a live one, which are - # different quantities and not an ordering on runs. A run cancelled by the - # concurrency group does not stop the instant its replacement starts — the - # runner has to wind down — so predecessor.completedAt > 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:capture|C5DEF5|draft/capture — reading the live world into a manifest -scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify -scope:secrets|C5DEF5|secrets, age, the encrypted state repo -scope:fleet|C5DEF5|fleet/inventory/server — placement -scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language -scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client -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 e4f3dce..0000000 --- a/.github/scripts/release-notes.sh +++ /dev/null @@ -1,31 +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 (#96; box#83's extraction). 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.test.ts -# 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.1.0 — 2026-07-18') is the bare version — -# compared WHOLE, so 0.1.0 can never match a 0.1.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 (#96)" >&2; exit 1; } -printf '%s\n' "$notes" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79bad59..99cf36f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,16 +11,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - # fetch-depth: 0, for the changelog-monotonic step below and only - # for it. That check is about a DIFF — which release headings the - # merge base had — so it needs the base branch's history present, - # and the default depth-1 checkout has none of it. An explicit - # `git fetch origin ` would be narrower, but it has to be - # right on both event types and on fork PRs, and getting it subtly - # wrong degrades to a SKIP (a guard that silently stops guarding — - # the exact failure this repo keeps refusing). Full history on a - # tree this size costs a second; the STRICT flag below turns any - # remaining skip red rather than green. + # changelog-monotonic compares HEAD against the merge base; a + # checkout that cannot resolve it is a hard failure in CI, not + # a skip (a guard that can quietly stop guarding is the failure + # shape this repo keeps refusing). fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -40,55 +34,35 @@ jobs: # tracked *.sh, so a new script cannot go unlinted quietly (#118). - name: shellcheck — every tracked shell script run: bash .github/scripts/shellcheck-all.sh - - name: labels state-machine tests - run: bash test/labels-reconcile.sh - # ...and no SHIPPED release heading was deleted or DUPLICATED (#133; - # box#122's guard, box#143's ordering fix). Its own step so that when it - # goes red the log names the invariant that broke — and a DIFFERENT - # invariant from the arming rule npm test carries: arming is a fact - # about this tree, monotonicity is a fact about this tree versus its - # merge base. STRICT=1 so a checkout that cannot reach the base ref - # fails here instead of skipping quietly forever. + # The release guards, doctrine in heavy-duty/ceremony's README + # (ceremony#15 is this conversion). Each guard's war story — why it + # exists, what it refuses — lives with its implementation upstream; + # the four pins below and the two workflow callers must always name + # the same ceremony tag. # - # NOT pull-request-only, and that is the #133 fix at the workflow level. - # The two halves have different vacuity: DELETION is vacuous on a push - # to main (the merge base IS HEAD), but DUPLICATION is vacuous on no - # tree at all, so gating the whole script on `pull_request` left a - # duplicate that reached main by any other route unasserted forever. - # - # The `|| github.ref_name` fallback is load-bearing, not defensive. On a - # push event `github.base_ref` is EMPTY, so the argument would collapse - # to a bare `origin/`, which does not resolve — and STRICT=1 correctly - # promotes that to a hard failure, turning every push to main red. With - # the fallback it resolves to the pushed branch, whose merge base with - # HEAD is HEAD or its parent: containment passes vacuously, exactly as - # the old `if` intended, while uniqueness now runs on every push. - - name: no shipped changelog heading was deleted or duplicated - env: - CHANGELOG_MONOTONIC_STRICT: "1" - run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}" - - # ...and a release carries its DRILL RECORD. CONTRIBUTING has always - # asked for the real-hardware drill; nothing asserted it, so it was - # performed exactly as often as a reviewer remembered to ask — which is - # never, across every release in the family, until a bot blocked on it. - # Here it is a fact about the tree instead of a fact about somebody's - # memory. - # - # No `if:` guard on the event or the label. The script keys off - # package.json itself: a `-dev` tree has no ship claim and passes - # trivially, a bare version is a release ceremony tree and must have a - # record. Gating this step on the `release` label instead would put the - # assert behind a hand-applied label — the guard would be absent from - # exactly the PR that mislabels itself, and unasserted PRs are how the - # drill went missing in the first place. - # - # It requires a RECORD, not a PASS: a maintainer waiver is legal, and is - # itself the content of drills/.md. Skipping stays possible and - # stays visible. - - name: a release version has a drill record - run: bash .github/scripts/drill-recorded.sh + # changelog-armed: the version-keyed arming rule (rig#66 is the + # incident; the unconditional form cast#108 reverted — this is its + # correct return). + - uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0 + with: + version-source: package-json + # changelog-monotonic: no shipped heading deleted or duplicated + # (#133; box#122's guard, box#143's ordering fix). Strict by default: + # an unresolvable base ref is red, never a quiet skip — hence the + # fetch-depth: 0 above. + - uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0 + # drill-recorded: a release version carries drills/.md + # (cast's drill meaning: drills/README.md). Vacuous on -dev trees; it + # requires a RECORD, not a pass — a maintainer waiver is legal, and + # is itself the content of the file. + - uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0 + with: + version-source: package-json + # docs-sync: the .ceremony/ doctrine mirror is byte-identical to the + # pin read from release.yml (ceremony#19) — a hand edit or a + # half-done pin bump goes red here. + - uses: heavy-duty/ceremony/actions/docs-sync@0.1.0 # The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at # this checkout, so CI proves the installer under review (the versioned diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 26587ab..c40289f 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,78 +1,19 @@ name: labels -# The automation LABELS.md promises. Two halves: -# scope — path-derived scope:* labels on PRs (actions/labeler) -# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh) -# -# pull_request_target, not pull_request: every PR here arrives from a fork, -# where pull_request (and pull_request_review) run with a READ-ONLY token and -# cannot label anything. _target is safe in this workflow because no PR code -# is ever checked out or executed — labeler reads changed paths via the API, -# and reconcile checks out the BASE branch only. Keep it that way. -# -# There is no pull_request_review_target, so a review landing cannot wake this -# workflow directly — and the */15 cron is advisory: GitHub deprioritises short -# intervals hard enough that a quiet repo goes hours between ticks. So the -# handoff wakes the sweep itself: the author sets state:needs-human when handing -# the PR to the maintainer (CONTRIBUTING step 6), and `labeled` fires this -# workflow, which confirms or corrects that optimistic write within seconds. The -# cron stays as the last resort, for the round an agent forgets to hand off. -# -# This cannot loop: the reconciler's own label writes use GITHUB_TOKEN, and -# GitHub does not create workflow runs from GITHUB_TOKEN-triggered events. Agent -# writes use a PAT and therefore do trigger — exactly the asymmetry wanted. +# The automation LABELS.md promises, now implemented upstream +# (heavy-duty/ceremony — ceremony#15 is this conversion): scope labeling and +# the state reconciler live in the reusable workflow this caller pins. Cast +# keeps the triggers and permissions (a called workflow cannot define them), +# its path map in .github/labeler.yml, and its panel + scope taxonomy in +# .github/labels.conf. on: - schedule: - - cron: "*/15 * * * *" - workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo + schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake + workflow_dispatch: # bootstraps missing labels on a fresh repo pull_request_target: - types: - [ - opened, - reopened, - ready_for_review, - converted_to_draft, - synchronize, - labeled, - unlabeled, - ] - + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] permissions: contents: read issues: write pull-requests: write - jobs: - scope: - # Not on labeled/unlabeled: those events change no paths, so labeler has - # nothing new to derive — and label churn is precisely what they are. - if: >- - github.event_name == 'pull_request_target' && - github.event.action != 'labeled' && - github.event.action != 'unlabeled' - runs-on: ubuntu-latest - concurrency: - group: labels-scope-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - - uses: actions/labeler@v5 - with: - # additive only — a hand-applied scope must survive the machine - sync-labels: false - - reconcile: - runs-on: ubuntu-latest - # ONE shared group: every reconcile sweeps every open PR, so cron and - # PR-event runs must serialize or two sweeps race the same PR's labels - # and both pass the request-the-human-once guard. GitHub keeps at most - # one queued run per group (older queued runs are superseded), which - # coalesces bursts instead of piling them up. - concurrency: - group: labels-reconcile - cancel-in-progress: false - steps: - - uses: actions/checkout@v4 # base branch only — never the PR's code - - name: reconcile state + stale - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - run: bash .github/scripts/labels-reconcile.sh + labels: + uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd87fcd..b727761 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,250 +1,24 @@ name: release -# The release publisher (#96; box#83's design) — two ways in, one act (#111; -# box#96's design): -# -# - Merging a `release`-labeled PR into main IS the release. The ceremony -# PR carries the bumped version and the stamped changelog; the -# maintainer's merge is the ship decision, and tagging after it is -# transcription — exactly where humans err silently and machines fail -# loudly. This path asserts four facts (each fail-loud, creating -# nothing), then tags the merge commit and publishes. -# - A bare X.Y.Z tag push (no 'v' prefix — box's and rig's tag scheme) -# stays as the documented manual fallback and backfill. -# -# Both paths converge on the SAME steps below — one notes extraction, one -# build, one asset name, one create — so they cannot drift. -# -# Where cast differs from its siblings: the release carries a PREBUILT -# asset. box and rig are pure bash, so GitHub's source tarball for the tag -# IS their package; cast's source tarball is not runnable — it needs npm ci -# and tsc first. So the build happens ONCE, here, and the asset is the -# runnable tree: bin/, dist/, production node_modules/, package.json. +# The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its +# README; ceremony#15 is this conversion). Both doors — merge and tag push — +# live in the reusable workflow this caller pins; cast keeps only what a +# called workflow cannot define (triggers and permissions) plus its one +# genuinely local piece: the prebuilt-asset build, now the artifact hook at +# .github/actions/release-artifact/, which both doors invoke. +# Triggers and permissions MUST live here (a called workflow cannot define them): on: - # ONE push key, both filters — YAML maps are last-key-wins, so a second - # sibling `push:` would silently REPLACE the first and kill a door - # (grok's round-2 catch: the tag fallback had stopped triggering). + # ONE push key, both filters — YAML maps are last-key-wins; a second sibling + # `push:` silently replaces the first and kills a door (rig's review catch). push: - # Every tag, not a shape filter (box's and rig's precedent): a tag that - # mismatches package.json — a habitual v0.1.0, a typo — must fail the - # assert LOUDLY below, not be silently skipped by a pattern that didn't - # match. - tags: ["**"] - # The merge-is-the-release path (#111) rides pushes to MAIN, not - # pull_request events: a pull_request run from a public FORK gets a - # READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling — - # and every ceremony PR this org merges is cross-repo from the bot - # fork; the tag create would 403 after green asserts. A push to main - # is an in-repo event with the full write token, whoever authored the - # PR. The steps split on the pushed ref. + tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly, + # never be skipped by a shape filter that didn't match branches: [main] - permissions: - contents: write # tag create via the API + gh release create + the bump push - # Two consumers (labels.yml precedent — a declared permissions: block - # zeroes every unspecified scope): the decide step's label read - # (commits//pulls) and the bump fallback's `gh pr create --label`. - pull-requests: write - # ...and the --label on that fallback PR rides the ISSUES API (labels.yml - # grants the same pair for the same reason). - issues: write - + contents: write # tag ref create + release create + the bump push + pull-requests: write # decide's label read; the bump-fallback `gh pr create` + issues: write # --label on that fallback PR rides the issues API jobs: release: - # Tag pushes and main pushes both enter (the asserts below are the - # filter); the steps split on the ref. The hand-set `release` label - # (LABELS.md: `release` is the operator's — automation never guesses - # intent) is read via the API off the merge commit's PR, inside the - # decide step — a push event carries no PR payload, and the PR itself - # lives on a fork (the trigger comment). - if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Either door: the pushed ref — a tag, or main's new head (the - # merge commit the maintainer shipped, which the tag created - # below will name). - ref: ${{ github.sha }} - # Depth 2: the pushed head's first parent must be resolvable for - # the decide step's all-zeros fallback (event.before on a - # branch-creation push). - fetch-depth: 2 - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - - name: "tag push: the tag must name package.json's version" - if: startsWith(github.ref, 'refs/tags/') - run: | - ver="$(node -p 'require("./package.json").version')" - if [ "$GITHUB_REF_NAME" != "$ver" ]; then - echo "tag '$GITHUB_REF_NAME' does not match package.json version '$ver' — creating nothing." >&2 - echo "A release is a PR, then a tag (#96): the release PR bumps package.json (and package-lock.json) and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2 - exit 1 - fi - echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" - # The decide step — the version asserts fused, because the `release` - # label carries TWO legitimate meanings (LABELS.md: "release flow and - # version/packaging work"): the ceremony PR that ships a version, and - # ordinary work ON the release machinery — the PR that added this very - # trigger included. The version tells them apart, in four states: - # -dev, unchanged → work under the label: green NOTICE - # no-op, not a red run per infra PR - # -dev, changed → still a dev tree, so still work — - # the post-release bump PR above all - # (bare -> -dev after every release): - # green NOTICE no-op - # bare, unchanged, released → work merged in the post-release - # window (ceremony landed, the -dev - # bump has not — and cast's ENTIRE - # pre-0.1.1 era, since 0.1.0 never - # carried -dev): green NOTICE no-op - # bare, unchanged, UNreleased→ the label says ship but this PR did - # not mint the version: refuse to - # guess. This is also the known - # first-release edge (#111): the 0.1.0 - # ceremony (#110) ships by manual tag, - # the fallback path; the automation - # applies from 0.1.1 on. - # bare, changed → the ceremony: proceed - - name: 'decide: ceremony, or release-flow work under the label?' - id: decide - if: github.ref == 'refs/heads/main' - env: - BASE_SHA: ${{ github.event.before }} - GH_TOKEN: ${{ github.token }} - run: | - # Versions read via node, never regex (the pkg_version discipline). - ver="$(node -p 'require("./package.json").version')" - # event.before is all-zeros on a branch-create push; the pushed - # head's first parent is main the instant before, either way. - case "$BASE_SHA" in *[!0]*) ;; *) BASE_SHA="$(git rev-parse "$GITHUB_SHA^1")" ;; esac - git fetch --depth=1 origin "$BASE_SHA" || true - git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json" - base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").version')" - case "$ver" in - *-dev) - if [ "$base" = "$ver" ]; then - echo "NOTICE: version '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish." - echo "ceremony=no" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "NOTICE: version changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish." - echo "ceremony=no" >> "$GITHUB_OUTPUT" - exit 0 ;; - esac - if [ "$base" = "$ver" ]; then - if gh release view "$ver" > /dev/null 2>&1; then - echo "NOTICE: version '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish." - echo "ceremony=no" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "version '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." >&2 - echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&2 - exit 1 - fi - # The version transitioned — now the LABEL, the operator's declared - # intent, read via the API because a push event carries no PR - # payload (and the PR lives on a fork — the trigger comment). No - # merged, release-labeled PR behind this commit = a transition - # nobody declared: refuse. - if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ - -q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then - echo "version transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#111), not a bare push — creating nothing." >&2 - exit 1 - fi - echo "ceremony=yes" >> "$GITHUB_OUTPUT" - echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV" - - name: release notes — the version's own CHANGELOG.md section - if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes' - # Assert 3 on the merge path, the same fact on the tag path: - # release-notes.sh fails loudly on a missing/empty section, which - # fails the release here — before anything is created. - run: | - bash .github/scripts/release-notes.sh "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md" - cat "$RUNNER_TEMP/notes.md" - - name: "merged release PR: nothing exists yet, then tag the merge commit" - if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes' - env: - GH_TOKEN: ${{ github.token }} - MERGE_SHA: ${{ github.sha }} - run: | - # Assert 4 — no tag and no release exist for this version. Re-runs - # of a completed ceremony REFUSE LOUDLY (red, creating nothing — - # the correct direction), and a manual race (an operator who - # tagged by hand between merge and here) fails the same way - # instead of double-publishing. - if git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION" > /dev/null; then - echo "tag '$RELEASE_VERSION' already exists — creating nothing (already released, or a manual tag won the race)." >&2 - exit 1 - fi - if gh release view "$RELEASE_VERSION" > /dev/null 2>&1; then - echo "release '$RELEASE_VERSION' already exists — creating nothing." >&2 - exit 1 - fi - # The act begins: tag the merge commit via the API. A tag created - # with GITHUB_TOKEN does not trigger other workflows, so the - # tag-push trigger above CANNOT fire on this tag and - # double-publish — which is also why the publish must happen in - # THIS job. - gh api "repos/$GITHUB_REPOSITORY/git/refs" \ - -f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA" - - name: build the prebuilt dist asset - if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes' - # Build ONCE, in CI — the whole point of the asset (#96): the - # installer's release channels never run npm or tsc. Deliberately no - # check/tests here: ci.yml already gated the merge commit this - # release names, and the test suite needs `age`, which this runner - # does not install. The staged tree is exactly what an install needs - # to run. - run: | - npm ci - npm run build - npm prune --omit=dev - mkdir -p "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION" - cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/" - tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION" - - name: create the release - if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes' - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "$RELEASE_VERSION" --verify-tag \ - --title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/notes.md" \ - "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" - # The post-release bump, folded into the release act (#111 followup — - # operator decision: a mechanical one-liner deserves no PR of its - # own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed - # straight to main with this job's token. A GITHUB_TOKEN push fires - # no workflows (anti-recursion), so the bump triggers neither the - # merge path nor a red run; should branch protection ever refuse the - # direct push, the step opens the bump PR itself and says so, loudly. - # Merge-door only (the decide gate): the manual tag path stays a - # fallback and does not rewrite main. - - name: bump main to the next -dev — the release re-arms main itself - if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes' - env: - GH_TOKEN: ${{ github.token }} - run: | - # next is computed from the RELEASE tree (the checkout), then - # applied to whatever main is by the time of the push — if main - # moved in the window, release+1 still lands on the newer head, - # which is the intended arithmetic either way. - next="$(node -p 'const v = require("./package.json").version.split("."); v[2] = String(Number(v[2]) + 1) + "-dev"; v.join(".")')" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch origin main - git checkout -B main origin/main - npm pkg set version="$next" - npm install --package-lock-only --ignore-scripts - git add package.json package-lock.json - git commit -m "chore: bump main to $next — a dev install must not impersonate $RELEASE_VERSION" - if ! git push origin main; then - echo "direct push refused (branch protection?) — opening the bump PR instead" >&2 - git checkout -b "chore/bump-$next" - git push origin "chore/bump-$next" - gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \ - --title "chore: bump main to $next" \ - --body "The post-release re-arm, opened by release.yml because the direct push was refused." \ - --label release - fi + uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0 + with: + version-source: package-json 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 ] From becf41940d0b135a0218faea566e64f0ebd4ab58 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 13:58:17 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20vendor=20the=20ceremony=20doctrine?= =?UTF-8?q?=20at=200.1.0=20=E2=80=94=20.ceremony/=20mirror=20+=20AGENTS.md?= =?UTF-8?q?=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs-sync --fix materializes .ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER, LABELS}.md byte-identical to heavy-duty/ceremony@0.1.0 (the pin docs-sync reads from release.yml — one pin governs machinery and doctrine), plus the machine-managed README marker and the root AGENTS.md stub. Root LABELS.md retires: a hand-maintained copy beside a machine-verified mirror is the drift the mirror exists to end (rig's precedent, rig#112). Co-Authored-By: Claude Fable 5 --- .ceremony/AGENTS.md | 49 ++++++++++++ .ceremony/BUILDER.md | 76 ++++++++++++++++++ .ceremony/LABELS.md | 98 +++++++++++++++++++++++ .ceremony/README.md | 14 ++++ .ceremony/REVIEWER.md | 70 ++++++++++++++++ .ceremony/TRIAGE.md | 95 ++++++++++++++++++++++ AGENTS.md | 7 ++ LABELS.md | 182 ------------------------------------------ 8 files changed, 409 insertions(+), 182 deletions(-) create mode 100644 .ceremony/AGENTS.md create mode 100644 .ceremony/BUILDER.md create mode 100644 .ceremony/LABELS.md create mode 100644 .ceremony/README.md create mode 100644 .ceremony/REVIEWER.md create mode 100644 .ceremony/TRIAGE.md create mode 100644 AGENTS.md delete mode 100644 LABELS.md diff --git a/.ceremony/AGENTS.md b/.ceremony/AGENTS.md new file mode 100644 index 0000000..b8af0fa --- /dev/null +++ b/.ceremony/AGENTS.md @@ -0,0 +1,49 @@ +# AGENTS.md — start here + +You are an agent working in a repo governed by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is +the router: find your role below, read its file, then act. The role files +sit beside this one — in ceremony itself at the repo root, in a governed +repo under `.ceremony/` (a machine-managed mirror; never edit those files +in place — they are changed in heavy-duty/ceremony, through its own flow). + +## Your role + +You were told your role when you were pointed at this repo ("you are a +reviewer here"). That one word is your whole onboarding: + +| you are the… | read | your job in one line | +|---|---|---| +| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through | +| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria | +| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human | + +Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels +are the shared state machine, and misusing one lies to every other agent on +the board. + +**Not told a role?** Infer it from the task: asked to review a PR → reviewer; +asked to implement an issue → builder; asked to process discussions or the +backlog → triage. Still ambiguous → ask before acting. Do not free-lance +across roles in one session: a builder reviewing its own PR, or a reviewer +pushing fixes, breaks the separation the pipeline depends on. + +## The pipeline you are part of + +``` +discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release + (anyone) (agent) (queue) (agent) (agents) (human) (ceremony) +``` + +Two rules bind every role: + +- **Only triage mints issues.** Found work? Open or extend a discussion. +- **Only humans merge.** Convergence ends at `state:needs-human`, never at + a merge button. + +## Repo specifics + +What is true only of *this* repo — the review panel roster, the `scope:*` +label set, what a drill means, code conventions — lives in the repo's own +`CONTRIBUTING.md`. Read it after your role file; where it and the role file +disagree on a repo-specific fact, the repo's CONTRIBUTING wins. diff --git a/.ceremony/BUILDER.md b/.ceremony/BUILDER.md new file mode 100644 index 0000000..a1680f1 --- /dev/null +++ b/.ceremony/BUILDER.md @@ -0,0 +1,76 @@ +# BUILDER.md — the builder role + +You turn one issue into one PR. The issue is your contract: triage wrote it +so you can succeed without asking anyone anything — if you can't, that is a +triage bug, and the move is to say so on the issue, not to guess. + +## Picking + +- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`, + never an `epic` (epics organize; their children are the work). +- Respect dependency order: inside an epic, take the earliest unblocked + unclaimed child. Between epics and strays, prefer the issue that unblocks + the most other work. +- **One issue at a time.** Finish or release your claim before taking + another. + +## Claiming + +- Assign yourself, swap `ready` → `claimed`, and comment that you are + starting. The claim is a promise of a draft PR soon — a claim with no PR + and no activity is what the staleness sweep reclaims. +- **Abandoning is fine; ghosting is not.** If you stop, say where you got to, + push the branch if it holds anything useful, unassign, and restore + `ready`. + +## Building + +- Branch per issue; open the PR **as a draft early**, `Closes #N` in the + body. Drafts are invisible to the reviewer panel on purpose — the draft + phase is yours. +- **The issue's acceptance criteria are your definition of done.** Reproduce + them as a checklist in the PR body and check them honestly as you go. If + one turns out to be wrong or unreachable, say so on the issue and get it + amended by triage — do not silently ship less than the issue says. +- Every behavior change adds one line to `CHANGELOG.md` under + `## Unreleased` — insert **above** the heading below it, never over it + (the monotonic guard's whole reason to exist). +- Follow the repo's conventions file and match the code you touch. Tests are + not optional: the issue's test plan is the floor, not the ceiling. +- **Scope discipline: the PR does the issue — whole, and nothing else.** + Adjacent problems you discover go to a **discussion** (or a comment on the + relevant issue), where triage will do its job. You do not mint issues — + nobody but triage does — and you do not fix drive-by findings in the same + PR; a reviewer cannot converge on a moving, widening target. + +## The review round + +(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the +panel roster and any repo-specific flow notes live in that repo's own +CONTRIBUTING; everything below is the shared flow.) + +1. Mark ready-for-review; request **the whole panel** (the roster is in the + repo's CONTRIBUTING). +2. **Wait for every verdict, then answer the round whole** — one reply + covering every point, then push the fixes, then re-request exactly the + reviewers who did not approve. Prefer verification over argument: when a + reviewer doubts behavior, add the test that settles it. +3. Never dismiss a review, never merge, never mark your own work as passed. + A blocking point you disagree with is answered with evidence or escalated + in the PR — a maintainer can be asked for a ruling; silence and + force-forward are not options. + +## Handoff + +When the round passes — every panel verdict approves the **current head**, +and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if +this is a release PR) — hand it to the human, in order: + +1. post the round summary (what changed per round, what was verified); +2. request the human's review; +3. set `state:needs-human` yourself. + +The label write is optimistic — the reconciler validates it, and takes it +back if the PR is not actually mergeable-right-now. Then stop: the PR is the +human's. Address what comes back (`state:addressing`) and re-hand-off the +same way. diff --git a/.ceremony/LABELS.md b/.ceremony/LABELS.md new file mode 100644 index 0000000..4be1fb3 --- /dev/null +++ b/.ceremony/LABELS.md @@ -0,0 +1,98 @@ +# Labels + +The taxonomy shared across the heavy-duty repos. Only the `scope:` set +differs per repo (each repo's `.github/labels.conf` names its actual +surfaces); everything else below is core and identical everywhere, created by +the labels workflow's bootstrap dispatch (issue #10). + +Two state machines share the taxonomy: the **PR machine** (proven in +box/rig/cast, reconciled by machinery) and the **issue flow** (the +triage → build queue, doctrine-enforced today, machinery to follow — +issue #18). One rule joins everything: **states are machine-owned, intent +labels are hand-set** — a hand-moved state label is a lie waiting to happen, +and the reconciler recomputes it from GitHub's own facts. + +## PR state — who is the ball with? (exactly one per open PR) + +| Label | Color | Waiting on | +|---|---|---| +| `state:building` | `#FBCA04` | the builder — PR is a draft | +| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) | +| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up | +| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head | + +`bots-reviewing` vs `addressing` is deliberate: staleness in the first means +*poke the reviewers*, in the second *the builder dropped the ball*. And +`state:needs-human` means exactly one thing — a human could merge this now — +so it requires zero blockers and head-current approvals; anything less and +the reconciler takes it back. The author sets it at handoff (the one +hand-set state); the `labeled` event fires the sweep that validates the +write within seconds. + +## PR blockers — what is in the way? (facts, as many as apply) + +| Label | Color | Means | +|---|---|---| +| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** | +| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide | +| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked | +| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) | + +States answer *whose ball*; blockers answer *what's in the way*. They are +separate axes because the single-label version kept lying — independent facts +projected onto one totally-ordered label meant one always won and the losers +vanished off the board (box's `state:needs-rebase`, retired: the reconciler +strips it on sight). + +## Issue flow — the work queue (exactly one per open, triaged, non-epic issue) + +| Label | Color | Means | Set by | +|---|---|---|---| +| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage | +| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage | +| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder | +| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it | +| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage | + +The invariant a board scan relies on: every open issue is either +`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` / +`blocked`. A `claimed` issue with no open PR and no activity is what the +staleness sweep will reclaim (issue #18); until that machinery exists, +[TRIAGE.md](TRIAGE.md) owns the hygiene by hand. + +## Cross-cutting (PRs and issues) + +| Label | Color | Meaning | +|---|---|---| +| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied | +| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) | +| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself | +| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it | + +## Scope — which surface? (PRs and issues, any number) + +All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The +set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via +actions/labeler, issues get theirs from triage. This repo's set: + +| Label | Covers | +|---|---| +| `scope:release-flow` | the reusable release workflow, decide, the doors | +| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded | +| `scope:labels` | the labels workflow, reconciler, this taxonomy | +| `scope:docs` | README doctrine, CONSUMERS.md, the role files | + +## Issue types + +`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry +their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label +on a PR would say the same thing twice and drift. + +## Maintenance + +The labels workflow (issue #10) recomputes PR state statelessly on PR events +plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on +manual dispatch. Issue-flow labels are doctrine-owned until #18 lands +machinery for them. Default GitHub labels (`duplicate`, `invalid`, +`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at +bootstrap — a `question` is a discussion, not an issue. diff --git a/.ceremony/README.md b/.ceremony/README.md new file mode 100644 index 0000000..34bc9c4 --- /dev/null +++ b/.ceremony/README.md @@ -0,0 +1,14 @@ +# .ceremony/ — the vendored doctrine mirror + +Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit +these files here: they are byte-identical copies of +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this +repository's pinned ref, and CI re-diffs them on every PR — a hand edit +goes red. They are changed in heavy-duty/ceremony, through its own flow, +and arrive here when the pin moves. + +The pin lives in `.github/workflows/release.yml` — the single +`uses: heavy-duty/ceremony/.github/workflows/release.yml@` line. One +pin governs machinery and doctrine alike: bump it and re-sync this mirror +in the same PR (`docs-sync --fix`, or let the red check on the bump PR say +what is stale). diff --git a/.ceremony/REVIEWER.md b/.ceremony/REVIEWER.md new file mode 100644 index 0000000..17ac893 --- /dev/null +++ b/.ceremony/REVIEWER.md @@ -0,0 +1,70 @@ +# REVIEWER.md — the reviewer role + +You are one voice on a panel. The panel's job is to converge — on an +approval the human can trust, or on a precise statement of what is wrong. +The machine reads only your **verdict**; humans read your reasons. + +## The verdict doctrine + +- **Every review ends in a verdict**: approve, or request changes. A + comment-only review is a non-verdict — it does not say whether the round + passed, the state machine treats it as not-approved, and the PR simply + stalls. If you have an opinion, you have a verdict; commenting without one + only wedges the flow. +- **The verdict carries blockingness only; the body carries the feedback.** + Non-blocking nits ride an **approval**, and the builder addresses them at + their discretion. Anything blocking — including a question whose answer + gates your approval — is **request changes**, saying exactly what + unblocks it. +- An approval you would not defend to the human is a defect. You are not + being asked to be agreeable; you are being asked to be right. + +## What you review against + +In order of authority: + +1. **The issue's acceptance criteria** — the PR's `Closes #N` names your + spec. Check every criterion; a PR that ships less than the issue says is + a request-changes even if the code is beautiful. +2. **The repo's load-bearing constraints** — the rules bought with + incidents (in ceremony itself: issue #1's constraint list; in a governed + repo: its own CONTRIBUTING plus ceremony's README). A change that + "simplifies away" a constraint gets request-changes with a link to the + incident that made the rule. +3. **The code itself** — correctness first, then tests (does the test plan's + floor exist? do the failure cases actually fail?), then conventions. + Changelog line present for behavior changes; comments carry why, not + what. + +**Verify over opine.** Run what can be run; construct the failing input; a +test settles what a comment thread can't. A review that says "I ran X and +saw Y" outranks one that says "this looks like it might". + +## What you do not do + +- **Re-litigate the spec.** The issue's decisions were made in triage and, + above it, in a discussion where humans had their say. If you think the + spec itself is wrong, say so with reasons — as a comment pointing at the + discussion, while still reviewing the implementation against the spec as + written. Spec changes go through triage, not through a review round. +- **Merge, or tell the builder to merge.** Convergence hands the PR to a + human; only humans merge. +- **Approve a moving target.** Your approval is of a specific head. If the + builder pushes after your approval, GitHub stales it — that is correct, + and the builder owes a re-request, not an assumption. + +## The round rhythm + +- Review the **whole PR at the current head** each round, not just the diff + since your last comments — the fix for someone else's point can break + yours. +- The builder answers rounds whole and re-requests you; until re-requested, + the ball is not yours (`state:addressing` is the builder working — pile-on + reviews mid-address just churn the target). +- Convergence = every panel verdict approves the current head, no + `blocker:*` standing. Then the builder hands off (`state:needs-human`) and + the panel's job is done. +- If a round exposes a disagreement **within the panel**, argue it in the PR + with evidence until one side concedes or the builder escalates to the + maintainer for a ruling. Two reviewers pulling a builder in opposite + directions without resolution is a panel failure, not a builder failure. diff --git a/.ceremony/TRIAGE.md b/.ceremony/TRIAGE.md new file mode 100644 index 0000000..3dcf655 --- /dev/null +++ b/.ceremony/TRIAGE.md @@ -0,0 +1,95 @@ +# TRIAGE.md — the triage role + +You are the only door issues come through. Humans and agents open +**discussions**; you decide what becomes work. The quality of every +downstream stage — a builder succeeding without asking, a reviewer having a +spec to review against — is set here, by you, and nowhere else. + +## Why this door exists + +Discussions are allowed to be ambiguous; issues are not. An issue is a work +order a builder must be able to execute **without asking anyone anything**. +Keeping one accountable role between the two is what keeps the bar from +eroding — the moment anyone can mint an issue, the backlog fills with +"improve X" entries nobody can build, and builders start guessing. Guessing +is the failure this whole flow exists to prevent. + +## Your inputs + +- **Every open discussion** in the repo you serve. +- **Stray issues** — anything filed directly, by anyone. Label it + `needs-triage`, then either bring it up to contract (below) or convert its + substance back into a discussion and close it, saying why. Do not shame the + filer; do route the work correctly. + +## For each discussion, converge on exactly one outcome + +1. **Answer.** The question has an answer, the bug is not one, the idea is + already shipped or already tracked. Reply with the answer (link the code, + the doc, the existing issue), mark answered. +2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the + repo, its history, or its docs. Ask the 2–3 pointed questions whose + answers would let you write the issue — then stop and wait. Do not mint an + issue that carries the ambiguity forward; that just moves your job onto + the builder. +3. **Escalate.** The blocker is a *decision* only a human owns — scope, + money, product direction, breaking a public contract. Say precisely what + the decision is, list the options with your recommendation, and name the + decider. The discussion is where humans decide; wait there. +4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link + where it belongs if anywhere, close. A refusal with reasons is a good + outcome; a zombie discussion is not. +5. **Accept.** It justifies work → mint the issue(s). The contract below is + the bar. + +## The issue contract + +Every issue you mint carries, in this order: + +- **A title that names the deliverable** — "lib/version.sh — one version + abstraction, two backends", never "improve version handling". +- **Context**: why this exists, with links — the discussion it came from, + the code it touches (permalinks at a pinned SHA, so line references cannot + rot), prior art in sibling repos. +- **The spec**: decisions made, not options listed. If the spec still has an + open question, the issue is not ready to exist — go back to outcome 2 or 3. +- **Tasks**: the steps, checkboxed, in order. +- **Acceptance criteria**: checkboxed, verifiable, and honest — these become + the builder's definition of done and the reviewer's review spec, verbatim. +- **Test plan**: what proves it, including the cases that must fail. +- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an + epic organizes it. +- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and + exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). + +The bar, stated once: **a competent builder who has read only this issue and +the repo can succeed.** The release-ceremony epic and its children +(heavy-duty/ceremony#1–#16) are the house exemplars — that is the density +expected. + +## Multi-issue work + +When an acceptance produces more than one issue, mint an **epic** (`epic` +label): the approach, the decisions, the constraint list, and a +dependency-ordered task list of child issues. Children reference the epic; +the epic's checklist is the progress view. Builders never pick the epic +itself. Keep the checklist current — a stale epic misleads every scan. + +## Backlog hygiene (yours until #18 automates it) + +- **Dedup before minting** — search issues *and* closed issues; extend or + reopen before duplicating. +- **Flip `blocked` → `ready`** when the named dependency lands. +- **Reclaim abandoned claims**: `claimed` + no open PR + no activity → + comment, unassign, restore `ready`. +- **Close obsolete issues** with the reason and a link to what obsoleted + them. Every label on every open issue stays true; the board is only worth + scanning if it does not lie. + +## What you never do + +- Write code, review code, or build the thing yourself. +- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)). +- Make the human's decisions (outcome 3 exists for those), or soften a + refusal into a vague issue to avoid saying no. +- Mint an issue to "discuss" something — that is a discussion. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e047dee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# AGENTS.md — start at .ceremony/ + +This repository is governed by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read +`.ceremony/AGENTS.md` first — it routes you to your role file, vendored +beside it. Repo specifics (the review panel roster, the scope labels, what +a drill means here, code conventions) live in CONTRIBUTING.md. diff --git a/LABELS.md b/LABELS.md deleted file mode 100644 index b589208..0000000 --- a/LABELS.md +++ /dev/null @@ -1,182 +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` | `#E99695` | a `release` PR whose version has **no drill record** at [`drills/.md`](drills/README.md) — the ceremony is correct but *unevidenced* | the record (or a recorded maintainer waiver) lands for that version | - -`blocker:drill-pending` is the only blocker that says nothing is *wrong* with -the code. The version bump, the stamp, the re-arm can all be perfect; what is -missing is the evidence that anyone ran the real-hardware drill against the -tree about to ship. It clears on a RECORD, not on a pass — a recorded waiver -clears it too, which is the point: skipping the drill stays possible and stays -visible. `.github/scripts/drill-recorded.sh` is what turns CI red meanwhile. - -**The label does not exist in the repo yet.** Creating a label needs push -access and the bot account gets a 403, so a maintainer account has to run the -`gh label create` line below (or the workflow's manual dispatch) once. Until -then the reconciler cannot apply it, and plain `blocked` stands in — coarser, -but it keeps an unevidenced release off the merge path, which is the job. - -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:capture` | `draft.ts`, `capture.ts` — reading the live world into a manifest | -| `scope:apply` | `apply.ts`, `diff.ts`, `destroy.ts` — reconciling the manifest onto Coolify | -| `scope:secrets` | `secrets.ts`, age handling, the encrypted state repo | -| `scope:fleet` | `fleet.ts`, `inventory.ts`, `server.ts` — placement and the server side | -| `scope:manifest` | `manifest.ts`, `resolve.ts`, `envtemplate.ts` — the manifest language itself | -| `scope:coolify-api` | `coolify.ts`, the OpenAPI reference — the client surface | - -## 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 -# needs a MAINTAINER account — the bot 403s on label creation, and until this -# runs the reconciler cannot apply it and `blocked` stands in. -gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record at drills/.md — ceremony correct, unevidenced" --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:capture" --color C5DEF5 --description "draft/capture — reading the live world into a manifest" --force -gh label create "scope:apply" --color C5DEF5 --description "apply/diff/destroy — reconciling onto Coolify" --force -gh label create "scope:secrets" --color C5DEF5 --description "secrets, age, the encrypted state repo" --force -gh label create "scope:fleet" --color C5DEF5 --description "fleet/inventory/server — placement" --force -gh label create "scope:manifest" --color C5DEF5 --description "manifest/resolve/envtemplate — the manifest language" --force -gh label create "scope:coolify-api" --color C5DEF5 --description "coolify.ts + OpenAPI reference — the client" --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 -``` From f223aa699eb35846774c302c276d48f3403c96a3 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 13:59:24 +0000 Subject: [PATCH 3/5] docs: CONTRIBUTING points at the mirror; docs swept for deleted-path pointers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING keeps only what is genuinely cast's — the panel roster, the local checks, the changelog house style, the prebuilt-asset contract and the promotion drill meaning; the review-round doctrine and label taxonomy now live in .ceremony/. drills/README.md and labeler.yml repoint at the pinned guard and the vendored LABELS.md (the sweep rule from ceremony#12, learned in rig#112). Co-Authored-By: Claude Fable 5 --- .github/labeler.yml | 4 +- CONTRIBUTING.md | 235 +++++++++++--------------------------------- drills/README.md | 4 +- 3 files changed, 59 insertions(+), 184 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 0daec5f..e76585d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,5 +1,5 @@ -# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope -# story (issues are hand-scoped at triage; paths only exist on PRs). Additive +# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's +# scope story (issues are hand-scoped at triage; paths only exist on PRs). Additive # only: sync-labels stays off in labels.yml, so a hand-applied scope survives. "scope:capture": - changed-files: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6e028a..71a1c85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,59 +1,32 @@ # 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 repo is governed by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents: +read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to +your role file (builder, reviewer, triage), vendored beside it, +byte-identical to ceremony at the pin named in +[`.github/workflows/release.yml`](.github/workflows/release.yml) and +guarded by the `docs-sync` step in CI. The review-round doctrine — drafts, +whole-round replies, verdicts, the handoff — lives there and in +[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is +genuinely cast's. -## The PR loop +## The PR loop, cast specifics 1. **Fork and branch.** Contributors work from forks; upstream branches are for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`). -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. - - 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**: `npm run check`, `npm run build`, and - `npm test` locally mirror what CI runs. -8. **Feature PRs land their changelog entry as part of the PR** (box's - convention): add it under `CHANGELOG.md`'s `## Unreleased` heading — - that section becomes the release notes verbatim when a release is cut. +2. **The review panel** (`.github/labels.conf`'s `panel=` line): + `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl` — + the required verdicts for a PR are the panel minus its author. The + maintainer (`danmt`) takes the last word and merges. +3. **Checks must be green**: `npm run check`, `npm run build`, and + `npm test` locally mirror what CI runs, plus the shellcheck sweep + (`npm run check:shell`). The release guards (`changelog-armed`, + `changelog-monotonic`, `drill-recorded`, `docs-sync`) run as ceremony's + pinned actions. +4. **Feature PRs land their changelog entry as part of the PR**: add it + under `CHANGELOG.md`'s `## Unreleased` heading — that section becomes + the release notes verbatim when a release is cut. ## Changelog entries @@ -89,144 +62,46 @@ Not an entry — that is a PR body: ## Releasing -A release is a PR, and merging it IS the release -([#111](https://github.com/heavy-duty/cast/issues/111); box#96's design, -on box#83's shape): +A release is a PR, and merging it is the release. The ceremony — the two +doors, the decide table, the stamps, the post-release re-arm — is +heavy-duty/ceremony's machinery, consumed by reference: +[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md) +is the doctrine, `.github/workflows/release.yml` here is the ≤20-line +caller pinning it (`version-source: package-json` — the version lives in +`package.json`, and the post-release bump keeps `package-lock.json` in +step), and the guards run in `ci.yml` from the same pin. Bare `X.Y.Z` +tags, no `v`. -1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s - `version` (and `package-lock.json`; `npm install --package-lock-only` - keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as - `## X.Y.Z — YYYY-MM-DD`. **Then re-arm: add a fresh, empty - `## Unreleased` immediately above the section you just stamped.** The - same PR, the same diff — stamping without re-arming leaves main with no - `## Unreleased`, and the next PR that was authored before the release - and merged after has its entry land *inside the shipped section*, which - git does cleanly, with no conflict to warn anyone - (heavy-duty/rig#66 — it happened there). `test/release.test.ts` keys - this to the version, and checks both halves of the stamp: - - while `package.json` is bare, the top section may be the stamp or the - re-armed `## Unreleased`, but a `## X.Y.Z` section for the version you - are shipping **must exist and extract non-empty** — a bump without a - stamp is red here rather than after the merge, in release.yml; - - the moment step 4's `-dev` bump lands, the top section must be - `## Unreleased` or CI is red. +What stays cast's beyond that pin: - The empty `## Unreleased` this step adds is deliberately tolerated: what - must extract non-empty is the section that SHIPS, not the top one. CI - green on it, same loop as any PR. -2. **Drill, and record it.** Before the PR can be handed over, run the full - real-hardware drill — two live Coolify instances, the whole A→B promotion: - team, apply, an idempotent diff, smoke, inventory, emit-draft, fleet, - destroy, and the read-only guard — and record it in a file named for the - version, one record per version: - - drills/X.Y.Z.md - - The name matches `package.json`'s `version` exactly, and the file must hold - at least one non-whitespace character. See - [drills/README.md](drills/README.md) for what a record contains. - - [.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh) - enforces this on every release PR (a `-dev` tree has no ship claim and - passes trivially). It is **not a thing a reviewer has to remember** — that - is precisely how every release in this family shipped without one until a - bot blocked on it. - - So the release flow is: **draft → ready → bot round → drill → - `state:needs-human` → maintainer merge (which IS the release).** - - **The three repos' drills are independent.** Run them in any order, on any - schedule, in separate sittings. They are not three phases of one script. - - What makes that safe is that every drill **pins the same fixed set of - candidate refs**, so each one exercises exactly the combination that will - ship rather than whatever `main` happens to be that afternoon. The run - drills **candidate refs, not released artifacts**: `RIG_REPO` and `RIG_REF` - are mint-time environment variables (default `heavy-duty/rig@main`), so a - run pins the exact commits under test. - - That pinning — **not sequencing** — is what dissolves the box↔rig - recursion. box and rig *are* mutually recursive: rig builds the host that - runs box, and box's seed calls rig back to converge the guest. But - candidate refs are static identifiers that exist as soon as the release - branches do, long before any drill runs, so a cycle at runtime becomes - independent tests against one fixed pair. No repo has to be released - before another can be drilled, and there is **no fixed order in which the - three releases must be published.** - - Each repo also drills a **different thing**: box asserts the isolation - contract (the VM trust boundary), rig asserts convergence (a machine - reaches its role, idempotently), cast asserts promotion (A→B reproduces, - and the diff is idempotent). Three different exercises sharing a - substrate — which is exactly why the records are per-repo. - - cast's legs are the **least coupled** of the three: two Coolify instances - can be stood up by hand, as the July drill did for instance B via a - parameterised compose file. Within a single drill you of course bring the - substrate up before probing it — a host before a guest before Coolify — - but that is how you run *a* drill, not an ordering rule *between repos*. - - Drilling the candidate **is** drilling the release. A release PR's diff is - the version file and `CHANGELOG.md` — nothing executable differs between - the tree that was drilled and the tree that ships, so the evidence carries - across the ceremony commit. - - Each repo records ITS OWN legs in its own `drills/X.Y.Z.md`, citing the - shared **run ID** that names the pinned set and the other two repos' commit - SHAs — which is what lets separate records be reassembled into one picture. - The guard still reads only this repo's files: cast never queries box's or - rig's drill records to decide whether cast may ship, because a cross-repo - lookup degrades to "pass" the moment it fails to resolve — the - unreadable-rollup bug wearing a different hat. - - 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 maintainer **waiver** is possible — but it must be RECORDED in - `drills/X.Y.Z.md` for that version, saying who waived it and what is - untested. The guard requires a *record*, not a passing result, so skipping - the drill stays possible and stays visible and deliberate. -3. **Merge. That's the ship decision — nothing else to do.** - [release.yml](.github/workflows/release.yml) fires on the merged, - `release`-labeled PR and asserts, in order, each fail-loud and creating - nothing: the merged version is non-`-dev`; the version *changed in this - PR* (the `-dev` transition is the interlock — a mislabeled ordinary PR - fails here); that version's changelog section extracts non-empty - ([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh)); - 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 — box's tag scheme), builds - the package once (`npm ci && npm run build && npm prune --omit=dev`), and - publishes the release with the runnable tree — `bin/`, `dist/`, - production `node_modules/`, `package.json` — attached as - `cast-X.Y.Z.tgz`. That asset is what the installer's release channels - download: the build happens once, in CI, never on an operator's machine. - *Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge - commit yourself — the same workflow runs the same asserts, build, and - publish from the tag. -4. **The release re-arms main itself**: the same workflow run bumps - `package.json` (and `package-lock.json`) 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). - Installs are versioned by the tree's `package.json` version, so a - `CAST_REF=main` install between releases must land as - `versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must - not impersonate the release it merely descends from. On the *manual* - tag path the bump stays yours: open the one-line PR after publishing. - This step re-arms the **version** only — the `## Unreleased` heading is - step 1's, in the ceremony PR's own diff, because no workflow ever writes - `CHANGELOG.md`. The two halves meet in `test/release.test.ts`: once this - bump makes the version `-dev`, a missing `## Unreleased` is CI-red. +- **The prebuilt asset** — + [`.github/actions/release-artifact/`](.github/actions/release-artifact/action.yml), + the artifact hook both doors invoke: the build happens ONCE, in CI, and + `cast-X.Y.Z.tgz` is the runnable tree (`bin/`, `dist/`, production + `node_modules/`, `package.json`). That asset is what the installer's + release channels download — never a source tarball, never an + operator-machine build. +- **The drill** — the real-hardware gate before the handoff of a release + PR. Cast's drill asserts **promotion**: two live Coolify instances, the + full A→B run (team, apply, an idempotent diff, smoke, inventory, + emit-draft, fleet, destroy, the read-only guard) — A→B reproduces, and + the diff is idempotent. The full meaning — the fixed candidate-ref + pinning that dissolves the box↔rig recursion, the per-version record + files, the waiver rule — is [`drills/README.md`](drills/README.md); the + `drill-recorded` guard enforces the record on every release tree. ## 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: +The taxonomy and state machine are +[`.ceremony/LABELS.md`](.ceremony/LABELS.md); cast's `scope:*` rows live in +`.github/labels.conf` (reconciled by the labels caller) and their path map +in `.github/labeler.yml`. 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.* | +| `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 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. | diff --git a/drills/README.md b/drills/README.md index 7345e8e..aa15237 100644 --- a/drills/README.md +++ b/drills/README.md @@ -5,8 +5,8 @@ Per-release evidence: **one file per version**, named `.md`, where `0.2.0.md`, `0.2.0-rc1` in `0.2.0-rc1.md`. A release PR's version must have its file here, holding at least one -non-whitespace character, before CI will let it merge -(`.github/scripts/drill-recorded.sh`, wired into ci.yml). +non-whitespace character, before CI will let it merge (the +`heavy-duty/ceremony/actions/drill-recorded` guard, pinned in ci.yml). ## One file per version, and why the parser went away From 53ef175662f7d4eb4cf453879e5650fa54237963 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 14:03:15 +0000 Subject: [PATCH 4/5] test: release.test.ts keeps only cast's own surfaces; changelog line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machinery halves — notes extraction, arming, monotonicity, the drill gate, the old workflow's shape pins — go where their subjects went: ceremony's test/ carries changelog.test.sh, changelog-armed.test.sh, changelog-monotonic.test.sh and drill-recorded.test.sh at the 0.1.0 pin (upstream equivalence verified per ceremony#13's rule), and the pinned actions enforce them in ci.yml. What stays, retargeted at what can still break HERE: the caller stubs' load-bearing shape (one push key, the package-json backend, CONSUMERS.md's same-tag rule across all six ceremony references), the artifact hook's install contract (asset name, staged layout, no tests, own toolchain), the drill doctrine in cast's own docs (independence + candidate-ref pinning, now against drills/README.md), and the installer's three channels, untouched. 1374 -> 418 lines; 722 tests green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 + test/release.test.ts | 1104 +++--------------------------------------- 2 files changed, 82 insertions(+), 1028 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b750e2..11df8fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ actually cutting it, and this file starts there. ## Unreleased +### Changed + +- The release flow and labels automation now run heavy-duty/ceremony's shared + machinery at 0.1.0; the prebuilt-asset build moves to the release-artifact + hook (heavy-duty/ceremony#15) + ## 0.2.0 — 2026-07-21 ### Added diff --git a/test/release.test.ts b/test/release.test.ts index 8811145..6fe8188 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -14,19 +14,20 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { tmp } from "./helpers/tmp.js"; -// The release flow (#96), proven offline. Two surfaces: the changelog-section -// extraction release.yml publishes (.github/scripts/release-notes.sh), and -// the installer's three channels — REAL install.sh runs against throwaway -// roots, with a stub curl on PATH standing in for GitHub and a POISONED npm -// proving the release channels never build. Nothing here touches the -// network. (`cast --version` itself is test/version-cli.test.ts's; the +// What remains CAST'S OWN of the release flow, after the ceremony moved +// upstream (heavy-duty/ceremony#15): the caller stubs' load-bearing shape, +// the artifact hook's install contract, the drill doctrine cast's docs must +// not lose, and the installer's three channels — REAL install.sh runs +// against throwaway roots, with a stub curl on PATH standing in for GitHub +// and a POISONED npm proving the release channels never build. Nothing here +// touches the network. The machinery the old halves of this file drove — +// notes extraction, arming, monotonicity, the drill gate — is tested +// upstream in ceremony's test/ and enforced here by the pinned actions in +// ci.yml. (`cast --version` itself is test/version-cli.test.ts's; the // versioned LAYOUT every channel lands in is test/install-sh.test.ts's — // here the layout is asserted only where a channel decides what fills it.) const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const NOTES = join(ROOT, ".github/scripts/release-notes.sh"); -const MONOTONIC = join(ROOT, ".github/scripts/changelog-monotonic.sh"); -const DRILL = join(ROOT, ".github/scripts/drill-recorded.sh"); function run( cmd: string, @@ -51,922 +52,93 @@ function run( }); } -// --- release-notes.sh — the extraction release.yml publishes ---------------- -// A fixture changelog carrying every boundary: an Unreleased section that -// must never leak into a release, adjacent versions, a version that prefixes -// another (0.7.0 vs 0.7.0-rc1), and a stamped-but-empty section that must -// refuse. +// --- the ceremony callers — the stubs' load-bearing shape ------------------- +// The workflow logic lives upstream at the pin; what can still break HERE is +// the caller: its triggers, its permissions grant, its backend input, and the +// pins themselves. Fail-closed, same discipline as the old workflow pins. -const FIXTURE = `# Changelog +describe("the ceremony callers", () => { + const RY = readFileSync(join(ROOT, ".github/workflows/release.yml"), "utf8"); -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 - -`; - -describe("release-notes.sh", () => { - const work = tmp("cast-relnotes-"); - const fix = join(work, "CHANGELOG.md"); - writeFileSync(fix, FIXTURE); - const notes = (ver: string, file = fix) => run("bash", [NOTES, ver, file]); - - it("prints the asked-for version's section, subheaders included", async () => { - const r = await notes("0.7.0"); - expect(r.code).toBe(0); - expect(r.output).toContain("The seven-oh entry"); - expect(r.output).toContain("### Added"); + it("ONE push key, both filters — a second sibling push: silently kills a door", () => { + // YAML maps are last-key-wins (grok's round-2 catch on the old + // workflow: the tag fallback had stopped triggering). + expect(RY.match(/^ {2}push:$/gm)).toHaveLength(1); + expect(RY).toContain('tags: ["**"]'); + expect(RY).toContain("branches: [main]"); + // The merge door rides push, never pull_request: a fork PR's token is + // read-only and permissions: cannot raise it (box#97). + expect(RY).not.toContain("pull_request:"); }); - it("stops at the next section and never prints a header", async () => { - const r = await notes("0.7.0"); - expect(r.output).not.toContain("The rc entry"); - expect(r.output).not.toContain("six-oh"); - expect(r.output).not.toMatch(/^## /m); + it("the version backend is package-json", () => { + expect(RY).toContain("version-source: package-json"); }); - it("never leaks Unreleased into a release body", async () => { - const r = await notes("0.7.0"); - expect(r.output).not.toContain("Not yet released"); - }); - - it("matches the version WHOLE — 0.7.0-rc1 is its own section", async () => { - const r = await notes("0.7.0-rc1"); - expect(r.code).toBe(0); - expect(r.output).toContain("The rc entry"); - expect(r.output).not.toContain("seven-oh"); - }); - - it("an adjacent older version still resolves", async () => { - const r = await notes("0.6.0"); - expect(r.code).toBe(0); - expect(r.output).toContain("six-oh"); - }); - - it("a missing version refuses by name, citing the ritual", async () => { - const r = await notes("9.9.9"); - expect(r.code).toBe(1); - expect(r.output).toContain("no section for '9.9.9'"); - expect(r.output).toContain("#96"); - }); - - it("a stamped-but-EMPTY section refuses", async () => { - const r = await notes("0.5.0"); - expect(r.code).toBe(1); - expect(r.output).toContain("no section for '0.5.0'"); - }); - - it("no version argument is a usage error", async () => { - const r = await run("bash", [NOTES]); - expect(r.code).toBe(2); - expect(r.output).toContain("usage:"); - }); - - it("a missing changelog refuses by path", async () => { - const r = await notes("1.0.0", join(work, "nope.md")); - expect(r.code).toBe(1); - expect(r.output).toContain("no such file"); - }); - - // The REAL changelog: the guard against header-format drift. The file has - // two legitimate states, and this test used to know only one (#108, found - // the day the first release PR turned CI red): BETWEEN releases the top - // section is `## Unreleased`; on a `release: X.Y.Z` tree — the ceremony's - // own PR stamps that heading into `## X.Y.Z — date` — and on main right - // after it, the top section IS the stamped release. Demanding the literal - // Unreleased (with an issue number inside it, rotting per release) made - // the release PR unshippable by construction, invisible to fork - // rehearsals (a tag push runs release.yml, never ci.yml). - // - // But keying the assert to the TOP section was only ever a stand-in for - // "the section release.yml will publish", and the re-arm (#113) breaks the - // stand-in: the ceremony PR now leaves a fresh, deliberately EMPTY - // `## Unreleased` on top of the section it just stamped, and an empty - // section is exactly what release-notes.sh refuses. Asserting the top - // section extracts would make the re-armed ceremony tree CI-red — #108's - // unshippability by another route, and the re-arm and the guard would - // contradict each other. So the assert retargets to the section that - // SHIPS, keyed on package.json the same way the arming rule below is - // (rig#67 made the identical move): - // - // version BARE — the tree is, or follows, the release of that version. - // `## ` is what release.yml extracts. It must - // exist and be non-empty. The top section is NOT - // constrained here; an empty re-armed Unreleased above - // it is correct. - // version -dev — nothing ships from this tree, and the top section is - // `## Unreleased`, legitimately empty between releases. - // Drift coverage retargets to the most recent STAMPED - // section, which release.yml did publish. Before the - // first release there is none, and that is not a fault. - it("the real CHANGELOG.md's SHIPPING section extracts (#113)", async () => { - const version = realVersion(); - const changelog = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8"); - const target = version.endsWith("-dev") ? firstStamped(changelog) : version; - if (!target) return; // greenfield -dev: nothing has shipped yet. - const r = await notes(target, join(ROOT, "CHANGELOG.md")); - expect(r.code).toBe(0); - expect(r.output.trim()).not.toBe(""); + it("every ceremony reference in .github/ names ONE tag", () => { + // CONSUMERS.md's same-tag rule: the two workflow callers and each guard + // step pin the same ceremony tag — one reference bumped alone leaves + // the repo split across ceremony versions. + const all = [ + "workflows/release.yml", + "workflows/labels.yml", + "workflows/ci.yml", + ] + .map((f) => readFileSync(join(ROOT, ".github", f), "utf8")) + .join("\n"); + const refs = [...all.matchAll(/heavy-duty\/ceremony\/[^@\s]+@(\S+)/g)].map( + (m) => m[1], + ); + expect(refs.length).toBeGreaterThanOrEqual(6); // 2 callers + 4 guards + expect(new Set(refs).size).toBe(1); }); }); -// --- the changelog is ARMED — the version says which state is legal -------- -// heavy-duty/rig#66. The section above proves the SHIPPING section extracts; -// it deliberately does not care what the top section is CALLED, and cannot: -// #108 relaxed exactly that, because the ceremony PR's own tree has a -// stamped `## X.Y.Z` on top and a literal-Unreleased demand made the -// release unshippable by construction. So nothing on main notices when -// `## Unreleased` is simply gone. -// -// That gap is not theoretical. A PR that writes its entry under -// `## Unreleased`, is authored before a release and merged after, has that -// entry land under whatever heading now occupies the position — the -// just-shipped `## X.Y.Z`. Git merges it CLEANLY: the stamped heading and -// the incoming entry never overlap textually, so the one signal an author -// relies on ("git told me to look") is absent precisely when the outcome is -// wrong. It happened in rig: #60's entry landed inside published `## 0.1.0`. -// -// The rule that separates the two states #108 collapsed, without demanding -// Unreleased unconditionally: **the package.json version keys it.** A bare -// `X.Y.Z` means the tree IS (or immediately follows) a release — the -// ceremony's stamped top section is legal there, and so is a re-armed -// Unreleased. A `-dev` version means main between releases, where a stamped -// top section can only mean the re-arm was skipped: `## Unreleased` is -// mandatory. Green through the whole ceremony; red on a disarmed `-dev` -// main, which is the state the guard exists to name. +// --- the artifact hook — the install contract the workflow used to carry ---- +// The asset name `cast-X.Y.Z.tgz` and the staged layout are what the +// installer's release channels download; they never run npm or tsc, so the +// build happens ONCE, in the hook, and the asset is the runnable tree. -/** package.json's version — the fact the whole rule is keyed on. */ -function realVersion(): string { - return JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) - .version as string; -} +describe("release-artifact hook", () => { + const HOOK = readFileSync( + join(ROOT, ".github/actions/release-artifact/action.yml"), + "utf8", + ); -/** The top `## ` section's token — `Unreleased`, or a stamped version. */ -function topSection(changelog: string): string { - const top = changelog.match(/^## (\S+)/m); - if (!top) throw new Error("changelog has no ## section at all"); - return top[1]; -} - -/** The newest stamped (non-Unreleased) section's token, or null if none. */ -function firstStamped(changelog: string): string | null { - for (const m of changelog.matchAll(/^## (\S+)/gm)) { - if (m[1] !== "Unreleased") return m[1]; - } - return null; -} - -/** Does `## ` appear as a section heading at all? */ -function hasSection(changelog: string, token: string): boolean { - return [...changelog.matchAll(/^## (\S+)/gm)].some((m) => m[1] === token); -} - -/** null = armed. A string = why this (version, changelog) pair is illegal. */ -function disarmedBecause(version: string, changelog: string): string | null { - const top = topSection(changelog); - - // Idempotence: re-arming twice leaves two `## Unreleased` headings, and - // the section awk extracts is then the EMPTY first one — armed by the - // heading test, unpublishable in fact. One heading, always. - const unreleased = [...changelog.matchAll(/^## Unreleased\s*$/gm)].length; - if (unreleased > 1) { - return `the changelog carries ${unreleased} '## Unreleased' headings — the re-arm ran twice. Entries split across them, and the section release-notes.sh extracts is the empty first one.`; - } - - if (version.endsWith("-dev")) { - return top === "Unreleased" - ? null - : `version ${version} is a dev tree, so the top section must be '## Unreleased' — found '## ${top}'. The release ceremony stamps Unreleased into the shipped version and must re-add an empty one (heavy-duty/rig#66); without it the next PR's entry lands inside ${top}'s published notes, with no merge conflict to warn anyone.`; - } - - if (top !== "Unreleased" && top !== version) { - return `version ${version} is bare, so the top section must be '## Unreleased' (re-armed) or the matching '## ${version}' (the ceremony tree) — found '## ${top}'.`; - } - - // A bare version is a SHIP claim: release.yml will extract `## ` - // and publish it. Every legal bare state has that section — the ceremony - // tree (stamped on top), the re-armed ceremony tree (stamped under an - // empty Unreleased), and main in the post-release window. Its absence is - // the half-ceremony: bumped, never stamped. That passed the heading rule - // alone and failed only AFTER merge, in release.yml's notes step — past - // the ship decision, leaving main with a minted, unreleased bare version - // the decide step then refuses on re-runs. Red here, one round earlier. - if (!hasSection(changelog, version)) { - return `version ${version} is bare — a ship claim — but there is no '## ${version}' section to publish. The ceremony bumped the version without stamping the changelog; release.yml's notes step would refuse AFTER the merge, past the ship decision.`; - } - - return null; -} - -describe("the changelog is armed for the next entry (rig#66)", () => { - const work = tmp("cast-arming-"); - const dated = (v: string) => `## ${v} — 2026-07-19`; - const body = "\n\n- **An entry** — prose.\n"; - const armed = `# Changelog\n\n## Unreleased${body}\n${dated("0.2.0")}${body}`; - const stamped = `# Changelog\n\n${dated("0.2.0")}${body}`; - // The tree CONTRIBUTING step 1 actually mandates: the stamped section with - // a fresh, EMPTY `## Unreleased` above it. The `armed` fixture gives - // Unreleased a body and so never exercises this one. - const rearmed = `# Changelog\n\n## Unreleased\n\n${dated("0.2.0")}${body}`; - - /** Run the REAL release-notes.sh against a fixture changelog. */ - const extract = (name: string, changelog: string, ver: string) => { - const file = join(work, `${name}.md`); - writeFileSync(file, changelog); - return run("bash", [NOTES, ver, file]); - }; - - it("the REAL tree is armed — package.json and CHANGELOG.md agree", () => { - const changelog = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8"); - expect(disarmedBecause(realVersion(), changelog)).toBeNull(); + it("builds the prod-only tree once and stages the runnable layout", () => { + expect(HOOK).toContain("npm ci"); + expect(HOOK).toContain("npm run build"); + expect(HOOK).toContain("npm prune --omit=dev"); + expect(HOOK).toContain("cp -R bin dist node_modules package.json"); }); - // The ceremony, walked end to end. Every state green — this is the #108 - // regression the guard must not re-introduce. - it("stays green through the ceremony: the release PR's own stamped tree", () => { - expect(disarmedBecause("0.2.0", stamped)).toBeNull(); + it("drops cast-.tgz into $RELEASE_ASSETS_DIR — the channels' exact download name", () => { + expect(HOOK).toContain('"$RELEASE_ASSETS_DIR/cast-$VERSION.tgz"'); + expect(HOOK).toContain('"$RUNNER_TEMP/stage/cast-$VERSION"'); }); - it("stays green through the ceremony: the ceremony PR that re-arms too", () => { - expect(disarmedBecause("0.2.0", armed)).toBeNull(); + it("runs no tests — ci.yml gated the merge commit already, and the suite needs age", () => { + expect(HOOK).not.toContain("npm test"); + expect(HOOK).not.toContain("npm run check"); }); - // The state the whole re-arm turns on, and the one this guard is most at - // risk of contradicting: CONTRIBUTING's mandated tree, whose top section is - // an EMPTY `## Unreleased`. Asserted end to end — the arming rule passes it - // AND the exact tool release.yml runs extracts the section that ships. An - // assert aimed at the TOP section here is #108's unshippability again - // (rig#67 retargeted the same assert for the same reason). - it("the MANDATED ceremony tree — empty Unreleased over the stamp — is green end to end", async () => { - expect(disarmedBecause("0.2.0", rearmed)).toBeNull(); - const r = await extract("rearmed", rearmed, "0.2.0"); - expect(r.code).toBe(0); - expect(r.output).toContain("An entry"); - // And the empty top section is untouchable by release.yml, as intended. - const top = await extract("rearmed", rearmed, "Unreleased"); - expect(top.code).toBe(1); - }); - - it("stays green through the ceremony: main in the post-release window", () => { - // Merged, tagged, published — the -dev bump has not landed yet. - expect(disarmedBecause("0.2.0", stamped)).toBeNull(); - }); - - it("stays green through the ceremony: main after the -dev bump, re-armed", () => { - expect(disarmedBecause("0.2.1-dev", armed)).toBeNull(); - }); - - // And red on the one state the extraction guard cannot see. - it("goes RED on a disarmed -dev main — the rig#66 failure, exactly", () => { - const why = disarmedBecause("0.2.1-dev", stamped); - expect(why).toContain("must be '## Unreleased'"); - expect(why).toContain("rig#66"); - }); - - it("goes RED when a bare version's stamp names a different release", () => { - // A hand-stamp that drifted from the bump it shipped with. - expect( - disarmedBecause("0.2.0", `# Changelog\n\n${dated("0.1.9")}${body}`), - ).toContain("the ceremony tree"); - }); - - // The half-ceremony: bumped, never stamped. Green under the heading rule - // alone — the top IS `## Unreleased`, re-armed and populated — and it fails - // only after the merge, in release.yml's notes step, past the ship - // decision. Asserted against the real tool so the post-merge failure this - // pre-empts is the actual one, not a paraphrase of it. - it("goes RED on the half-ceremony: bumped bare, changelog never stamped", async () => { - const half = `# Changelog\n\n## Unreleased${body}`; - const why = disarmedBecause("0.2.0", half); - expect(why).toContain("no '## 0.2.0' section"); - // Exactly what release.yml would have done instead, after the merge. - const r = await extract("half", half, "0.2.0"); - expect(r.code).toBe(1); - expect(r.output).toContain("no section for '0.2.0'"); - }); - - // Idempotence: re-arming an already-armed file is silently wrong, because - // the section awk extracts is then the empty first heading. - it("goes RED on a double re-arm — two Unreleased headings", () => { - const twice = `# Changelog\n\n## Unreleased\n\n## Unreleased${body}\n${dated("0.2.0")}${body}`; - expect(disarmedBecause("0.2.1-dev", twice)).toContain( - "2 '## Unreleased' headings", - ); - }); - - it("refuses a changelog with no sections at all rather than passing it", () => { - expect(() => disarmedBecause("0.2.1-dev", "# Changelog\n")).toThrow( - "no ## section", - ); + it("owns its toolchain — setup-node moved INTO the hook; the shared workflow is node-free", () => { + expect(HOOK).toContain("actions/setup-node"); }); }); -// --- no SHIPPED release heading was deleted (#133) -------------------------- -// The arming block above guards ONE heading — the top one, the one a PR is -// about to write under — and is keyed on a single tree. This guards the REST -// of the file, which no single tree can be asked about: "a heading -// disappeared" is not a property of a tree, it is a property of a DIFF. So the -// fixtures here are real throwaway git repos with a base branch and a PR -// branch, and the assertions drive the REAL script the CI step runs, the same -// way the block above drives the real release-notes.sh. -// -// Two halves, and they catch different shapes. CONTAINMENT catches a DELETED -// heading (base's set must be a subset of HEAD's). It cannot catch a -// DUPLICATED one — a duplicate is head-side SURPLUS, and base-minus-head is -// blind to extras on the head side — so UNIQUENESS on HEAD is asserted -// alongside it. The duplicate half matters more in cast than in box: -// release-notes.sh has no `exit`, so `grab` re-arms on every matching '## ' -// line and two copies of a version heading make the published body ABSORB -// whatever sits between them. +// --- the drill doctrine, in cast's own docs --------------------------------- +// The drill-recorded GATE is ceremony's (actions/drill-recorded, tested +// upstream); the MEANING is cast's. The three drills are INDEPENDENT — any +// order, any schedule, separate sittings — because each pins the same fixed +// candidate refs: static identifiers that exist as soon as the release +// branches do, which is what dissolves the box<->rig recursion. The docs +// must not re-acquire an ordering rule between repos. -describe("changelog-monotonic.sh — release headings are append-only (#133)", () => { - const dated = (v: string) => `## ${v} — 2026-07-19`; - const body = "\n\n- **An entry** — prose.\n"; - /** The base branch's changelog: two shipped releases under an Unreleased. */ - const BASE = `# Changelog\n\n## Unreleased\n\n${dated("0.1.1")}${body}\n${dated("0.1.0")}${body}`; - - const git = (repo: string, ...args: string[]) => - execFileSync("git", args, { cwd: repo, encoding: "utf8" }); - - /** - * A throwaway repo with `base` carrying BASE, checked out on a PR branch - * whose CHANGELOG.md is `head` (unchanged when omitted). - */ - function repoWith(head?: string): string { - const repo = tmp("cast-monotonic-"); - git(repo, "init", "-q"); - git(repo, "config", "user.email", "test@example.com"); - git(repo, "config", "user.name", "test"); - git(repo, "checkout", "-q", "-b", "base"); - writeFileSync(join(repo, "CHANGELOG.md"), BASE); - git(repo, "add", "CHANGELOG.md"); - git(repo, "commit", "-qm", "base"); - git(repo, "checkout", "-q", "-b", "pr"); - if (head !== undefined) { - writeFileSync(join(repo, "CHANGELOG.md"), head); - git(repo, "add", "CHANGELOG.md"); - git(repo, "commit", "-qm", "the PR"); - } - return repo; - } - - const check = ( - repo: string, - env: Record = {}, - base = "base", - ) => run("bash", [MONOTONIC, base], env, repo); - - it("a branch that touches nothing passes, and says how many headings it checked", async () => { - // A branch that touches nothing has HEAD as its own merge base, which is - // now the VACUOUS-containment path (#133), so the count this asserts moved - // to uniqueness's — which serves the stated intent better anyway: it says - // the parser read the file and found real headings in it, rather than that - // a comparison of the file against itself came out equal. - const r = await check(repoWith()); - expect(r.code).toBe(0); - expect(r.output).toContain( - "uniqueness on HEAD checked 2 release heading(s)", - ); - }); - - // --- the push-to-main shape: containment vacuous, uniqueness real -------- - // With the pull_request gate gone (#133), 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 these pin which one - // each event shape gets, including that they do not collapse into one. - - it("HEAD as its own base reports containment VACUOUS, not verified", async () => { - const r = await check(repoWith(), {}, "HEAD"); - expect(r.code).toBe(0); - expect(r.output).toContain("containment vacuous"); - }); - - it("...and names uniqueness as the half that actually ran", async () => { - const r = await check(repoWith(), {}, "HEAD"); - expect(r.output).toContain("uniqueness on HEAD checked"); - }); - - it("...and does NOT claim the headings were still present", async () => { - const r = await check(repoWith(), {}, "HEAD"); - expect(r.output).not.toContain("are still present"); - }); - - it("...while a REAL base still reports containment, naming the count", async () => { - // The PR shape. The two wordings must not collapse into one. - const good = BASE.replace( - "## Unreleased\n", - "## Unreleased\n\n### Fixed\n\n- **A new entry**\n", - ); - const r = await check(repoWith(good)); - expect(r.code).toBe(0); - expect(r.output).toContain("all 2 release heading(s)"); - expect(r.output).toContain("are still present"); - expect(r.output).not.toContain("containment vacuous"); - }); - - it("adding an entry the CORRECT way — above the heading, never over it — passes", async () => { - const good = BASE.replace( - "## Unreleased\n", - "## Unreleased\n\n### Fixed\n\n- **A new entry**\n", - ); - const r = await check(repoWith(good)); - expect(r.code).toBe(0); - }); - - it("goes RED when an entry REPLACED the shipped heading below it — the #133 failure, exactly", async () => { - // The one-line edit git merges cleanly and nothing else notices: the - // author typed over `## 0.1.1 — …` instead of inserting above it. - const clobbered = BASE.replace( - `${dated("0.1.1")}`, - "## Unreleased\n\n### Fixed\n\n- **An entry**", - ); - const r = await check(repoWith(clobbered)); - expect(r.code).toBe(1); - expect(r.output).toContain("DELETES release heading(s)"); - expect(r.output).toContain("## 0.1.1"); - expect(r.output).toContain("APPEND-ONLY"); - // 0.1.0, untouched, must not be accused. - expect(r.output).not.toContain(" ## 0.1.0"); - }); - - it("goes RED on a DUPLICATED version heading — the case containment cannot see", async () => { - // Head-side surplus: base {0.1.0, 0.1.1} minus head is still empty, so - // only the uniqueness half catches this. It is also the case the arming - // rule's "double re-arm" test does NOT cover — that one counts duplicate - // '## Unreleased' headings, not duplicate VERSION headings, and it is the - // version ones that reach release-notes.sh. - const twice = BASE.replace( - `${dated("0.1.1")}${body}`, - `${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`, - ); - const r = await check(repoWith(twice)); - expect(r.code).toBe(1); - expect(r.output).toContain("DUPLICATE release heading(s)"); - expect(r.output).toContain("## 0.1.1"); - expect(r.output).toContain("absorbs"); - }); - - it("the duplicate half survives the entry sitting BETWEEN the copies — the absorbing shape", async () => { - // What release-notes.sh would publish for 0.1.1 if this landed: its own - // prose, the stranded entry, AND the second copy's prose. Asserted with - // the real extractor, so the consequence is the actual one. - const absorbing = BASE.replace( - `${dated("0.1.1")}${body}`, - `${dated("0.1.1")}${body}\n- **A stranded entry**\n\n${dated("0.1.1")}${body}`, - ); - const repo = repoWith(absorbing); - const r = await check(repo); - expect(r.code).toBe(1); - const notes = await run("bash", [ - NOTES, - "0.1.1", - join(repo, "CHANGELOG.md"), - ]); - expect(notes.output).toContain("A stranded entry"); - }); - - it("'## Unreleased' is NOT in the guarded set — the ceremony legitimately consumes it", async () => { - // The release stamp: Unreleased becomes 0.2.0. That ADDS a version - // heading and removes none, and the Unreleased that disappeared is not a - // version heading at all. The arming rule owns that one. - const stamped = `${BASE.replace("## Unreleased\n", `${dated("0.2.0")}${body}\n`)}`; - const r = await check(repoWith(stamped)); - expect(r.code).toBe(0); - // And deleting Unreleased outright — a disarmed tree, red under the - // arming rule — is still not this guard's business. - const disarmed = BASE.replace("## Unreleased\n\n", ""); - expect((await check(repoWith(disarmed))).code).toBe(0); - }); - - /** - * A repo whose `base` has NO changelog at all — the PR INTRODUCES the file. - * The merge-base blob is absent, which is the degradation path that used to - * `exit 0` before uniqueness had run (#133, box#143). - */ - function repoIntroducing(head: string): string { - const repo = tmp("cast-monotonic-new-"); - git(repo, "init", "-q"); - git(repo, "config", "user.email", "test@example.com"); - git(repo, "config", "user.name", "test"); - git(repo, "checkout", "-q", "-b", "base"); - writeFileSync(join(repo, "README.md"), "# hi\n"); - git(repo, "add", "README.md"); - git(repo, "commit", "-qm", "base"); - git(repo, "checkout", "-q", "-b", "pr"); - writeFileSync(join(repo, "CHANGELOG.md"), head); - git(repo, "add", "CHANGELOG.md"); - git(repo, "commit", "-qm", "add the changelog"); - return repo; - } - - it("a changelog absent at the merge base is nothing-to-have-deleted, not a failure", async () => { - const r = await check(repoIntroducing(BASE)); - expect(r.code).toBe(0); - expect(r.output).toContain("does not exist at the merge base"); - }); - - // --- #133: 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 this fix the duplicate check sat DOWNSTREAM of the - // base-ref, merge-base and base-blob conditions, so each of the degradation - // paths below exited 0 on a tree carrying 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 above was green before and after). - // - // The inversion mattered most here: cast's release-notes.sh re-arms `grab` - // on every '## ' line, so duplication is the half with a LIVE extraction bug - // behind it — and it was the half with the most ways to silently not run. - - it("a duplicate introduced where the base had NO changelog is caught (#133)", async () => { - const dup = `# Changelog\n\n## Unreleased\n\n${dated("0.1.1")}${body}\n- **A stranded entry**\n\n${dated("0.1.1")}${body}`; - const r = await check(repoIntroducing(dup)); - expect(r.code).toBe(1); - expect(r.output).toContain("DUPLICATE release heading(s)"); - expect(r.output).toContain("## 0.1.1"); - // The old message must NOT be what this tree gets. - expect(r.output).not.toContain("nothing could have been deleted"); - }); - - it("...and STRICT does not change that — it was never a skip", async () => { - const dup = `# Changelog\n\n## Unreleased\n\n${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`; - const r = await check(repoIntroducing(dup), { - CHANGELOG_MONOTONIC_STRICT: "1", - }); - expect(r.code).toBe(1); - expect(r.output).toContain("DUPLICATE release heading(s)"); - }); - - it("...while a CLEAN introduced changelog still passes, SAYING uniqueness ran", async () => { - const r = await check(repoIntroducing(BASE)); - expect(r.code).toBe(0); - expect(r.output).toContain("nothing could have been deleted"); - expect(r.output).toContain("uniqueness on HEAD already passed"); - }); - - it("a duplicate OUTSIDE a git work tree is caught (#133)", async () => { - // No git at all — a tarball, an unpacked release. Uniqueness still has - // everything it needs; only containment does not. - const dir = tmp("cast-monotonic-nogit-"); - writeFileSync( - join(dir, "CHANGELOG.md"), - `# Changelog\n\n${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`, - ); - const r = await run("bash", [MONOTONIC, "base"], {}, dir); - expect(r.code).toBe(1); - expect(r.output).toContain("DUPLICATE release heading(s)"); - }); - - it("a duplicate is caught even when the base ref will not resolve (#133)", async () => { - const twice = BASE.replace( - `${dated("0.1.1")}${body}`, - `${dated("0.1.1")}${body}\n${dated("0.1.1")}${body}`, - ); - const r = await check(repoWith(twice), {}, "origin/no-such-branch"); - expect(r.code).toBe(1); - expect(r.output).toContain("DUPLICATE release heading(s)"); - expect(r.output).not.toContain("containment SKIPPED"); - }); - - it("a missing changelog refuses by path — never a silent pass", async () => { - const r = await run("bash", [MONOTONIC, "base", "nope.md"], {}, repoWith()); - expect(r.code).toBe(1); - expect(r.output).toContain("no such file"); - }); - - // The fail-closed switch, both directions. A guard that can quietly stop - // guarding is the failure shape this whole family of checks refuses, so the - // degradation that is sensible locally must be RED in CI. - it("an unresolvable base ref SKIPS CONTAINMENT locally — not everything (#133)", async () => { - const r = await check(repoWith(), {}, "origin/no-such-branch"); - expect(r.code).toBe(0); - expect(r.output).toContain("containment SKIPPED"); - // ...and it must not claim nothing was checked: uniqueness already ran. - expect(r.output).toContain("already ran and passed"); - expect(r.output).not.toContain("Nothing was checked"); - }); - - it("...and the SAME condition is a hard FAILURE under STRICT=1, naming fetch-depth", async () => { - const r = await check( - repoWith(), - { CHANGELOG_MONOTONIC_STRICT: "1" }, - "origin/no-such-branch", - ); - expect(r.code).toBe(1); - expect(r.output).toContain("CHANGELOG_MONOTONIC_STRICT=1"); - expect(r.output).toContain("fetch-depth: 0"); - expect(r.output).not.toContain("SKIPPED"); - // Even here the message must scope itself to containment (#133). - expect(r.output).toContain("it is containment that cannot run"); - }); - - // The wiring, pinned the same way release.yml's is — the script existing is - // no use if CI stops running it, and every clause here is load-bearing. - it("ci.yml runs it on EVERY event, STRICT, against the base ref, with full history", () => { - const CI = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8"); - expect(CI).toContain(".github/scripts/changelog-monotonic.sh"); - // #133: NOT pull-request-only. Deletion is vacuous on a push to main, but - // duplication is vacuous on no tree — gating the whole script left a - // duplicate that reached main by any other route unasserted forever. - // - // Scoped to the step's OWN block, deliberately. As a file-wide negative it - // would forbid any FUTURE step in ci.yml from being pull_request-gated and - // would fail citing #133 when one legitimately is — #133 constrains this - // step, not the file. The companion assert below keeps the extractor from - // silently matching nothing and turning the negative into a tautology. - // Bounded by the next STEP *or* the next JOB. The job boundary is not - // optional: the monotonic step is the LAST step of its job, so splitting on - // steps alone runs the block into the job below and swallows that job's - // level `if:` — reintroducing the bug this scoping fixed, moved from "any - // step in the file" to "this step plus the head of the next job". - const ciLines = CI.split("\n"); - const monoStart = ciLines.findIndex((l) => - /^ {6}- name: no shipped changelog heading/.test(l), - ); - const after = ciLines.slice(monoStart + 1); - const monoEnd = after.findIndex( - (l) => /^ {6}- /.test(l) || /^ {2}\S/.test(l), - ); - const monoBlock = - monoStart < 0 - ? undefined - : [ - ciLines[monoStart], - ...after.slice(0, monoEnd < 0 ? after.length : monoEnd), - ].join("\n"); - expect(monoBlock).toBeDefined(); - expect(monoBlock).toContain("changelog-monotonic.sh"); - // Anchored: an `if:` inside a `run:` line is not a step condition. - expect(monoBlock).not.toMatch(/^ {8}if:/m); - // ...and dropping that gate is only safe WITH the fallback: on a push - // `github.base_ref` is empty, a bare `origin/` does not resolve, and - // STRICT promotes that to a hard failure on every push to main. - expect(CI).toContain('"origin/${{ github.base_ref || github.ref_name }}"'); - expect(CI).toContain("CHANGELOG_MONOTONIC_STRICT"); - // ...which is only reachable because the checkout has the base history. - expect(CI).toContain("fetch-depth: 0"); - }); -}); - -// --- a release carries its DRILL RECORD ------------------------------------- -// CONTRIBUTING has always asked for the full real-hardware drill on a release -// PR; nothing asserted it, so no release in the family ever carried one. The -// gate moves the requirement out of a reviewer's memory and into the tree. -// -// What it asserts is that a RECORD EXISTS, never that the drill passed — a -// maintainer waiver is legal and is itself the content of drills/.md. -// That is the point: skipping stays possible and stays a deliberate, -// reviewable commit. So the cases below are all about presence and emptiness; -// none of them inspect a result. -// -// ONE FILE PER VERSION. The earlier design kept every record in one -// drill/RUNS.md, so the guard parsed headings — and the heading grammar was -// where both of this feature's review defects lived (a whitespace bypass, and -// drift from box's stricter form). `0.2.0.md` and `0.2.0-rc1.md` are simply -// different files, so the whole-version rule is now the filesystem's rather -// than a comparison that can be got wrong, and the tests that existed only to -// pin heading syntax (including the stray-tail case) are gone with it. -// -// Every fixture carries its OWN package.json and its OWN drills dir inside a -// temp tree. Pointing the guard at the repo's real package.json would make -// these cases change meaning on every version bump — green or red depending -// on where the ceremony happens to be standing, the opposite of a fixture. - -describe("drill-recorded.sh — a release version has a drill record", () => { - const work = tmp("cast-drill-"); - - /** - * A fixture tree: its own package.json, plus an optional `drills/` dir. - * - * @param records omit for NO drills dir at all; pass a map of - * `.md` -> contents (possibly empty) to create the dir. - */ - function treeWith( - name: string, - version: string, - records?: Record, - ): string { - const dir = join(work, name); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, "package.json"), - `${JSON.stringify({ name: "cast", version }, null, 2)}\n`, - ); - if (records !== undefined) { - mkdirSync(join(dir, "drills"), { recursive: true }); - for (const [file, body] of Object.entries(records)) { - writeFileSync(join(dir, "drills", file), body); - } - } - return dir; - } - - const check = (dir: string) => - run("bash", [DRILL, "drills", "package.json"], {}, dir); - - const legs = - "# Release drill\n\nInstances: A and B, live. All legs pass: team, apply,\ndiff, smoke, inventory, emit-draft, fleet, destroy, read-only guard.\n"; - - it("a -dev tree passes with no drills dir at all — nothing ships from it", async () => { - const r = await check(treeWith("dev", "0.1.2-dev")); - expect(r.code).toBe(0); - expect(r.output).toContain("development tree"); - }); - - it("a bare version with a matching, non-empty record passes", async () => { - const r = await check(treeWith("ok", "0.2.0", { "0.2.0.md": legs })); - expect(r.code).toBe(0); - expect(r.output).toContain("0.2.0"); - }); - - it("a bare version with NO drills dir fails, naming the version", async () => { - const r = await check(treeWith("nodir", "0.2.0")); - expect(r.code).toBe(1); - expect(r.output).toContain("version 0.2.0 is a release"); - expect(r.output).toContain("drills/0.2.0.md"); - }); - - it("a drills dir with no file for THIS version fails", async () => { - const r = await check( - treeWith("othersonly", "0.2.0", { "0.1.0.md": legs, "README.md": legs }), - ); - expect(r.code).toBe(1); - expect(r.output).toContain("drills/0.2.0.md"); - }); - - // A file that exists but says nothing is ceremony without evidence — the - // exact shape a hurried release produces, and the one an `[ -f ]` check - // would wave through. release-notes.sh refuses an empty section likewise. - it("a present-but-EMPTY record fails — a filename is not a record", async () => { - const r = await check(treeWith("empty", "0.2.0", { "0.2.0.md": "" })); - expect(r.code).toBe(1); - expect(r.output).toContain("drills/0.2.0.md"); - }); - - // ...and whitespace is not evidence either. This is the one surviving piece - // of the heading-parser era: the first cut extracted with `sed '/./,$!d'`, - // where `.` matches a space, so a heading followed by one tab passed the - // gate — while the comment above the extractor claimed the opposite. An - // evidence-free release for the price of an invisible character, on the one - // check whose whole job is to demand evidence. Found independently by all - // three reviewers on #138. The file layout changed; this rule did not. - it("a record of only spaces, tabs and newlines fails (#138)", async () => { - const r = await check( - treeWith("blank", "0.2.0", { "0.2.0.md": " \n\t\n \n" }), - ); - expect(r.code).toBe(1); - expect(r.output).toContain("drills/0.2.0.md"); - }); - - // The version is matched WHOLE, both directions — but now by the FILESYSTEM - // rather than by a comparison. A release candidate's drill is not the - // release's drill: different tree, different build, and under the old - // heading parser a prefix match would have silently accepted it. - it("0.2.0-rc1's record does NOT satisfy 0.2.0", async () => { - const r = await check( - treeWith("rc-for-bare", "0.2.0", { "0.2.0-rc1.md": legs }), - ); - expect(r.code).toBe(1); - expect(r.output).toContain("drills/0.2.0.md"); - }); - - it("...and 0.2.0's record does NOT satisfy 0.2.0-rc1", async () => { - const r = await check( - treeWith("bare-for-rc", "0.2.0-rc1", { "0.2.0.md": legs }), - ); - expect(r.code).toBe(1); - expect(r.output).toContain("drills/0.2.0-rc1.md"); - }); - - it("...while each still matches its own record", async () => { - const both = { "0.2.0.md": legs, "0.2.0-rc1.md": legs }; - expect((await check(treeWith("both-a", "0.2.0", both))).code).toBe(0); - expect((await check(treeWith("both-b", "0.2.0-rc1", both))).code).toBe(0); - }); - - // The message is the whole user interface of a blocking guard. A failure - // that names the problem without naming the way out gets bypassed rather - // than satisfied — including the waiver, which must be visibly ALLOWED or - // somebody will route around the gate instead of recording one. - it("the failure names the unblock: run the drill, or record a waiver", async () => { - const r = await check(treeWith("unblock", "0.2.0", {})); - expect(r.code).toBe(1); - expect(r.output).toContain("drills/0.2.0.md"); - expect(r.output).toContain("run the drill and record it"); - expect(r.output).toContain("WAIVER"); - expect(r.output).toContain("RECORD, not a"); - }); - - it("a missing version file refuses by path", async () => { - const r = await run("bash", [DRILL, "drills", "nope.json"], {}, work); - expect(r.code).toBe(1); - expect(r.output).toContain("no such file"); - }); - - it("too many arguments is a usage error", async () => { - const r = await run("bash", [DRILL, "a", "b", "c"], {}, work); - expect(r.code).toBe(2); - expect(r.output).toContain("usage:"); - }); - - // The REAL tree, run with the REAL defaults. The property is that the - // guard's VERDICT IS CORRECT FOR THIS TREE — not that it always passes. - // - // The previous wording here claimed "whatever state the ceremony is in, this - // repo must satisfy its own gate", and that is exactly wrong: a ceremony tree - // CANNOT satisfy the gate until a human has run the drill and written the - // record, which is the entire point of the gate. Demanding exit 0 made this - // suite un-greenable on every release branch before its drill, and surfaced - // as a `build` failure rather than as the gate doing its job — the same - // misattribution shape as box#146. Caught when box#148 went red for the - // wrong-looking reason. - it("the guard's verdict on the real tree matches the tree's own state", async () => { - const version = realVersion(); - const r = await run("bash", [DRILL], {}, ROOT); - - if (version.endsWith("-dev")) { - // Vacuous: nothing ships from a development tree. - expect(r.code).toBe(0); - expect(r.output).toContain("development tree"); - return; - } - - // A ceremony tree: green only once its record exists. - const recorded = - existsSync(join(ROOT, "drills", `${version}.md`)) && - readFileSync(join(ROOT, "drills", `${version}.md`), "utf8").trim() !== ""; - expect(r.code).toBe(recorded ? 0 : 1); - if (!recorded) expect(r.output).toContain("no drill record"); - }); - - it("drills/README.md documents the naming rule and the waiver", () => { +describe("drills/README.md — the independent, ref-pinned drills", () => { + it("documents the record files and the INDEPENDENT, ref-pinned drills", () => { const doc = readFileSync(join(ROOT, "drills/README.md"), "utf8"); - expect(doc).toContain(".md"); - // The placeholder example version can never collide with a real release: - // under any scheme, a realistic version in the docs is a real record. - expect(doc).toContain("drills/9.9.9.md"); - // Per-repo by construction: cast records cast's legs and never reads - // another repo's record to decide whether cast may ship. - expect(doc).toMatch(/waiver/i); - expect(doc).toMatch(/failed drill is still a valid record/i); - }); - - it("the old single-file log is gone — records are per version", () => { - expect(existsSync(join(ROOT, "drill/RUNS.md"))).toBe(false); - }); - - it("ci.yml runs it, ungated by event or label", () => { - const CI = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8"); - expect(CI).toContain(".github/scripts/drill-recorded.sh"); - // Scoped to the step's own block (the monotonic assert above explains the - // bounding): an `if:` here would put the guard behind a hand-applied - // label, absent from exactly the PR that mislabels itself. - const lines = CI.split("\n"); - const start = lines.findIndex((l) => - /^ {6}- name: a release version has a drill record/.test(l), - ); - expect(start).toBeGreaterThanOrEqual(0); - const after = lines.slice(start + 1); - const end = after.findIndex((l) => /^ {6}- /.test(l) || /^ {2}\S/.test(l)); - const block = [ - lines[start], - ...after.slice(0, end < 0 ? after.length : end), - ].join("\n"); - expect(block).toContain("drill-recorded.sh"); - expect(block).not.toMatch(/^ {8}if:/m); - }); - - // The three drills are INDEPENDENT — any order, any schedule, separate - // sittings. What makes that safe is that each pins the same fixed candidate - // refs, so every drill exercises the combination that will ship. That - // pinning, not sequencing, is what dissolves the box<->rig recursion: the - // refs are static identifiers that exist as soon as the release branches - // do. The docs must not re-acquire an ordering rule between repos. - it("CONTRIBUTING documents the gate and the INDEPENDENT, ref-pinned drills", () => { - const doc = readFileSync(join(ROOT, "CONTRIBUTING.md"), "utf8"); - expect(doc).toContain("drills/X.Y.Z.md"); - expect(doc).toContain("drill-recorded.sh"); + expect(doc).toContain("`.md`"); expect(doc).toMatch(/drills are independent/i); expect(doc).toMatch(/any order/i); expect(doc).toMatch(/pins the same fixed set of\s+candidate\s+refs/i); @@ -976,7 +148,7 @@ describe("drill-recorded.sh — a release version has a drill record", () => { expect(doc).toMatch(/mutually recursive/); expect(doc).toContain("RIG_REF"); expect(doc).toMatch(/candidate refs, not released artifacts/); - expect(doc).toMatch(/no fixed order|not.*published in a fixed order/i); + expect(doc).toMatch(/no fixed order/i); // Each repo drills a different thing — which is WHY records are per-repo. expect(doc).toMatch(/isolation\s+contract/i); expect(doc).toMatch(/convergence/i); @@ -986,130 +158,6 @@ describe("drill-recorded.sh — a release version has a drill record", () => { }); }); -// --- release.yml — the wiring, pinned -------------------------------------- -// The workflow itself only runs on a tag push upstream, so its load-bearing -// pieces are pinned here, fail-closed (the house discipline: the labels -// harness greps its workflow the same way). - -describe("release.yml", () => { - const RY = readFileSync(join(ROOT, ".github/workflows/release.yml"), "utf8"); - - it("triggers on EVERY tag — the manual fallback survives, and a mismatch must fail loudly, not be pattern-skipped", () => { - expect(RY).toContain('tags: ["**"]'); - }); - - it("the merge door rides pushes to main — fork PR tokens are read-only (#111 r1)", () => { - // A pull_request run from a public fork 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. The door triggers on push to main; the doors split on - // the pushed ref; the release label — still the operator's declared - // intent — is read via the API off the merge commit's PR, and a - // transition with no labeled PR behind it refuses. - expect(RY).toContain("branches: [main]"); - // YAML maps are last-key-wins: a second sibling push: key silently - // replaces the first and kills a door (grok's round-2 catch — the tag - // fallback had stopped triggering). Exactly ONE push key may exist. - expect(RY.match(/^ {2}push:$/gm)).toHaveLength(1); - expect(RY).toContain("startsWith(github.ref, 'refs/tags/')"); - expect(RY).toContain("github.ref == 'refs/heads/main'"); - expect(RY).toContain("commits/$GITHUB_SHA/pulls"); - expect(RY).toContain("no merged, release-labeled PR is behind this commit"); - expect(RY).not.toContain("pull_request:"); - }); - - it("the release re-arms main itself — the -dev bump folds into the release act", () => { - // Operator decision (#111 followup): the post-release bump PR was - // ceremony debris. Direct push with the job's token, PR fallback when - // branch protection refuses, merge-door only. - expect(RY).toContain("bump main to the next -dev"); - expect(RY).toContain("opening the bump PR instead"); - expect(RY).toContain("npm install --package-lock-only"); - }); - - it("asserts tag == package.json version, and the assert precedes the create", () => { - expect(RY).toContain('require("./package.json").version'); - expect(RY).toContain("creating nothing"); - expect(RY.indexOf("creating nothing")).toBeLessThan( - RY.indexOf('gh release create "$RELEASE_VERSION"'), - ); - }); - - it("the merge path decides, then asserts, IN ORDER, all before tag-create, build, and publish", () => { - // The decide step (the fused version asserts — see the workflow's - // four-state table): base read from git, versions via node, work under - // the label no-ops green, half-ceremonies refuse. Then: the shared - // notes extraction, the no-existing-tag/release asserts, and only then - // the acts — API-tag the merge commit, build, publish. Every marker - // present, strictly in file order, fail-closed. - const markers = [ - 'git show "$BASE_SHA:package.json"', // decide — base vs merge - // Code-unique phrasings (the workflow's own comment table paraphrases - // these states, so the pins anchor on the echo strings, not prose): - "release-flow work under the release label, not a ceremony. Nothing to publish.", // work no-op, green - "a dev tree is by definition not a release", // -dev endstate: always work (the bump PR no-ops green) - "release-flow work merged in the post-release window (before the -dev bump)", // window no-op - "Refusing to guess — creating nothing.", // bare, unchanged, unreleased: refuse - ".github/scripts/release-notes.sh", // assert: notes extract - 'git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION"', // assert: no tag - 'gh release view "$RELEASE_VERSION"', // assert: no release (the decide's own view sits earlier — count checked below) - 'gh api "repos/$GITHUB_REPOSITORY/git/refs"', // act: tag the merge commit - "npm prune --omit=dev", // act: build - 'gh release create "$RELEASE_VERSION"', // act: publish - ]; - let at = -1; - for (const m of markers) { - const i = RY.indexOf(m); - expect(i, m).toBeGreaterThan(at); - at = i; - } - }); - - it("the -dev interlock reads versions via node, never regex, and names the 0.1.0 first-release edge", () => { - expect(RY).not.toMatch(/grep.*version/); - expect(RY).toContain("node -p 'require(\"./package.json\").version'"); - // 0.1.0 never carried -dev, so the interlock correctly skips #110's - // ceremony — the workflow must say so where the next reader will look. - expect(RY).toContain("applies from 0.1.1"); - }); - - it("tag, build, and publish happen in the SAME job — a GITHUB_TOKEN tag fires no workflows", () => { - const jobs = RY.slice(RY.indexOf("\njobs:")).match(/^ {2}\S+:\s*$/gm) ?? []; - expect(jobs).toEqual([" release:"]); // one job under jobs: - expect(RY).toContain("does not trigger other workflows"); - expect(RY).toContain('-f "sha=$MERGE_SHA"'); - }); - - it("the body comes from the shared extraction script", () => { - expect(RY).toContain(".github/scripts/release-notes.sh"); - }); - - it("the release is bound to its tag (--verify-tag)", () => { - expect(RY).toContain("--verify-tag"); - }); - - it("builds the prod-only tree once and attaches it as the asset", () => { - expect(RY).toContain("npm prune --omit=dev"); - expect(RY).toContain("cp -R bin dist node_modules package.json"); - expect(RY).toContain("cast-$RELEASE_VERSION.tgz"); - }); - - it("both trigger paths converge on the SAME asset name — one build, one tar, no per-path naming", () => { - // Each path's entry step exports RELEASE_VERSION; everything downstream - // (notes, stage dir, tarball, release title) reads only that. A second - // tar or a $GITHUB_REF_NAME-named asset would be the paths drifting - // apart — the exact failure this shape exists to prevent. - expect(RY.match(/>> "\$GITHUB_ENV"/g)).toHaveLength(2); - expect(RY.match(/tar -C/g)).toHaveLength(1); - expect(RY).not.toContain("cast-$GITHUB_REF_NAME"); - }); - - it("runs no tests — ci.yml gated the merge commit already", () => { - expect(RY).not.toContain("npm test"); - expect(RY).not.toContain("npm run check"); - }); -}); - // --- the installer's three channels, driven for real ------------------------ // Full install.sh runs against throwaway roots. The curl on PATH is a stub // scripted via env (CURL_*); the npm on PATH is POISONED (exits 97) unless a From 26129672ea24e25139da86efe98dd4c8c0756aa7 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Thu, 23 Jul 2026 15:14:42 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20the=20panel=20roster=20is=20the=20CU?= =?UTF-8?q?RRENT=20bench=20=E2=80=94=20kimi=20was=20dropped=20by=20porting?= =?UTF-8?q?=20a=20stale=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversion preserved cast's pre-ceremony CONTRIBUTING panel verbatim, but that list predates kimi-bot joining the bench — and the omission then self-licensed: the handoff read the roster this very PR had authored, counted codex+grok as the full panel, and handed off one reviewer short. A conversion PR must write the roster from the current family bench, not inherit whatever the repo last knew. Co-Authored-By: Claude Fable 5 --- .github/labels.conf | 2 +- CONTRIBUTING.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/labels.conf b/.github/labels.conf index 7e28a82..7fe8bef 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 scope:capture|C5DEF5|draft/capture — reading the live world into a manifest scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify scope:secrets|C5DEF5|secrets, age, the encrypted state repo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71a1c85..c804ceb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,8 @@ genuinely cast's. 1. **Fork and branch.** Contributors work from forks; upstream branches are for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`). 2. **The review panel** (`.github/labels.conf`'s `panel=` line): - `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl` — + `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`, + `kimi-bot-andresmgsl` — the required verdicts for a PR are the panel minus its author. The maintainer (`danmt`) takes the last word and merges. 3. **Checks must be green**: `npm run check`, `npm run build`, and