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