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 ]