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