lib/changelog.sh + changelog-armed — the shape anchor becomes declarable; ceremony flips to grouped #183
14 changed files with 296 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
### Changed
|
||||
|
||||
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
### Added
|
||||
|
||||
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
10
changelog.d/182.md
Normal 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).
|
||||
|
|
@ -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
1
changelog.d/shape
Normal file
|
|
@ -0,0 +1 @@
|
|||
grouped
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue