forked from heavy-duty/rig
feat: the machinery hands over — guard scripts deleted, CI pins ceremony's actions at 0.1.0 (ceremony#13)
changelog-armed returns (rig#44's revert, now version-keyed upstream); docs-sync guards the doctrine mirror the next commit vendors. test/release.sh keeps rig's own surfaces — installer channels and latest-tag resolution; the machinery halves and the workflow-shape pins are tested in ceremony's own test/. test/labels-reconcile.sh goes whole: it drove the deleted reconciler. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e6584ceb2b
commit
be71e1c8b2
7 changed files with 29 additions and 2144 deletions
244
.github/scripts/changelog-monotonic.sh
vendored
244
.github/scripts/changelog-monotonic.sh
vendored
|
|
@ -1,244 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# changelog-monotonic.sh [<base-ref>] [<changelog>] — assert that no SHIPPED
|
||||
# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings
|
||||
# on HEAD must be a SUPERSET of the set at the merge base.
|
||||
#
|
||||
# The failure it exists to catch (#98; ported from heavy-duty/box#122, which
|
||||
# was caught in review of box#118) leaves no trace. An author adding an entry
|
||||
# under '## Unreleased' REPLACES the line below it instead of inserting above
|
||||
# it:
|
||||
#
|
||||
# -## 0.2.0 — 2026-07-19
|
||||
# +## Unreleased
|
||||
# +
|
||||
# +### Fixed
|
||||
# +
|
||||
# +- **An entry**
|
||||
#
|
||||
# git merges that cleanly — it is a one-line edit inside a file nobody has
|
||||
# touched concurrently — so there is no conflict and no signal. 0.2.0's whole
|
||||
# body is now sitting under '## Unreleased', and 0.2.0 has no section at all.
|
||||
#
|
||||
# The arming rule is green on exactly that tree, correctly. changelog_armed()
|
||||
# in test/release.sh asks only whether the TOP section agrees with VERSION,
|
||||
# and deleting '## 0.2.0' leaves '## Unreleased' on top. It is not wrong, it
|
||||
# is narrow — it guards ONE heading, the one a PR is about to write under.
|
||||
# This guards the REST of the file, the part no single tree can be asked
|
||||
# about at all, because "a heading disappeared" is not a property of a tree —
|
||||
# it is a property of a DIFF.
|
||||
#
|
||||
# The damage surfaces at the next release, in changelog_section()
|
||||
# (.github/scripts/release-lib.sh), which anchors on the heading:
|
||||
#
|
||||
# awk -v ver="$2" '
|
||||
# /^## / { if (found) exit; found = ($2 == ver); next }
|
||||
# ...
|
||||
#
|
||||
# No heading, no section — and release.yml's "refusing to publish an empty
|
||||
# release" assert is the first thing that notices, one whole release too late.
|
||||
#
|
||||
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
|
||||
# ceremony (CONTRIBUTING, "Releases") adds one and never removes one; nothing
|
||||
# else in the documented flow 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 VERSION, and the ceremony legitimately consumes it.
|
||||
#
|
||||
# A file of its own, NOT a clause inside test/release.sh's arming check, 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 rule is driven by test/release.sh against constructed VERSION +
|
||||
# CHANGELOG.md trees that are not git repos at all — folding a git-dependent
|
||||
# assert into it would make every one of those cases either skip or lie.
|
||||
# Same discipline as release-lib.sh: its own file so a test can drive it.
|
||||
|
||||
base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}"
|
||||
changelog="${2:-CHANGELOG.md}"
|
||||
|
||||
# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local
|
||||
# degradation becomes a red run there instead. A guard that can silently
|
||||
# stop guarding is the failure shape this whole family of checks exists to
|
||||
# refuse, so the skip path is loud and CI refuses to take it at all.
|
||||
strict="${CHANGELOG_MONOTONIC_STRICT:-0}"
|
||||
|
||||
skip() {
|
||||
if [ "$strict" = "1" ]; then
|
||||
echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2
|
||||
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
|
||||
echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2
|
||||
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "changelog-monotonic: containment SKIPPED — $*"
|
||||
echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading"
|
||||
echo " half needs the history. In CI this same condition is a hard failure.)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; }
|
||||
|
||||
# The set of RELEASE headings: '## <token> ...' where <token> looks like a
|
||||
# version. Field $2, the same split changelog_section() uses, so the two
|
||||
# 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.2.0} minus head
|
||||
# {0.2.0, 0.2.0} is empty. Multiset comparison does not close it either, for
|
||||
# the same reason. The assert that does is uniqueness of version headings ON
|
||||
# HEAD, kept alongside containment rather than replacing it.
|
||||
#
|
||||
# This is the shape box#118's bad rebase produced: two `## 0.2.0 — 2026-07-19`
|
||||
# headings with an incoming entry between them. Every other guard stays green
|
||||
# — conflict markers absent, the arming rule happy (the top section is still
|
||||
# right), tests and shellcheck clean.
|
||||
#
|
||||
# rig's symptom differs from box's, and the difference matters. box's
|
||||
# release-notes.sh RE-ARMS its grab on every matching '## ' line, so a
|
||||
# duplicate makes it ABSORB whatever sits between the copies. rig's
|
||||
# changelog_section() has `if (found) exit`, so it stops dead at the second
|
||||
# copy instead: a duplicate TRUNCATES. The published body is only what sits
|
||||
# BETWEEN the two headings, and everything under the second copy — the real
|
||||
# body of that release — is silently dropped. Different symptom, same class:
|
||||
# no conflict, no red run, discovered only by a human reading the published
|
||||
# notes.
|
||||
#
|
||||
# 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 <<EOF
|
||||
Each version heading must appear exactly once. A repeat splits one release
|
||||
into two same-named sections, and changelog_section() stops at the FIRST
|
||||
'## ' line after the one it matched — so the published body for that version
|
||||
is only what sits BETWEEN the copies, and the real body under the second
|
||||
copy is dropped from the release notes entirely.
|
||||
|
||||
This is the box#118 shape: an entry meant for '## Unreleased' was inserted
|
||||
after a shipped heading, and the heading re-added below it. The fix is one
|
||||
heading, with the entry above it under '## Unreleased':
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
|
||||
|
||||
Quick check on any changelog-touching rebase:
|
||||
|
||||
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
|
||||
EOF
|
||||
} >&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 (#98; fixed upstream in heavy-duty/box#143, where rig's copy of
|
||||
# this script came from). It asks nothing of the history, and gating it behind
|
||||
# these conditions let a duplicate exit 0 on a message about deletion.
|
||||
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
||||
|| skip "not inside a git work tree, so there is no history to compare against"
|
||||
|
||||
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|
||||
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
|
||||
|
||||
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
|
||||
[ -n "$merge_base" ] \
|
||||
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
|
||||
|
||||
# The changelog may not exist at the merge base at all (the commit that adds
|
||||
# it). Nothing to have deleted, so nothing to assert.
|
||||
base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
|
||||
[ -n "$base_file" ] || {
|
||||
echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
base_headings="$(printf '%s\n' "$base_file" | headings)"
|
||||
head_headings="$(headings < "$changelog")"
|
||||
|
||||
# comm -23: lines in the base set that are NOT in the head set — exactly the
|
||||
# headings this branch removed.
|
||||
missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
{
|
||||
echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:"
|
||||
echo
|
||||
printf '%s\n' "$missing" | sed 's/^/ ## /'
|
||||
echo
|
||||
cat <<EOF
|
||||
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
|
||||
|
||||
Release headings are APPEND-ONLY. The ceremony adds one (CONTRIBUTING,
|
||||
"Releases"); nothing ever legitimately removes one. So this is not a
|
||||
judgement call — it is a defect, and almost always the same one (#98): an
|
||||
entry written under '## Unreleased' REPLACED the heading below it instead of
|
||||
being inserted ABOVE it. The shipped section's body is now sitting under
|
||||
'## Unreleased', and the version it belonged to has no section at all.
|
||||
|
||||
Nothing else will say so. git merges that edit cleanly — no conflict, no
|
||||
signal — and the arming rule stays green, because the TOP section is still
|
||||
the right one for this VERSION. The damage surfaces at the NEXT release,
|
||||
when changelog_section() cannot find the section it extracts by heading and
|
||||
release.yml refuses to publish an empty release — one whole release late.
|
||||
|
||||
The fix is to put the heading back and INSERT above it, never over it:
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
|
||||
|
||||
If you are genuinely renaming a released version, that is a rewrite of
|
||||
history this guard is meant to stop; say so in the PR and change the guard
|
||||
deliberately, in its own commit.
|
||||
EOF
|
||||
} >&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 in #98 —
|
||||
# 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
|
||||
166
.github/scripts/drill-recorded.sh
vendored
166
.github/scripts/drill-recorded.sh
vendored
|
|
@ -1,166 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# drill-recorded.sh [<drills-dir>] [<version-file>] — assert that the version
|
||||
# this tree is about to ship has a DRILL RECORD at <drills-dir>/<version>.md.
|
||||
#
|
||||
# defaults: drills VERSION
|
||||
#
|
||||
# CONTRIBUTING ("Releasing") says a release carries a real-hardware drill.
|
||||
# Nothing enforced it, so no release in this family has ever had one: the
|
||||
# ceremony is four correct mechanical steps — bump VERSION, stamp the
|
||||
# changelog, re-arm, merge — and every one of them is checked by a script,
|
||||
# while the one step that costs an afternoon on real hardware was checked by
|
||||
# a reviewer remembering. Reviewers remember exactly as long as the release is
|
||||
# interesting, which is never at 0.4.3. A bot finally blocked on it; this is
|
||||
# that block, moved into CI where it does not depend on anyone's attention.
|
||||
#
|
||||
# ONE FILE PER VERSION, which is what this script is now mostly about. The
|
||||
# first cut of this guard kept every record as a section inside one
|
||||
# drill/RUNS.md, and paid for it: it needed an awk extractor that matched a
|
||||
# literal '## Release drill — ' prefix, tolerated an optional ' — DATE' tail,
|
||||
# compared the version WHOLE so that '0.3.0-rc1' could not answer for '0.3.0',
|
||||
# and then separately insisted the extracted body hold a non-blank line. Every
|
||||
# one of those rules existed only because records shared a file. Both sibling
|
||||
# repos shipped a DEFECT out of that complexity during review — a
|
||||
# `sed '/./,$!d'` extractor where `.` matches a space, so a heading plus one
|
||||
# tab satisfied the gate (box#149, cast#138), and heading-grammar drift on the
|
||||
# other side. Splitting the records makes nearly all of it unrepresentable:
|
||||
# `0.3.0.md` and `0.3.0-rc1.md` are simply different files, there is no
|
||||
# heading to parse and no grammar to drift, and the whole-version comparison
|
||||
# is done by the filesystem.
|
||||
#
|
||||
# PER-REPO, and that is the load-bearing design decision. The obvious
|
||||
# alternative — have rig ask box's repo whether the drill ran — cannot fail
|
||||
# safely: the lookup needs a network call, a token, and a checkout that may be
|
||||
# a fork, and every one of those failure modes lands on "could not read", which
|
||||
# a naive implementation spells `|| true` and reads as PASS. That is exactly
|
||||
# the UNREADABLE-vs-NONE bug #90 fixed one layer up (an unreadable check rollup
|
||||
# reading as "nothing is failing"), and re-introducing it in the release gate
|
||||
# would be worse: it degrades to green on precisely the tree that ships. So rig
|
||||
# records rig's own legs in rig's own repo, and this script reads a file that
|
||||
# is either in the checkout or is not.
|
||||
#
|
||||
# The directory is `drills/`, NOT `.drills/`. A dot-directory is invisible to
|
||||
# every glob that has not set `dotglob`, which is how #70 here and box#116 /
|
||||
# box#118 all happened: a file that exists but that no sweep can see is worse
|
||||
# than no file, because it reads as covered.
|
||||
#
|
||||
# What it asserts is a RECORD, not a RESULT — and that is deliberate, not a
|
||||
# weakness. A gate that demanded "the drill passed" would have to parse
|
||||
# somebody's prose for a verdict, and would leave a maintainer who consciously
|
||||
# ships without a full drill (a doc-only release, a hardware outage) with no
|
||||
# move except deleting the check. Requiring a record means the waiver is
|
||||
# WRITTEN DOWN, in a file named for the version it applies to, in a commit a
|
||||
# reviewer sees. Skipping stays possible; skipping silently does not.
|
||||
#
|
||||
# Vacuous on a `-dev` tree, which is why it needs no trigger scoping in
|
||||
# ci.yml (unlike changelog-monotonic.sh, whose input is a diff): every ordinary
|
||||
# PR carries a `-dev` VERSION and passes without a drill record existing at
|
||||
# all. The check has something to say on exactly one tree — the release
|
||||
# ceremony PR — and that is the tree it must be impossible to merge without.
|
||||
|
||||
drills="${1:-drills}"
|
||||
version_file="${2:-VERSION}"
|
||||
|
||||
# An unreadable version file is an ERROR, never a silent pass. There is no
|
||||
# version to be lenient about, so leniency here could only mean "ship
|
||||
# unevidenced" — the exact degradation the per-repo decision above exists to
|
||||
# avoid.
|
||||
[ -f "$version_file" ] || {
|
||||
echo "drill-recorded: no such file: $version_file" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
version="$(tr -d '[:space:]' < "$version_file")"
|
||||
[ -n "$version" ] || {
|
||||
echo "drill-recorded: $version_file is empty — there is no version to check a drill against." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The -dev half. A development tree is not shipping anything, so there is
|
||||
# nothing to evidence; saying so out loud (rather than exiting 0 in silence)
|
||||
# is the #98 lesson — a guard that prints nothing is indistinguishable from a
|
||||
# guard that did nothing.
|
||||
case "$version" in
|
||||
*-dev)
|
||||
echo "drill-recorded: VERSION is $version — a development tree has nothing to assert (the drill gates a RELEASE, and this is not one)."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
record="$drills/$version.md"
|
||||
|
||||
# WHITESPACE IS NOT A RECORD. This is the one surviving piece of the rule set
|
||||
# the old section-parsing guard needed, and it survives because it is the one
|
||||
# part that splitting the files does not make unrepresentable: an empty file,
|
||||
# or a file holding only spaces, tabs and newlines, exists at the right path
|
||||
# and is still no evidence. It is the same property box#149 and cast#138 both
|
||||
# got wrong with `sed '/./,$!d'` (`.` matches a space), where a record of one
|
||||
# tab shipped an evidence-free release. `grep -q '[^[:space:]]'` is the whole
|
||||
# check now, with no extractor in front of it to get wrong.
|
||||
#
|
||||
# The negated form below, matching box's and cast's twins exactly, so there is
|
||||
# no divergence between the three to explain.
|
||||
#
|
||||
# It also avoids a real `set -e` hazard, which is worth naming precisely
|
||||
# because an earlier draft of this comment named it BACKWARDS. A bare
|
||||
# `[ -f "$record" ] && grep -q ... "$record"` mid-script does NOT abort when
|
||||
# the file is missing: the left-hand side of `&&` is exempt from errexit, so a
|
||||
# miss simply continues. What DOES abort is the other case — the file exists
|
||||
# and `grep` finds nothing, i.e. exactly the whitespace-only record this guard
|
||||
# is here to refuse. The script would die on its most interesting input,
|
||||
# before printing the message that explains it.
|
||||
#
|
||||
# Verified rather than reasoned about:
|
||||
# bash -ec '[ -f /nonexistent ] && r=yes; echo reached' -> prints, exit 0
|
||||
# bash -ec 'f=$(mktemp); echo " " >"$f"
|
||||
# [ -f "$f" ] && grep -q "[^[:space:]]" "$f"
|
||||
# echo reached' -> silent, exit 1
|
||||
#
|
||||
# Caught by all three reviewers on #104. The lesson is the same one #149 and
|
||||
# cast#138 taught: this family's comments get read as contracts, so a comment
|
||||
# that misstates the semantics is a defect even when the code is correct.
|
||||
if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then
|
||||
{
|
||||
echo "drill-recorded: VERSION is $version, and there is no drill record at $record."
|
||||
echo
|
||||
cat <<EOF
|
||||
This tree is a release ceremony tree — VERSION is bare, so merging it ships
|
||||
$version. CONTRIBUTING ("Releasing") requires that release to carry a real
|
||||
hardware drill, recorded in a file named for the version, exactly:
|
||||
|
||||
$drills/$version.md
|
||||
|
||||
One file per version, so the name IS the match: a record for
|
||||
$version-rc1 lives at a different path and does not count. The file must
|
||||
hold at least one non-whitespace character — an empty file, or one of only
|
||||
spaces and tabs, is not a record.
|
||||
|
||||
Two ways to unblock, and both are a commit on this PR:
|
||||
|
||||
1. RUN THE DRILL and record it. What ran, on what hardware, the numbers,
|
||||
and what failed. rig's drill asserts CONVERGENCE — a machine reaches
|
||||
its role, idempotently — against a PINNED set of candidate refs
|
||||
(RIG_REPO/RIG_REF and BOX_REF are mint-time variables, so the run pins
|
||||
the commits under test). Drilling the candidate IS drilling the
|
||||
release, since a release PR's diff is VERSION + CHANGELOG.md and
|
||||
nothing executable differs. Cite the run ID and the other repos' SHAs.
|
||||
The three repos' drills are independent — rig's does not wait on box's.
|
||||
|
||||
2. RECORD AN EXPLICIT MAINTAINER WAIVER in that same file, saying who
|
||||
waived it and why. This guard asks for a RECORD, not a passing result,
|
||||
so a deliberate skip is allowed — it just has to be visible and
|
||||
reviewable rather than silent.
|
||||
|
||||
See $drills/README.md for what a record should contain.
|
||||
|
||||
Do not delete this step to get green. A release that cannot say what was
|
||||
drilled is the state this check exists to end.
|
||||
EOF
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lines="$(grep -c . "$record" || true)"
|
||||
echo "drill-recorded: $record records a drill for $version ($lines non-blank line(s))."
|
||||
486
.github/scripts/labels-reconcile.sh
vendored
486
.github/scripts/labels-reconcile.sh
vendored
|
|
@ -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:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
|
||||
scope:users|C5DEF5|users-* — class model, apply/status, close-root
|
||||
scope:runner|C5DEF5|runner-* — GitHub runner lifecycle
|
||||
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
|
||||
scope:db|C5DEF5|db.sh — dump/restore
|
||||
scope:installer|C5DEF5|install.sh — how rig lands on a machine
|
||||
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
|
||||
23
.github/scripts/release-lib.sh
vendored
23
.github/scripts/release-lib.sh
vendored
|
|
@ -1,23 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Release plumbing shared by .github/workflows/release.yml and the test
|
||||
# harness (test/release.sh) — pure functions, sourced, never executed on
|
||||
# their own (repo precedent: labels-reconcile.sh's decide_state, the
|
||||
# commands/lib/*.sh parsers).
|
||||
|
||||
# changelog_section <file> <version>
|
||||
#
|
||||
# Print the BODY of that version's CHANGELOG.md section: everything between
|
||||
# its heading and the next '## ' heading (or EOF). A release heading is
|
||||
# stamped '## <version> — <date>' and the Unreleased one is bare
|
||||
# '## Unreleased'; the second field is the version either way, so both
|
||||
# shapes match. The heading itself is not printed — the release title
|
||||
# already names the version — and leading blank lines are dropped. Empty
|
||||
# output means "no such section", which release.yml turns into a refusal: a
|
||||
# tag with no changelog entry must not ship an empty release.
|
||||
changelog_section() {
|
||||
awk -v ver="$2" '
|
||||
/^## / { if (found) exit; found = ($2 == ver); next }
|
||||
found && !body && /^[[:space:]]*$/ { next }
|
||||
found { body = 1; print }
|
||||
' "$1"
|
||||
}
|
||||
86
.github/workflows/ci.yml
vendored
86
.github/workflows/ci.yml
vendored
|
|
@ -9,16 +9,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 <base>` 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
|
||||
# pure-bash tree 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 these checks exist to refuse).
|
||||
fetch-depth: 0
|
||||
- name: shellcheck
|
||||
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
|
||||
|
|
@ -43,61 +37,27 @@ jobs:
|
|||
shellcheck -x "${files[@]}"
|
||||
- name: cli tests
|
||||
run: bash test/cli.sh
|
||||
# test/labels-reconcile.sh existed here since #87 but ran nowhere: the
|
||||
# label state machine gates every PR on this repo and its fixtures were
|
||||
# green only when someone remembered to run them by hand. Same step, same
|
||||
# place as heavy-duty/box.
|
||||
- name: labels state-machine tests
|
||||
run: bash test/labels-reconcile.sh
|
||||
- name: release-flow tests
|
||||
- name: release tests — rig's own surfaces
|
||||
run: bash test/release.sh
|
||||
# No SHIPPED release heading was deleted or DUPLICATED (#98). Its own step
|
||||
# rather than a line inside test/release.sh: that suite drives the arming
|
||||
# rule against constructed VERSION + CHANGELOG.md trees that are not git
|
||||
# repos, and this assert needs a git history — folding it in would make
|
||||
# those cases skip or lie. It is also a DIFFERENT invariant: 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 (#13's
|
||||
# conversion). Each one'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 #98 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 }}"
|
||||
# The release this tree would ship has a recorded real-hardware drill
|
||||
# (drills/<version>.md). CONTRIBUTING ("Releasing") has always required one and
|
||||
# nothing enforced it, so no release in this family has ever carried one
|
||||
# — the drill was the single ceremony step checked by a reviewer
|
||||
# remembering rather than by a script.
|
||||
#
|
||||
# Deliberately NOT trigger-scoped, and for the opposite reason to the
|
||||
# step above. That one needs a base ref, so its argument has to be right
|
||||
# on both event types; this one reads two files in the checkout and is
|
||||
# VACUOUS BY CONSTRUCTION on a `-dev` VERSION, which every ordinary PR
|
||||
# and every push to main carries. It has something to say on exactly one
|
||||
# tree — the `release: X.Y.Z` ceremony PR — so an `if:` could only add a
|
||||
# way for that one tree to slip past.
|
||||
#
|
||||
# PER-REPO on purpose: rig reads rig's own record, never box's repo. A
|
||||
# cross-repo lookup fails on a token, a network blip or a fork checkout,
|
||||
# and every one of those lands on "could not read" — which degrades to
|
||||
# green on precisely the tree that ships (the UNREADABLE-vs-NONE shape
|
||||
# #90 fixed).
|
||||
- name: a release version has a recorded drill
|
||||
run: bash .github/scripts/drill-recorded.sh
|
||||
# changelog-armed: the version-keyed arming rule (rig#66; the
|
||||
# unconditional form rig#44 reverted — this is its correct return).
|
||||
- uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
|
||||
# changelog-monotonic: no shipped heading deleted or duplicated
|
||||
# (#98, box#122). 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/<version>.md
|
||||
# (rig's drill meaning: drills/README.md). Vacuous on -dev trees.
|
||||
- uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
|
||||
# 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
|
||||
|
||||
# Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and
|
||||
# stands up throwaway containers, and a slow image pull must never delay the
|
||||
|
|
|
|||
|
|
@ -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 ]
|
||||
752
test/release.sh
752
test/release.sh
|
|
@ -1,17 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
# The release flow's testable half (#32): changelog extraction, latest-tag
|
||||
# resolution, and the installer's three channels. Dependency-free and
|
||||
# Rig's own half of the release surface (#32; trimmed in ceremony#13's
|
||||
# conversion): latest-tag resolution and the installer's three channels.
|
||||
# The machinery halves — changelog extraction, the arming rule,
|
||||
# monotonicity, the drill gate, the workflow-shape pins — moved to
|
||||
# heavy-duty/ceremony, which tests them in its own test/; what stays is
|
||||
# everything that drives rig's install.sh and bin/. Dependency-free and
|
||||
# NETWORK-FREE — wherever the code under test would call curl, the curl on
|
||||
# PATH is a stub this harness wrote. Run: bash test/release.sh
|
||||
# Deliberately no `set -e` — the harness asserts on failing commands.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT" || exit 1
|
||||
# The extraction the workflow runs is the extraction under test — one
|
||||
# function, sourced by release.yml and by this harness (repo precedent:
|
||||
# test/labels-reconcile.sh sourcing the reconciler's decide_state).
|
||||
# shellcheck source=.github/scripts/release-lib.sh
|
||||
. "$ROOT/.github/scripts/release-lib.sh"
|
||||
PASS=0 FAIL=0
|
||||
|
||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
||||
|
|
@ -37,745 +36,6 @@ check() {
|
|||
WORK="$(mktemp -d)"
|
||||
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||
|
||||
# --- changelog_section: the release body, extracted --------------------------
|
||||
# A fixture changelog with the three heading shapes the flow produces: the
|
||||
# bare '## Unreleased', stamped '## X.Y.Z — date' releases, and a last
|
||||
# section that runs to EOF.
|
||||
FIXCH="$WORK/CHANGELOG.fixture.md"
|
||||
cat > "$FIXCH" <<'MD'
|
||||
# Changelog
|
||||
|
||||
History before 0.1.0 lives in git.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- an unreleased entry
|
||||
|
||||
## 0.2.0 — 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- **the newer entry** (#42) — prose.
|
||||
|
||||
### Fixed
|
||||
|
||||
- a fix in 0.2.0
|
||||
|
||||
## 0.1.0 — 2026-07-01
|
||||
|
||||
- the first entry
|
||||
MD
|
||||
sect_has() { changelog_section "$1" "$2" | grep -qF -e "$3"; }
|
||||
check "changelog: extracts the asked-for section" 0 "the newer entry" \
|
||||
changelog_section "$FIXCH" 0.2.0
|
||||
check "changelog: the whole section, subheadings included" 0 "a fix in 0.2.0" \
|
||||
changelog_section "$FIXCH" 0.2.0
|
||||
check "changelog: stops at the next release heading" 1 "" \
|
||||
sect_has "$FIXCH" 0.2.0 "the first entry"
|
||||
check "changelog: never leaks the preceding section" 1 "" \
|
||||
sect_has "$FIXCH" 0.2.0 "an unreleased entry"
|
||||
check "changelog: the heading itself is not the body" 1 "" \
|
||||
sect_has "$FIXCH" 0.2.0 "## 0.2.0"
|
||||
first_line() { changelog_section "$1" "$2" | head -n1; }
|
||||
check "changelog: leading blank lines are dropped" 0 "### Added" \
|
||||
first_line "$FIXCH" 0.2.0
|
||||
check "changelog: the bare Unreleased heading matches too" 0 "an unreleased entry" \
|
||||
changelog_section "$FIXCH" Unreleased
|
||||
check "changelog: the last section runs to EOF" 0 "the first entry" \
|
||||
changelog_section "$FIXCH" 0.1.0
|
||||
absent() { [ -z "$(changelog_section "$1" "$2")" ]; }
|
||||
check "changelog: an unknown version yields NOTHING (the refusal signal)" 0 "" \
|
||||
absent "$FIXCH" 3.3.3
|
||||
check "changelog: a date-stamped heading never matches by date" 0 "" \
|
||||
absent "$FIXCH" 2026-07-18
|
||||
|
||||
# ...and the SHIPPED changelog fits the extractor. The real file has two
|
||||
# legitimate states, and the old check knew only one (#44, found the day the
|
||||
# first release PR turned CI red): BETWEEN releases there is an `## Unreleased`
|
||||
# section feature PRs append to; on a `release: X.Y.Z` tree — and on main
|
||||
# right after it, until the next feature PR — that section IS the stamped
|
||||
# `## X.Y.Z — date`. Demanding the literal heading (or, worse, an issue
|
||||
# number inside it) made the release PR of the ceremony unshippable by
|
||||
# construction. What the guard is FOR is format drift: whatever the top
|
||||
# section is called, the exact function release.yml runs must extract it
|
||||
# non-empty.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CHANGELOG.md: has a top section (Unreleased or a stamped release)" 0 "" \
|
||||
bash -c '[ -n "$(grep -m1 "^## " "$1")" ]' _ "$ROOT/CHANGELOG.md"
|
||||
|
||||
# --- the arming rule: is main's changelog ready for a late merge? ------------
|
||||
# #66: stamping the Unreleased heading DISARMS the file. A PR authored before
|
||||
# a release and merged after it wrote its entry under `## Unreleased`; once
|
||||
# that heading has become `## X.Y.Z — date`, git lands the entry under the
|
||||
# release that already shipped — cleanly, no conflict, nothing for the author
|
||||
# to notice. It happened here: #60's #58 entry landed inside `## 0.1.0` at
|
||||
# 67386b4, repaired two minutes later by 0ff520c.
|
||||
#
|
||||
# The check above cannot see this, and #44 is why: demanding a literal
|
||||
# `## Unreleased` is FALSE BY CONSTRUCTION on the tree the ceremony's own PR
|
||||
# produces, which made the release PR unshippable. That relaxation must not
|
||||
# be undone.
|
||||
#
|
||||
# What distinguishes the two states the old guard collapsed is VERSION.
|
||||
# A stamped top section is legal exactly when VERSION is bare — the ceremony
|
||||
# PR, and main until the -dev bump lands. The moment VERSION carries -dev,
|
||||
# main is a place feature PRs merge into, and the top section MUST be
|
||||
# `## Unreleased` or the next late merge is misfiled.
|
||||
#
|
||||
# Note the asymmetry, which is deliberate: on a BARE version the top heading
|
||||
# is not constrained at all. The ceremony re-arms in the same PR
|
||||
# (CONTRIBUTING step 1), so its tree legitimately carries an EMPTY
|
||||
# `## Unreleased` above the section it just stamped — and an empty top
|
||||
# section is exactly what the old non-empty assert would have rejected.
|
||||
# What must extract non-empty on a bare VERSION is the section that SHIPS,
|
||||
# which is the same assert release.yml makes before it publishes.
|
||||
#
|
||||
# changelog_armed <version> <changelog-file> — 0 armed, 1 disarmed.
|
||||
changelog_armed() {
|
||||
local ver="$1" file="$2" top
|
||||
top="$(grep -m1 '^## ' "$file")"
|
||||
[ -n "$top" ] || return 1
|
||||
case "$ver" in
|
||||
*-dev) [ "$top" = "## Unreleased" ] ;;
|
||||
*) [ -n "$(changelog_section "$file" "$ver")" ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The guard itself, against the real tree.
|
||||
check "CHANGELOG.md: armed for the VERSION it carries (#66)" 0 "" \
|
||||
changelog_armed "$(cat "$ROOT/VERSION")" "$ROOT/CHANGELOG.md"
|
||||
|
||||
# ...and the rule proven against trees built for the purpose, because a guard
|
||||
# that is only ever run against a passing tree has not been shown to fail.
|
||||
# Each is a real VERSION + CHANGELOG.md pair the flow actually produces.
|
||||
armtree() { # armtree <name> <version> <changelog-body...> -> prints the dir
|
||||
local d="$WORK/arm-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION"
|
||||
shift 2; printf '%s\n' "$@" > "$d/CHANGELOG.md"; printf '%s' "$d"
|
||||
}
|
||||
armed() { changelog_armed "$(cat "$1/VERSION")" "$1/CHANGELOG.md"; }
|
||||
|
||||
# The ceremony PR's own tree, re-armed per CONTRIBUTING step 1: VERSION bare,
|
||||
# an empty Unreleased sitting above the section it just stamped. GREEN — this
|
||||
# is the case #44 was about, and the empty section must not break it.
|
||||
T="$(armtree ceremony 0.2.0 '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: the re-armed ceremony tree passes (#44 stays fixed)" 0 "" armed "$T"
|
||||
|
||||
# The same ceremony WITHOUT the re-arm — old-style, stamped straight over the
|
||||
# heading. Also GREEN: VERSION is bare, so a stamped top is legal. The guard
|
||||
# refuses to make the ceremony unshippable, which is the whole #44 lesson.
|
||||
T="$(armtree ceremony-old 0.2.0 '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: an un-re-armed ceremony tree still passes (bare VERSION)" 0 "" armed "$T"
|
||||
|
||||
# main AFTER release.yml's -dev bump, with the changelog left disarmed. This
|
||||
# is #66 exactly, and the state cast sat in at the time of writing. RED.
|
||||
T="$(armtree disarmed 0.2.1-dev '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: a -dev main with a stamped top section FAILS (#66)" 1 "" armed "$T"
|
||||
|
||||
# The same main, re-armed. The Unreleased section is EMPTY — no feature PR has
|
||||
# merged since the release — and that is a correct, expected state. GREEN.
|
||||
T="$(armtree rearmed 0.2.1-dev '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: a -dev main with an EMPTY Unreleased passes (no entries yet)" 0 "" armed "$T"
|
||||
|
||||
# Steady state between releases: entries accumulating under Unreleased.
|
||||
T="$(armtree steady 0.2.1-dev '# Changelog' '' '## Unreleased' '' '### Fixed' '' '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: the normal between-releases tree passes" 0 "" armed "$T"
|
||||
|
||||
# A release PR that bumped VERSION but forgot to stamp: the version it claims
|
||||
# to ship has no section, so release.yml would publish empty notes. RED here,
|
||||
# one round earlier than the workflow's own refusal.
|
||||
T="$(armtree unstamped 0.3.0 '# Changelog' '' '## Unreleased' '' '- **A pending thing** (#2) — prose.')"
|
||||
check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed "$T"
|
||||
|
||||
# And a file with no '## ' heading at all is disarmed, not silently fine.
|
||||
T="$(armtree headless 0.2.1-dev '# Changelog' '' 'no sections here')"
|
||||
check "arming: a changelog with no sections FAILS" 1 "" armed "$T"
|
||||
|
||||
# --- the monotonicity rule: was a SHIPPED heading deleted? -------------------
|
||||
# #98. Arming asks about ONE heading — does the top section agree with
|
||||
# VERSION? — so it is silent about the rest of the file. The failure it cannot
|
||||
# see is an entry written under '## Unreleased' that REPLACES the heading
|
||||
# below it instead of inserting above it: git merges the one-line edit
|
||||
# cleanly, arming stays green (the top section is still right), and the
|
||||
# shipped release loses its section entirely. "A heading disappeared" is not a
|
||||
# property of a tree, it is a property of a DIFF — so unlike every check
|
||||
# above, these cases need real git repos, which is why the guard is its own
|
||||
# script rather than a function sourced here.
|
||||
MONO="$ROOT/.github/scripts/changelog-monotonic.sh"
|
||||
check "changelog-monotonic.sh: exists and is the guard under test" 0 "" test -f "$MONO"
|
||||
|
||||
# The stock changelog every case below starts from: an Unreleased section and
|
||||
# two shipped releases, committed on branch 'base' — which plays origin/main.
|
||||
# The caller then rewrites CHANGELOG.md on 'work' and commits.
|
||||
MONO_BASE=('# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.')
|
||||
monorepo() { # monorepo <name> -> prints the dir, left checked out on 'work'
|
||||
local d="$WORK/mono-$1"; mkdir -p "$d"
|
||||
git -C "$d" init -q -b base
|
||||
git -C "$d" config user.email harness@example.invalid
|
||||
git -C "$d" config user.name harness
|
||||
printf '%s\n' "${MONO_BASE[@]}" > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md
|
||||
git -C "$d" commit -qm 'base: two shipped releases'
|
||||
git -C "$d" checkout -q -b work
|
||||
printf '%s' "$d"
|
||||
}
|
||||
monowrite() { # monowrite <dir> <line...> — rewrite CHANGELOG.md and commit
|
||||
local d="$1"; shift
|
||||
printf '%s\n' "$@" > "$d/CHANGELOG.md"
|
||||
git -C "$d" commit -qam 'work: edit the changelog'
|
||||
}
|
||||
mono() { # mono <dir> [VAR=val ...] — run the guard there, base ref 'base'
|
||||
local d="$1"; shift
|
||||
( cd "$d" && env "$@" bash "$MONO" base 2>&1 )
|
||||
}
|
||||
|
||||
# An untouched branch with NO commit of its own: 'work' still points at the
|
||||
# base commit, so the merge base IS HEAD and containment compared the file
|
||||
# against itself. That is the vacuous path (#98), not a containment result —
|
||||
# the green message therefore names uniqueness, the half that actually ran.
|
||||
# A guard that prints nothing is indistinguishable from one that did nothing,
|
||||
# but a guard that prints the WRONG half is worse: it is a false receipt.
|
||||
T="$(monorepo clean)"
|
||||
check "monotonic: an untouched branch passes" 0 "uniqueness on HEAD checked 2" mono "$T"
|
||||
check "monotonic: ...saying containment was VACUOUS, not that it verified 2" 0 \
|
||||
"containment vacuous" mono "$T"
|
||||
# A negative, because the point is that the two wordings do NOT collapse: with
|
||||
# the pull_request gate gone (#98) this is the shape of EVERY push to main, and
|
||||
# "are still present" there would be a containment claim on the one event where
|
||||
# deletion is undetectable by construction.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "monotonic: ...and never claims the headings are still present" 1 "" \
|
||||
bash -c 'cd "$1" && bash "$2" base | grep -q "are still present"' _ "$T" "$MONO"
|
||||
|
||||
# The same shape against a REAL base — an unrelated commit on 'work', the
|
||||
# changelog untouched — which is what an untouched-changelog PR branch
|
||||
# actually looks like. Here containment genuinely ran and held, so this is
|
||||
# the case that pins the containment wording and its count. The two forms
|
||||
# must not collapse into one another.
|
||||
T="$(monorepo clean-realbase)"
|
||||
printf '%s\n' '# rig' > "$T/README.md"
|
||||
git -C "$T" add README.md
|
||||
git -C "$T" commit -qm 'work: an unrelated commit, changelog untouched'
|
||||
check "monotonic: an untouched changelog on a REAL base reports containment" 0 \
|
||||
"all 2 release heading(s)" mono "$T"
|
||||
check "monotonic: ...and says they are still present, the containment claim" 0 \
|
||||
"are still present" mono "$T"
|
||||
|
||||
# The legitimate edit this guard must never object to: a new entry INSERTED
|
||||
# above the shipped heading, which is left alone.
|
||||
T="$(monorepo insert)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: an entry inserted ABOVE the shipped heading passes" 0 "" mono "$T"
|
||||
|
||||
# ...and the bug itself: the same entry typed OVER '## 0.2.0'. 0.2.0's body is
|
||||
# now under '## Unreleased' and 0.2.0 has no section. RED, naming the version.
|
||||
T="$(monorepo deleted)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: a DELETED shipped heading FAILS (#98)" 1 "DELETES release heading" mono "$T"
|
||||
check "monotonic: ...and the failure names the version that vanished" 1 "## 0.2.0" mono "$T"
|
||||
|
||||
# Deleting the OLDEST release is the same defect, not a lesser one — the set
|
||||
# is a set, position in the file buys no leniency.
|
||||
T="$(monorepo deleted-old)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.'
|
||||
check "monotonic: deleting an OLDER release heading fails too" 1 "## 0.1.0" mono "$T"
|
||||
|
||||
# The duplicate half. Containment cannot catch this: the second copy is
|
||||
# head-side SURPLUS and `comm -23` (base minus head) is blind to extras on the
|
||||
# head side, so uniqueness-on-HEAD is a separate assert. rig's symptom is not
|
||||
# box's — changelog_section() has `if (found) exit`, so it stops at the second
|
||||
# copy and TRUNCATES rather than absorbing.
|
||||
T="$(monorepo dupe)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: a DUPLICATED version heading FAILS" 1 "DUPLICATE release heading" mono "$T"
|
||||
check "monotonic: ...and the failure names the repeated version" 1 "## 0.2.0" mono "$T"
|
||||
# ...and that the duplicate really does truncate, so the assert above is
|
||||
# guarding a live defect rather than a stylistic preference: extraction stops
|
||||
# at the second copy, dropping the body that sits under it.
|
||||
check "monotonic: the duplicate TRUNCATES extraction (rig's symptom, not box's)" 0 \
|
||||
"A pending thing" changelog_section "$T/CHANGELOG.md" 0.2.0
|
||||
check "monotonic: ...the real body under the second copy is dropped" 1 "" \
|
||||
sect_has "$T/CHANGELOG.md" 0.2.0 "A shipped thing"
|
||||
|
||||
# '## Unreleased' is deliberately OUTSIDE the guarded set: it fails the
|
||||
# version shape, so the ceremony stamping it away — the one edit that legally
|
||||
# removes a top heading — is invisible here. This is the case that would make
|
||||
# every release PR unshippable if the set were "all '## ' headings".
|
||||
T="$(monorepo stamp)"
|
||||
monowrite "$T" '# Changelog' '' '## 0.3.0 — 2026-07-20' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: stamping '## Unreleased' into a release passes (not guarded)" 0 "" mono "$T"
|
||||
# ...and the ceremony's re-arm — a fresh empty Unreleased above the stamp —
|
||||
# is equally fine, which is CONTRIBUTING step 1's tree.
|
||||
T="$(monorepo stamp-rearmed)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.3.0 — 2026-07-20' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: the re-armed ceremony tree passes too" 0 "" mono "$T"
|
||||
|
||||
# The skip path, both halves. A base ref that does not resolve is a sensible
|
||||
# local degradation — and a silent one, which is the failure shape this family
|
||||
# of checks exists to refuse. So STRICT flips exactly that case red.
|
||||
mono_noref() { local d="$1"; shift; ( cd "$d" && env "$@" bash "$MONO" no/such/ref 2>&1 ); }
|
||||
T="$(monorepo noref)"
|
||||
check "monotonic: an unresolvable base ref SKIPS containment locally" 0 "containment SKIPPED" mono_noref "$T"
|
||||
check "monotonic: ...and the skip says uniqueness already ran, not that nothing did" 0 \
|
||||
"already ran and passed" mono_noref "$T"
|
||||
check "monotonic: ...but is a FAILURE under STRICT=1 (what CI sets)" 1 "STRICT=1" \
|
||||
mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1
|
||||
check "monotonic: ...and the STRICT failure blames the checkout, not the script" 1 \
|
||||
"fetch-depth: 0" mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1
|
||||
|
||||
# A missing changelog is an error on any setting — it is not a degradation,
|
||||
# it is a wrong invocation.
|
||||
T="$(monorepo nofile)"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "monotonic: a missing changelog file is an error, never a skip" 1 "no such file" \
|
||||
bash -c 'cd "$1" && bash "$2" base nope.md 2>&1' _ "$T" "$MONO"
|
||||
|
||||
# --- #98: uniqueness is a property of HEAD, so nothing base-side may gate it --
|
||||
# Containment needs the merge base. Uniqueness needs only the file in front of
|
||||
# it. As first written (and as inherited from heavy-duty/box, fixed there in
|
||||
# box#144 for box#143) the duplicate check sat DOWNSTREAM of the base-ref,
|
||||
# merge-base and base-blob conditions, so each of the degradation paths below
|
||||
# exited 0 on a tree carrying a duplicate in plain sight — the base-blob one
|
||||
# not even through skip(), but a bare `exit 0` that STRICT could not reach.
|
||||
#
|
||||
# These cases pin the ORDER, which is the actual invariant. Every monorepo
|
||||
# fixture above commits MONO_BASE on 'base', so no case up there ever reaches
|
||||
# the base-absent branch at all; and asserting the exit code alone is what let
|
||||
# the original ship, since the clean base-absent case is green either way.
|
||||
mononocl() { # mononocl <name> -> a repo whose 'base' has NO changelog, on 'work'
|
||||
local d="$WORK/mono-$1"; mkdir -p "$d"
|
||||
git -C "$d" init -q -b base
|
||||
git -C "$d" config user.email harness@example.invalid
|
||||
git -C "$d" config user.name harness
|
||||
printf '%s\n' '# rig' > "$d/README.md"
|
||||
git -C "$d" add README.md
|
||||
git -C "$d" commit -qm 'base: no changelog yet'
|
||||
git -C "$d" checkout -q -b work
|
||||
printf '%s' "$d"
|
||||
}
|
||||
monoadd() { # monoadd <dir> <line...> — the branch INTRODUCES CHANGELOG.md
|
||||
local d="$1"; shift
|
||||
printf '%s\n' "$@" > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md
|
||||
git -C "$d" commit -qm 'work: introduce the changelog'
|
||||
}
|
||||
|
||||
# The changelog is absent at the merge base AND the branch introduces a
|
||||
# duplicate. Before the fix this exited 0 on "nothing could have been deleted".
|
||||
T="$(mononocl 98-newdup)"
|
||||
monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.'
|
||||
check "monotonic: a duplicate introduced where the base had NO changelog is CAUGHT (#98)" 1 \
|
||||
"DUPLICATE release heading" mono "$T"
|
||||
check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 \
|
||||
"DUPLICATE release heading" mono "$T" CHANGELOG_MONOTONIC_STRICT=1
|
||||
# ...and the clean counterpart still passes, now SAYING uniqueness ran. Without
|
||||
# this the case above could be satisfied by failing the base-absent path
|
||||
# outright, which would redden every changelog-introducing branch.
|
||||
T="$(mononocl 98-newok)"
|
||||
monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.'
|
||||
check "monotonic: ...while a CLEAN introduced changelog still passes" 0 \
|
||||
"nothing could have been deleted" mono "$T"
|
||||
check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 \
|
||||
"uniqueness on HEAD already passed" mono "$T"
|
||||
|
||||
# No git at all (a tarball, an unpacked release): uniqueness still has
|
||||
# everything it needs, so a duplicate is caught rather than skipped past.
|
||||
mkdir -p "$WORK/mono-98-nogit"
|
||||
printf '%s\n' '# Changelog' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'## 0.2.0 — 2026-07-19' > "$WORK/mono-98-nogit/CHANGELOG.md"
|
||||
check "monotonic: a duplicate OUTSIDE a git work tree is caught (#98)" 1 \
|
||||
"DUPLICATE release heading" mono "$WORK/mono-98-nogit"
|
||||
|
||||
# An unresolvable base ref: same — the skip belongs to containment, not to the
|
||||
# script, so uniqueness has already run by the time skip() is reachable.
|
||||
T="$(monorepo 98-nobase)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: a duplicate is caught even when the base ref will not resolve (#98)" 1 \
|
||||
"DUPLICATE release heading" mono_noref "$T"
|
||||
|
||||
# --- ci.yml: the monotonic step is actually wired (#98) ----------------------
|
||||
# The guard runs from ci.yml, not from this suite, so pin the wiring the same
|
||||
# way release.yml's is pinned — a script nothing invokes is not a check.
|
||||
CIY="$ROOT/.github/workflows/ci.yml"
|
||||
check "ci.yml: runs the monotonic guard" 0 "" \
|
||||
grep -q "changelog-monotonic.sh" "$CIY"
|
||||
check "ci.yml: ...with STRICT=1, so a skip is red rather than quietly green" 0 "" \
|
||||
grep -qF "CHANGELOG_MONOTONIC_STRICT: '1'" "$CIY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "ci.yml: ...against the PR's base branch" 0 "" \
|
||||
grep -qF 'origin/${{ github.base_ref' "$CIY"
|
||||
# The step must NOT be pull-request-only. 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 left a duplicate reaching main by any other route
|
||||
# unasserted. Dropping the gate is only safe with the ref_name fallback:
|
||||
# `github.base_ref` is EMPTY on a push, a bare `origin/` does not resolve, and
|
||||
# STRICT=1 promotes that to a hard failure on every push to main.
|
||||
#
|
||||
# Scoped to the step's OWN block, deliberately. As a file-wide grep this
|
||||
# negative forbade any FUTURE step in ci.yml from being pull_request-gated and
|
||||
# would have failed citing #98 when one legitimately was — #98 constrains this
|
||||
# step, not the file. The companion check below is what keeps the awk honest:
|
||||
# an extractor that matched nothing would turn the negative into a tautology
|
||||
# that passes forever, including after someone renames the step and re-adds
|
||||
# the gate.
|
||||
# Terminates on a new STEP or a new JOB. The job boundary is not optional: the
|
||||
# monotonic step is the LAST step of its job, so stopping only at the next
|
||||
# `- name:` runs the block into the job below and swallows that job's
|
||||
# level `if:` — the same bug this scoping fixed, moved from "any step in the
|
||||
# file" to "this step plus the head of the next job" (found on box#144).
|
||||
mono_step_block() {
|
||||
awk '/^ - name: no shipped changelog heading/ {f=1; print; next}
|
||||
f && (/^ - / || /^ [^ ]/) {exit}
|
||||
f {print}' "$CIY"
|
||||
}
|
||||
# Anchored: an `if:` inside a `run:` line is not a step condition.
|
||||
mono_step_gated() { mono_step_block | grep -q '^ if:'; }
|
||||
check "ci.yml: the monotonic step itself is NOT pull_request-gated (#98)" 1 "" \
|
||||
mono_step_gated
|
||||
check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \
|
||||
"changelog-monotonic" mono_step_block
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \
|
||||
grep -qF 'github.base_ref || github.ref_name' "$CIY"
|
||||
# Without full history the base ref does not resolve, and STRICT turns that
|
||||
# into a red run — so the fetch depth is load-bearing, not incidental.
|
||||
check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \
|
||||
grep -qF "fetch-depth: 0" "$CIY"
|
||||
|
||||
# --- the drill rule: does the version being shipped have a record? ----------
|
||||
# CONTRIBUTING ("Releasing") has always required a real-hardware drill and
|
||||
# nothing enforced it, so no release in this family has ever carried one: every
|
||||
# other ceremony step is checked by a script, and the one that costs an
|
||||
# afternoon was checked by a reviewer remembering. A bot finally blocked on it.
|
||||
#
|
||||
# Records are ONE FILE PER VERSION, at drills/<version>.md. The first cut of
|
||||
# this guard kept them as sections in a single drill/RUNS.md and needed a
|
||||
# heading grammar, an optional-date tail, a whole-version comparison and a
|
||||
# non-blank-body rule to read them back — all of it there only because the
|
||||
# records shared a file, and both sibling repos shipped a defect out of it in
|
||||
# review. Splitting the files deletes most of these tests along with the code
|
||||
# they covered: `0.3.0.md` and `0.3.0-rc1.md` cannot be confused by any
|
||||
# grammar, because there is no grammar.
|
||||
#
|
||||
# Fixtures carry their OWN version file and their OWN drills dir, inside the
|
||||
# fixture dir. This is not tidiness — it is heavy-duty/box#146, verbatim:
|
||||
# fixtures that read the REPO's VERSION exercised the `-dev` branch on every
|
||||
# ordinary tree, so the whole bare-version half of the guard was untested and
|
||||
# went red for the first time while somebody was cutting a release. A fixture
|
||||
# must state the tree it is about.
|
||||
DRILL="$ROOT/.github/scripts/drill-recorded.sh"
|
||||
check "drill-recorded.sh: exists and is the guard under test" 0 "" test -f "$DRILL"
|
||||
check "drill-recorded.sh: is executable" 0 "" test -x "$DRILL"
|
||||
|
||||
drilltree() { # drilltree <name> <version> -> prints the dir (no drills/ yet)
|
||||
local d="$WORK/drill-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION"
|
||||
printf '%s' "$d"
|
||||
}
|
||||
drillrec() { # drillrec <dir> <version> <line...> — write drills/<version>.md
|
||||
mkdir -p "$1/drills"; local f="$1/drills/$2.md"; shift 2
|
||||
printf '%s\n' "$@" > "$f"
|
||||
}
|
||||
drill() { bash "$DRILL" "$1/drills" "$1/VERSION" 2>&1; }
|
||||
|
||||
# A development tree. Vacuous by construction — every ordinary PR looks like
|
||||
# this, and none of them can be asked to have drilled a release that does not
|
||||
# exist. It passes with no drills/ directory present AT ALL, which is the
|
||||
# state this repo ships in today.
|
||||
T="$(drilltree dev 0.2.1-dev)"
|
||||
check "drill: a -dev tree passes with NO drills dir at all" 0 "" drill "$T"
|
||||
check "drill: ...saying so out loud, not exiting 0 in silence" 0 \
|
||||
"nothing to assert" drill "$T"
|
||||
|
||||
# The release ceremony tree, drilled and recorded. GREEN.
|
||||
T="$(drilltree recorded 0.3.0)"
|
||||
drillrec "$T" 0.3.0 '# Release drill — 0.3.0 — 2026-07-21' '' \
|
||||
'Host: bare Debian 13. Candidate refs pinned: box@1a2b3c4, cast@9a0b1c2.' '' \
|
||||
'- convergence, then re-converge: clean' \
|
||||
'- db-integration: 14/14' '- runner lifecycle: PASS'
|
||||
drillrec "$T" 0.2.0 '# Release drill — 0.2.0 — 2026-07-01' '' 'an older run'
|
||||
check "drill: a bare VERSION with a non-empty record for it passes" 0 \
|
||||
"records a drill for 0.3.0" drill "$T"
|
||||
|
||||
# The gate itself: a release tree with no drills/ directory at all. RED,
|
||||
# naming the version, because "which release is unevidenced" is the only fact
|
||||
# the author needs. This is the state a repo is in the first time it cuts a
|
||||
# release under the gate — it must read as a to-do, not a broken invocation.
|
||||
T="$(drilltree norecord 0.3.0)"
|
||||
check "drill: a bare VERSION with NO drills dir FAILS" 1 \
|
||||
"no drill record" drill "$T"
|
||||
check "drill: ...and the failure names the version" 1 "VERSION is 0.3.0" drill "$T"
|
||||
check "drill: ...and names the file it wanted" 1 "drills/0.3.0.md" drill "$T"
|
||||
|
||||
# A drills/ that exists but holds nothing for THIS version. Same failure —
|
||||
# other releases having been drilled says nothing about this one.
|
||||
T="$(drilltree otherversion 0.4.0)"
|
||||
drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the previous release'
|
||||
check "drill: a drills dir with no file for THIS version FAILS" 1 \
|
||||
"no drill record" drill "$T"
|
||||
check "drill: ...naming the version that is unevidenced" 1 "VERSION is 0.4.0" drill "$T"
|
||||
|
||||
# ...and the failure has to say how to get out of it. Both moves are a commit
|
||||
# on the PR, and the second one is the point of asking for a RECORD rather
|
||||
# than a RESULT: a waiver is allowed, it just cannot be silent.
|
||||
check "drill: ...and the failure names the unblock — run the drill" 1 \
|
||||
"RUN THE DRILL" drill "$T"
|
||||
check "drill: ...and the waiver, recorded, as the other way out" 1 \
|
||||
"MAINTAINER WAIVER" drill "$T"
|
||||
check "drill: ...and points at the README for what a record contains" 1 \
|
||||
"README.md" drill "$T"
|
||||
|
||||
# An EMPTY file at the right path. This is the failure a laxer guard invites —
|
||||
# the ceremony PR touches the file to get green and fills it in never.
|
||||
T="$(drilltree emptyfile 0.3.0)"
|
||||
mkdir -p "$T/drills"; : > "$T/drills/0.3.0.md"
|
||||
check "drill: a PRESENT but EMPTY record FAILS" 1 "no drill record" drill "$T"
|
||||
|
||||
# ...and WHITESPACE is not a record either. This is the ONE piece of the old
|
||||
# section-parsing rule set that splitting the files did not make
|
||||
# unrepresentable, so it is the one that still needs a test. It is here because
|
||||
# the siblings got it wrong: box#149 and cast#138 both extracted with
|
||||
# `sed '/./,$!d'`, where `.` matches a space, so one tab satisfied the gate and
|
||||
# shipped an evidence-free release. All three reviewers caught it there.
|
||||
# Nothing caught it here, because there was nothing to catch — which is exactly
|
||||
# the state in which a later simplification quietly reintroduces it.
|
||||
T="$(drilltree blank 0.3.0)"
|
||||
drillrec "$T" 0.3.0 ' ' ' ' ''
|
||||
check "drill: a record of only spaces, tabs and newlines FAILS (box#149, cast#138)" \
|
||||
1 "no drill record" drill "$T"
|
||||
|
||||
# The version is matched WHOLE, both directions — and now the filesystem does
|
||||
# it, since the version IS the filename. A drill run against a release
|
||||
# candidate is not evidence for the final release, and the reverse is equally
|
||||
# false: in both cases the string that matched is not the artefact that ships.
|
||||
T="$(drilltree whole-rc 0.3.0)"
|
||||
drillrec "$T" 0.3.0-rc1 '# Release drill — 0.3.0-rc1' '' 'the rc drill'
|
||||
check "drill: an -rc1 record does NOT satisfy the bare version" 1 \
|
||||
"no drill record" drill "$T"
|
||||
T="$(drilltree whole-final 0.3.0-rc1)"
|
||||
drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the final drill'
|
||||
check "drill: ...and a bare-version record does NOT satisfy the -rc1" 1 \
|
||||
"no drill record" drill "$T"
|
||||
|
||||
# A missing VERSION file is a wrong invocation, not a degradation — there is
|
||||
# no version to be lenient about, so it must never read as a pass.
|
||||
T="$(drilltree noversion 0.3.0)"
|
||||
rm -f "$T/VERSION"
|
||||
check "drill: a missing VERSION file is an error, never a pass" 1 "no such file" drill "$T"
|
||||
|
||||
# The real files, last. The shipped README must be readable, and the guard the
|
||||
# repo actually runs must pass on the VERSION the repo actually carries.
|
||||
check "drills/README.md: exists" 0 "" test -f "$ROOT/drills/README.md"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "drills/README.md: documents the one-file-per-version naming rule" 0 "" \
|
||||
bash -c 'grep -qF "drills/<version>.md" "$1"' _ "$ROOT/drills/README.md"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "drills/README.md: says a FAILED drill is still a valid record" 0 "" \
|
||||
bash -c 'grep -qi "failed drill is still a valid record" "$1"' _ "$ROOT/drills/README.md"
|
||||
# The old single-file record must be gone, not merely unreferenced: a stale
|
||||
# drill/RUNS.md would be a second place to write a record that nothing reads.
|
||||
check "drill/RUNS.md: is gone — records live one per version now" 1 "" \
|
||||
test -e "$ROOT/drill/RUNS.md"
|
||||
# The property is that the guard's VERDICT IS CORRECT FOR THIS TREE — not that
|
||||
# it always passes. Those come apart on a ceremony tree: a -dev tree is vacuous
|
||||
# and must pass, but a ceremony tree passes only once a human has run the drill
|
||||
# and written the record, which is the entire point of the gate. Asserting
|
||||
# exit 0 unconditionally made test/release.sh UN-GREENABLE on every release
|
||||
# branch before its drill, and surfaced as a `release-flow tests` failure rather
|
||||
# than as the gate doing its job — the same misattribution shape as box#146,
|
||||
# where a fixture read the repo's real VERSION and only misbehaved on the
|
||||
# ceremony tree. Caught when box#148 went red for the wrong-looking reason.
|
||||
THIS_VER="$(tr -d '[:space:]' < "$ROOT/VERSION")"
|
||||
case "$THIS_VER" in
|
||||
*-dev)
|
||||
check "drill-recorded.sh: THIS tree is -dev, and the guard is vacuous on it" 0 "" \
|
||||
bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" ;;
|
||||
*)
|
||||
if [ -s "$ROOT/drills/$THIS_VER.md" ]; then
|
||||
check "drill-recorded.sh: THIS ceremony tree HAS its record, and the guard accepts it" 0 "" \
|
||||
bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION"
|
||||
else
|
||||
check "drill-recorded.sh: THIS ceremony tree has NO record yet, and the guard refuses it" 1 "no drill record at" \
|
||||
bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION"
|
||||
fi ;;
|
||||
esac
|
||||
# ...and with no arguments at all, since that is how ci.yml invokes it. The
|
||||
# defaults must be the paths this repo actually uses.
|
||||
# ...and with its DEFAULT arguments, as CI runs it. What this pins is that the
|
||||
# defaults ARE the paths this repo uses — so it asserts the defaults reach the
|
||||
# same verdict as the explicit call above, not a fixed exit code. Hard-coding 0
|
||||
# here would fail on a ceremony tree for the same wrong reason the check above
|
||||
# used to.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "drill-recorded.sh: ...and its DEFAULT arguments agree, as CI runs it" 0 "" \
|
||||
bash -c '
|
||||
cd "$1" || exit 9
|
||||
bash .github/scripts/drill-recorded.sh >/dev/null 2>&1; d=$?
|
||||
bash .github/scripts/drill-recorded.sh drills VERSION >/dev/null 2>&1; e=$?
|
||||
[ "$d" -eq "$e" ]' _ "$ROOT"
|
||||
|
||||
# ci.yml: the guard runs from there, so pin the wiring — a script nothing
|
||||
# invokes is not a check (same reasoning as the monotonic pins above).
|
||||
check "ci.yml: runs the drill guard" 0 "" grep -q "drill-recorded.sh" "$CIY"
|
||||
# ...and is NOT trigger-gated. It is vacuous on every -dev tree already, so an
|
||||
# `if:` could only ever exempt the one tree it exists for.
|
||||
drill_step_block() {
|
||||
awk '/^ - name: a release version has a recorded drill/ {f=1; print; next}
|
||||
f && (/^ - / || /^ [^ ]/) {exit}
|
||||
f {print}' "$CIY"
|
||||
}
|
||||
drill_step_gated() { drill_step_block | grep -q '^ if:'; }
|
||||
check "ci.yml: the drill step itself is NOT trigger-gated" 1 "" drill_step_gated
|
||||
check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \
|
||||
"drill-recorded" drill_step_block
|
||||
|
||||
# CONTRIBUTING must state the gate, and must state what the drill actually is.
|
||||
# The three repos' drills are INDEPENDENT — run in any order, on any schedule —
|
||||
# and what makes that safe is that each one pins the same fixed set of
|
||||
# CANDIDATE refs, so box and rig measure the same pair. That, not sequencing,
|
||||
# is what dissolves the mutual recursion (rig builds the host box runs on, and
|
||||
# box mints the seeds rig converges). An earlier draft of this doc claimed a
|
||||
# fixed box → rig → cast release order; it is wrong, and this pins the
|
||||
# correction.
|
||||
CONTRIB="$ROOT/CONTRIBUTING.md"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: the release flow names the drill gate" 0 "" \
|
||||
bash -c 'grep -qF "drills/<version>.md" "$1"' _ "$CONTRIB"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...and says the three repos' drills are INDEPENDENT" 0 "" \
|
||||
bash -c 'grep -qi "drills are independent" "$1"' _ "$CONTRIB"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...pinned to one fixed set of candidate refs" 0 "" \
|
||||
bash -c 'grep -qi "same fixed set of candidate refs" "$1"' _ "$CONTRIB"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...which is what dissolves the recursion, not sequencing" 0 "" \
|
||||
bash -c 'grep -qF "RIG_REF" "$1"' _ "$CONTRIB"
|
||||
# The negative that keeps the correction from being re-lost: no fixed release
|
||||
# order may be claimed. Nothing requires box to ship before rig.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...and never claims a fixed box-then-rig release order" 1 "" \
|
||||
bash -c 'grep -qi "box first, then rig" "$1"' _ "$CONTRIB"
|
||||
|
||||
# --- release.yml: the pins ---------------------------------------------------
|
||||
# The workflow itself runs only on a tag push upstream, so pin its
|
||||
# load-bearing pieces the way the harness pins root-only paths (repo
|
||||
# precedent: the tag-refusal greps in test/cli.sh).
|
||||
RY="$ROOT/.github/workflows/release.yml"
|
||||
check "release.yml: exists" 0 "" test -f "$RY"
|
||||
check "release.yml: triggers on tag pushes" 0 "" grep -q "tags:" "$RY"
|
||||
check "release.yml: sources the shared lib (one extractor, not a copy)" 0 "" \
|
||||
grep -q "release-lib.sh" "$RY"
|
||||
check "release.yml: the body comes from changelog_section" 0 "" \
|
||||
grep -q "changelog_section CHANGELOG.md" "$RY"
|
||||
check "release.yml: a tag/VERSION mismatch refuses to create" 0 "" \
|
||||
grep -q "refusing to create a release" "$RY"
|
||||
check "release.yml: an empty changelog section refuses too" 0 "" \
|
||||
grep -q "has no '## " "$RY"
|
||||
check "release.yml: gh release create verifies the tag" 0 "" \
|
||||
grep -q -- "--verify-tag" "$RY"
|
||||
# Ordering: the mismatch assert must precede the create (line compare, the
|
||||
# repo's marker-then-box idiom; defaults fail closed).
|
||||
assert_at="$(grep -n "refusing to create a release" "$RY" | head -n1 | cut -d: -f1)"
|
||||
create_at="$(grep -n "gh release create" "$RY" | head -n1 | cut -d: -f1)"
|
||||
check "release.yml: the assert precedes the create" \
|
||||
0 "" test "${assert_at:-999999}" -lt "${create_at:-0}"
|
||||
|
||||
# --- release.yml, the merge path: the pins (#47; box#96's design) ------------
|
||||
# Merging the release-labeled ceremony PR IS the release. Same grep-pin
|
||||
# treatment for the merge path's load-bearing pieces: the gate, the four
|
||||
# fail-loud asserts, the same-job tag+publish, and the surviving tag-push
|
||||
# fallback.
|
||||
# The merge door rides pushes to MAIN, not pull_request events: a fork PR's
|
||||
# pull_request run gets a read-only GITHUB_TOKEN (permissions: cannot raise
|
||||
# it), and every ceremony PR this org merges is cross-repo from the bot
|
||||
# fork — the tag create would 403 after green asserts (#48 round 1). The
|
||||
# label — the operator's intent — is read via the API off the merge commit.
|
||||
check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \
|
||||
grep -qF "branches: [main]" "$RY"
|
||||
# YAML maps are last-key-wins: a second sibling push: key silently replaces
|
||||
# the first and kills a door (grok's round-2 catch — the tag fallback had
|
||||
# stopped triggering). Exactly ONE push key may exist.
|
||||
check "release.yml: exactly one on.push key (duplicate keys drop a door)" 0 "1" \
|
||||
grep -cE '^ push:' "$RY"
|
||||
check "release.yml: ...and the doors split on the ref (tag door takes tags)" 0 "" \
|
||||
grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: the release label is read via the API off the merge commit" 0 "" \
|
||||
grep -qF 'commits/$MERGE_SHA/pulls' "$RY"
|
||||
check "release.yml: a transition without a labeled PR refuses" 0 "" \
|
||||
grep -qF "no merged, release-labeled PR is behind this commit" "$RY"
|
||||
# The decide step tells the label's two meanings apart (LABELS.md gives
|
||||
# `release` to release-flow WORK as well as to the ceremony PR): work under
|
||||
# the label is a green NOTICE no-op — in the -dev steady state and in the
|
||||
# post-release window (bare, unchanged, already released) — while every
|
||||
# half-ceremony refuses. Pin each verdict's message and the gating output.
|
||||
check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \
|
||||
grep -qF "release-flow work under the release label, not a ceremony" "$RY"
|
||||
check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \
|
||||
grep -qF "a dev tree is by definition not a release" "$RY"
|
||||
check "release.yml: decide — post-release-window work no-ops green" 0 "" \
|
||||
grep -qF "release-flow work merged in the post-release window" "$RY"
|
||||
check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \
|
||||
grep -qF "Refusing to guess" "$RY"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "release.yml: decide gates every later step on ceremony=yes" 0 "" \
|
||||
bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$RY"
|
||||
check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \
|
||||
grep -qF "refusing to publish an empty release" "$RY"
|
||||
check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \
|
||||
grep -qF "refusing to re-release" "$RY"
|
||||
# Same-job matters: a GITHUB_TOKEN-created tag fires no tag-push workflow,
|
||||
# so the publish must live NEXT TO the tag creation. The workflow keeps
|
||||
# release-on-merge as its last job (pinned by comment there) so the awk
|
||||
# range runs to EOF; both acts must land inside it.
|
||||
MJOB="$(awk '/^ release-on-merge:/,0' "$RY")"
|
||||
mjob_has() { printf '%s' "$MJOB" | grep -qF -e "$1"; }
|
||||
check "release.yml: the merge job API-creates the tag itself" 0 "" \
|
||||
mjob_has "git/refs"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: ...at the pushed main head (github.sha = the merge commit)" 0 "" mjob_has 'sha="$MERGE_SHA"'
|
||||
# The release re-arms main itself: the post-release -dev bump is arithmetic,
|
||||
# not judgment, so it rides the same job — direct push, PR fallback.
|
||||
check "release.yml: the release bumps main to the next -dev itself" 0 "" \
|
||||
grep -qF "bump main to the next -dev" "$RY"
|
||||
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
|
||||
grep -qF "opening the bump PR instead" "$RY"
|
||||
check "release.yml: ...and publishes in the SAME job" 0 "" \
|
||||
mjob_has "gh release create"
|
||||
# Ordering, the marker-then-box idiom again: the last assert's refusal must
|
||||
# precede the tag creation (asserts first, acts last; defaults fail closed).
|
||||
massert_at="$(grep -n "refusing to re-release" "$RY" | head -n1 | cut -d: -f1)"
|
||||
mtag_at="$(grep -n "git/refs" "$RY" | head -n1 | cut -d: -f1)"
|
||||
check "release.yml: the merge-path asserts precede the tag" \
|
||||
0 "" test "${massert_at:-999999}" -lt "${mtag_at:-0}"
|
||||
# ...and the manual path SURVIVES: tag-push trigger plus a push-gated job,
|
||||
# the documented fallback and backfill.
|
||||
check "release.yml: the tag-push trigger survives (manual fallback intact)" 0 "" \
|
||||
grep -qF "tags: ['**']" "$RY"
|
||||
check "release.yml: the fallback job is gated to push events" 0 "" \
|
||||
grep -qF "github.event_name == 'push'" "$RY"
|
||||
|
||||
# --- the installer's ref logic, extracted ------------------------------------
|
||||
# install.sh must stay a single curl|bash file, so its channel functions live
|
||||
|
|
|
|||
Loading…
Reference in a new issue