Merge branch 'main' into build/115-changelog-armed-fragment-mode

This commit is contained in:
Daniel Marin 2026-07-24 11:11:03 +01:00 committed by GitHub
commit fc070ea896
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 718 additions and 21 deletions

View file

@ -73,6 +73,7 @@ jobs:
fetch-depth: 0
- uses: ./actions/changelog-armed
- uses: ./actions/changelog-monotonic
- uses: ./actions/changelog-assembled
- uses: ./actions/drill-recorded
- uses: ./actions/runner-isolated

View file

@ -15,7 +15,7 @@ triage bug, and the move is to say so on the issue, not to guess.
writing or revising a deliverable — finish or release that work before
starting new work. The rule counts build work in flight, not claims: a
claim does not consume the slot while it is **parked**, meaning the next
move belongs to someone else. Exactly four shapes qualify:
move belongs to someone else. Exactly five shapes qualify:
1. the issue carries `needs-ruling`, its escalation names a decider, and
its `Blocked:` line stops the remaining work;
2. the deliverable is in a review round where every outstanding verdict
@ -28,6 +28,10 @@ triage bug, and the move is to say so on the issue, not to guess.
4. the deliverable is **handed off** — the round passed, no `blocker:*`
stands, and you set `state:needs-human` per Handoff (below). The
remaining move is the human's merge.
5. the claim is **held by directive** — triage or the operator has told
you to stop, the direction names what the hold waits on, and that thing
is not yours to move. This is not "waiting for a good moment": somebody
else has decided the work must not proceed, and only they end it.
Not parked — these are what the rule defends against: waiting on
yourself, waiting on CI, or waiting for a good moment. An issue you have
simply stopped working on is not parked either — that is abandonment,
@ -63,7 +67,14 @@ triage bug, and the move is to say so on the issue, not to guess.
- **Pick up `attention` before anything else.** On your claim, first post a
short pickup comment and remove `attention`; the removal is the ack. A
demand on a parked claim is usually its unpark, so take the slot back under
the existing rule below rather than leaving the demand parked.
the existing rule below rather than leaving the demand parked. A demand
that *is* the park is different: the pickup comment is the declaration,
so one comment does both jobs, and the demand does not take the slot back.
- **A directed hold keeps its bookkeeping visible.** The PR carries `blocked`
with a comment naming what it waits on; the issue stays `claimed` and
carries `attention` until the builder acknowledges it. Nobody unassigns
the issue, and the 48-hour reclaim does not fire because the claim has an
open PR. Unparking follows the existing rule below.
- **Unparking is a claim like any other.** When the wait ends, the parked
issue is work again and takes the slot. If you are already active
elsewhere, finish or release that work first, and say which you did on

View file

