Skip to content

Commit baed0f7

Browse files
os-zhuangclaude
andauthored
fix(release-tooling): say which changeset copy was printed, or refuse (#10844)
Section 2 of collect-release-notes.sh asked two history questions that a shallow checkout answers wrongly and silently, and printed the answers under normal headings. The list: `git log --diff-filter=D PREV..NEW` looks safe because both endpoints are named, but an endpoint that RESOLVES does not make the range WALKABLE. A clone holding PREV as its own shallow island walks down from NEW, stops at its graft and exits 0, so every changeset consumed below the floor is absent from the list. Measured on the fixture in this file's --self-test: complete clone lists 2, depth-12 island clone lists 1, both exit 0. It now routes through the same `git-history.mjs ensure --no-fetch` predicate section 4 already uses, and withholds the list rather than printing it short. The bodies: every body now names the object it was read from, and the `|| git show "<prev>:<path>"` fallback is deleted rather than guarded. That copy is the pre-edit prose for any changeset revised during the dev cycle, and it was printed with nothing to say so. It was also not the safety net it read as — a changeset consumed this cycle usually did not exist at PREV_REF, so the fallback failed too and took the script down mid-output under `set -e`. The route the filing card predicted (the fallback firing because the deleting commit sits below the floor) does NOT reproduce, and is structurally closed: a file reaches the list only when its deleting commit is above the graft, and such a commit always has its parent present. At the boundary git reports the tree as ADDED, never DELETED, so the file drops out of the list instead of reaching the fallback. Measured at clone depths 5/8/9/10/11/12/15/25. The harm it named is real by another route, which the self-test now pins directly. Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent db82944 commit baed0f7

1 file changed

Lines changed: 237 additions & 33 deletions

File tree

scripts/collect-release-notes.sh

Lines changed: 237 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,19 @@
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"
@@ -51,6 +56,37 @@
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

5591
set -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+
86192
if [[ "${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
148335
fi
149336

150337
PREV_REF="${1:?usage: collect-release-notes.sh <prev-ref> [<new-ref>]}"
151338
NEW_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

154348
cd "$FRAMEWORK_ROOT"
155349

@@ -179,25 +373,35 @@ echo
179373
echo "## 2. Framework — changesets consumed in this release"
180374
echo
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)"
190382
else
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
201405
fi
202406

203407
echo "## 3. Console UI (objectui) — pin range"
@@ -232,16 +436,16 @@ echo
232436
if [[ -z "$CLOUD_ROOT" || ! -d "$CLOUD_ROOT/.git" ]]; then
233437
echo "_cloud checkout not found (set CLOUD_ROOT); scan it by this window manually._"
234438
elif ! cloud_window_guard "$CLOUD_ROOT" "$prev_date"; then
235-
withheld=1
439+
mark_withheld "section 4 (the cloud commit list)"
236440
else
237441
print_log_split "$CLOUD_ROOT" --since="$prev_date" --until="$new_date"
238442
fi
239443

240444
echo
241445
echo "---"
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
246450
fi
247451
echo "_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
257461
fi

0 commit comments

Comments
 (0)