lib/changelog.sh + changelog-armed — the shape anchor becomes declarable; ceremony flips to grouped #183

Merged
claude-bot-andresmgsl merged 4 commits from build/182-shape-sentinel into main 2026-07-25 13:43:53 +00:00
14 changed files with 296 additions and 5 deletions

View file

@ -62,12 +62,15 @@ done
# 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.
# except README.md, the directory's marker (#112 D1), and 'shape', the
# declared anchor (#182), which changelog_shape_problem validates below and
# which deliberately survives the consumption: it is the declaration a
# reader in the directory finds, and it must still be there after the
# release empties the fragments out.
for f in "$dir"/*; do
[ -e "$f" ] || continue
[ "${f##*/}" = "README.md" ] && continue
[ "${f##*/}" = "shape" ] && continue
if ! diagnosis="$(changelog_fragment_problem "$f")"; then
refuse "$diagnosis"
fi

View file

@ -1,3 +1,5 @@
### Added
- `changelog_fragment_problem` bounds every entry at 300 normalized
characters, red on the PR that writes the fragment; the armed guard and
the assembler inherit the one definition (#167).

View file

@ -1 +1,3 @@
### Changed
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).

View file

@ -1 +1,3 @@
### Added
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.

View file

@ -1,3 +1,5 @@
### Changed
- BUILDER.md: a park declaration stands until its facts change — a
nothing-changed resumption posts nothing; only a no-open-PR park owes a
refresh, inside the 48-hour reclaim window (#178).

View file

@ -1,3 +1,5 @@
### Fixed
- `decide_state()` refuses `state:needs-human` while the hand-set `blocked`
label stands — the PR falls to `state:addressing`, exactly parallel to the
`needs-ruling` exclusion; never emitted by `blockers()` (#180).

10
changelog.d/182.md Normal file
View file

@ -0,0 +1,10 @@
### Added
- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`,
that pins the fragment set's shape and outranks the newest-published-section
inference; absent, the inference binds unchanged (#182).
### Changed
- Ceremony's changelog is grouped from this release forward: the pending
fragments carry `### ` headings under a `grouped` sentinel (#182).

View file

@ -7,4 +7,5 @@ published, nothing else — and the release PR folds them all into the next
filenames never conflict, which is this directory's whole reason to exist.
This README is the marker that keeps the directory tracked when it holds no
fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not
delete it.
delete it. The `shape` sentinel beside it declares the set's shape —
`grouped` here, so every fragment carries `### ` headings (#182).

1
changelog.d/shape Normal file
View file

@ -0,0 +1 @@
grouped

View file

@ -480,7 +480,13 @@ CONTRIBUTING may sharpen it, but this is the floor the guards assume:
kind. A repo is grouped or flat, never both (#112 D4). The assembler
merges groups in canonical order — Added, Changed, Fixed, Removed,
Deprecated, Security, then anything else first-seen — and inside a
group entries read newest issue first (#112 D5).
group entries read newest issue first (#112 D5). Which shape binds is
inferred from the newest published section, unless an optional sentinel
`changelog.d/shape` — one line, exactly `flat` or `grouped` — declares
it and outranks the inference (#182). To flip a repo's shape, land one
PR that adds the sentinel and converts every pending fragment to the
declared shape, bullets byte-identical; the sentinel stays after the
release, as the declaration a reader in the directory finds.
- **One line: say what changed, and stop.** Lead with the surface, not
the mechanism — "`state:needs-human` is set at handoff" beats "the
labels workflow now also wakes on `labeled`". The why and the how

View file

@ -199,9 +199,38 @@ changelog_fragment_problem() {
# definition shared by the PR-time guard and the release-time assembler:
# fragments may not mix grouped headings with ungrouped bullets, and a
# non-empty set must match the newest published section when one exists.
#
# The anchor is declarable (#182): an optional sentinel '<dir>/shape',
# holding exactly 'flat' or 'grouped' on one line, pins the set's shape and
# outranks the newest-published-section inference — the door a deliberate
# flip walks through, while undeclared drift stays red (#159). Absent, the
# inference binds unchanged. Any other content — empty, trailing junk, an
# unknown word — is a diagnosis naming the file, never a silent fallback.
# The sentinel lives in the fragments dir so it binds in both callers: the
# assembler calls with changelog="" and still sees it. It is not a fragment
# — changelog_fragments matches *.md only, so 'shape' never enters the list.
changelog_shape_problem() {
local changelog="$1" dir="$2"
local fragments f grouped_in="" ungrouped_in="" published="" published_body=""
local sentinel="$dir/shape" declared=""
if [ -f "$sentinel" ]; then
# The one-line contract is checked on the file itself: command
# substitution strips every trailing newline, so the captured word
# cannot tell 'grouped' from 'grouped' plus blank lines.
if [ "$(wc -l <"$sentinel")" -gt 1 ]; then
printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel"
return 1
fi
declared="$(cat "$sentinel")"
case "$declared" in
flat | grouped) ;;
*)
printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel"
return 1
;;
esac
fi
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || return 0
@ -227,6 +256,20 @@ changelog_shape_problem() {
return 1
fi
if [ -n "$declared" ]; then
if [ "$declared" = "grouped" ] && [ -n "$ungrouped_in" ]; then
printf "fragment '%s' is flat but '%s' declares grouped — a repo is one shape or the other\n" \
"$ungrouped_in" "$sentinel"
return 1
fi
if [ "$declared" = "flat" ] && [ -n "$grouped_in" ]; then
printf "fragment '%s' is grouped but '%s' declares flat — a repo is one shape or the other\n" \
"$grouped_in" "$sentinel"
return 1
fi
return 0
fi
if [ -f "$changelog" ]; then
published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")"
fi

View file

@ -362,6 +362,53 @@ check "fragment mode refuses a flat set over a grouped published section" 1 \
"changelog.d/115.md' is flat but newest published section '1.2.3'" \
in_tree fragments-dev-flat-over-grouped
# The declared anchor (#182): the flip tree — a grouped set under a
# 'grouped' sentinel over a flat published section — is green, where the
# same tree minus the sentinel is the all-grouped-over-flat red row above.
fragment_tree fragments-dev-flip 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment.
EOF
check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \
"fragment mode" in_tree fragments-dev-flip
# Post-flip drift is refused on its own PR: a flat probe fragment atop the
# flip tree goes red — beside grouped fragments the mix rule names it first.
printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md"
check "fragment mode: a flat probe atop the flip tree is refused" 1 \
"changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \
in_tree fragments-dev-flip
rm "$TMP/fragments-dev-flip/changelog.d/116.md"
# And once the grouped fragments are consumed, the sentinel alone still
# holds the shape: an all-flat set under 'grouped' is refused, sentinel
# named — the published-section inference never gets a say.
rm "$TMP/fragments-dev-flip/changelog.d/115.md"
printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md"
check "fragment mode: a flat set under the 'grouped' sentinel refused, sentinel named" 1 \
"changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \
in_tree fragments-dev-flip
rm "$TMP/fragments-dev-flip/changelog.d/116.md"
printf '%s\n' "Grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
check "fragment mode: a malformed sentinel is refused, file named" 1 \
"'changelog.d/shape' declares neither shape" \
in_tree fragments-dev-flip
printf 'grouped\n\n' >"$TMP/fragments-dev-flip/changelog.d/shape"
check "fragment mode: a sentinel with a trailing blank line is refused, file named" 1 \
"'changelog.d/shape' declares neither shape" \
in_tree fragments-dev-flip
fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF'
# Changelog

View file

@ -143,6 +143,51 @@ check "grouped: the written file is exact" 0 "" \
assert_file "$TMP/grouped/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n### Fixed\n\n- The shipped entry.'
# --- the declared anchor: the first grouped release over a flat history ------
# The flip ceremony (#182): a 'grouped' sentinel admits grouped fragments
# over a flat newest published section, the sentinel is never a stray file,
# and it survives the consumption (D5) — the next -dev tree still declares
# its shape.
tree flip <<EOF
$BASE_CHANGELOG
EOF
printf 'grouped\n' >"$TMP/flip/changelog.d/shape"
frag flip 40.md <<'EOF'
### Added
- Forty landed.
EOF
check "sentinel: the flip release assembles grouped over a flat published section" 0 \
"consumed 1 fragment" in_tree flip 0.2.0 2026-07-24
check "sentinel: the written flip section is exact" 0 "" \
assert_file "$TMP/flip/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
check "sentinel: changelog.d/shape survives consumption" 0 "" \
test -e "$TMP/flip/changelog.d/shape"
tree flip-flat-frag <<EOF
$BASE_CHANGELOG
EOF
printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape"
frag flip-flat-frag 41.md <<'EOF'
- Flat forty-one.
EOF
check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \
"changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24
tree flip-malformed <<EOF
$BASE_CHANGELOG
EOF
printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape"
frag flip-malformed 42.md <<'EOF'
### Added
- Forty-two.
EOF
check "sentinel: a malformed sentinel refuses, file named" 1 \
"changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24
# --- a changelog holding only its preamble -----------------------------------
tree preamble-only <<'EOF'

View file

@ -469,4 +469,129 @@ rm "$SHAPE_DIR/1.md"
check "shape: empty fragment set makes the anchor rule vacuous" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
# --- the declarable anchor: <dir>/shape (#182) -------------------------------
# The sentinel pins the set's shape and outranks the newest-published-section
# inference — the door a deliberate flip walks through, while the undeclared
# drift rows above stay red, verbatim.
cat >"$SHAPE_CHANGELOG" <<'EOF'
# Changelog
## 2.0.0 — 2026-07-24
- Newest section is flat.
EOF
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
EOF
printf 'grouped\n' >"$SHAPE_DIR/shape"
check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \
changelog_shape_problem "" "$SHAPE_DIR"
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
check "shape: flat fragment under a 'grouped' sentinel refused, fragment and sentinel named" 1 \
"fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_CHANGELOG" <<'EOF'
# Changelog
## 2.0.0 — 2026-07-24
### Fixed
- Newest section is grouped.
EOF
printf 'flat\n' >"$SHAPE_DIR/shape"
check "shape: 'flat' sentinel admits a flat set over a grouped published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
EOF
check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \
"fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'grouped\n' >"$SHAPE_DIR/shape"
printf -- '- Flat two.\n' >"$SHAPE_DIR/2.md"
check "shape: a mixed set is refused regardless of the sentinel" 1 \
"fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/2.md"
check "shape: empty fragment set with a valid sentinel passes" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'Grouped\n' >"$SHAPE_DIR/shape"
check "shape: a capitalized sentinel is refused, file named" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
: >"$SHAPE_DIR/shape"
check "shape: an empty sentinel is refused — never a silent fallback" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'grouped\nflat\n' >"$SHAPE_DIR/shape"
check "shape: a two-line sentinel is refused" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
# Trailing blank lines are the case command substitution launders away: the
# captured word is a clean 'grouped', only the file's line count still knows.
printf 'grouped\n\n' >"$SHAPE_DIR/shape"
check "shape: 'grouped' with a trailing blank line is refused, file named" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'flat\n\n' >"$SHAPE_DIR/shape"
check "shape: 'flat' with a trailing blank line is refused, file named" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
# The sentinel is not a fragment (#182 D3): the *.md glob is the mechanism,
# but the assertion is on the list itself, so a glob change cannot silently
# start publishing the sentinel.
printf 'grouped\n' >"$SHAPE_DIR/shape"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
EOF
assert_fragments_exclude_sentinel() {
local out
out="$(changelog_fragments "$SHAPE_DIR")"
[ -n "$out" ] || { echo "wanted a non-empty fragment list"; return 1; }
if printf '%s\n' "$out" | grep -q '/shape$'; then
printf 'the sentinel leaked into the fragment list:\n%s\n' "$out"
return 1
fi
}
check "fragments: the shape sentinel never enters the fragment list" 0 "" \
assert_fragments_exclude_sentinel
rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/shape"
AS="$TMP/assemble-sentinel"
mkdir -p "$AS"
printf 'grouped\n' >"$AS/shape"
cat >"$AS/30.md" <<'EOF'
### Fixed
- Fixed thirty.
EOF
cat >"$AS/31.md" <<'EOF'
### Added
- Added thirty-one.
EOF
check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \
assert_assemble "$AS" $'### Added\n\n- Added thirty-one.\n\n### Fixed\n\n- Fixed thirty.'
rm "$AS/30.md" "$AS/31.md"
printf -- '- Flat probe.\n' >"$AS/29.md"
check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \
"declares grouped" \
changelog_assemble "$AS"
summary