@ -7,10 +7,14 @@ so entries say what changed, cite the issue, and stop.
## Unreleased
- `changelog-armed` — treat `changelog.d/` as the arming, validate every development fragment, and require bare releases to consume the directory into their exact publishable section (#115).
- `actions/changelog-assembled` — a release PR's stamped section must be byte-for-byte what the fragments it consumed assemble to, replayed from the merge base; inapplicable trees pass with a NOTICE (#116).
- `lib/changelog.sh` + `bin/changelog-assemble` — read the `changelog.d/` fragments, assemble one release section (canonical group order, one shape per repo), and consume exactly what was published (#114).
- BUILDER.md — the directed hold is the parked claim's fifth shape, its attention demand is acknowledged in the declaration comment, and its board bookkeeping covers in-flight work; TRIAGE.md no longer excludes it (#113).
- BUILDER.md — the handed-off PR is the parked claim's fourth shape, its handoff is its declaration, and shape 2 covers the round awaiting its first verdicts (#109).
- `labels-reconcile` — a degraded mergeability/checks read now logs gh's actual stderr (collapsed, bounded) beside the byte-identical counted line, and the blind-sweep warning leads with the observed reason instead of asserting the permissions cause (#101).
- `LABELS.md` — drop the vendored scope-table enumeration; the per-repo set lives in `.github/labels.conf` and the repo's own CONTRIBUTING (#104).
- Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98).
- `labels-reconcile` — warn once per sweep when a repository lacks labels declared by the pinned core taxonomy (#105).
- `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95).
- `labels-reconcile` — the bootstrap now retires the six GitHub defaults `LABELS.md` publishes as deleted, tolerating both an already-absent label and a refused delete (#93).
- `issueflow-reconcile` — a triage-authored issue arrival stands down with exit 0 instead of killing the run before the sweep (#91).

View file

@ -86,7 +86,11 @@ roster is config, not doctrine — swapping a vendor is an edit to this table
(and to `panel=` in `.github/labels.conf` once #10 lands), nothing more.
Each governed repo names its own roster in its CONTRIBUTING; this one is
ceremony's.
ceremony's. Its `scope:*` set is the same kind of repo-specific fact:
ceremony's scopes are defined in [`.github/labels.conf`](.github/labels.conf)
— one `name|color|description` row each, with PR path mapping in
[`.github/labeler.yml`](.github/labeler.yml). The conf is the set; no prose
table repeats it (#104).
## Code conventions

View file

@ -170,15 +170,13 @@ re-arms mentions, but only the writer can declare that a move is owed (#83).
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
actions/labeler, issues get theirs from triage. This repo's set:
| Label | Covers |
|---|---|
| `scope:release-flow` | the reusable release workflow, decide, the doors |
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
set is per-repo: PRs get theirs from changed paths via actions/labeler, issues
get theirs from triage. This file never enumerates a set — it is mirrored
byte-identically into every governed repo, and any list it carried would be
true in one repo and false in the rest (#104). The set for the repo you are
standing in lives in the two places that are true wherever you read them: its
`.github/labels.conf` (the definitions, one `name|color|description` row per
scope) and its own `CONTRIBUTING.md`, beside the other repo-specific facts.
## Issue types
@ -190,7 +188,8 @@ on a PR would say the same thing twice and drift.
The labels workflow (issue #10) recomputes PR state statelessly on PR events
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`, `invalid`,
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
bootstrap — a `question` is a discussion, not an issue.
manual dispatch. The sweep warns when the core taxonomy declares a label the
repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` is a discussion, not an issue.

View file

@ -44,9 +44,9 @@ is the failure this whole flow exists to prevent.
blocks something already on the board — an existing issue, or minted work
a discussion's ruling gates — set `needs-ruling` on it too, so the board
shows where the human's turn is; the issue keeps its queue label.
When you direct a builder to hold a claim that has no build work left,
say the claim is **parked** and name what it waits on — the directive
and the builder's doctrine
When you direct a builder to hold a claim, say the claim is **parked**,
name what it waits on, and set `attention` so the assignee's ack is visible
on the board — the directive and the builder's doctrine
([BUILDER.md](BUILDER.md#claiming)) must use one word.
Past 24 hours from the current episode's `labeled` event, if the ruling
still stands and doubt remains, it is triage's duty to pick the option the

View file

@ -0,0 +1,53 @@
name: Changelog assembled
description: >-
Assert a release PR's stamped section is exactly what the changelog.d/
fragments it consumed assemble to — the fragments as of the MERGE BASE
are replayed through the assembler's --check and compared byte-for-byte
against the section on HEAD (#116; the fragment flow is #112). Needs the
HISTORY: the caller's checkout must use fetch-depth: 0, or the base ref
will not resolve and strict mode (the default — CI must never skip)
fails red with a message naming that fix. Inapplicable trees — a -dev
tree, a legacy repo with no fragments directory at the merge base — pass
with a NOTICE, never a silent skip.
inputs:
base-ref:
description: >-
The ref the fragment set is read relative to. The default resolves
the event's base branch — the PR's target on pull_request, the
pushed branch itself on push (where the merge base IS HEAD and the
check is vacuous by construction, named honestly in the log).
required: false
default: origin/${{ github.base_ref || github.ref_name }}
changelog:
description: Path to the changelog, relative to the workspace
required: false
default: CHANGELOG.md
fragments-dir:
description: Path to the fragments directory, relative to the workspace
required: false
default: changelog.d
version-source:
description: Where the tree's version lives ("file" or "package-json")
required: false
default: file
strict:
description: >-
"1" (the default) makes an unresolvable base ref a hard failure
instead of a loud skip — a guard that can quietly stop guarding is
the failure shape this family of checks exists to refuse. The local
default in the script itself stays "0", so a plain working-copy run
degrades sensibly.
required: false
default: "1"
runs:
using: composite
steps:
- name: changelog assembled
shell: bash
env:
CHANGELOG_ASSEMBLED_BASE: ${{ inputs.base-ref }}
CHANGELOG: ${{ inputs.changelog }}
CHANGELOG_ASSEMBLED_DIR: ${{ inputs.fragments-dir }}
VERSION_SOURCE: ${{ inputs.version-source }}
CHANGELOG_ASSEMBLED_STRICT: ${{ inputs.strict }}
run: bash "$GITHUB_ACTION_PATH/changelog-assembled.sh"

View file

@ -0,0 +1,251 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-assembled.sh [<base-ref>] [<changelog>] [<fragments-dir>] [<version-source>]
# — assert that a release PR's stamped section is EXACTLY what the fragments
# it consumed assemble to: read the fragments as of the MERGE BASE (they are
# gone from HEAD's tree — that is the point of the ceremony), replay the
# assembler's --check over that set, and compare byte-for-byte against
# changelog_section on HEAD (#116; the fragment flow is #112).
#
# The failure it exists to catch leaves no trace — the shape every guard in
# this family was bought by. bin/changelog-assemble is run BY HAND in the
# release PR, deliberately: the assembled section must land in the PR diff
# where the panel reads it (#114). Drop one fragment from the deletion and
# its entry is simply absent from the release: the file is well-formed,
# changelog-armed is green (the section exists and has prose),
# changelog-monotonic is green (no heading was deleted), and the publisher
# happily publishes the shortened section. Hand-edit one word of the
# assembled prose and the published history quietly stops being what the
# authors wrote. The only way anyone finds out is by reading the release
# body against a directory that no longer exists.
#
# Why this cannot live in changelog-armed: "the section matches the
# fragments it consumed" is not a property of a TREE — no single tree holds
# both the fragments and the section they became. It is a property of a
# DIFF: what existed at the merge base versus what HEAD stamped. That is
# exactly the argument changelog-monotonic made for being its own git-aware
# action rather than a clause inside changelog-armed, and this is the third
# guard on the same reasoning. changelog-armed.sh stays drivable against
# constructed two-file trees that are not git repos at all.
#
# The date is never compared as prose: --check prints the section BODY with
# no '## ' heading, and changelog_section extracts the body below HEAD's
# heading — so the date HEAD stamped into its heading never enters the
# comparison, and a date difference can never masquerade as a prose one.
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
dir="${3:-${CHANGELOG_ASSEMBLED_DIR:-changelog.d}}"
version_source="${4:-${VERSION_SOURCE:-file}}"
# Fail-closed switch, changelog-monotonic's stance exactly: CI sets it (the
# action defaults strict to "1"), so a degradation that is sensible on a
# laptop becomes a red run there. A guard that can quietly stop guarding is
# the failure shape this whole family of checks exists to refuse.
strict="${CHANGELOG_ASSEMBLED_STRICT:-0}"
# The shared libs and the assembler travel with this action: a consumer's
# `uses: heavy-duty/ceremony/actions/changelog-assembled@<tag>` downloads
# this whole repository at that ref, so ../../lib and ../../bin are always
# present and always at the same ref — no checkout step, no version skew.
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/version.sh
. "$here/../../lib/version.sh"
# shellcheck source=lib/changelog.sh
. "$here/../../lib/changelog.sh"
assemble="$here/../../bin/changelog-assemble"
skip() {
if [ "$strict" = "1" ]; then
echo "changelog-assembled: $* — and CHANGELOG_ASSEMBLED_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 " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
exit 1
fi
echo "changelog-assembled: SKIPPED — $*"
echo " (Everything this guard asserts compares HEAD against the merge base —"
echo " without the history there is nothing it can honestly say. In CI this"
echo " same condition is a hard failure.)"
exit 0
}
# A pass with a NOTICE, never a skip in silence: an inapplicable tree is a
# legitimate green, and the log says why instead of implying a check ran.
notice() {
echo "changelog-assembled: NOTICE — $*"
exit 0
}
[ -f "$changelog" ] || { echo "changelog-assembled: no such file: $changelog" >&2; exit 1; }
# --- everything here needs the HISTORY ---------------------------------------
# Even applicability does: "fragment mode" is a fact about the merge base,
# not about HEAD's tree, whose fragments are consumed by construction. So
# unlike changelog-monotonic there is no history-free half to run first —
# an unusable checkout degrades (or, under STRICT, refuses) before the
# guard claims anything.
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| skip "not inside a git work tree, so there is no merge base to read the fragments from"
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)"
short_base="$(git rev-parse --short "$merge_base")"
# Push-to-main shape: the merge base IS HEAD, so the fragment set this guard
# would replay is HEAD's own — nothing was consumed between the two points,
# and comparing a tree against itself would assert nothing. Named honestly,
# the same discipline as changelog-monotonic's vacuous line.
if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then
echo "changelog-assembled: vacuous (the merge base IS HEAD, so no fragments were consumed between them — there is no diff for the section to answer to)."
exit 0
fi
# --- applicability: the ceremony PR, and nothing else ------------------------
if ! git cat-file -e "$merge_base:$dir" 2>/dev/null; then
notice "no '$dir/' at the merge base ($short_base) — legacy mode; the changelog is edited directly and there is no fragment set for a section to answer to"
fi
# version_read refuses loudly on a missing or empty source; the wrapper line
# names the guard so a workflow log shows which check refused.
ver="$(version_read "$version_source")" || {
echo "changelog-assembled: cannot read the version (version-source: $version_source)" >&2
exit 1
}
if version_is_dev "$ver"; then
notice "version '$ver' is a development tree — no release section is being stamped; whatever this PR does to '$dir/' is cargo for a future ceremony, not a consumption to verify"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
git show "$merge_base:$changelog" >"$tmp/base-changelog.md" 2>/dev/null || : >"$tmp/base-changelog.md"
# A branch that merely SITS on a release is not the ceremony that stamped
# it: right after a release merges, main's version is bare until the -dev
# bump lands, and a PR branched in that window would otherwise be asked to
# answer for a consumption that happened at its merge base, not on it. If
# the section already existed at the merge base, this branch did not stamp
# it. Whole-version match, as everywhere in this family.
if awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$tmp/base-changelog.md"; then
notice "the section for '$ver' already exists at the merge base ($short_base) — this branch is not the ceremony that stamped it, so there is no consumed set to answer to"
fi
# --- the replay: the merge base's fragment set, byte for byte ----------------
# Every blob in the directory is extracted — strays included, and subtrees
# recreated — so the replay refuses exactly what a real assembler run over
# that tree would have refused, instead of quietly narrowing the set.
mkdir -p "$tmp/$dir"
base_frags=""
while IFS= read -r -d '' entry; do
meta="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
name="${path##*/}"
case "$otype" in
blob)
git show "$merge_base:$path" >"$tmp/$dir/$name"
case "$name" in
README.md) ;;
*.md) base_frags="${base_frags}${path}"$'\n' ;;
esac
;;
tree)
mkdir -p "$tmp/$dir/$name"
;;
esac
done < <(git ls-tree -z "$merge_base" -- "$dir/")
frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
failures=0
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
# ceremony deletes exactly what it assembles (#112) — a fragment that
# survives its own release sits in the directory and is assembled AGAIN
# into the NEXT section, republishing its prose as if it were new.
survivors=""
while IFS= read -r p; do
[ -n "$p" ] || continue
if [ -e "$p" ]; then
survivors="${survivors} ${p}"$'\n'
fi
done <<<"$base_frags"
if [ -n "$survivors" ]; then
{
echo "changelog-assembled: fragment(s) present at the merge base ($short_base) are STILL PRESENT on HEAD:"
echo
printf '%s' "$survivors"
echo
echo " The ceremony deletes exactly what it assembles. A fragment that survives"
echo " its own release is assembled AGAIN into the next section, republishing"
echo " its prose as if it were new. Delete it in this PR — its entry is (or"
echo " should be) already in the stamped section."
} >&2
failures=$((failures + 1))
fi
expected=""
if expected="$( (cd "$tmp" && "$assemble" "$ver" --check --changelog "$tmp/base-changelog.md" --dir "$dir") 2>&1)"; then
found="$(changelog_section "$changelog" "$ver")"
if [ -z "$found" ]; then
{
echo "changelog-assembled: the version is '$ver' (a release tree) and '$dir/' at the"
echo " merge base ($short_base) holds $frag_count fragment(s), but $changelog has no"
echo " non-empty section for '$ver' on HEAD."
echo
echo " The fragments were consumed and their prose went nowhere: the release"
echo " this tree is about to publish would have a body the authors never got"
echo " to write. The ceremony's edit is one tool run, in this PR:"
echo
echo " bin/changelog-assemble '$ver'"
} >&2
failures=$((failures + 1))
else
if ! diff_out="$(diff -u \
--label "expected — assembled from the $frag_count fragment(s) at the merge base ($short_base)" \
--label "found — section '$ver' in $changelog on HEAD" \
<(printf '%s\n' "$expected") <(printf '%s\n' "$found"))"; then
{
echo "changelog-assembled: the section '$ver' in $changelog is NOT what the fragments it consumed assemble to:"
echo
printf '%s\n' "$diff_out" | sed 's/^/ /'
echo
echo " The release body is published verbatim from this section, so any"
echo " difference here ships: a missing line is an author's entry silently"
echo " dropped from history, an extra or edited line is prose nobody wrote,"
echo " and a re-ordering is not the canonical order the assembler produces."
echo " The fix is to redo the ceremony's edit with the tool — re-run"
echo " bin/changelog-assemble '$ver' <date> from the merge base's fragments —"
echo " never to hand-edit the section into agreement."
} >&2
failures=$((failures + 1))
fi
fi
else
{
echo "changelog-assembled: the merge base's fragment set does not assemble — the replay refuses:"
echo
printf '%s\n' "$expected" | sed 's/^/ /'
echo
echo " The set replayed is exactly '$dir/' as of the merge base ($short_base)."
echo " A section that bin/changelog-assemble would refuse to produce cannot"
echo " have been produced by it — whatever stamped this section did it by"
echo " hand, and the release body cannot be trusted to be what the fragment"
echo " authors wrote."
} >&2
failures=$((failures + 1))
fi
[ "$failures" -eq 0 ] || exit 1
echo "changelog-assembled: section '$ver' in $changelog is byte-for-byte the assembly of the $frag_count fragment(s) consumed at the merge base ($short_base)"

View file

@ -96,6 +96,21 @@ read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101)
fi
}
missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names
local rows="$1" repo_labels="$2" row name missing=""
[ -n "$repo_labels" ] || return 0
while IFS= read -r row; do
[ -n "$row" ] || continue
name="${row%%|*}"
if ! grep -qxF "$name" <<<"$repo_labels"; then
if [ -n "$missing" ]; then missing="$missing, $name"; else missing="$name"; fi
fi
done <<<"$rows"
if [ -n "$missing" ]; then
echo "::warning::labels: missing core label(s): $missing; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy"
fi
}
load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
local conf="$1" line panel_seen=false
[ -f "$conf" ] || {
@ -627,6 +642,7 @@ main() {
# 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"
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
local n output status total=0 unreadable=0 sampled_reason=""
while IFS= read -r n; do

View file

@ -319,7 +319,10 @@ rows remain consumer-owned because paths and surfaces differ by repository.
After adding the caller and configuration, run `workflow_dispatch` once to
bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only
reconcile; they do not repeatedly upsert the taxonomy.
reconcile; they do not repeatedly upsert the taxonomy. When a ceremony pin
bump adds a core label, bump the pin first and then re-dispatch
`workflow_dispatch`; the scheduled sweep warns when the pinned taxonomy
declares a core label the repository lacks.
## Doctrine mirror

View file

@ -0,0 +1,325 @@
#!/usr/bin/env bash
# Contract tests for actions/changelog-assembled (issue #116). Like the
# monotonic guard's suite, every applicable case is a constructed git repo —
# "the section matches the fragments it consumed" is a property of a DIFF:
# the fragments are gone from HEAD's tree by construction, so the fixture is
# a history: a base commit holding the fragments and a -dev version, a HEAD
# commit holding the ceremony's edit, and a 'base' branch standing in for
# origin/main. set -u, not -e: failing commands are behavior for the
# harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
SCRIPT="$ROOT/actions/changelog-assembled/changelog-assembled.sh"
ARMED="$ROOT/actions/changelog-armed/changelog-armed.sh"
MONOTONIC="$ROOT/actions/changelog-monotonic/changelog-monotonic.sh"
ASSEMBLE="$ROOT/bin/changelog-assemble"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
init_repo() {
local dir="$TMP/$1"
mkdir -p "$dir"
git -C "$dir" init -q -b main
git -C "$dir" config user.email ci@example.invalid
git -C "$dir" config user.name ci
}
# commit_base <name> — commit the tree as the merge base and pin 'base' there.
commit_base() {
git -C "$TMP/$1" add -A
git -C "$TMP/$1" commit -qm base
git -C "$TMP/$1" branch base
}
commit_head() {
git -C "$TMP/$1" add -A
git -C "$TMP/$1" commit -qm head
}
# seed_flat <name> — the shared pre-ceremony tree: an armed changelog, two
# flat fragments (numeric order will put 12 before 9), a -dev version.
seed_flat() {
local name="$1" dir="$TMP/$1"
init_repo "$name"
mkdir -p "$dir/changelog.d"
printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$dir/changelog.d/README.md"
cat >"$dir/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.1.1-dev\n' >"$dir/VERSION"
printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md"
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md"
commit_base "$name"
}
# ceremony <name> <ver> <date> — the faithful release edit on the working
# tree, exactly as a human runs it: the real assembler in write mode, then
# the bare version stamp. Deliberately does NOT commit, so a case can break
# the tree before committing HEAD.
ceremony() {
local name="$1" ver="$2" stamp="$3"
(cd "$TMP/$name" && "$ASSEMBLE" "$ver" "$stamp" >/dev/null 2>&1) || return 1
printf '%s\n' "$ver" >"$TMP/$name/VERSION"
}
run() { local name="$1"; shift; (cd "$TMP/$name" && bash "$SCRIPT" "$@"); }
run_strict() { local name="$1"; shift; (cd "$TMP/$name" && CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT" "$@"); }
# --- the documented flow passes ----------------------------------------------
seed_flat faithful-flat
ceremony faithful-flat 0.2.0 2026-07-24
commit_head faithful-flat
check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
"byte-for-byte" run faithful-flat base
seed_flat faithful-grouped
printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md"
printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md"
rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"
git -C "$TMP/faithful-grouped" add -A
git -C "$TMP/faithful-grouped" commit -qm regroup
git -C "$TMP/faithful-grouped" branch -f base
ceremony faithful-grouped 0.2.0 2026-07-24
commit_head faithful-grouped
check "faithful grouped ceremony passes" 0 "byte-for-byte" run faithful-grouped base
# The date is HEAD's to choose: a stamp nowhere near today must not read as
# a prose difference — the comparison is body against body, headings (and
# so dates) never enter it.
seed_flat old-date
ceremony old-date 0.2.0 2020-01-01
commit_head old-date
check "the stamp's date never enters the comparison" 0 "byte-for-byte" \
run old-date base
# --- inapplicable trees: green NOTICE, never a silent skip -------------------
seed_flat ordinary-add
printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md"
commit_head ordinary-add
check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base
seed_flat ordinary-none
printf 'code\n' >"$TMP/ordinary-none/code.txt"
commit_head ordinary-none
check "-dev PR touching no fragment: green NOTICE" 0 "NOTICE" run ordinary-none base
seed_flat ordinary-del
rm "$TMP/ordinary-del/changelog.d/12.md"
commit_head ordinary-del
check "-dev PR even deleting a fragment: green NOTICE" 0 "NOTICE" run ordinary-del base
# Legacy mode: no changelog.d/ at the merge base — always a NOTICE, even on
# a release tree, because the mid-adoption ceremony edits the changelog by
# hand and there is no fragment set for its section to answer to.
init_repo legacy
printf '# Changelog\n\n## Unreleased\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md"
printf '0.1.1-dev\n' >"$TMP/legacy/VERSION"
commit_base legacy
printf '# Changelog\n\n## 0.2.0 — 2026-07-24\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md"
printf '0.2.0\n' >"$TMP/legacy/VERSION"
commit_head legacy
check "legacy repo (no changelog.d at base): green NOTICE, even on a release tree" 0 \
"NOTICE" run legacy base
# The un-rearmed window: a PR branched right after a release merges sits on
# a bare version whose section was stamped at its MERGE BASE — it is not
# the ceremony and must not be asked to answer for one.
seed_flat post-release
ceremony post-release 0.2.0 2026-07-24
git -C "$TMP/post-release" add -A
git -C "$TMP/post-release" commit -qm release
git -C "$TMP/post-release" branch -f base
printf 'code\n' >"$TMP/post-release/code.txt"
commit_head post-release
check "a PR atop the un-rearmed release: green NOTICE (this branch did not stamp)" 0 \
"NOTICE" run post-release base
# --- the refusals ------------------------------------------------------------
# The issue's headline failure: one fragment kept out of the ceremony — it
# survives at HEAD and its entry is absent from the section. Both refusals
# fire: the diff names the missing entry, the survivor list names the file.
seed_flat dropped
mv "$TMP/dropped/changelog.d/9.md" "$TMP/dropped/9.md.hold"
ceremony dropped 0.2.0 2026-07-24
mv "$TMP/dropped/9.md.hold" "$TMP/dropped/changelog.d/9.md"
commit_head dropped
check "a fragment kept out of the ceremony fails" 1 "" run dropped base
check "the dropped-entry diff names the missing entry" 1 "Nine landed" \
run dropped base
check "the surviving fragment is listed by path" 1 "changelog.d/9.md" \
run dropped base
# The same drop, but the fragment was deleted anyway: its prose vanished
# without ever being published. Only the diff can say so.
seed_flat vanished
mv "$TMP/vanished/changelog.d/9.md" "$TMP/vanished/9.md.hold"
ceremony vanished 0.2.0 2026-07-24
rm "$TMP/vanished/9.md.hold"
commit_head vanished
check "a deleted fragment whose entry never landed fails" 1 "Nine landed" \
run vanished base
seed_flat edited
ceremony edited 0.2.0 2026-07-24
sed -i 's/Twelve landed/Twelve allegedly landed/' "$TMP/edited/CHANGELOG.md"
commit_head edited
check "a hand-edited entry fails with a unified diff" 1 "+++" run edited base
check "the edit is visible in the diff" 1 "allegedly" run edited base
check "the diff failure teaches redo-with-the-tool, never hand-edit" 1 \
"never to hand-edit" run edited base
# Re-ordering away from the canonical order: the section is hand-built with
# the right entries in the wrong order (the assembler puts 12 before 9).
seed_flat reordered
rm "$TMP/reordered/changelog.d/12.md" "$TMP/reordered/changelog.d/9.md"
cat >"$TMP/reordered/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.2.0 — 2026-07-24
- Nine landed, and its prose wraps onto a
continuation line.
- Twelve landed.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.2.0\n' >"$TMP/reordered/VERSION"
commit_head reordered
check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base
# A faithful assembly that forgot one deletion: the section is right, the
# directory is not — only the survivor refusal fires.
seed_flat survivor
ceremony survivor 0.2.0 2026-07-24
printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md"
commit_head survivor
check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
run survivor base
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
run survivor base
# Fragments consumed, section never stamped: the prose went nowhere.
seed_flat halfdone
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
printf '0.2.0\n' >"$TMP/halfdone/VERSION"
commit_head halfdone
check "fragments consumed but no section stamped fails" 1 \
"non-empty section for '0.2.0'" run halfdone base
# A release stamped out of a fragment-free directory: the replay refuses the
# way the real assembler would have — an empty release.
seed_flat empty-release
rm "$TMP/empty-release/changelog.d/12.md" "$TMP/empty-release/changelog.d/9.md"
git -C "$TMP/empty-release" add -A
git -C "$TMP/empty-release" commit -qm consume-early
git -C "$TMP/empty-release" branch -f base
cat >"$TMP/empty-release/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.2.0 — 2026-07-24
- Prose from nowhere.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.2.0\n' >"$TMP/empty-release/VERSION"
commit_head empty-release
check "a section stamped from zero fragments fails on the replay's refusal" 1 \
"zero fragments" run empty-release base
check "missing changelog fails" 1 "no such file" run faithful-flat base NOPE.md
# --- the trio interaction row (the issue's whole argument) -------------------
# On the faithful tree all three guards are green; on the dropped-entry tree
# armed is green (the section exists and has prose), monotonic is green (no
# heading was deleted) — this guard is the ONLY one that goes red.
run_armed() { (cd "$TMP/$1" && bash "$ARMED"); }
run_monotonic() { local name="$1"; shift; (cd "$TMP/$name" && bash "$MONOTONIC" "$@"); }
check "trio, faithful tree: changelog-armed green" 0 "agrees" run_armed faithful-flat
check "trio, faithful tree: changelog-monotonic green" 0 "still present" \
run_monotonic faithful-flat base
check "trio, faithful tree: changelog-assembled green" 0 "byte-for-byte" \
run faithful-flat base
check "trio, dropped-entry tree: changelog-armed stays green" 0 "agrees" \
run_armed dropped
check "trio, dropped-entry tree: changelog-monotonic stays green" 0 "still present" \
run_monotonic dropped base
check "trio, dropped-entry tree: changelog-assembled is the only red" 1 "" \
run dropped base
# --- degradation: the loud skip and the STRICT refusal -----------------------
seed_flat no-base
ceremony no-base 0.2.0 2026-07-24
commit_head no-base
check "base ref missing, STRICT=0: loud skip, exit 0" 0 "SKIPPED" \
run no-base does-not-exist
check "base ref missing, STRICT=1: hard failure" 1 "FAILURE" \
run_strict no-base does-not-exist
check "the STRICT failure names the checkout fix, not the script" 1 "fetch-depth: 0" \
run_strict no-base does-not-exist
mkdir -p "$TMP/plain"
printf '# Changelog\n\n## Unreleased\n' >"$TMP/plain/CHANGELOG.md"
check "not a git repo, STRICT=0: loud skip" 0 "not inside a git work tree" \
run plain
check "not a git repo, STRICT=1: hard failure" 1 "FAILURE" run_strict plain
# --- the honest edges --------------------------------------------------------
# Push-to-main shape: the merge base IS HEAD, nothing was consumed between
# them, and the success line must say so instead of claiming a comparison.
seed_flat vacuous
ceremony vacuous 0.2.0 2026-07-24
commit_head vacuous
check "merge base IS HEAD: vacuous, named honestly" 0 "vacuous" run vacuous HEAD
# --- the action's wiring: inputs arrive as env vars --------------------------
# Non-default names for everything action.yml passes, STRICT included, prove
# the env vars are honored the way the composite sets them.
init_repo env-tree
mkdir -p "$TMP/env-tree/frags"
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md"
printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION"
printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md"
git -C "$TMP/env-tree" add -A
git -C "$TMP/env-tree" commit -qm base
git -C "$TMP/env-tree" branch fixture-base
(cd "$TMP/env-tree" && "$ASSEMBLE" 0.2.0 2026-07-24 --changelog NOTES.md --dir frags >/dev/null 2>&1)
printf '0.2.0\n' >"$TMP/env-tree/VERSION"
git -C "$TMP/env-tree" add -A
git -C "$TMP/env-tree" commit -qm head
env_tree() {
(cd "$TMP/env-tree" && \
CHANGELOG_ASSEMBLED_BASE=fixture-base CHANGELOG=NOTES.md \
CHANGELOG_ASSEMBLED_DIR=frags VERSION_SOURCE=file \
CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT")
}
check "env vars drive the script the way action.yml does" 0 "byte-for-byte" env_tree
summary

View file

@ -74,6 +74,24 @@ expect "...within the 304-byte bound" yes \
exact_reason="$(read_failure_reason "$(printf 'e%.0s' {1..300})")"
expect "a 300-char reason passes through whole" 300 "${#exact_reason}"
# -- a missing core taxonomy row is visible without mutating labels ----------
core_rows="$(core_label_rows)"
core_names="$(cut -d'|' -f1 <<<"$core_rows")"
expect "a complete core taxonomy does not warn" "" \
"$(missing_core_labels_warning "$core_rows" "$core_names")"
expect "one missing core label is named exactly" \
"::warning::labels: missing core label(s): attention; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy" \
"$(missing_core_labels_warning "$core_rows" "$(grep -vxF attention <<<"$core_names")")"
expect "three missing core labels are named in table order" \
"::warning::labels: missing core label(s): offsite, needs-ruling, attention; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy" \
"$(missing_core_labels_warning "$core_rows" "$(grep -vxF -e offsite -e needs-ruling -e attention <<<"$core_names")")"
expect "an unreadable empty label set does not report the taxonomy missing" "" \
"$(missing_core_labels_warning "$core_rows" "")"
expect "unrelated scope labels do not affect a complete core taxonomy" "" \
"$(missing_core_labels_warning "$core_rows" "$core_names
scope:consumer-one
scope:consumer-two")"
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"
@ -636,7 +654,7 @@ blind_main_probe() {
LABELS_CONF=.github/labels.conf
gh() {
if [ "$1" = label ] && [ "$2" = list ]; then
printf 'state:building\nstate:addressing\n'
core_label_rows | cut -d'|' -f1
elif [ "$1" = pr ] && [ "$2" = list ]; then
printf '101\n102\n'
elif [ "$1" = pr ] && [ "$2" = view ]; then

View file

@ -53,4 +53,16 @@ load_config "$TMP/good.conf"
set_required_bots two
check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}"
# LABELS.md is mirrored byte-identically into every governed repo, so any
# scope enumeration it carries is true at home and false everywhere else —
# 14 of 16 vendored rows were false across the family when this fired (#104).
# The set lives in labels.conf and the repo's CONTRIBUTING; the mirror never
# names it. A concrete label is `scope:` followed by a name character — the
# doctrine spellings (bare `scope:`, wildcard `scope:*`) put a backtick or `*`
# there instead, so any name, current or future, in any shape (table row,
# name|color|description row, prose) re-reds this while doctrine stays green.
# grep -c prints the count and exits 1 when that count is 0.
check "LABELS.md enumerates no repo's scope labels" 1 "0" \
grep -c 'scope:[a-z0-9]' "$ROOT/LABELS.md"
summary