From 67afcb7e67fe6b8add4529a7c0694ff10d9d6284 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 13:10:54 +0000 Subject: [PATCH] fix(release-tooling): say which changeset copy was printed, or refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ":"` 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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- scripts/collect-release-notes.sh | 270 +++++++++++++++++++++++++++---- 1 file changed, 237 insertions(+), 33 deletions(-) diff --git a/scripts/collect-release-notes.sh b/scripts/collect-release-notes.sh index dc050ea18e..55650fdcee 100755 --- a/scripts/collect-release-notes.sh +++ b/scripts/collect-release-notes.sh @@ -29,14 +29,19 @@ # # Self-test: scripts/collect-release-notes.sh --self-test (temp fixtures, no network) # -# ## Section 4 asks a WINDOWED history question, so it is horizon-guarded (#9902) +# ## Two sections ask a history question a shallow checkout answers WRONGLY # -# Sections 1-3 ask range questions (`..`): a missing endpoint is a -# `fatal: bad object` and `set -e` stops the script. Section 4 cannot fail that -# way — `git log --since/--until` over a shallow checkout answers from whatever -# part of the window is present, exits 0, and prints no warning. This script -# has no CI caller at all; it runs at release time from a seat, and it reads a -# cloud checkout it does not own. +# Sections 1 and 3 ask range questions whose endpoints must both be objects +# (`..`): a missing endpoint is a `fatal: bad object` and `set -e` +# stops the script. Sections 2 and 4 have no such stop — in two different ways — +# so each carries a horizon guard. This script has no CI caller at all; it runs +# at release time from a seat, and section 4 reads a cloud checkout it does not +# own. +# +# ### Section 4 — a WINDOWED question (#9902) +# +# `git log --since/--until` over a shallow checkout answers from whatever part +# of the window is present, exits 0, and prints no warning. # # Measured on a depth-5 fixture whose true answer for the window is 21 commits: # the shallow clone printed **5**, exit 0, under the same "### feat / fix" @@ -51,6 +56,37 @@ # rather than whether the clone is shallow, so a shallow cloud checkout with # enough depth still prints its section. Deepening is left to the operator — # this script must not reach into someone else's checkout and move it. +# +# ### Section 2 — a RANGE question whose endpoints both resolve (#10509) +# +# `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 +# (#9450). A clone holding PREV as its own shallow island, the shape that +# `git fetch --shallow-since` and `git fetch --depth=1 origin ` both +# produce, walks down from NEW, stops at its graft, and exits 0: every changeset +# consumed below that floor is simply absent from the list. +# +# Measured on this file's own 40-commit fixture (`--self-test`), two changesets +# consumed in the range: the complete clone lists **2**, a depth-12 clone +# holding PREV as an island lists **1** — both exit 0, both under this same +# heading. And when the floor sits above every deletion the list comes back +# empty, at which point the stock "_None found — is NEW_REF past the 'chore: +# version packages' commit?_" line printed a plausible WRONG diagnosis, sending +# the operator to check their arguments instead of their clone. So section 2 is +# withheld on the same terms as section 4, through the same predicate. +# +# The filing card predicted a different failure here — the per-file +# `git show "$(git log -1 NEW -- $f)~1:$f" || git show "PREV:$f"` fallback +# quietly serving the PREV copy — and that route does NOT reproduce. It 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, so the +# primary lookup cannot fail for a file that is IN the list; at the boundary +# itself git reports the whole 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 all the same — the PREV copy +# IS the pre-edit prose for a changeset edited during the cycle — so the +# fallback is gone rather than guarded: every body now names the object it was +# read from, and one that cannot be resolved is withheld, not substituted. set -euo pipefail @@ -83,6 +119,76 @@ cloud_window_guard() { # return $rc } +# Can `dir` walk the whole of `..`? Prints the refusal block +# (markdown) on stdout and returns non-zero when it cannot; prints nothing and +# returns 0 when it can. +# +# The range is asked about as an INSTANT, through the same predicate section 4 +# uses, rather than with `merge-base --is-ancestor`: `PREV..NEW` is contained in +# [date(PREV), date(NEW)], so "is the floor below date(PREV)" answers this too — +# while `--is-ancestor` would also refuse PREV tagged off a side line, which is +# a legitimate release shape rather than a truncated clone. +changeset_range_guard() { # + local dir="$1" prev="$2" new="$3" err since rc=0 + since="$(git -C "$dir" log -1 --format=%cI "$prev")" + err="$(mktemp)" + if node "${FRAMEWORK_ROOT}/scripts/pm/git-history.mjs" ensure \ + --since="$since" --ref="$new" --no-fetch --cwd="$dir" 2> "$err"; then + rm -f "$err" + return 0 + fi + rc=1 + echo "> ⛔ **Changeset list WITHHELD — this checkout cannot walk the whole ${prev}..${new} range.**" + echo ">" + echo "> Both endpoints resolve, so the walk exits 0 and prints a list — but any changeset" + echo "> consumed below the shallow floor is missing from that list, and a short list under" + echo "> this heading reads exactly like a release that shipped fewer items (#10509)." + echo + echo '```' + cat "$err" + echo '```' + rm -f "$err" + return $rc +} + +# Print ONE consumed changeset's body, prefixed by the object it was read from. +# Returns non-zero — having printed a withheld block INSTEAD of a body — when +# the copy the release actually consumed is not in this checkout. +# +# There is deliberately no `|| git show :` fallback here. That copy +# is the PRE-EDIT prose for any changeset revised during the dev cycle, and +# printed under this heading it is indistinguishable from the consumed one, so +# the release page quotes prose that was rewritten before it shipped. It is also +# not the safety net it reads as: a changeset consumed in this cycle usually did +# not exist at PREV_REF at all, so the fallback fails too and takes the whole +# script down mid-output under `set -e`. +changeset_body() { # + local dir="$1" prev="$2" new="$3" f="$4" del body + del="$(git -C "$dir" log --diff-filter=D --pretty=%H -1 "${prev}..${new}" -- "$f" || true)" + # One `git show`, with its failure READ rather than discarded. This loop runs + # once per consumed changeset — 2398 of them across an 11-day range on this + # repo — so it stays at the two git processes per file the unguarded version + # used; a separate `cat-file -e` probe would have added a third. + if [[ -n "$del" ]] && body="$(git -C "$dir" show "${del}~1:${f}" 2>/dev/null)"; then + echo "_source: \`${del:0:9}~1:${f}\` — the state this file had when the release consumed it._" + echo + echo '```md' + printf '%s\n' "$body" + echo '```' + return 0 + fi + echo "> ⛔ **Body WITHHELD — the copy this release consumed is not in this checkout.**" + echo ">" + echo "> No commit deleting this file is visible in \`${prev}..${new}\`, so the state it was in" + echo "> when \`changeset version\` consumed it cannot be read here." + echo ">" + echo "> The \`${prev}\` copy is NOT a substitute — for a changeset edited during the cycle" + echo "> that copy is the pre-edit prose. Read it deliberately if you want it anyway:" + echo ">" + echo "> git show ${prev}:${f}" + return 1 +} + if [[ "${1:-}" == "--self-test" ]]; then fails=0 ok() { if [[ "$2" == "1" ]]; then echo " ✓ $1"; else echo " ✗ $1${3:+ @@ -99,12 +205,35 @@ if [[ "${1:-}" == "--self-test" ]]; then for i in $(seq 0 39); do d="$(node -e "console.log(new Date(Date.parse('2026-06-01T12:00:00Z') + $i * 864e5).toISOString())")" printf 'commit %s\n' "$i" > f.txt - git add f.txt + # Two changesets, each EDITED mid-cycle before being consumed, so a + # pre-edit copy is distinguishable from the consumed one by its text. + # `.changeset/` is recreated every round: git drops the directory when the + # last file in it is deleted, and a `>` into a missing directory fails + # silently enough to leave a fixture that looks built and is not. + mkdir -p .changeset + case "$i" in + 2) printf -- "---\n'@objectstack/core': patch\n---\n\nSPANS-PREV pre-edit prose.\n" > .changeset/spans-prev.md ;; + 10) printf -- "---\n'@objectstack/core': patch\n---\n\nSPANS-PREV post-edit prose.\n" > .changeset/spans-prev.md ;; + 18) git rm --quiet .changeset/spans-prev.md ;; + 20) printf -- "---\n'@objectstack/core': minor\n---\n\nLATE pre-edit prose.\n" > .changeset/late.md ;; + 24) printf -- "---\n'@objectstack/core': minor\n---\n\nLATE post-edit prose.\n" > .changeset/late.md ;; + 30) git rm --quiet .changeset/late.md ;; + esac + git add -A GIT_AUTHOR_DATE="$d" GIT_COMMITTER_DATE="$d" git commit --quiet -m "feat: change $i" + if [[ "$i" == 5 ]]; then git tag prev; fi + if [[ "$i" == 39 ]]; then git tag new; fi done ) git clone --quiet "file://$tmp/up" "$tmp/full" git clone --quiet --depth=5 "file://$tmp/up" "$tmp/shallow" + # PREV_REF present, the range to it NOT walkable: a depth-12 clone, then the + # old tag fetched in as its own shallow island. This is the shape a release + # seat actually arrives at — `git fetch --shallow-since` and + # `git fetch --depth=1 origin ` both produce it — and it is the one where + # `PREV..NEW` answers instead of failing. + git clone --quiet --depth=12 "file://$tmp/up" "$tmp/island" + git -C "$tmp/island" fetch --quiet --depth=1 origin 'refs/tags/prev:refs/tags/prev' WIN_SINCE=2026-06-20T00:00:00Z WIN_UNTIL=2026-07-11T00:00:00Z @@ -130,6 +259,50 @@ if [[ "${1:-}" == "--self-test" ]]; then ok "and it is still shallow afterwards — the guard never deepens a checkout it does not own" \ "$([[ "$(git -C "$tmp/shallow" rev-parse --is-shallow-repository)" == true ]] && echo 1 || echo 0)" + # ── section 2: the changesets the release consumed (#10509) ─────────────── + consumed_of() { # + git -C "$1" log --diff-filter=D --name-only --pretty=format: prev..new -- '.changeset/*.md' \ + | grep -v 'README' | grep . | sort -u || true + } + n_cs_full=$(consumed_of "$tmp/full" | grep -c . || true) + n_cs_island=$(consumed_of "$tmp/island" | grep -c . || true) + 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)" \ + "$([[ "$n_cs_full" == 2 && "$n_cs_island" == 1 ]] && echo 1 || echo 0)" "full=$n_cs_full island=$n_cs_island" + + if out="$(changeset_range_guard "$tmp/full" prev new)"; then g2_full=1; else g2_full=0; fi + ok "a complete clone passes the range guard and it prints nothing" \ + "$([[ "$g2_full" == 1 && -z "$out" ]] && echo 1 || echo 0)" "out=$out" + + if out="$(changeset_range_guard "$tmp/island" prev new)"; then g2_isl=1; else g2_isl=0; fi + ok "a clone whose PREV_REF RESOLVES but whose range is not walkable is REFUSED" \ + "$([[ "$g2_isl" == 0 ]] && echo 1 || echo 0)" "out=$out" + ok "and that refusal says so, names the floor, and carries the remedy" \ + "$(grep -q 'WITHHELD' <<< "$out" && grep -q 'shallow floor: 2026-06-29' <<< "$out" && grep -q 'unshallow' <<< "$out" && echo 1 || echo 0)" "$out" + ok "and it names no changeset at all — nothing in it can be transcribed as the section" \ + "$(grep -q '\.changeset/' <<< "$out" && echo 0 || echo 1)" "$out" + + body="$(changeset_body "$tmp/full" prev new .changeset/spans-prev.md)" + ok "the body printed is the copy the release consumed, not the pre-edit one" \ + "$(grep -q 'SPANS-PREV post-edit prose' <<< "$body" && ! grep -q 'SPANS-PREV pre-edit prose' <<< "$body" && echo 1 || echo 0)" "$body" + ok "and it names the object it was read from, so WHICH copy it is is answerable from the output" \ + "$(grep -qE '^_source: .[0-9a-f]{7,}~1:\.changeset/spans-prev\.md.' <<< "$body" && echo 1 || echo 0)" "$body" + + # The harm the filing card named, reproduced head-on. In the island clone the + # consumed copy is unreachable and the PREV_REF copy is the PRE-EDIT prose: + # the deleted `|| git show :` fallback served exactly that, under + # the normal heading, with nothing said. The refusal must not carry it. + stale="$(git -C "$tmp/island" show "prev:.changeset/spans-prev.md")" + ok "PRECONDITION — the PREV_REF copy the old fallback served is the PRE-EDIT prose" \ + "$(grep -q 'SPANS-PREV pre-edit prose' <<< "$stale" && echo 1 || echo 0)" "$stale" + if body="$(changeset_body "$tmp/island" prev new .changeset/spans-prev.md)"; then b_isl=1; else b_isl=0; fi + ok "a body whose consuming commit is below the floor is WITHHELD, not served from PREV_REF" \ + "$([[ "$b_isl" == 0 ]] && echo 1 || echo 0)" "$body" + # `SPANS-PREV` is uppercase in the fixture PROSE and lowercase in the PATH, so + # this matches leaked body text without matching the remedy line that quotes + # the path — and without matching the words "pre-edit prose" in the warning. + ok "and that refusal carries no fenced body a release page could be written from" \ + "$(grep -q 'SPANS-PREV' <<< "$body" && echo 0 || echo 1)" "$body" + # Wiring: the guard is worthless if section 4 stops calling it. # # The needle is ASSEMBLED from two adjacent literals rather than written out, @@ -142,6 +315,20 @@ if [[ "${1:-}" == "--self-test" ]]; then ok "section 4 routes its windowed question through the guard" \ "$(grep -qF "$needle" "${BASH_SOURCE[0]}" && echo 1 || echo 0)" "needle: $needle" + needle2="if ! changeset_range""_guard \"\$FRAMEWORK_ROOT\"" + ok "section 2 routes its range question through the guard" \ + "$(grep -qF "$needle2" "${BASH_SOURCE[0]}" && echo 1 || echo 0)" "needle: $needle2" + + needle3="if ! changeset""_body \"\$FRAMEWORK_ROOT\"" + ok "section 2 prints every body through the reader that labels it" \ + "$(grep -qF "$needle3" "${BASH_SOURCE[0]}" && echo 1 || echo 0)" "needle: $needle3" + + # Inverted pin, so it too is assembled: written out, it would match its own + # source line and report the fallback still present on a clean file. + needle4="|| git show \"\${PREV""_REF}" + ok "and the unlabelled PREV_REF fallback is GONE from the file, not merely bypassed" \ + "$(grep -qF "$needle4" "${BASH_SOURCE[0]}" && echo 0 || echo 1)" "needle: $needle4" + echo 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 exit 0 @@ -149,7 +336,14 @@ fi PREV_REF="${1:?usage: collect-release-notes.sh []}" NEW_REF="${2:-HEAD}" -withheld=0 +withheld_names="" + +# Record a section (or a single changeset) as withheld, for the INCOMPLETE +# trailer and the non-zero exit. Both are needed: the trailer is for the human +# reading the markdown, the exit code for `... > material.md` in a pipeline. +mark_withheld() { # + withheld_names="${withheld_names:+${withheld_names}; }$1" +} cd "$FRAMEWORK_ROOT" @@ -179,25 +373,35 @@ echo echo "## 2. Framework — changesets consumed in this release" echo -# Changesets deleted anywhere in the range were consumed by `changeset -# version` for this release. (An endpoint diff would miss files added and -# consumed within the same dev cycle, so walk the log instead.) -consumed=$(git log --diff-filter=D --name-only --pretty=format: "${PREV_REF}".."${NEW_REF}" -- '.changeset/*.md' \ - | grep -v 'README' | grep . | sort -u || true) - -if [[ -z "$consumed" ]]; then - echo "_None found — is ${NEW_REF} past the 'chore: version packages' commit?_" +# WHICH changesets were consumed is a range question, and both endpoints +# resolving does not make the range walkable — so ask the horizon before +# answering. An unguarded short list here is a release page missing however +# many items were consumed below the graft, with nothing to say so. +if ! changeset_range_guard "$FRAMEWORK_ROOT" "$PREV_REF" "$NEW_REF"; then + mark_withheld "section 2 (the changeset list)" else - while IFS= read -r f; do - echo "### ${f}" - echo - echo '```md' - # The file may have been added after PREV_REF; show its last pre-deletion state. - git show "$(git log --diff-filter=D --pretty=%H -1 "${NEW_REF}" -- "$f")~1:$f" 2>/dev/null \ - || git show "${PREV_REF}:${f}" - echo '```' - echo - done <<< "$consumed" + # Changesets deleted anywhere in the range were consumed by `changeset + # version` for this release. (An endpoint diff would miss files added and + # consumed within the same dev cycle, so walk the log instead.) + consumed=$(git log --diff-filter=D --name-only --pretty=format: "${PREV_REF}".."${NEW_REF}" -- '.changeset/*.md' \ + | grep -v 'README' | grep . | sort -u || true) + + if [[ -z "$consumed" ]]; then + echo "_None found — is ${NEW_REF} past the 'chore: version packages' commit?_" + else + while IFS= read -r f; do + echo "### ${f}" + echo + # Prints the body AND the object it came from, or withholds it. The file + # may have been added after PREV_REF, so the copy wanted is its last + # pre-deletion state — never the PREV_REF one, which is the pre-edit + # prose whenever the changeset was revised during the cycle. + if ! changeset_body "$FRAMEWORK_ROOT" "$PREV_REF" "$NEW_REF" "$f"; then + mark_withheld "section 2 (the body of ${f})" + fi + echo + done <<< "$consumed" + fi fi echo "## 3. Console UI (objectui) — pin range" @@ -232,16 +436,16 @@ echo if [[ -z "$CLOUD_ROOT" || ! -d "$CLOUD_ROOT/.git" ]]; then echo "_cloud checkout not found (set CLOUD_ROOT); scan it by this window manually._" elif ! cloud_window_guard "$CLOUD_ROOT" "$prev_date"; then - withheld=1 + mark_withheld "section 4 (the cloud commit list)" else print_log_split "$CLOUD_ROOT" --since="$prev_date" --until="$new_date" fi echo echo "---" -if [[ "$withheld" == 1 ]]; then - echo "_⛔ INCOMPLETE: section 4 was withheld — see the block above. This material is not a" - echo "complete basis for a release page until that checkout is deepened._" +if [[ -n "$withheld_names" ]]; then + echo "_⛔ INCOMPLETE — WITHHELD: ${withheld_names}. See the block(s) above. This material is" + echo "not a complete basis for a release page until those checkouts are deepened._" echo fi 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._" # A withheld section must be legible to a pipeline too, not only to the reader: # `... > material.md` succeeding is otherwise the only signal, and it lies. -if [[ "$withheld" == 1 ]]; then - echo "collect-release-notes: INCOMPLETE — the cloud section was withheld (see the output)." >&2 +if [[ -n "$withheld_names" ]]; then + echo "collect-release-notes: INCOMPLETE — withheld: ${withheld_names} (see the output)." >&2 exit 2 fi