2929#
3030# Self-test: scripts/collect-release-notes.sh --self-test (temp fixtures, no network)
3131#
32- # ## Section 4 asks a WINDOWED history question, so it is horizon-guarded (#9902)
32+ # ## Two sections ask a history question a shallow checkout answers WRONGLY
3333#
34- # Sections 1-3 ask range questions (`<ref>..<ref>`): a missing endpoint is a
35- # `fatal: bad object` and `set -e` stops the script. Section 4 cannot fail that
36- # way — `git log --since/--until` over a shallow checkout answers from whatever
37- # part of the window is present, exits 0, and prints no warning. This script
38- # has no CI caller at all; it runs at release time from a seat, and it reads a
39- # cloud checkout it does not own.
34+ # Sections 1 and 3 ask range questions whose endpoints must both be objects
35+ # (`<ref>..<ref>`): a missing endpoint is a `fatal: bad object` and `set -e`
36+ # stops the script. Sections 2 and 4 have no such stop — in two different ways —
37+ # so each carries a horizon guard. This script has no CI caller at all; it runs
38+ # at release time from a seat, and section 4 reads a cloud checkout it does not
39+ # own.
40+ #
41+ # ### Section 4 — a WINDOWED question (#9902)
42+ #
43+ # `git log --since/--until` over a shallow checkout answers from whatever part
44+ # of the window is present, exits 0, and prints no warning.
4045#
4146# Measured on a depth-5 fixture whose true answer for the window is 21 commits:
4247# the shallow clone printed **5**, exit 0, under the same "### feat / fix"
5156# rather than whether the clone is shallow, so a shallow cloud checkout with
5257# enough depth still prints its section. Deepening is left to the operator —
5358# this script must not reach into someone else's checkout and move it.
59+ #
60+ # ### Section 2 — a RANGE question whose endpoints both resolve (#10509)
61+ #
62+ # `git log --diff-filter=D PREV..NEW` looks safe because both endpoints are
63+ # named — but an endpoint that RESOLVES does not make the range WALKABLE
64+ # (#9450). A clone holding PREV as its own shallow island, the shape that
65+ # `git fetch --shallow-since` and `git fetch --depth=1 origin <tag>` both
66+ # produce, walks down from NEW, stops at its graft, and exits 0: every changeset
67+ # consumed below that floor is simply absent from the list.
68+ #
69+ # Measured on this file's own 40-commit fixture (`--self-test`), two changesets
70+ # consumed in the range: the complete clone lists **2**, a depth-12 clone
71+ # holding PREV as an island lists **1** — both exit 0, both under this same
72+ # heading. And when the floor sits above every deletion the list comes back
73+ # empty, at which point the stock "_None found — is NEW_REF past the 'chore:
74+ # version packages' commit?_" line printed a plausible WRONG diagnosis, sending
75+ # the operator to check their arguments instead of their clone. So section 2 is
76+ # withheld on the same terms as section 4, through the same predicate.
77+ #
78+ # The filing card predicted a different failure here — the per-file
79+ # `git show "$(git log -1 NEW -- $f)~1:$f" || git show "PREV:$f"` fallback
80+ # quietly serving the PREV copy — and that route does NOT reproduce. It is
81+ # structurally closed: a file reaches the list only when its deleting commit is
82+ # ABOVE the graft, and such a commit always has its parent present, so the
83+ # primary lookup cannot fail for a file that is IN the list; at the boundary
84+ # itself git reports the whole tree as ADDED, never DELETED, so the file drops
85+ # out of the list instead of reaching the fallback (measured at clone depths
86+ # 5/8/9/10/11/12/15/25). The harm it named is real all the same — the PREV copy
87+ # IS the pre-edit prose for a changeset edited during the cycle — so the
88+ # fallback is gone rather than guarded: every body now names the object it was
89+ # read from, and one that cannot be resolved is withheld, not substituted.
5490
5591set -euo pipefail
5692
@@ -83,6 +119,76 @@ cloud_window_guard() { # <dir> <since-iso>
83119 return $rc
84120}
85121
122+ # Can `dir` walk the whole of `<prev>..<new>`? Prints the refusal block
123+ # (markdown) on stdout and returns non-zero when it cannot; prints nothing and
124+ # returns 0 when it can.
125+ #
126+ # The range is asked about as an INSTANT, through the same predicate section 4
127+ # uses, rather than with `merge-base --is-ancestor`: `PREV..NEW` is contained in
128+ # [date(PREV), date(NEW)], so "is the floor below date(PREV)" answers this too —
129+ # while `--is-ancestor` would also refuse PREV tagged off a side line, which is
130+ # a legitimate release shape rather than a truncated clone.
131+ changeset_range_guard () { # <dir> <prev-ref> <new-ref>
132+ local dir=" $1 " prev=" $2 " new=" $3 " err since rc=0
133+ since=" $( git -C " $dir " log -1 --format=%cI " $prev " ) "
134+ err=" $( mktemp) "
135+ if node " ${FRAMEWORK_ROOT} /scripts/pm/git-history.mjs" ensure \
136+ --since=" $since " --ref=" $new " --no-fetch --cwd=" $dir " 2> " $err " ; then
137+ rm -f " $err "
138+ return 0
139+ fi
140+ rc=1
141+ echo " > ⛔ **Changeset list WITHHELD — this checkout cannot walk the whole ${prev} ..${new} range.**"
142+ echo " >"
143+ echo " > Both endpoints resolve, so the walk exits 0 and prints a list — but any changeset"
144+ echo " > consumed below the shallow floor is missing from that list, and a short list under"
145+ echo " > this heading reads exactly like a release that shipped fewer items (#10509)."
146+ echo
147+ echo ' ```'
148+ cat " $err "
149+ echo ' ```'
150+ rm -f " $err "
151+ return $rc
152+ }
153+
154+ # Print ONE consumed changeset's body, prefixed by the object it was read from.
155+ # Returns non-zero — having printed a withheld block INSTEAD of a body — when
156+ # the copy the release actually consumed is not in this checkout.
157+ #
158+ # There is deliberately no `|| git show <prev>:<path>` fallback here. That copy
159+ # is the PRE-EDIT prose for any changeset revised during the dev cycle, and
160+ # printed under this heading it is indistinguishable from the consumed one, so
161+ # the release page quotes prose that was rewritten before it shipped. It is also
162+ # not the safety net it reads as: a changeset consumed in this cycle usually did
163+ # not exist at PREV_REF at all, so the fallback fails too and takes the whole
164+ # script down mid-output under `set -e`.
165+ changeset_body () { # <dir> <prev-ref> <new-ref> <path>
166+ local dir=" $1 " prev=" $2 " new=" $3 " f=" $4 " del body
167+ del=" $( git -C " $dir " log --diff-filter=D --pretty=%H -1 " ${prev} ..${new} " -- " $f " || true) "
168+ # One `git show`, with its failure READ rather than discarded. This loop runs
169+ # once per consumed changeset — 2398 of them across an 11-day range on this
170+ # repo — so it stays at the two git processes per file the unguarded version
171+ # used; a separate `cat-file -e` probe would have added a third.
172+ if [[ -n " $del " ]] && body=" $( git -C " $dir " show " ${del} ~1:${f} " 2> /dev/null) " ; then
173+ echo " _source: \` ${del: 0: 9} ~1:${f} \` — the state this file had when the release consumed it._"
174+ echo
175+ echo ' ```md'
176+ printf ' %s\n' " $body "
177+ echo ' ```'
178+ return 0
179+ fi
180+ echo " > ⛔ **Body WITHHELD — the copy this release consumed is not in this checkout.**"
181+ echo " >"
182+ echo " > No commit deleting this file is visible in \` ${prev} ..${new} \` , so the state it was in"
183+ echo " > when \` changeset version\` consumed it cannot be read here."
184+ echo " >"
185+ echo " > The \` ${prev} \` copy is NOT a substitute — for a changeset edited during the cycle"
186+ echo " > that copy is the pre-edit prose. Read it deliberately if you want it anyway:"
187+ echo " >"
188+ echo " > git show ${prev} :${f} "
189+ return 1
190+ }
191+
86192if [[ " ${1:- } " == " --self-test" ]]; then
87193 fails=0
88194 ok () { if [[ " $2 " == " 1" ]]; then echo " ✓ $1 " ; else echo " ✗ $1 ${3: +
@@ -99,12 +205,35 @@ if [[ "${1:-}" == "--self-test" ]]; then
99205 for i in $( seq 0 39) ; do
100206 d=" $( node -e " console.log(new Date(Date.parse('2026-06-01T12:00:00Z') + $i * 864e5).toISOString())" ) "
101207 printf ' commit %s\n' " $i " > f.txt
102- git add f.txt
208+ # Two changesets, each EDITED mid-cycle before being consumed, so a
209+ # pre-edit copy is distinguishable from the consumed one by its text.
210+ # `.changeset/` is recreated every round: git drops the directory when the
211+ # last file in it is deleted, and a `>` into a missing directory fails
212+ # silently enough to leave a fixture that looks built and is not.
213+ mkdir -p .changeset
214+ case " $i " in
215+ 2) printf -- " ---\n'@objectstack/core': patch\n---\n\nSPANS-PREV pre-edit prose.\n" > .changeset/spans-prev.md ;;
216+ 10) printf -- " ---\n'@objectstack/core': patch\n---\n\nSPANS-PREV post-edit prose.\n" > .changeset/spans-prev.md ;;
217+ 18) git rm --quiet .changeset/spans-prev.md ;;
218+ 20) printf -- " ---\n'@objectstack/core': minor\n---\n\nLATE pre-edit prose.\n" > .changeset/late.md ;;
219+ 24) printf -- " ---\n'@objectstack/core': minor\n---\n\nLATE post-edit prose.\n" > .changeset/late.md ;;
220+ 30) git rm --quiet .changeset/late.md ;;
221+ esac
222+ git add -A
103223 GIT_AUTHOR_DATE=" $d " GIT_COMMITTER_DATE=" $d " git commit --quiet -m " feat: change $i "
224+ if [[ " $i " == 5 ]]; then git tag prev; fi
225+ if [[ " $i " == 39 ]]; then git tag new; fi
104226 done
105227 )
106228 git clone --quiet " file://$tmp /up" " $tmp /full"
107229 git clone --quiet --depth=5 " file://$tmp /up" " $tmp /shallow"
230+ # PREV_REF present, the range to it NOT walkable: a depth-12 clone, then the
231+ # old tag fetched in as its own shallow island. This is the shape a release
232+ # seat actually arrives at — `git fetch --shallow-since` and
233+ # `git fetch --depth=1 origin <tag>` both produce it — and it is the one where
234+ # `PREV..NEW` answers instead of failing.
235+ git clone --quiet --depth=12 " file://$tmp /up" " $tmp /island"
236+ git -C " $tmp /island" fetch --quiet --depth=1 origin ' refs/tags/prev:refs/tags/prev'
108237
109238 WIN_SINCE=2026-06-20T00:00:00Z
110239 WIN_UNTIL=2026-07-11T00:00:00Z
@@ -130,6 +259,50 @@ if [[ "${1:-}" == "--self-test" ]]; then
130259 ok " and it is still shallow afterwards — the guard never deepens a checkout it does not own" \
131260 " $( [[ " $( git -C " $tmp /shallow" rev-parse --is-shallow-repository) " == true ]] && echo 1 || echo 0) "
132261
262+ # ── section 2: the changesets the release consumed (#10509) ───────────────
263+ consumed_of () { # <dir>
264+ git -C " $1 " log --diff-filter=D --name-only --pretty=format: prev..new -- ' .changeset/*.md' \
265+ | grep -v ' README' | grep . | sort -u || true
266+ }
267+ n_cs_full=$( consumed_of " $tmp /full" | grep -c . || true)
268+ n_cs_island=$( consumed_of " $tmp /island" | grep -c . || true)
269+ ok " BASELINE — prev..new lists 2 consumed changesets in a complete clone and $n_cs_island where PREV is a shallow island, both exit 0 (the defect, reproduced)" \
270+ " $( [[ " $n_cs_full " == 2 && " $n_cs_island " == 1 ]] && echo 1 || echo 0) " " full=$n_cs_full island=$n_cs_island "
271+
272+ if out=" $( changeset_range_guard " $tmp /full" prev new) " ; then g2_full=1; else g2_full=0; fi
273+ ok " a complete clone passes the range guard and it prints nothing" \
274+ " $( [[ " $g2_full " == 1 && -z " $out " ]] && echo 1 || echo 0) " " out=$out "
275+
276+ if out=" $( changeset_range_guard " $tmp /island" prev new) " ; then g2_isl=1; else g2_isl=0; fi
277+ ok " a clone whose PREV_REF RESOLVES but whose range is not walkable is REFUSED" \
278+ " $( [[ " $g2_isl " == 0 ]] && echo 1 || echo 0) " " out=$out "
279+ ok " and that refusal says so, names the floor, and carries the remedy" \
280+ " $( grep -q ' WITHHELD' <<< " $out" && grep -q ' shallow floor: 2026-06-29' <<< " $out" && grep -q ' unshallow' <<< " $out" && echo 1 || echo 0) " " $out "
281+ ok " and it names no changeset at all — nothing in it can be transcribed as the section" \
282+ " $( grep -q ' \.changeset/' <<< " $out" && echo 0 || echo 1) " " $out "
283+
284+ body=" $( changeset_body " $tmp /full" prev new .changeset/spans-prev.md) "
285+ ok " the body printed is the copy the release consumed, not the pre-edit one" \
286+ " $( grep -q ' SPANS-PREV post-edit prose' <<< " $body" && ! grep -q ' SPANS-PREV pre-edit prose' <<< " $body" && echo 1 || echo 0) " " $body "
287+ ok " and it names the object it was read from, so WHICH copy it is is answerable from the output" \
288+ " $( grep -qE ' ^_source: .[0-9a-f]{7,}~1:\.changeset/spans-prev\.md.' <<< " $body" && echo 1 || echo 0) " " $body "
289+
290+ # The harm the filing card named, reproduced head-on. In the island clone the
291+ # consumed copy is unreachable and the PREV_REF copy is the PRE-EDIT prose:
292+ # the deleted `|| git show <prev>:<path>` fallback served exactly that, under
293+ # the normal heading, with nothing said. The refusal must not carry it.
294+ stale=" $( git -C " $tmp /island" show " prev:.changeset/spans-prev.md" ) "
295+ ok " PRECONDITION — the PREV_REF copy the old fallback served is the PRE-EDIT prose" \
296+ " $( grep -q ' SPANS-PREV pre-edit prose' <<< " $stale" && echo 1 || echo 0) " " $stale "
297+ if body=" $( changeset_body " $tmp /island" prev new .changeset/spans-prev.md) " ; then b_isl=1; else b_isl=0; fi
298+ ok " a body whose consuming commit is below the floor is WITHHELD, not served from PREV_REF" \
299+ " $( [[ " $b_isl " == 0 ]] && echo 1 || echo 0) " " $body "
300+ # `SPANS-PREV` is uppercase in the fixture PROSE and lowercase in the PATH, so
301+ # this matches leaked body text without matching the remedy line that quotes
302+ # the path — and without matching the words "pre-edit prose" in the warning.
303+ ok " and that refusal carries no fenced body a release page could be written from" \
304+ " $( grep -q ' SPANS-PREV' <<< " $body" && echo 0 || echo 1) " " $body "
305+
133306 # Wiring: the guard is worthless if section 4 stops calling it.
134307 #
135308 # The needle is ASSEMBLED from two adjacent literals rather than written out,
@@ -142,14 +315,35 @@ if [[ "${1:-}" == "--self-test" ]]; then
142315 ok " section 4 routes its windowed question through the guard" \
143316 " $( grep -qF " $needle " " ${BASH_SOURCE[0]} " && echo 1 || echo 0) " " needle: $needle "
144317
318+ needle2=" if ! changeset_range" " _guard \"\$ FRAMEWORK_ROOT\" "
319+ ok " section 2 routes its range question through the guard" \
320+ " $( grep -qF " $needle2 " " ${BASH_SOURCE[0]} " && echo 1 || echo 0) " " needle: $needle2 "
321+
322+ needle3=" if ! changeset" " _body \"\$ FRAMEWORK_ROOT\" "
323+ ok " section 2 prints every body through the reader that labels it" \
324+ " $( grep -qF " $needle3 " " ${BASH_SOURCE[0]} " && echo 1 || echo 0) " " needle: $needle3 "
325+
326+ # Inverted pin, so it too is assembled: written out, it would match its own
327+ # source line and report the fallback still present on a clean file.
328+ needle4=" || git show \"\$ {PREV" " _REF}"
329+ ok " and the unlabelled PREV_REF fallback is GONE from the file, not merely bypassed" \
330+ " $( grep -qF " $needle4 " " ${BASH_SOURCE[0]} " && echo 0 || echo 1) " " needle: $needle4 "
331+
145332 echo
146333 if [[ " $fails " == 0 ]]; then echo " collect-release-notes --self-test: all cases passed." ; else echo " collect-release-notes --self-test: $fails FAILED." ; exit 1; fi
147334 exit 0
148335fi
149336
150337PREV_REF=" ${1:? usage: collect-release-notes.sh <prev-ref> [<new-ref>]} "
151338NEW_REF=" ${2:- HEAD} "
152- withheld=0
339+ withheld_names=" "
340+
341+ # Record a section (or a single changeset) as withheld, for the INCOMPLETE
342+ # trailer and the non-zero exit. Both are needed: the trailer is for the human
343+ # reading the markdown, the exit code for `... > material.md` in a pipeline.
344+ mark_withheld () { # <name>
345+ withheld_names=" ${withheld_names: +${withheld_names} ; } $1 "
346+ }
153347
154348cd " $FRAMEWORK_ROOT "
155349
@@ -179,25 +373,35 @@ echo
179373echo " ## 2. Framework — changesets consumed in this release"
180374echo
181375
182- # Changesets deleted anywhere in the range were consumed by `changeset
183- # version` for this release. (An endpoint diff would miss files added and
184- # consumed within the same dev cycle, so walk the log instead.)
185- consumed=$( git log --diff-filter=D --name-only --pretty=format: " ${PREV_REF} " .." ${NEW_REF} " -- ' .changeset/*.md' \
186- | grep -v ' README' | grep . | sort -u || true)
187-
188- if [[ -z " $consumed " ]]; then
189- echo " _None found — is ${NEW_REF} past the 'chore: version packages' commit?_"
376+ # WHICH changesets were consumed is a range question, and both endpoints
377+ # resolving does not make the range walkable — so ask the horizon before
378+ # answering. An unguarded short list here is a release page missing however
379+ # many items were consumed below the graft, with nothing to say so.
380+ if ! changeset_range_guard " $FRAMEWORK_ROOT " " $PREV_REF " " $NEW_REF " ; then
381+ mark_withheld " section 2 (the changeset list)"
190382else
191- while IFS= read -r f; do
192- echo " ### ${f} "
193- echo
194- echo ' ```md'
195- # The file may have been added after PREV_REF; show its last pre-deletion state.
196- git show " $( git log --diff-filter=D --pretty=%H -1 " ${NEW_REF} " -- " $f " ) ~1:$f " 2> /dev/null \
197- || git show " ${PREV_REF} :${f} "
198- echo ' ```'
199- echo
200- done <<< " $consumed"
383+ # Changesets deleted anywhere in the range were consumed by `changeset
384+ # version` for this release. (An endpoint diff would miss files added and
385+ # consumed within the same dev cycle, so walk the log instead.)
386+ consumed=$( git log --diff-filter=D --name-only --pretty=format: " ${PREV_REF} " .." ${NEW_REF} " -- ' .changeset/*.md' \
387+ | grep -v ' README' | grep . | sort -u || true)
388+
389+ if [[ -z " $consumed " ]]; then
390+ echo " _None found — is ${NEW_REF} past the 'chore: version packages' commit?_"
391+ else
392+ while IFS= read -r f; do
393+ echo " ### ${f} "
394+ echo
395+ # Prints the body AND the object it came from, or withholds it. The file
396+ # may have been added after PREV_REF, so the copy wanted is its last
397+ # pre-deletion state — never the PREV_REF one, which is the pre-edit
398+ # prose whenever the changeset was revised during the cycle.
399+ if ! changeset_body " $FRAMEWORK_ROOT " " $PREV_REF " " $NEW_REF " " $f " ; then
400+ mark_withheld " section 2 (the body of ${f} )"
401+ fi
402+ echo
403+ done <<< " $consumed"
404+ fi
201405fi
202406
203407echo " ## 3. Console UI (objectui) — pin range"
@@ -232,16 +436,16 @@ echo
232436if [[ -z " $CLOUD_ROOT " || ! -d " $CLOUD_ROOT /.git" ]]; then
233437 echo " _cloud checkout not found (set CLOUD_ROOT); scan it by this window manually._"
234438elif ! cloud_window_guard " $CLOUD_ROOT " " $prev_date " ; then
235- withheld=1
439+ mark_withheld " section 4 (the cloud commit list) "
236440else
237441 print_log_split " $CLOUD_ROOT " --since=" $prev_date " --until=" $new_date "
238442fi
239443
240444echo
241445echo " ---"
242- if [[ " $withheld " == 1 ]]; then
243- echo " _⛔ INCOMPLETE: section 4 was withheld — see the block above. This material is not a "
244- echo " complete basis for a release page until that checkout is deepened._"
446+ if [[ -n " $withheld_names " ]]; then
447+ echo " _⛔ INCOMPLETE — WITHHELD: ${withheld_names} . See the block(s) above. This material is"
448+ echo " not a complete basis for a release page until those checkouts are deepened._"
245449 echo
246450fi
247451echo " _Write the curated page at content/docs/releases/, register it in"
@@ -251,7 +455,7 @@ echo "check — every developer-visible feat/fix should be accounted for._"
251455
252456# A withheld section must be legible to a pipeline too, not only to the reader:
253457# `... > material.md` succeeding is otherwise the only signal, and it lies.
254- if [[ " $withheld " == 1 ]]; then
255- echo " collect-release-notes: INCOMPLETE — the cloud section was withheld (see the output)." >&2
458+ if [[ -n " $withheld_names " ]]; then
459+ echo " collect-release-notes: INCOMPLETE — withheld: ${withheld_names} (see the output)." >&2
256460 exit 2
257461fi
0 commit comments