From fbfe7dd1c1e0efe168399ea337f2096d1d96c503 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 00:47:37 +0000 Subject: [PATCH 1/4] feat: report why the degraded read degraded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconciler's mergeability/checks read kept its correct degrade but threw the reason away: 2>/dev/null dropped gh's stderr, leaving a permanent denial and a network hiccup byte-identical in the log (#95 had to infer a cause from a control case, and the inference did not survive incubator#48/#49). Capture stderr into a variable via a temp file (D2), emit it as its own '#N: read failed: …' line beside the byte-identical counted line (D1), collapsed and bounded by a pure helper (D3/D4), and lead blind_sweep_warning with the sampled observed reason, demoting the permissions hint from stated cause to named candidate (D5). Part of #101 groundwork; tests and changelog follow. Co-Authored-By: Claude Fable 5 --- actions/labels-reconcile/labels-reconcile.sh | 58 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index f84ed2d..87c301e 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -60,9 +60,39 @@ run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi } -blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs +blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled read-failure reason + # Report, do not diagnose (#101 D5). The old text asserted the caller's + # checks:/statuses: grants as THE cause — an inference #95 made from a + # control case, and the merged consumer-side fix (incubator#48/PR #49) + # left the symptom standing while the run emitting this warning held the + # evidence that would have said so. Lead with what gh actually said this + # sweep; the permissions hint stays, demoted to one named candidate. if [ "$2" -gt 0 ] && [ "$1" -eq "$2" ]; then - echo "::warning::labels: every open PR was unreadable; grant checks: read and statuses: read in the caller (private repos do not imply them)" + local reason="${3:-}" + if [ -n "$reason" ]; then + echo "::warning::labels: every open PR was unreadable; sampled reason: $reason — one candidate is missing checks: read and statuses: read in the caller (private repos do not imply them)" + else + echo "::warning::labels: every open PR was unreadable; no reason was captured — one candidate is missing checks: read and statuses: read in the caller (private repos do not imply them)" + fi + fi +} + +read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101) + # Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL + # blobs. Collapsed so the reason is exactly one log line — a raw newline + # inside the captured per-PR output block could collide with a matched + # string — and truncated because an unbounded paste per PR per sweep is + # noise, and annotations are capped anyway. + local reason + reason="$(printf '%s' "${1-}" | tr '\n' ' ')" + if [ -z "$reason" ]; then + # Empty stderr is itself a fact (D4): a read that failed silently is a + # different observation from a denial, and must not read as one. + echo "no error output" + elif [ "${#reason}" -gt 300 ]; then + printf '%s…\n' "${reason:0:300}" + else + printf '%s\n' "$reason" fi } @@ -598,7 +628,7 @@ main() { 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 output status total=0 unreadable=0 + local n output status total=0 unreadable=0 sampled_reason="" while IFS= read -r n; do [ -n "$n" ] || continue total=$((total + 1)) @@ -622,14 +652,28 @@ main() { # 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 '{}')" + # The WHY goes to gh's stderr, and 2>/dev/null threw it away — a + # permanent denial and a network hiccup left byte-identical evidence, + # and #95 had to infer a cause from a control case instead of reading + # it off a run (wrongly, it turned out). Captured into a file (#101 + # D2), never left to interleave raw into the per-PR output block, + # where an unlucky line could collide with a matched string. + GH_VIEW_ERR_FILE="$(mktemp)" + GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>"$GH_VIEW_ERR_FILE" || echo '{}')" + GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")" + rm -f "$GH_VIEW_ERR_FILE" 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 + # Two lines on purpose (#101 D1): the sweep detects a wholly blind + # pass by whole-line-matching the counted line below, so the reason + # rides its OWN line — folding it in would silently break the + # `unreadable` counter and the wholly-blind warning #96 landed. log "#$n: could not read mergeability/checks — left alone this pass" + log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")" exit 0 fi reconcile_pr "$n" @@ -638,11 +682,15 @@ main() { [ -n "$output" ] && printf '%s\n' "$output" if grep -qxF "labels: #$n: could not read mergeability/checks — left alone this pass" <<<"$output"; then unreadable=$((unreadable + 1)) + # the first observed reason stands in for the sweep in the blind warning + if [ -z "$sampled_reason" ]; then + sampled_reason="$(sed -n "s/^labels: #$n: read failed: //p" <<<"$output" | head -n1)" + fi elif [ "$status" -ne 0 ]; then log "#$n: reconcile failed — continuing with the remaining PRs" fi done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number') - blind_sweep_warning "$unreadable" "$total" + blind_sweep_warning "$unreadable" "$total" "$sampled_reason" log "reconciled." } -- 2.45.2 From 09d2ea764f627623bc18755783408832a04bcd9c Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 00:49:22 +0000 Subject: [PATCH 2/4] test: pin the two-line degrade, the bounded reason, and the demoted diagnosis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit block now feeds blind_sweep_warning a sampled reason and asserts the new lead plus two must-fail guards: the disproven 'grant checks: read and statuses: read' diagnosis stated as fact goes red, and so does any drift in the counted line's whole-line shape (exactly the blind PRs match, no more, no less — a reason line that matched would double-count, a folded reason would undercount). read_failure_reason is covered pure: D4 wording for empty stderr, multi-line collapse to one line, 400 chars truncated to 300 plus ellipsis within the 304-byte bound, 300 passing through whole. blind_main_probe's gh pr view stub now fails with a denial on stderr, the way real gh fails. Part of #101. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + test/labels-reconcile.test.sh | 60 +++++++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8760aa..7932638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ so entries say what changed, cite the issue, and stop. ## Unreleased - 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). - Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98). - `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). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 724c800..8b1849f 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -39,16 +39,40 @@ rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review obj reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array # -- a sweep-wide read failure is visible without changing any PR ------------ -warning="$(blind_sweep_warning 3 3)" -expect "a wholly blind sweep warns" \ - "::warning::labels: every open PR was unreadable; grant checks: read and statuses: read in the caller (private repos do not imply them)" \ +warning="$(blind_sweep_warning 3 3 "HTTP 403: Resource not accessible by integration")" +expect "a wholly blind sweep warns, leading with the observed reason" \ + "::warning::labels: every open PR was unreadable; sampled reason: HTTP 403: Resource not accessible by integration — one candidate is missing checks: read and statuses: read in the caller (private repos do not imply them)" \ "$warning" expect "the blind warning names checks: read" named \ "$(grep -qF "checks: read" <<<"$warning" && echo named || echo missing)" expect "the blind warning names statuses: read" named \ "$(grep -qF "statuses: read" <<<"$warning" && echo named || echo missing)" -expect "a partially blind sweep does not warn" "" "$(blind_sweep_warning 1 3)" -expect "a sweep with no open PRs does not warn" "" "$(blind_sweep_warning 0 0)" +# must-fail (#101 D5): the #95 inference — disproven on incubator while the +# run held the evidence — must never again be stated as the cause +expect "the warning no longer asserts the permissions diagnosis as fact" no \ + "$(grep -qF "grant checks: read and statuses: read" <<<"$warning" && echo yes || echo no)" +warning="$(blind_sweep_warning 3 3 "")" +expect "with no reason captured the warning says exactly that" yes \ + "$(grep -qF "no reason was captured" <<<"$warning" && echo yes || echo no)" +expect "...and keeps the permissions candidate" named \ + "$(grep -qF "checks: read" <<<"$warning" && echo named || echo missing)" +expect "a partially blind sweep does not warn" "" "$(blind_sweep_warning 1 3 "x")" +expect "a sweep with no open PRs does not warn" "" "$(blind_sweep_warning 0 0 "")" + +# -- the reason helper: facts in, one bounded line out (#101 D3/D4) ---------- +expect "empty stderr is reported as its own fact" "no error output" \ + "$(read_failure_reason "")" +expect "multi-line stderr collapses to one line" \ + "GraphQL: Resource not accessible by integration (repository.pullRequest.mergeable) Resource not accessible by integration (repository.pullRequest.statusCheckRollup)" \ + "$(read_failure_reason $'GraphQL: Resource not accessible by integration (repository.pullRequest.mergeable)\nResource not accessible by integration (repository.pullRequest.statusCheckRollup)')" +long_reason="$(printf 'e%.0s' {1..400})" +short_reason="$(read_failure_reason "$long_reason")" +expect "400 chars of stderr truncate to 300 plus an ellipsis, one line" \ + "$(printf 'e%.0s' {1..300})…" "$short_reason" +expect "...within the 304-byte bound" yes \ + "$([ "${#short_reason}" -le 304 ] && echo yes || echo no)" +exact_reason="$(read_failure_reason "$(printf 'e%.0s' {1..300})")" +expect "a 300-char reason passes through whole" 300 "${#exact_reason}" # -- drafts are building, whoever is requested -------------------------------- DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' @@ -616,7 +640,9 @@ blind_main_probe() { elif [ "$1" = pr ] && [ "$2" = list ]; then printf '101\n102\n' elif [ "$1" = pr ] && [ "$2" = view ]; then - printf '{}\n' + # a denial with its reason on stderr, the way real gh fails (#101) + printf 'GraphQL: Resource not accessible by integration (repository.pullRequest.statusCheckRollup)\n' >&2 + return 1 elif [ "$1" = api ] && [[ "$*" = *"/reviews"* ]]; then return 0 elif [ "$1" = api ]; then @@ -631,12 +657,28 @@ blind_main_probe() { } blind_main="$(blind_main_probe)" -expect "a wholly blind main sweep emits one actionable annotation" 1 \ +expect "a wholly blind main sweep emits exactly one annotation" 1 \ + "$(grep -c '^::warning::' <<<"$blind_main")" +expect "...leading with the reason the sweep actually observed" 1 \ + "$(grep -c '^::warning::.*Resource not accessible by integration' <<<"$blind_main")" +expect "...still naming the permissions candidate" 1 \ "$(grep -c '^::warning::.*checks: read.*statuses: read' <<<"$blind_main")" +# must-fail (#101 D5): red if the disproven diagnosis is re-asserted as fact +expect "...never as a stated cause" 0 \ + "$(grep -c 'grant checks: read and statuses: read' <<<"$blind_main" || true)" expect "a wholly blind main sweep leaves every PR untouched" no \ "$(grep -q '^MUTATION:' <<<"$blind_main" && echo yes || echo no)" -expect "the existing per-PR skip still runs for every blind PR" 2 \ - "$(grep -c 'could not read mergeability/checks — left alone this pass' <<<"$blind_main")" +expect "each blind PR keeps its counted line, matched by the sweep's own grep -qxF" yes \ + "$(grep -qxF 'labels: #101: could not read mergeability/checks — left alone this pass' <<<"$blind_main" \ + && grep -qxF 'labels: #102: could not read mergeability/checks — left alone this pass' <<<"$blind_main" \ + && echo yes || echo no)" +expect "each blind PR logs its reason as its own line beside the counted one" 2 \ + "$(grep -c '^labels: #10[12]: read failed: GraphQL: Resource not accessible by integration' <<<"$blind_main")" +# must-fail (#101 D1): red if a reason line whole-line-matches the counted +# string (the counter would double-count) or the counted line changed (the +# counter would miss it and the warning never fire) +expect "exactly the blind PRs match the counted shape whole-line — no more, no less" 2 \ + "$(grep -c '^labels: #[0-9]*: could not read mergeability/checks — left alone this pass$' <<<"$blind_main")" # --------------------------------------------------------------------------- # bootstrap_labels retires the GitHub defaults (#93). LABELS.md published -- 2.45.2 From fffff75b8032afe45bc72a7a9620433b1081e1a5 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 08:25:44 +0000 Subject: [PATCH 3/4] feat: fragment reader, well-formedness predicate, assembler, and bin/changelog-assemble lib/changelog.sh gains changelog_fragments (publication order: trailing issue number descending, filename tie-break), changelog_fragment_problem (the release-time rules moved onto the PR that writes the fragment, #112 D9), and changelog_assemble (canonical group order per D5, one shape per repo per D4). bin/changelog-assemble folds changelog.d/ into one release section, deletes exactly what it consumed, and --check proves the body without touching the tree. Part of #112. Closes #114 groundwork; tests follow. Co-Authored-By: Claude Fable 5 --- bin/changelog-assemble | 121 +++++++++++++++++++++++++++++ lib/changelog.sh | 169 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100755 bin/changelog-assemble diff --git a/bin/changelog-assemble b/bin/changelog-assemble new file mode 100755 index 0000000..7f09db8 --- /dev/null +++ b/bin/changelog-assemble @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Fold the changelog.d/ fragments into one release section (#112, #114). +# Run by hand in the release PR, from a checkout of ceremony at the +# consumer's pin — deliberately not a CI step, because the assembled +# section must land in the PR diff where the panel reads it. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=lib/changelog.sh +source "$ROOT/lib/changelog.sh" + +usage() { + echo "usage: changelog-assemble [] [--changelog ] [--dir ] [--check]" >&2 + exit 2 +} + +refuse() { + printf 'changelog-assemble: %s\n' "$1" >&2 + exit 1 +} + +ver="" +stamp="" +changelog="CHANGELOG.md" +dir="changelog.d" +checkmode=0 +while [ $# -gt 0 ]; do + case "$1" in + --changelog) + [ $# -ge 2 ] || usage + changelog="$2" + shift 2 + ;; + --dir) + [ $# -ge 2 ] || usage + dir="$2" + shift 2 + ;; + --check) + checkmode=1 + shift + ;; + -*) + usage + ;; + *) + if [ -z "$ver" ]; then + ver="$1" + elif [ -z "$stamp" ]; then + stamp="$1" + else + usage + fi + shift + ;; + esac +done +[ -n "$ver" ] || usage +[ -n "$stamp" ] || stamp="$(date -u +%F)" + +[ -f "$changelog" ] || refuse "no such file: $changelog" + +# Every entry in the directory must be a publishable fragment. A stray file +# in a machine-assembled directory is a mistake to surface, never to skip — +# except README.md, the directory's marker (#112 D1). This runs before the +# zero-fragments check so a directory holding only 'notes.txt' names the +# stray file instead of claiming emptiness. +for f in "$dir"/*; do + [ -e "$f" ] || continue + [ "${f##*/}" = "README.md" ] && continue + if ! diagnosis="$(changelog_fragment_problem "$f")"; then + refuse "$diagnosis" + fi +done + +fragments="$(changelog_fragments "$dir")" +[ -n "$fragments" ] || refuse "zero fragments in '$dir' — a release publishes prose; refusing to publish an empty release" + +if ! body="$(changelog_assemble "$dir")"; then + refuse "$body" +fi + +# Whole-version match, as everywhere in this family: 0.2.0-rc1 in the +# changelog never blocks assembling 0.2.0. +if awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$changelog"; then + refuse "$changelog already has a section for '$ver' — the ceremony was already run" +fi + +if [ "$checkmode" = 1 ]; then + # --check prints the body, not the heading: the body is the invariant a + # caller can compare — #116 checks it against the release PR's stamped + # section, whose date the PR chose, not the day the check runs. + printf '%s\n' "$body" + exit 0 +fi + +heading="## $ver — $stamp" +lineno="$(grep -n -m1 '^## ' "$changelog" | cut -d: -f1)" || lineno="" +tmp="$(mktemp "$changelog.XXXXXX")" +if [ -n "$lineno" ]; then + { + head -n "$((lineno - 1))" "$changelog" + printf '%s\n\n%s\n\n' "$heading" "$body" + tail -n +"$lineno" "$changelog" + } >"$tmp" +else + # No section yet: the whole file is preamble, and the section goes after it. + { + cat "$changelog" + printf '\n%s\n\n%s\n' "$heading" "$body" + } >"$tmp" +fi +mv "$tmp" "$changelog" + +count=0 +while IFS= read -r f; do + rm -- "$f" + count=$((count + 1)) +done <<<"$fragments" + +printf "changelog-assemble: wrote '%s' to %s, consumed %d fragment(s)\n" "$heading" "$changelog" "$count" >&2 diff --git a/lib/changelog.sh b/lib/changelog.sh index 4489898..df34832 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -65,3 +65,172 @@ changelog_section_problem() { return 1 fi } + +# changelog_fragments +# +# Print fragment paths in publication order, one per line: trailing issue +# number descending — newest issue first, the way every section in this +# family already reads — tie-broken on the filename. Considers *.md only +# and skips README.md, the marker that keeps the directory trackable when +# it holds no fragments (#112 D1). An absent or fragment-free directory +# prints nothing and succeeds: whether "no fragments" is a problem belongs +# to the caller — the assembler refuses an empty release, the arming guard +# is satisfied by the directory existing. +changelog_fragments() { + local dir="$1" f base num + [ -d "$dir" ] || return 0 + for f in "$dir"/*.md; do + [ -e "$f" ] || continue + base="${f##*/}" + [ "$base" = "README.md" ] && continue + num="${base%.md}" + num="${num##*[!0-9]}" + [ -n "$num" ] || num=0 + printf '%s\t%s\t%s\n' "$num" "$base" "$f" + done | sort -t "$(printf '\t')" -k1,1nr -k2,2 | cut -f3- +} + +# changelog_fragment_problem +# +# Print the first reason a fragment cannot publish and return 1; silence +# returns 0. The same contract as changelog_section_problem, moved onto the +# PR that writes the fragment (#112 D9): a fragment is checkable the moment +# it exists, so malformedness fails the PR that wrote it, not the release +# that consumes it. The rules, and the failure each refuses: +# - name '.md' or '-.md': anything else has no +# derivable order, and an invented name is the "two builders, one +# filename" collision the naming scheme exists to avoid (#112 D2); +# - no '## ' line: the section heading is the assembler's to write, and +# a smuggled one would split the published section; +# - at least one bullet: a heading is not an entry — the rule the +# publisher enforces at release time, moved onto the PR; +# - no '### ' heading without a bullet before the next heading or EOF: +# the dangling grouped heading #98 taught us to refuse. +changelog_fragment_problem() { + local file="$1" base problem + base="${file##*/}" + + if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then + printf "fragment '%s' is not named for its issue — want .md or -.md\n" "$file" + return 1 + fi + + if grep -q '^## ' "$file"; then + printf "fragment '%s' carries a '## ' heading — the section heading is the assembler's to write\n" "$file" + return 1 + fi + + if ! grep -qE '^[[:space:]]*[-*][[:space:]]' "$file"; then + printf "fragment '%s' has no entries — a heading is not an entry\n" "$file" + return 1 + fi + + problem="$( + awk ' + /^### / { + if (heading != "" && !entry) { + reported = 1 + print heading + exit + } + heading = $0 + entry = 0 + next + } + heading != "" && /^[[:space:]]*[-*][[:space:]]/ { entry = 1 } + END { + if (!reported && heading != "" && !entry) print heading + } + ' "$file" + )" + if [ -n "$problem" ]; then + printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem" + return 1 + fi +} + +# changelog_assemble +# +# Print the assembled section body — no '## ' line; that heading belongs to +# the caller — for every fragment in changelog_fragments order. Assumes each +# fragment already passed changelog_fragment_problem; the one property only +# the whole set can show is shape: a repo is grouped or flat, never both +# (#112 D4), because merging the shapes would silently strand ungrouped +# bullets, so a mix prints a diagnosis naming the offending fragments and +# returns 1. Group order is canonical (#112 D5): Added, Changed, Fixed, +# Removed, Deprecated, Security, then any other group in first-seen order — +# appended, never dropped. Inside a group, fragment order is preserved, and +# a bullet's continuation lines travel with it verbatim: entries in this +# family wrap, and reflowing someone's prose is not this tool's business. +# An empty directory prints nothing and succeeds; refusing an empty release +# is the caller's stance, not this function's. +changelog_assemble() { + local dir="$1" nl=$'\n' + local fragments f grouped_in="" ungrouped_in="" chunk g seen="" ordered="" body first=1 + fragments="$(changelog_fragments "$dir")" + [ -n "$fragments" ] || return 0 + + while IFS= read -r f; do + if [ -z "$grouped_in" ] && grep -q '^### ' "$f"; then + grouped_in="$f" + fi + if [ -z "$ungrouped_in" ] && awk ' + /^### / { exit(found ? 0 : 1) } + /^[[:space:]]*[-*][[:space:]]/ { found = 1 } + END { exit(found ? 0 : 1) }' "$f"; then + ungrouped_in="$f" + fi + done <<<"$fragments" + + if [ -n "$grouped_in" ] && [ -n "$ungrouped_in" ]; then + if [ "$grouped_in" = "$ungrouped_in" ]; then + printf "fragment '%s' mixes grouped headings and ungrouped bullets — a repo is one shape or the other\n" "$grouped_in" + else + printf "fragment '%s' is grouped but fragment '%s' is not — a repo is one shape or the other\n" "$grouped_in" "$ungrouped_in" + fi + return 1 + fi + + if [ -z "$grouped_in" ]; then + while IFS= read -r f; do + chunk="$(awk 'body || !/^[[:space:]]*$/ { body = 1; print }' "$f")" + [ -n "$chunk" ] || continue + printf '%s\n' "$chunk" + done <<<"$fragments" + return 0 + fi + + while IFS= read -r f; do + while IFS= read -r g; do + printf '%s' "$seen" | grep -qFx -- "$g" || seen="$seen$g$nl" + done < <(awk '/^### / { name = substr($0, 5); sub(/[[:space:]]+$/, "", name); print name }' "$f") + done <<<"$fragments" + + for g in Added Changed Fixed Removed Deprecated Security; do + printf '%s' "$seen" | grep -qFx -- "$g" && ordered="$ordered$g$nl" + done + while IFS= read -r g; do + [ -n "$g" ] || continue + case "$g" in + Added | Changed | Fixed | Removed | Deprecated | Security) ;; + *) ordered="$ordered$g$nl" ;; + esac + done <<<"$seen" + + while IFS= read -r g; do + [ -n "$g" ] || continue + body="" + while IFS= read -r f; do + chunk="$(awk -v want="$g" ' + /^### / { name = substr($0, 5); sub(/[[:space:]]+$/, "", name); ingroup = (name == want); next } + ingroup' "$f" | awk 'body || !/^[[:space:]]*$/ { body = 1; print }')" + [ -n "$chunk" ] || continue + body="${body:+$body$nl}$chunk" + done <<<"$fragments" + [ -n "$body" ] || continue + [ "$first" = 1 ] || printf '\n' + printf '### %s\n\n%s\n' "$g" "$body" + first=0 + done <<<"$ordered" + return 0 +} -- 2.45.2 From 4c0ecf10d820003f751e5cf213caa10c108ca573 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 08:28:15 +0000 Subject: [PATCH 4/4] test: lib fragment trio and the changelog-assemble CLI; changelog entry test/changelog.test.sh drives changelog_fragments (order, marker, absent dir), changelog_fragment_problem (every rule, file named each time), and changelog_assemble (both shapes, canonical order, mixed-shape refusals). test/changelog-assemble.test.sh drives the CLI against constructed trees: exact-byte writes, provably read-only --check, every refusal from the spec, the publisher/assembler round trip, and idempotence. Closes #114. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + test/changelog-assemble.test.sh | 364 ++++++++++++++++++++++++++++++++ test/changelog.test.sh | 170 +++++++++++++++ 3 files changed, 535 insertions(+) create mode 100644 test/changelog-assemble.test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d8760aa..45b2f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop. ## Unreleased +- `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 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). - Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98). - `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95). diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh new file mode 100644 index 0000000..c21fc1a --- /dev/null +++ b/test/changelog-assemble.test.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# Contract tests for bin/changelog-assemble (issue #114). Constructed +# fixture trees, no git repos — the same discipline as +# test/changelog-armed.test.sh. 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" +# shellcheck source=lib/changelog.sh +. "$ROOT/lib/changelog.sh" + +TOOL="$ROOT/bin/changelog-assemble" +SECTION="$ROOT/bin/changelog-section" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# tree — a fixture tree with changelog.d/ and its README marker; +# the changelog body arrives on stdin. +tree() { + mkdir -p "$TMP/$1/changelog.d" + printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$TMP/$1/changelog.d/README.md" + cat >"$TMP/$1/CHANGELOG.md" +} + +# frag — a fragment; body on stdin. +frag() { + cat >"$TMP/$1/changelog.d/$2" +} + +# The tool reads the consumer's tree at its working directory, so every +# case runs from inside a constructed fixture tree. +in_tree() { + local dir="$1" + shift + (cd "$TMP/$dir" && "$TOOL" "$@") +} + +assert_file() { + local file="$1" expected="$2" actual + actual="$(cat "$file")" + [ "$actual" = "$expected" ] || { + printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual" + return 1 + } +} + +BASE_CHANGELOG=$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' + +# --- flat write: exact bytes, exact deletions -------------------------------- + +tree flat-one <"$TMP/flagged/NOTES.md" +printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md" +check "--changelog and --dir override the defaults" 0 "" \ + "$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags" +check "the flag-driven write landed in the named changelog" 0 "" \ + grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md" + +# --- refusals: each names the file responsible ------------------------------- + +tree empty-frags <"$TMP/no-changelog/changelog.d/2.md" +check "a missing changelog refuses" 1 "no such file" \ + in_tree no-changelog 0.2.0 + +# --- usage errors exit 2 ----------------------------------------------------- + +check "no arguments is a usage error" 2 "usage:" in_tree flat-one +check "an unknown flag is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --frobnicate +check "a third positional is a usage error" 2 "usage:" in_tree flat-one 0.2.0 2026-07-24 extra +check "--dir without a value is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --dir + +# --- round trip: the publisher and the assembler agree by test --------------- + +tree round-trip <"$FRAG/README.md" +check "fragments: README.md is the directory marker, never a fragment" 0 "" \ + changelog_fragments "$FRAG" + +printf -- '- Two.\n' >"$FRAG/2.md" +printf -- '- Nine.\n' >"$FRAG/9.md" +printf -- '- Ten.\n' >"$FRAG/10.md" +printf -- '- Cross.\n' >"$FRAG/ceremony-14.md" +printf -- '- Local fourteen.\n' >"$FRAG/14.md" + +assert_fragments_order() { + local expected="$1" actual + actual="$(changelog_fragments "$FRAG" | awk -F/ '{ print $NF }' | tr '\n' ' ')" + actual="${actual% }" + [ "$actual" = "$expected" ] || { + printf 'wanted: %s\ngot: %s\n' "$expected" "$actual" + return 1 + } +} +check "fragments: issue number descending (numeric, 10 before 9), filename tie-break" 0 "" \ + assert_fragments_order "14.md ceremony-14.md 10.md 9.md 2.md" + +# --- the fragment predicate (#114) ------------------------------------------- + +PF="$TMP/frag-problems" +mkdir -p "$PF" + +printf -- '- Fine.\n' >"$PF/7.md" +check "fragment predicate: a flat fragment passes" 0 "" \ + changelog_fragment_problem "$PF/7.md" + +cat >"$PF/8.md" <<'EOF' +### Added + +- Grouped fine. +EOF +check "fragment predicate: a grouped fragment passes" 0 "" \ + changelog_fragment_problem "$PF/8.md" + +printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md" +check "fragment predicate: a cross-repo name passes" 0 "" \ + changelog_fragment_problem "$PF/ceremony-14.md" + +printf -- '- Bad name.\n' >"$PF/Fix-12.md" +check "fragment predicate: an uppercase prefix is refused, file named" 1 "Fix-12.md" \ + changelog_fragment_problem "$PF/Fix-12.md" +printf -- '- Bad name.\n' >"$PF/notes.txt" +check "fragment predicate: a non-.md file is refused, file named" 1 "notes.txt" \ + changelog_fragment_problem "$PF/notes.txt" +printf -- '- Bad name.\n' >"$PF/12.markdown" +check "fragment predicate: .markdown is refused, file named" 1 "12.markdown" \ + changelog_fragment_problem "$PF/12.markdown" +printf -- '- No number.\n' >"$PF/notes.md" +check "fragment predicate: a name with no trailing issue number is refused" 1 "notes.md" \ + changelog_fragment_problem "$PF/notes.md" + +cat >"$PF/20.md" <<'EOF' +## 1.0.0 — 2026-07-24 + +- Smuggled heading. +EOF +check "fragment predicate: a '## ' line is refused — the heading is the assembler's" 1 \ + "the section heading is the assembler's to write" \ + changelog_fragment_problem "$PF/20.md" + +printf '### Added\n' >"$PF/21.md" +check "fragment predicate: no bullet anywhere is refused" 1 \ + "has no entries — a heading is not an entry" \ + changelog_fragment_problem "$PF/21.md" + +cat >"$PF/22.md" <<'EOF' +### Added + +### Fixed + +- Fixed entry. +EOF +check "fragment predicate: a dangling grouped heading is refused, heading named" 1 \ + "has an empty heading: '### Added'" \ + changelog_fragment_problem "$PF/22.md" + +# --- the assembler (#114) ---------------------------------------------------- + +assert_assemble() { + local dir="$1" expected="$2" actual + actual="$(changelog_assemble "$dir")" + [ "$actual" = "$expected" ] || { + printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual" + return 1 + } +} + +AF="$TMP/assemble-flat" +mkdir -p "$AF" +printf 'marker\n' >"$AF/README.md" +cat >"$AF/3.md" <<'EOF' +- Three — an em dash, and prose that + wraps onto a continuation line. +EOF +printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md" +check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \ + assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.' + +check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \ + changelog_assemble "$TMP/no-such-dir" + +AG="$TMP/assemble-grouped" +mkdir -p "$AG" +cat >"$AG/21.md" <<'EOF' +### Fixed + +- Fixed twenty-one. +EOF +cat >"$AG/20.md" <<'EOF' +### Added + +- Added twenty. + +### Docs + +- Docs twenty. +EOF +cat >"$AG/19.md" <<'EOF' +### Security + +- Security nineteen. + +### Added + +- Added nineteen. +EOF +check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \ + assert_assemble "$AG" $'### Added\n\n- Added twenty.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.' + +AM="$TMP/assemble-mixed" +mkdir -p "$AM" +printf -- '- Flat five.\n' >"$AM/5.md" +cat >"$AM/6.md" <<'EOF' +### Added + +- Grouped six. +EOF +check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \ + changelog_assemble "$AM" +check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \ + changelog_assemble "$AM" + +AX="$TMP/assemble-selfmixed" +mkdir -p "$AX" +cat >"$AX/7.md" <<'EOF' +- Ungrouped lead. + +### Added + +- Grouped follow. +EOF +check "assemble: one fragment mixing both shapes is refused, file named" 1 \ + "'$AX/7.md' mixes grouped headings and ungrouped bullets" \ + changelog_assemble "$AX" + summary -- 2.45.2