Merge branch 'main' into build/291-terminal-fragment-cites

This commit is contained in:
Daniel Marin 2026-08-04 14:11:20 +01:00 committed by GitHub
commit acf4125fd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 413 additions and 104 deletions

View file

@ -119,7 +119,7 @@ jobs:
EOF
mkdir changelog.d
printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md
printf -- '- The entry this release ships.\n' > changelog.d/42.md
printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md
git add VERSION CHANGELOG.md changelog.d
git commit -qm "base"
printf '0.7.0\n' > VERSION

View file

@ -203,7 +203,13 @@ triage bug, and the move is to say so on the issue, not to guess.
a change genuinely is one. An entry is at most 300 characters — the
fragment guard reds longer (#167) — so a genuinely long change ships
several short entries, never one long one; wrapping an entry over
continuation lines is fine and never counts against it. Never edit
continuation lines is fine and never counts against it. Every entry
**ends with its issue citation**, and the same guard reds an entry
without one: a single `(` group of `#N`, `repo#N` or `owner/repo#N`
references separated by `, `, then `)`, then the final `.` and nothing
after it — `(#262).` locally, `(#236, #250).` when one entry honestly
lands two. The citation need not name the fragment's own issue, because
the filename already carries the authorizing one (#262). Never edit
`CHANGELOG.md` for an entry — the
release PR assembles the section from the fragments (#112); the monotonic
guard still refuses anything that deletes a shipped heading.

View file

@ -5,6 +5,9 @@ published verbatim as that release's body (lib/changelog.sh extracts it),
so entries say what changed, cite the issue, and stop — at most 300
characters each, guard-enforced on the PR that writes the fragment (#167);
a genuinely long change ships several short entries, never one long one.
The citation is guard-enforced too, and it closes the entry: one `(#N)`
group, then the final `.` and nothing after it (#262). Sections published
before that rule keep their prose; the guard reads fragments only.
Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
an edit to this file — and the release PR assembles them into the next
section here (`bin/changelog-assemble`, #112).

View file

@ -2,4 +2,4 @@
- Define the doors-unchanged drill record and an executable release-path list,
so a release may reuse live evidence only when its door bytes are unchanged
since the last rehearsed tag. (#237)
since the last rehearsed tag (#237).

View file

@ -1,3 +1,3 @@
### Added
- Release epics now announce release initialization when their declared dependency gates clear. (#253)
- Release epics now announce release initialization when their declared dependency gates clear (#253).

20
changelog.d/262.md Normal file
View file

@ -0,0 +1,20 @@
### Added
- The fragment guard now requires each entry to end with its issue
citation: one `(#N)` group — local, `repo#N` or `owner/repo#N`
references separated by `, ` — then the final `.` and nothing after it
(#262).
- The refusal distinguishes an entry carrying no reference at all from one
whose reference is present but not terminal, and names the shape to
write in both (#262).
- The 300-character bound still outranks the citation across the whole
fragment, and the outranked problem stays out of the message it lost
to: one fragment, one diagnosis, wherever in the file it sits (#262).
### Changed
- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced
rather than as house style, beside the 300-character bound it now sits
next to (#262).
- Four fragments in flight gained a terminal citation; published sections
are untouched, so no shipped prose is re-opened (#262).

View file

@ -2,4 +2,4 @@
- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule,
retain at most one sentence of why, cite the local record bare, and leave the
incident narrative in that record. (#280)
incident narrative in that record (#280).

View file

@ -115,8 +115,22 @@ changelog_fragments() {
# splits the measured history: every healthy entry passes untouched,
# the drift cluster does not. mawk's length() counts bytes; prose here
# is ASCII and the fuzz is acceptable.
# - every entry ends with its issue citation (#262): one '(' group of
# '#N', 'repo#N' or 'owner/repo#N' references separated by ', ', then
# ')', then the final '.' and nothing after it. Stated as style and
# enforced by nobody, this rule cost #255 a full four-bot round on a
# missing '(#248)'; the fragment rules that live in this guard drew no
# review comment at all across the same fifteen PRs. Measured on the
# same normalized entry as the bound above, so a citation that wraps
# onto a continuation line still counts. The repo token is the one the
# filename rule already admits, so '<repo>-<issue>.md' and its cite
# cannot drift apart; the two halves of one convention. A single group
# is what makes 'terminal' checkable — '(#236, #250).' lands two issues
# in one entry, '(#236) and (#250).' does not. The citation need not
# name the file's own issue: the filename already carries the
# authorizing one, so a fragment may cite the incident beside it.
changelog_fragment_problem() {
local file="$1" base problem
local file="$1" base problem kind detail rest
base="${file##*/}"
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then
@ -157,9 +171,35 @@ changelog_fragment_problem() {
return 1
fi
# One walk of the entries, two rules, and the order between them is
# deliberate: an over-long entry anywhere outranks a citation problem
# anywhere, so the length diagnosis a fragment already draws is the same
# one it drew before the citation rule existed. Both read the entry the
# same normalizer produces, which is the whole reason they share a pass.
problem="$(
awk -v max=300 '
function flush( len, e) {
# cite_problem <entry> — "", "uncited" or "misplaced". Counting the
# groups is what distinguishes the two admitted shapes: one group
# closing the entry passes however many references it carries, and a
# second group anywhere means no single group is terminal.
function cite_problem(e, rest, groups, consumed, group_end) {
rest = e
groups = 0
consumed = 0
while (match(rest, /\((([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+(, (([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+)*\)/)) {
groups++
group_end = consumed + RSTART + RLENGTH - 1
consumed = group_end
rest = substr(rest, RSTART + RLENGTH)
}
if (groups == 0) return e ~ /#[0-9]/ ? "misplaced" : "uncited"
if (groups > 1) return "misplaced"
return (group_end == length(e) - 1 && substr(e, group_end + 1) == ".") ? "" : "misplaced"
}
function excerpt(e) {
return length(e) > 60 ? substr(e, 1, 60) "…" : e
}
function flush( len, e, kind) {
if (entry == "") return 0
e = entry
entry = ""
@ -168,9 +208,15 @@ changelog_fragment_problem() {
sub(/ $/, "", e)
len = length(e)
if (len > max) {
printf "%d\t%s\n", len, substr(e, 1, 60)
reported = 1
printf "long\t%d\t%s\n", len, excerpt(e)
return 1
}
kind = cite_problem(e)
if (kind != "" && cite_kind == "") {
cite_kind = kind
cite_excerpt = excerpt(e)
}
return 0
}
/^### / { if (flush()) exit; next }
@ -182,12 +228,37 @@ changelog_fragment_problem() {
}
/^[[:space:]]*$/ { next }
entry != "" { entry = entry " " $0 }
END { flush() }
# An exit from a main rule still runs END, so a length row printed
# mid-file would be followed by the citation row it outranks — two
# lines spliced into one diagnosis, the internal protocol row landing
# inside the human-facing excerpt (#262 round 1). The reported flag is
# the same guard the empty-heading walk above uses, for the same
# reason: one diagnosis per fragment is the contract.
END {
if (flush()) exit
if (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt
}
' "$file"
)"
if [ -n "$problem" ]; then
printf "fragment '%s' has a %s-character entry — '%s…' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \
"$file" "${problem%%$'\t'*}" "${problem#*$'\t'}"
kind="${problem%%$'\t'*}"
rest="${problem#*$'\t'}"
detail="${rest%%$'\t'*}"
rest="${rest#*$'\t'}"
case "$kind" in
long)
printf "fragment '%s' has a %s-character entry — '%s' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \
"$file" "$detail" "$rest"
;;
uncited)
printf "fragment '%s' has an entry with no issue citation — '%s' — end it with the issue it comes from: '(#N).'\n" \
"$file" "$rest"
;;
*)
printf "fragment '%s' has an entry whose issue citation is not terminal — '%s' — exactly one '(#N)' group ends the entry, the final '.' after it\n" \
"$file" "$rest"
;;
esac
return 1
fi
}

View file

@ -275,7 +275,7 @@ fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF'
- The shipped entry.
EOF
printf '%s\n' "- Added fragment mode." >"$TMP/fragments-dev-flat/changelog.d/115.md"
printf '%s\n' "- Added fragment mode (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md"
check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \
in_tree fragments-dev-flat
@ -298,6 +298,38 @@ check "fragment mode over-bound refusal names the bound and the split fix" 1 \
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
in_tree fragments-dev-over-bound
# The terminal cite (#262) reds the PR that writes the fragment, through the
# same shared predicate — which is the whole point of the rule living there
# rather than in prose a reviewer has to remember.
fragment_tree fragments-dev-uncited 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "- An entry that never learned to cite its issue." \
>"$TMP/fragments-dev-uncited/changelog.d/115.md"
check "fragment mode refuses an uncited entry, fragment named" 1 \
"115.md' has an entry with no issue citation" \
in_tree fragments-dev-uncited
check "fragment mode uncited refusal names the shape to write" 1 \
"end it with the issue it comes from: '(#N).'" \
in_tree fragments-dev-uncited
fragment_tree fragments-dev-misplaced-cite 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "- The citation trails the period. (#115)" \
>"$TMP/fragments-dev-misplaced-cite/changelog.d/115.md"
check "fragment mode refuses a non-terminal citation, fragment named" 1 \
"115.md' has an entry whose issue citation is not terminal" \
in_tree fragments-dev-misplaced-cite
fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF'
# Changelog
@ -310,7 +342,7 @@ EOF
cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF'
### Changed
- Added fragment mode.
- Added fragment mode (#115).
EOF
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
in_tree fragments-dev-grouped
@ -322,11 +354,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF'
- The shipped entry.
EOF
printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#115).
EOF
check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \
"fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \
@ -342,7 +374,7 @@ EOF
cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#115).
EOF
check "fragment mode refuses an all-grouped set over a flat published section" 1 \
"changelog.d/115.md' is grouped but newest published section '1.2.3'" \
@ -357,7 +389,7 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF'
- The shipped entry.
EOF
printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
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
@ -376,14 +408,14 @@ printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#115).
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"
printf '%s\n' "- Flat probe (#116)." >"$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
@ -393,7 +425,7 @@ rm "$TMP/fragments-dev-flip/changelog.d/116.md"
# 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"
printf '%s\n' "- Flat probe (#116)." >"$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
@ -417,7 +449,7 @@ EOF
cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#115).
EOF
check "fragment mode accepts a consistent set with no published section" 0 \
"fragment mode" in_tree fragments-dev-no-published
@ -484,7 +516,7 @@ check "fragment bare + stamped section + consumed directory passes" 0 \
"fragment mode" in_tree fragments-bare-stamped
cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor"
printf '%s\n' "- This entry was not consumed." \
printf '%s\n' "- This entry was not consumed (#115)." \
>"$TMP/fragments-bare-survivor/changelog.d/115.md"
check "fragment bare refuses and lists surviving fragments" 1 \
"these fragments were not consumed: changelog.d/115.md" \

View file

@ -55,13 +55,13 @@ tree flat-one <<EOF
$BASE_CHANGELOG
EOF
frag flat-one 12.md <<'EOF'
- Twelve landed.
- Twelve landed (#12).
EOF
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
in_tree flat-one 0.2.0 2026-07-24
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
assert_file "$TMP/flat-one/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed (#12).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
check "flat: the consumed fragment is deleted" 1 "" \
test -e "$TMP/flat-one/changelog.d/12.md"
check "flat: README.md survives consumption" 0 "" \
@ -73,17 +73,17 @@ tree flat-many <<EOF
$BASE_CHANGELOG
EOF
frag flat-many 2.md <<'EOF'
- Two.
- Two (#2).
EOF
frag flat-many 9.md <<'EOF'
- Nine.
- Nine (#9).
EOF
frag flat-many 10.md <<'EOF'
- Ten.
- Ten (#10).
EOF
frag flat-many ceremony-14.md <<'EOF'
- Fourteen crossed over — naïve reflows would mangle this café's
continuation line, so it must survive verbatim.
continuation line, so it must survive verbatim (#14).
EOF
assert_check() {
@ -95,7 +95,7 @@ assert_check() {
}
}
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim.\n- Ten.\n- Nine.\n- Two.'
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim (#14).\n- Ten (#10).\n- Nine (#9).\n- Two (#2).'
# --- grouped write: canonical order, unnamed group appended ------------------
@ -113,28 +113,28 @@ EOF
frag grouped 21.md <<'EOF'
### Fixed
- Fixed twenty-one.
- Fixed twenty-one (#21).
EOF
frag grouped 20.md <<'EOF'
### Added
- Added twenty.
- Added twenty, second bullet.
- Added twenty (#20).
- Added twenty, second bullet (#20).
### Docs
- Docs twenty.
- Docs twenty (#20).
EOF
frag grouped 19.md <<'EOF'
### Security
- Security nineteen.
- Security nineteen (#19).
### Added
- Added nineteen.
- Added nineteen (#19).
EOF
GROUPED_BODY=$'### Added\n\n- Added twenty.\n- Added twenty, second bullet.\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.'
GROUPED_BODY=$'### Added\n\n- Added twenty (#20).\n- Added twenty, second bullet (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
assert_check grouped "$GROUPED_BODY"
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
@ -156,13 +156,13 @@ printf 'grouped\n' >"$TMP/flip/changelog.d/shape"
frag flip 40.md <<'EOF'
### Added
- Forty landed.
- Forty landed (#40).
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.'
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\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"
@ -171,7 +171,7 @@ $BASE_CHANGELOG
EOF
printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape"
frag flip-flat-frag 41.md <<'EOF'
- Flat forty-one.
- Flat forty-one (#41).
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
@ -183,7 +183,7 @@ printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape"
frag flip-malformed 42.md <<'EOF'
### Added
- Forty-two.
- Forty-two (#42).
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
@ -196,13 +196,45 @@ tree preamble-only <<'EOF'
Only preamble so far.
EOF
frag preamble-only 1.md <<'EOF'
- The first entry ever.
- The first entry ever (#1).
EOF
check "a changelog with no section yet gets the section after the preamble" 0 "" \
in_tree preamble-only 0.1.0 2026-07-24
check "preamble-only write is exact" 0 "" \
assert_file "$TMP/preamble-only/CHANGELOG.md" \
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.'
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).'
# --- the fragment predicate at release time (#262) ---------------------------
# The cite rule joins changelog_fragment_problem, so it binds both callers:
# the arming guard at PR time and this assembler at release time. Asserted
# rather than assumed — a release that publishes an uncited entry is the
# failure the PR-time guard exists to have already caught.
tree uncited-release <<EOF
$BASE_CHANGELOG
EOF
frag uncited-release 60.md <<'EOF'
- An entry that never learned to cite its issue.
EOF
check "release time: an uncited fragment refuses the release, fragment named" 1 \
"changelog.d/60.md' has an entry with no issue citation" \
in_tree uncited-release 0.2.0 2026-07-24
check "release time: the uncited refusal survives --check too" 1 \
"has an entry with no issue citation" \
in_tree uncited-release 0.2.0 2026-07-24 --check
check "release time: the refused release wrote nothing" 0 "" \
test -e "$TMP/uncited-release/changelog.d/60.md"
tree misplaced-release <<EOF
$BASE_CHANGELOG
EOF
frag misplaced-release 61.md <<'EOF'
- The citation trails the period. (#61)
EOF
check "release time: a non-terminal citation refuses the release" 1 \
"changelog.d/61.md' has an entry whose issue citation is not terminal" \
in_tree misplaced-release 0.2.0 2026-07-24
# --- --check is provably read-only -------------------------------------------
@ -210,10 +242,10 @@ tree check-readonly <<EOF
$BASE_CHANGELOG
EOF
frag check-readonly 5.md <<'EOF'
- Five.
- Five (#5).
EOF
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
check "--check prints the assembled body" 0 "Five." \
check "--check prints the assembled body" 0 "Five (#5)." \
in_tree check-readonly 0.2.0 2026-07-24 --check
check "--check is read-only: the tree is byte-identical before and after" 0 "" \
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
@ -229,11 +261,11 @@ check "the defaulted stamp is a UTC date" 0 "" \
mkdir -p "$TMP/flagged/frags"
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md"
printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md"
printf -- '- Flagged entry (#2).\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"
grep -qF -- "- Flagged entry (#2)." "$TMP/flagged/NOTES.md"
# --- refusals: each names the file responsible -------------------------------
@ -292,7 +324,7 @@ tree stray-txt <<EOF
$BASE_CHANGELOG
EOF
frag stray-txt 7.md <<'EOF'
- Seven.
- Seven (#7).
EOF
frag stray-txt notes.txt <<'EOF'
A stray scratchpad.
@ -322,12 +354,12 @@ tree mixed <<EOF
$BASE_CHANGELOG
EOF
frag mixed 5.md <<'EOF'
- Flat five.
- Flat five (#5).
EOF
frag mixed 6.md <<'EOF'
### Added
- Grouped six.
- Grouped six (#6).
EOF
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
in_tree mixed 0.2.0
@ -340,7 +372,7 @@ EOF
frag grouped-over-flat 6.md <<'EOF'
### Added
- Grouped six.
- Grouped six (#6).
EOF
check "an all-grouped set over a flat published section refuses before assembly" 1 \
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
@ -354,7 +386,7 @@ tree already <<'EOF'
- Already shipped.
EOF
frag already 4.md <<'EOF'
- A late fragment.
- A late fragment (#4).
EOF
check "an already-present section refuses — the ceremony was already run" 1 \
"already has a section for '0.2.0'" \
@ -370,13 +402,13 @@ tree rc-present <<'EOF'
- The candidate's entry.
EOF
frag rc-present 8.md <<'EOF'
- The real release entry.
- The real release entry (#8).
EOF
check "an rc section does not block assembling the bare version" 0 "" \
in_tree rc-present 0.2.0 2026-07-24
mkdir -p "$TMP/no-changelog/changelog.d"
printf -- '- Entry.\n' >"$TMP/no-changelog/changelog.d/2.md"
printf -- '- Entry (#2).\n' >"$TMP/no-changelog/changelog.d/2.md"
check "a missing changelog refuses" 1 "no such file" \
in_tree no-changelog 0.2.0
@ -404,12 +436,12 @@ frag round-trip 30.md <<'EOF'
### Added
- Thirty — wraps onto a
continuation line with a naïve café.
continuation line with a naïve café (#30).
EOF
frag round-trip 29.md <<'EOF'
### Fixed
- Fixed twenty-nine.
- Fixed twenty-nine (#29).
EOF
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
check "round trip: write mode succeeds after --check" 0 "" \

View file

@ -58,8 +58,8 @@ Preamble prose belongs to no section.
- 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"
printf -- '- Twelve landed (#12).\n' >"$dir/changelog.d/12.md"
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md"
commit_base "$name"
}
@ -86,8 +86,8 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
seed_flat faithful-grouped
sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md"
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"
printf -- '### Fixed\n\n- Fixed twenty-one (#21).\n' >"$TMP/faithful-grouped/changelog.d/21.md"
printf -- '### Added\n\n- Added twenty (#20).\n\n### Docs\n\n- Docs twenty (#20).\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
@ -108,7 +108,7 @@ check "the stamp's date never enters the comparison" 0 "byte-for-byte" \
# --- inapplicable trees: green NOTICE, never a silent skip -------------------
seed_flat ordinary-add
printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md"
printf -- '- Thirteen incoming (#13).\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
@ -195,8 +195,8 @@ Preamble prose belongs to no section.
## 0.2.0 — 2026-07-24
- Nine landed, and its prose wraps onto a
continuation line.
- Twelve landed.
continuation line (#9).
- Twelve landed (#12).
## 0.1.0 — 2026-07-01
@ -210,7 +210,7 @@ check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base
# 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"
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\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
@ -318,7 +318,7 @@ 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"
printf -- '- Flagged entry (#2).\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

View file

@ -182,11 +182,11 @@ printf 'marker\n' >"$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"
printf -- '- Two (#2).\n' >"$FRAG/2.md"
printf -- '- Nine (#9).\n' >"$FRAG/9.md"
printf -- '- Ten (#10).\n' >"$FRAG/10.md"
printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md"
printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md"
assert_fragments_order() {
local expected="$1" actual
@ -205,19 +205,19 @@ check "fragments: issue number descending (numeric, 10 before 9), filename tie-b
PF="$TMP/frag-problems"
mkdir -p "$PF"
printf -- '- Fine.\n' >"$PF/7.md"
printf -- '- Fine (#7).\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.
- Grouped fine (#8).
EOF
check "fragment predicate: a grouped fragment passes" 0 "" \
changelog_fragment_problem "$PF/8.md"
printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md"
printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md"
check "fragment predicate: a cross-repo name passes" 0 "" \
changelog_fragment_problem "$PF/ceremony-14.md"
@ -274,14 +274,14 @@ check "length bound: the refusal names the bound and the split fix" 1 \
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
changelog_fragment_problem "$PF/30.md"
printf -- '- %s\n' "$(mkchars 300)" >"$PF/31.md"
printf -- '- %s (#31).\n' "$(mkchars 293)" >"$PF/31.md"
check "length bound: an entry of exactly 300 passes" 0 "" \
changelog_fragment_problem "$PF/31.md"
{
printf -- '- %s\n' "$(mkchars 150)"
printf -- '- %s\n' "$(mkchars 150)"
printf -- '- %s\n' "$(mkchars 150)"
printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s (#32).\n' "$(mkchars 143)"
} >"$PF/32.md"
check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \
changelog_fragment_problem "$PF/32.md"
@ -291,14 +291,14 @@ check "length bound: several within-bound entries pass though the file totals ov
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s (#33).\n' "$(mkchars 50)"
} >"$PF/33.md"
check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \
changelog_fragment_problem "$PF/33.md"
{
printf '### Added\n\n'
printf -- '- %s\n' "$(mkchars 300)"
printf -- '- %s (#34).\n' "$(mkchars 293)"
} >"$PF/34.md"
check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \
changelog_fragment_problem "$PF/34.md"
@ -331,6 +331,151 @@ check "length bound: published sections stay unvalidated — 0.3.0's over-bound
check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0
# --- the terminal issue cite (#262) ------------------------------------------
# cite_case <number> <entry-line...> — a fragment holding exactly the given
# lines, so a case reads as the entry it is about.
cite_case() {
local num="$1"
shift
printf '%s\n' "$@" >"$PF/$num.md"
}
cite_case 40 '- Local (#262).'
check "cite: the canonical '(#N).' passes" 0 "" \
changelog_fragment_problem "$PF/40.md"
cite_case 41 '- Sibling repo (crew#309).'
check "cite: a sibling-repo reference passes" 0 "" \
changelog_fragment_problem "$PF/41.md"
cite_case 42 '- Fully qualified (heavy-duty/crew#309).'
check "cite: an owner/repo reference passes" 0 "" \
changelog_fragment_problem "$PF/42.md"
cite_case 43 '- Two issues, one entry (#236, #250).'
check "cite: one group carrying two references passes" 0 "" \
changelog_fragment_problem "$PF/43.md"
# The cite is measured on the normalized entry, so a citation that lands on
# a continuation line still closes the entry — the #167 lesson, repeated:
# wrapping alone must never red a compliant entry.
cite_case 44 '- An entry whose prose wraps onto a' ' continuation line, cite and all (#262).'
check "cite: a citation on a continuation line passes — the entry is normalized first" 0 "" \
changelog_fragment_problem "$PF/44.md"
cite_case 45 '### Added' '' '- Added one (#101).' '- Added two (#102).' '' \
'### Changed' '' '- Changed one (#103).' '' '### Fixed' '' '- Fixed one (#104).'
check "cite: a grouped fragment, three headings, every entry compliant, passes" 0 "" \
changelog_fragment_problem "$PF/45.md"
# The two diagnoses are distinct by construction (D5): a builder who reads
# one must not be told the other's fix.
cite_case 50 '- No cite here.'
check "cite: an entry with no reference at all is refused" 1 \
"50.md' has an entry with no issue citation" \
changelog_fragment_problem "$PF/50.md"
check "cite: the uncited refusal names the shape to write" 1 \
"end it with the issue it comes from: '(#N).'" \
changelog_fragment_problem "$PF/50.md"
cite_case 51 '- Cite before the period. (#262)'
check "cite: a citation trailing the period is refused — the 248.md shape" 1 \
"51.md' has an entry whose issue citation is not terminal" \
changelog_fragment_problem "$PF/51.md"
check "cite: the misplaced refusal names the shape to write" 1 \
"exactly one '(#N)' group ends the entry, the final '.' after it" \
changelog_fragment_problem "$PF/51.md"
cite_case 52 '- Trailing prose (#262) and then more.'
check "cite: a citation with prose after it is refused" 1 \
"has an entry whose issue citation is not terminal" \
changelog_fragment_problem "$PF/52.md"
cite_case 53 '- Two groups (#236) and (#250).'
check "cite: two citation groups are refused — one terminal group, or none (D2)" 1 \
"has an entry whose issue citation is not terminal" \
changelog_fragment_problem "$PF/53.md"
cite_case 54 '- Bad token (#abc).'
check "cite: a reference with no digits is no reference" 1 \
"has an entry with no issue citation" \
changelog_fragment_problem "$PF/54.md"
cite_case 55 '- Bad token (#).'
check "cite: an empty reference is no reference" 1 \
"has an entry with no issue citation" \
changelog_fragment_problem "$PF/55.md"
# The citation need not name the file's own issue (D3): the filename already
# carries the authorizing one, so an entry may cite the incident beside it.
cite_case 56 '- Cites another issue entirely (#101).'
check "cite: the reference need not match the filename" 0 "" \
changelog_fragment_problem "$PF/56.md"
# Ordering: the bound outranks the cite across the whole fragment, so a
# fragment that reds today draws the diagnosis it drew before this rule
# existed. The uncited entry comes FIRST here on purpose — the other order
# would pass whatever the precedence is.
{
printf -- '- Uncited, and it comes first.\n'
printf -- '- %s\n' "$(mkchars 301)"
} >"$PF/57.md"
check "cite: an over-bound entry outranks an earlier uncited one" 1 \
"57.md' has a 301-character entry" \
changelog_fragment_problem "$PF/57.md"
assert_one_diagnosis() {
local count
count="$(changelog_fragment_problem "$PF/$1.md" | wc -l)"
[ "$count" = 1 ] || {
printf 'wanted one diagnosis, got %s\n' "$count"
return 1
}
}
check "cite: the outranked citation problem is not reported beside it" 0 "" \
assert_one_diagnosis 57
# The axis 57.md cannot test: its over-bound entry is LAST, so the only
# flush that can print is END's, which exits immediately. A flush from a
# main rule exits too — but awk runs END on the way out, so the citation
# row a mid-file length row outranks would print after it unless END is
# guarded. Both ways out of the walk, a bullet and a heading.
{
printf -- '- Uncited, and it comes first.\n'
printf -- '- %s\n' "$(mkchars 301)"
printf -- '- A later entry the walk never reaches (#57).\n'
} >"$PF/58.md"
check "cite: an over-bound entry that is not the last one still reports the bound" 1 \
"58.md' has a 301-character entry" \
changelog_fragment_problem "$PF/58.md"
check "cite: and it is still one diagnosis, not the protocol row spliced into it" 0 "" \
assert_one_diagnosis 58
{
printf '### Fixed\n'
printf -- '- Misplaced, and it comes first. (#59)\n'
printf -- '- %s\n' "$(mkchars 301)"
printf '### Changed\n'
printf -- '- The heading is the other way out of the walk (#59).\n'
} >"$PF/59.md"
check "cite: a heading after the over-bound entry is the same one diagnosis" 1 \
"59.md' has a 301-character entry" \
changelog_fragment_problem "$PF/59.md"
check "cite: the misplaced row does not ride along with it either" 0 "" \
assert_one_diagnosis 59
# Published sections keep their pre-rule prose (D4): reddening history is a
# wall, not a guard. Every shipped section predates the cite.
check "cite: a published section with uncited entries still reds nothing" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0
# The fragments this repo carries right now are the rule's own first
# constituency — the guard is worth nothing if the tree it ships in fails it.
assert_tree_fragments() {
local f
while IFS= read -r f; do
[ -n "$f" ] || continue
changelog_fragment_problem "$f" || return 1
done <<<"$(changelog_fragments "$ROOT/changelog.d")"
}
check "cite: every fragment in this tree passes the rule it ships" 0 "" \
assert_tree_fragments
# --- the assembler (#114) ----------------------------------------------------
assert_assemble() {
@ -347,11 +492,11 @@ 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.
wraps onto a continuation line (#3).
EOF
printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md"
printf -- '- Ten (#10).\n- Ten again (#10).\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.'
assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).'
check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
changelog_assemble "$TMP/no-such-dir"
@ -361,36 +506,36 @@ mkdir -p "$AG"
cat >"$AG/21.md" <<'EOF'
### Fixed
- Fixed twenty-one.
- Fixed twenty-one (#21).
EOF
cat >"$AG/20.md" <<'EOF'
### Added
- Added twenty.
- Added twenty (#20).
### Docs
- Docs twenty.
- Docs twenty (#20).
EOF
cat >"$AG/19.md" <<'EOF'
### Security
- Security nineteen.
- Security nineteen (#19).
### Added
- Added nineteen.
- Added nineteen (#19).
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.'
assert_assemble "$AG" $'### Added\n\n- Added twenty (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
AM="$TMP/assemble-mixed"
mkdir -p "$AM"
printf -- '- Flat five.\n' >"$AM/5.md"
printf -- '- Flat five (#5).\n' >"$AM/5.md"
cat >"$AM/6.md" <<'EOF'
### Added
- Grouped six.
- Grouped six (#6).
EOF
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
changelog_assemble "$AM"
@ -400,11 +545,11 @@ check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \
AX="$TMP/assemble-selfmixed"
mkdir -p "$AX"
cat >"$AX/7.md" <<'EOF'
- Ungrouped lead.
- Ungrouped lead (#7).
### Added
- Grouped follow.
- Grouped follow (#7).
EOF
check "assemble: one fragment mixing both shapes is refused, file named" 1 \
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
@ -429,14 +574,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
- Older section is grouped.
EOF
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
check "shape: flat set matches newest flat published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#1).
EOF
check "shape: grouped set names its conflict with newest flat published section" 1 \
"fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \
@ -451,7 +596,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
- Newest section is grouped.
EOF
printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md"
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
check "shape: flat set names its conflict with newest grouped published section" 1 \
"fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
@ -459,7 +604,7 @@ check "shape: flat set names its conflict with newest grouped published section"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#1).
EOF
check "shape: grouped set matches newest grouped published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
@ -484,7 +629,7 @@ EOF
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#1).
EOF
printf 'grouped\n' >"$SHAPE_DIR/shape"
check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \
@ -492,7 +637,7 @@ check "shape: 'grouped' sentinel admits a grouped set over a flat published sect
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"
printf -- '- Flat fragment (#1).\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"
@ -512,14 +657,14 @@ check "shape: 'flat' sentinel admits a flat set over a grouped published section
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#1).
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"
printf -- '- Flat two (#2).\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"
@ -558,7 +703,7 @@ printf 'grouped\n' >"$SHAPE_DIR/shape"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment.
- Grouped fragment (#1).
EOF
assert_fragments_exclude_sentinel() {
local out
@ -579,17 +724,17 @@ printf 'grouped\n' >"$AS/shape"
cat >"$AS/30.md" <<'EOF'
### Fixed
- Fixed thirty.
- Fixed thirty (#30).
EOF
cat >"$AS/31.md" <<'EOF'
### Added
- Added thirty-one.
- Added thirty-one (#31).
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.'
assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).'
rm "$AS/30.md" "$AS/31.md"
printf -- '- Flat probe.\n' >"$AS/29.md"
printf -- '- Flat probe (#29).\n' >"$AS/29.md"
check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \
"declares grouped" \
changelog_assemble "$AS"