diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9c2a5d..ff863ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/BUILDER.md b/BUILDER.md index 426241e..47aa8bf 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index da37dc2..69a8c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4b9654..1384972 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/LABELS.md b/LABELS.md index 2136a92..fc5830b 100644 --- a/LABELS.md +++ b/LABELS.md @@ -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. diff --git a/TRIAGE.md b/TRIAGE.md index 6a05966..84e0626 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -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 diff --git a/actions/changelog-assembled/action.yml b/actions/changelog-assembled/action.yml new file mode 100644 index 0000000..9d3dd54 --- /dev/null +++ b/actions/changelog-assembled/action.yml @@ -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" diff --git a/actions/changelog-assembled/changelog-assembled.sh b/actions/changelog-assembled/changelog-assembled.sh new file mode 100644 index 0000000..93f03b1 --- /dev/null +++ b/actions/changelog-assembled/changelog-assembled.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +set -euo pipefail + +# changelog-assembled.sh [] [] [] [] +# — 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@` 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' 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)" diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 87c301e..d02e6aa 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -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 diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index efad3f8..2b285bc 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -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 diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh new file mode 100644 index 0000000..73b0a64 --- /dev/null +++ b/test/changelog-assembled.test.sh @@ -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 — 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 — 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 — 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 diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 8b1849f..4cb1c14 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -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 diff --git a/test/labels.test.sh b/test/labels.test.sh index 18f8c45..6d35a3a 100755 --- a/test/labels.test.sh +++ b/test/labels.test.sh @@ -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