diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3c3766ac90..52a19c68b4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1176,6 +1176,28 @@ jobs: - name: objectui pin-changeset digest guard run: pnpm check:objectui-changeset + # objectui pin write-ordering guard (#10797). Its sibling above is about + # WHAT the bump writes; this one is about WHEN. `bump-objectui.sh` wrote + # `.objectui-sha` before it read the commit subject out of the objectui + # checkout, so a commit object that cannot be read killed the run under + # `set -e` with the pin file ALREADY REWRITTEN — no changeset, no commit, + # a bare `fatal: bad object` as the whole explanation, and a tree the + # operator had to clean up by hand. Re-running did not self-correct: the + # pin file now held the bad SHA. Reachable with no argument at all — + # measured, git 2.43.0, `git rev-parse HEAD` exits 0 and prints the sha + # for a commit whose object is missing, because it resolves the ref + # without reading the object. + # + # The self-test asserts the invariant on BYTES, not on the message: both + # the broken and the fixed script exit non-zero on this input (128 from + # `set -e` vs 1 from the refusal), so "did it fail?" does not tell them + # apart and a test asking only that would have passed against the defect. + # It also drives two readable-commit cases all the way through, because + # `exit 1` at the top of the script would satisfy every refusal case on + # its own. Offline: throwaway git repos, no node, no network, ~1s. + - name: objectui pin write-ordering guard + run: pnpm check:objectui-bump + # Changeset-family gate self-tests (#6509). The SELF-TEST halves only — # the real scans stay in pr-automation.yml's `changeset-check`, and the # split is the whole point of this step. diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index 47066baafb..0c7d318e42 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -62,11 +62,10 @@ question and **warns** — it does not refuse: * **Not on `origin/main`** — a loud warning naming the branch(es) the commit *is* on, and saying which situation it is: pushed onto a branch that never merged, or never pushed at all. The pin is still written; deciding is yours. -* **Cannot be answered** — no `origin/main` in the checkout, or the commit object - is absent — it says *that*, and never borrows the wording of either verdict. - (`git merge-base --is-ancestor` exits **128** on an absent object: an error, not - a "no". And `git rev-parse HEAD` exits 0 for a commit whose object is missing, - so the pin arriving is no proof the object is there.) +* **Cannot be answered** — no `origin/main` in the checkout — it says *that*, and + never borrows the wording of either verdict. (`git merge-base --is-ancestor` + exits **128** rather than returning a verdict when it cannot read an object: an + error, not a "no".) It is a warning rather than a gate on purpose: `origin/main` is only as fresh as your last fetch, so a hard failure here would reject a legitimately-just-merged @@ -78,6 +77,37 @@ though, a bad pin merges and `pnpm sdui:manifest` below would ratchet spec↔registry parity against a tree that is not on main — which is why the producer half warns at all. +#### A commit object it cannot read is refused, not warned about (#10797) + +The bullets above are about a pin that is not on `main` — a real commit you can +still meaningfully pin. A pin whose commit **object cannot be read at all** is a +different thing: there is nothing to pin, and no changeset entry or commit message +can be derived from it. That refuses, and it refuses **before writing anything**: + +``` +✗ REFUSING to bump: the objectui commit object cannot be read in . + … + NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin. +``` + +`.objectui-sha` is byte-identical to what it was before the run — a failed bump +leaves no half-applied state. It used to: the pin file was written *before* the +run read the commit subject, so `git log` failing killed the script under `set -e` +with the file already rewritten, no changeset, no commit, and a bare +`fatal: bad object` as the whole explanation. Re-running did not self-correct, +because the pin file now held the bad SHA. + +You reach this with no argument at all — `git rev-parse HEAD` exits **0** and +prints the sha even when that commit's object is missing from the store, since it +resolves the ref without reading the object. A partial clone that has not fetched +the object, or an interrupted object store, is enough. The remedy the message +names is `git -C fetch origin`, then re-run the bump. + +`pnpm check:objectui-bump` (`scripts/bump-objectui.selftest.sh`, run by the lint +job) pins this: it asserts the file's **bytes** across a refused run, because the +broken and the fixed script both exit non-zero here and only the file tells them +apart. + #### After the pin moves: run the declaration-parity ratchet (#5960) The bump has a second half, and it is not optional: diff --git a/package.json b/package.json index f1cc06d0b7..a003b2dbca 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "check:console-injection": "node scripts/check-console-injection.mjs --self-test && node scripts/check-console-injection.mjs", "check:dev-prereqs": "node scripts/check-dev-prereqs.mjs --self-test && node scripts/check-dev-prereqs.mjs", "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test && node scripts/objectui-range.mjs --self-test", + "check:objectui-bump": "bash scripts/bump-objectui.selftest.sh", "check:prerelease-pins": "node scripts/check-prerelease-pin-watch.mjs --self-test && node scripts/check-prerelease-pin-watch.mjs", "check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs", "check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs", diff --git a/scripts/bump-objectui.selftest.sh b/scripts/bump-objectui.selftest.sh new file mode 100755 index 0000000000..3465754f57 --- /dev/null +++ b/scripts/bump-objectui.selftest.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# Self-test for scripts/bump-objectui.sh — the WRITE-ORDERING invariant (#10797). +# +# bash scripts/bump-objectui.selftest.sh +# +# THE INVARIANT UNDER TEST +# A bump that cannot read the objectui commit object REFUSES, and leaves +# `.objectui-sha` BYTE-IDENTICAL to what it was before the run. +# +# The bill this pins: the pin file used to be written BEFORE the run read the +# commit subject, so an unreadable commit object killed the script under `set -e` +# with `.objectui-sha` already rewritten — no changeset, no commit, and a bare +# `fatal: bad object` as the whole explanation. Re-running did not self-correct: +# the pin file now held the bad SHA, so the next run compared against it. +# +# WHY THE ASSERTION IS ON BYTES, NOT ON THE MESSAGE. Both the broken and the +# fixed script exit non-zero on this input — 128 from `set -e` versus 1 from the +# refusal — so "did it fail?" does not discriminate between them and a test that +# asked only that would have passed against the defect. What discriminates is the +# file: the broken ordering rewrites it, the fixed one does not. So every refusal +# case snapshots `.objectui-sha` and compares with `cmp` (byte-for-byte, and it +# needs no hashing tool), and the absent-pin case asserts the file was not +# CREATED. Verified by ablation, 2026-08-21: with the reads moved back after the +# write, cases 1 and 2 both go red on exactly that assertion. +# +# AND THE GUARD MUST NOT BE VACUOUS. `exit 1` at the top of bump-objectui.sh +# would satisfy every refusal case above, so cases 3 and 4 drive readable commits +# all the way through and assert the pin MOVES (and that an already-current pin +# is left alone). A guard that refuses everything fails them. +# +# Offline, no node, no network: throwaway git repos under `mktemp -d`, and the +# script under test is exercised as a byte copy inside a throwaway FRAMEWORK_ROOT +# (the real one is derived from `${BASH_SOURCE[0]}/..`, so a copy is the only way +# to run it without writing into this repo's own `.objectui-sha`). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +UNDER_TEST="${SCRIPT_DIR}/bump-objectui.sh" + +if [[ ! -f "$UNDER_TEST" ]]; then + echo "✗ cannot find the script under test at ${UNDER_TEST}" >&2 + exit 1 +fi + +TMPROOT="$(mktemp -d)" +trap 'rm -rf "$TMPROOT"' EXIT + +PASSED=0 +FAILED=0 +CASE="" + +ok() { echo " ✓ $*"; PASSED=$((PASSED + 1)); } +bad() { echo " ✗ $*" >&2; FAILED=$((FAILED + 1)); } +case_begin() { CASE="$1"; echo " • ${CASE}"; } + +# --- fixtures ---------------------------------------------------------------- + +# A throwaway objectui with two commits and an `origin/main` that matches HEAD, +# so the #10495 reachability report answers "on origin/main" instead of "UNKNOWN" +# and cannot be mistaken for the thing being tested here. +new_objectui() { + local d="$1" + mkdir -p "$d" + git -C "$d" init -q + git -C "$d" symbolic-ref HEAD refs/heads/main + git -C "$d" config user.email 'selftest@example.invalid' + git -C "$d" config user.name 'selftest' + git -C "$d" config commit.gpgsign false + # No commit-graph and no auto-gc: both can answer for an object that is no + # longer in the store, which would quietly defuse the fixture below. + git -C "$d" config gc.auto 0 + git -C "$d" config core.commitGraph false + git -C "$d" config fetch.writeCommitGraph false + git -C "$d" commit -q --allow-empty -m 'feat(console): the previous pin' + git -C "$d" commit -q --allow-empty -m 'feat(console): the commit being pinned' + git -C "$d" update-ref refs/remotes/origin/main HEAD + git -C "$d" rev-parse HEAD +} + +# A throwaway FRAMEWORK_ROOT holding a byte copy of the script under test. +new_framework() { + local d="$1" pin="${2-}" + mkdir -p "${d}/scripts" "${d}/.changeset" + cp "$UNDER_TEST" "${d}/scripts/bump-objectui.sh" + if [[ -n "$pin" ]]; then printf '%s\n' "$pin" > "${d}/.objectui-sha"; fi +} + +# Delete a commit object from the store, then PROVE it is gone. A fixture that +# silently failed to break anything is the one way this whole file could go +# green over nothing, so the proof is an assertion, not a comment. +break_commit_object() { + local d="$1" sha="$2" + local loose="${d}/.git/objects/${sha:0:2}/${sha:2}" + if [[ ! -f "$loose" ]]; then + bad "fixture: expected a loose object at ${loose}, found none (packed already?)" + return 1 + fi + rm -f "$loose" + if git -C "$d" cat-file -e "${sha}^{commit}" 2>/dev/null; then + bad "fixture: commit object ${sha:0:12} is still readable after deleting it" + return 1 + fi + if git -C "$d" log -1 --format=%s "$sha" >/dev/null 2>&1; then + bad "fixture: 'git log' still reads ${sha:0:12} after deleting the object" + return 1 + fi + # The card's own measurement, re-asserted here because it is what makes the + # DEFAULT path (no argument) able to reach the bug at all: `rev-parse HEAD` + # resolves the ref without reading the object, and exits 0. + local resolved rc=0 + resolved="$(git -C "$d" rev-parse HEAD 2>/dev/null)" || rc=$? + if [[ "$rc" -ne 0 || "$resolved" != "$sha" ]]; then + bad "fixture: 'git rev-parse HEAD' no longer resolves to ${sha:0:12} (rc=${rc})" + return 1 + fi + return 0 +} + +# Run the copy under test. No pipe anywhere near the exit code: redirect to a +# file, THEN read `$?`. +LOG="" +EC=0 +run_bump() { + local fw="$1" oui="$2"; shift 2 + LOG="${TMPROOT}/run-$$-${RANDOM}.log" + EC=0 + OBJECTUI_ROOT="$oui" bash "${fw}/scripts/bump-objectui.sh" "$@" > "$LOG" 2>&1 || EC=$? +} + +log_has() { grep -qF -- "$1" "$LOG"; } + +# --- case 1: unreadable commit object ⇒ refusal, pin file untouched ---------- +case_1() { + case_begin 'unreadable commit object ⇒ refuses, .objectui-sha byte-identical' + local fw="${TMPROOT}/c1/fw" oui="${TMPROOT}/c1/objectui" + local new_sha old_sha + new_sha="$(new_objectui "$oui")" + old_sha="$(git -C "$oui" rev-parse 'HEAD~1')" + new_framework "$fw" "$old_sha" + break_commit_object "$oui" "$new_sha" || return 0 + + cp "${fw}/.objectui-sha" "${TMPROOT}/c1.before" + run_bump "$fw" "$oui" --no-commit --no-changeset + + if [[ "$EC" -eq 0 ]]; then + bad "expected a non-zero exit, got 0 — the bump did not refuse" + else + ok "refused (exit ${EC})" + fi + # THE discriminating assertion. + if cmp -s "${TMPROOT}/c1.before" "${fw}/.objectui-sha"; then + ok ".objectui-sha is byte-identical to before the run" + else + bad ".objectui-sha CHANGED — half-applied state: $(cat "${fw}/.objectui-sha")" + fi + if log_has 'REFUSING to bump'; then + ok 'the refusal names itself rather than leaving git'"'"'s fatal as the whole message' + else + bad "no 'REFUSING to bump' in the output; got: $(head -3 "$LOG" | tr '\n' '|')" + fi + if log_has 'NOTHING WAS WRITTEN'; then + ok 'the message states that nothing was written' + else + bad "the refusal does not tell the operator the tree is clean" + fi + if [[ ! -e "${fw}/.changeset/console-${new_sha:0:12}.md" ]]; then + ok 'no changeset was emitted' + else + bad 'a changeset was emitted for a commit that cannot be read' + fi +} + +# --- case 2: same, with no pin file at all (initial pin) --------------------- +# Byte-identity of a file that does not exist is "it still does not exist". The +# broken ordering CREATES it here, which is the same defect wearing the +# first-ever-bump disguise. +case_2() { + case_begin 'unreadable commit object + no existing pin file ⇒ the file is not created' + local fw="${TMPROOT}/c2/fw" oui="${TMPROOT}/c2/objectui" + local new_sha + new_sha="$(new_objectui "$oui")" + new_framework "$fw" + break_commit_object "$oui" "$new_sha" || return 0 + + if [[ -e "${fw}/.objectui-sha" ]]; then + bad 'fixture: the pin file should not exist yet' + return 0 + fi + run_bump "$fw" "$oui" --no-commit --no-changeset + + if [[ "$EC" -ne 0 ]]; then ok "refused (exit ${EC})"; else bad 'expected a non-zero exit, got 0'; fi + if [[ ! -e "${fw}/.objectui-sha" ]]; then + ok '.objectui-sha was not created' + else + bad ".objectui-sha was CREATED holding $(cat "${fw}/.objectui-sha")" + fi +} + +# --- case 3: a readable commit still bumps (the guard is not vacuous) ------- +case_3() { + case_begin 'readable commit ⇒ the bump proceeds and the pin moves' + local fw="${TMPROOT}/c3/fw" oui="${TMPROOT}/c3/objectui" + local new_sha old_sha + new_sha="$(new_objectui "$oui")" + old_sha="$(git -C "$oui" rev-parse 'HEAD~1')" + new_framework "$fw" "$old_sha" + + run_bump "$fw" "$oui" --no-commit --no-changeset + + if [[ "$EC" -eq 0 ]]; then ok 'exited 0'; else bad "expected exit 0, got ${EC}: $(tail -5 "$LOG" | tr '\n' '|')"; fi + local written + written="$(tr -d '[:space:]' < "${fw}/.objectui-sha" 2>/dev/null || true)" + if [[ "$written" == "$new_sha" ]]; then + ok ".objectui-sha now holds ${new_sha:0:12}" + else + bad ".objectui-sha holds '${written}', expected ${new_sha}" + fi + if log_has '(on origin/main)'; then + ok 'the #10495 reachability report still runs and answers "on origin/main"' + else + bad "the reachability report did not answer: $(head -5 "$LOG" | tr '\n' '|')" + fi +} + +# --- case 4: an already-current pin is left alone --------------------------- +case_4() { + case_begin 'readable commit already pinned ⇒ nothing to do, pin file untouched' + local fw="${TMPROOT}/c4/fw" oui="${TMPROOT}/c4/objectui" + local new_sha + new_sha="$(new_objectui "$oui")" + new_framework "$fw" "$new_sha" + cp "${fw}/.objectui-sha" "${TMPROOT}/c4.before" + + run_bump "$fw" "$oui" --no-commit --no-changeset + + if [[ "$EC" -eq 0 ]]; then ok 'exited 0'; else bad "expected exit 0, got ${EC}"; fi + if log_has 'nothing to do'; then ok 'reported "nothing to do"'; else bad 'did not report "nothing to do"'; fi + if cmp -s "${TMPROOT}/c4.before" "${fw}/.objectui-sha"; then + ok '.objectui-sha is byte-identical to before the run' + else + bad '.objectui-sha was rewritten on a no-op bump' + fi +} + +echo "bump-objectui.sh self-test — write-ordering invariant (#10797)" +case_1 +case_2 +case_3 +case_4 + +echo +if [[ "$FAILED" -gt 0 ]]; then + echo "✗ bump-objectui self-test FAILED — ${FAILED} assertion(s) failed, ${PASSED} passed." >&2 + exit 1 +fi +echo "✓ bump-objectui self-test PASSED — ${PASSED} assertions across 4 cases." diff --git a/scripts/bump-objectui.sh b/scripts/bump-objectui.sh index 664c550d45..8217d6fceb 100755 --- a/scripts/bump-objectui.sh +++ b/scripts/bump-objectui.sh @@ -22,6 +22,11 @@ # a fresh full clone and REFUSES to cut (#9450). Three answers, never two — a # checkout that cannot answer the question says so rather than guessing (#10495). # +# It DOES refuse — before writing anything — when the commit object cannot be +# read in the objectui checkout at all. There is nothing meaningful to pin, and a +# failed run must leave no half-applied state: `.objectui-sha` is byte-identical +# to what it was before the run (#10797). +# # Env: # CONSOLE_BUMP=major|minor|patch # force the changeset bump type (default: auto — # # the HIGHEST level objectui itself declared in the @@ -71,6 +76,14 @@ # changesets' own "release-nothing". So `objectui-changeset-digest.mjs` reads the # changesets added over the range — package names decide inclusion, the declared # level decides the bump. Nothing is inferred from a subject line. +# +# SELF-TEST: `scripts/bump-objectui.selftest.sh` (`pnpm check:objectui-bump`, +# run unconditionally by the lint job). It drives this file's real bytes over +# throwaway git repos and pins the write-ordering invariant of #10797 — an +# unreadable commit object refuses and leaves `.objectui-sha` byte-identical — +# plus two readable-commit cases, so a guard that refused everything would fail +# it. Offline, no node, ~1s. Reordering anything between the reads above the +# first mutation and that mutation is what it exists to catch. set -euo pipefail @@ -130,6 +143,62 @@ else NEW_SHA="$(git -C "$OBJECTUI_ROOT" rev-parse HEAD)" fi +# --- READ THE COMMIT BEFORE TOUCHING ANYTHING (#10797) ----------------------- +# Everything this bump reads OUT of the objectui commit is read here, ahead of +# the first mutation of `.objectui-sha`. The ordering is the whole point. The pin +# write used to come first and `git log -1 --format=%s` second, so an unreadable +# commit object killed the run under `set -e` with the pin file ALREADY +# REWRITTEN: no changeset, no commit, a bare `fatal: bad object` as the entire +# explanation, and a working tree the operator had to clean up by hand. Nor did +# re-running self-correct — the pin file now held the bad SHA, so the next run +# compared against it. Measured, git 2.43.0, on a throwaway objectui whose HEAD +# commit object was deleted: `SCRIPT EXIT=128`, `.objectui-sha` modified. The +# invariant restored here is "a failed run leaves no half-applied state". +# +# THE GUARD IS THE READ ITSELF, not a probe standing in for it. `cat-file -e` +# answers "is the object present", which is one failure short of the question +# that matters: a present-but-unreadable object (corrupt zlib, truncated pack) +# passes it and still kills `git log`. Performing the real read means anything +# that would have failed later has already failed HERE, with the tree untouched. +# One implementation of the rule, and it is the rule — the same reason the range +# walk is asked inside the digest rather than copied into this shell (#9408). +# +# AND IT REFUSES RATHER THAN WARNS. The #10495 warning-not-gate ruling below is +# about a pin that is not on `origin/main` — a real commit you can still +# meaningfully pin, where `origin/main` may simply be stale and the judgement is +# the operator's. This is a pin whose object cannot be read AT ALL: there is +# nothing to pin, nothing for the operator to weigh, and neither the changeset +# entry nor the commit message can be derived from it. Warning here would only +# reinstate the half-applied write. (#10797 triage ruling, 2026-08-21.) +# +# Reachable from the DEFAULT path, not just from an explicit argument. Measured, +# git 2.43.0: `git rev-parse HEAD` exits **0** and prints the sha even when that +# commit object is missing from the object store — it resolves the ref, it does +# not read the object. A partial clone with the object not fetched, or an +# interrupted object store, gets here with no argument at all. +# +# git's own `fatal:` is left on stderr deliberately: it names WHICH failure this +# was, and the block below is the explanation it was missing — not a replacement +# for it. +SHORT="${NEW_SHA:0:12}" +if ! SUBJECT_LINE="$(git -C "$OBJECTUI_ROOT" log -1 --format=%s "$NEW_SHA")"; then + { + echo "✗ REFUSING to bump: the objectui commit object ${SHORT} cannot be read in" + echo " ${OBJECTUI_ROOT}." + echo " 'git log -1 --format=%s ${SHORT}' fails there, so this bump can derive neither" + echo " the @objectstack/console changeset nor the commit message from it — there is" + echo " nothing meaningful to pin." + echo " A ref can name a commit whose object is missing: 'git rev-parse' resolves the" + echo " ref WITHOUT reading the object, so a plain HEAD bump reaches this too, not just" + echo " an explicit argument." + echo " NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin." + echo " Fetch or repair the objectui object store, then re-run this bump:" + echo " git -C ${OBJECTUI_ROOT} fetch origin" + echo " git -C ${OBJECTUI_ROOT} cat-file -e ${SHORT}^{commit} && echo present" + } >&2 + exit 1 +fi + # --- Is the revision we are about to pin actually ON objectui main? (#10495) -- # `rev-parse HEAD` answers "what is checked out", never "is it on main". Bump # with a feature branch checked out — or pass a branch name — and the pin names @@ -164,8 +233,9 @@ fi # The absent-object case is reachable from the DEFAULT path, not just from an # explicit argument. Measured, git 2.43.0: `git rev-parse HEAD` exits **0** and # prints the sha even when that commit object is missing from the object store — -# it resolves the ref, it does not read the object. So NEW_SHA arriving here is -# not proof the object is present, and presence is asked as its own question. +# it resolves the ref, it does not read the object. So a resolved NEW_SHA is not +# proof the object is present; that question is settled ABOVE, before anything is +# written, as a refusal rather than as a report (#10797). REACH_TAG="" # parenthetical for the one line that is printed anyway REACH_RECALL="" # tail recall, so a warning cannot scroll out of the run @@ -209,26 +279,15 @@ report_objectui_reachability() { return 0 fi - # Q2 — is the object even present? `--is-ancestor` cannot return a verdict for - # an object it cannot read; it exits 128 there, and 128 must not be read as - # "no". Asked separately so the report can name WHICH failure this was. - if ! git -C "$OBJECTUI_ROOT" cat-file -e "${sha}^{commit}" 2>/dev/null; then - REACH_TAG=" (reachability UNKNOWN)" - REACH_RECALL=" — ⚠️ pin reachability UNKNOWN, see above" - { - echo - echo "⚠️ COULD NOT DETERMINE whether ${short} is on objectui main." - echo " The commit object is NOT PRESENT in ${OBJECTUI_ROOT}" - echo " ('git cat-file -e ${short}^{commit}' exits 128). A ref can name a commit" - echo " whose object is missing — 'git rev-parse' resolves the ref without reading" - echo " the object — so this is reachable even from a plain HEAD bump." - echo " 'merge-base --is-ancestor' exits 128 here too: an error, not a verdict." - echo " This is NOT 'the pin is fine' and NOT 'the pin left main'. It is unanswered." - echo " Fetch or repair the objectui object store, then re-run this bump." - echo - } >&2 - return 0 - fi + # Q2 — whether the object can be READ is deliberately NOT asked here. It is + # settled up front, by the read this bump actually needs (`git log -1 + # --format=%s`), and an unreadable object REFUSES there with the working tree + # untouched (#10797) — so `--is-ancestor` below cannot meet an absent object on + # any path that reaches this function. Asking it a second time with a weaker + # probe (`cat-file -e` tests presence, not readability) would be a second + # implementation of one rule, free to drift from the thing it guards. If that + # ordering is ever broken, the rc-is-not-1 branch below is the backstop: it + # reports 128 as the error it is and never as a verdict. # Q3 — the verdict. 0 / 1 / anything else, distinguished on purpose. git -C "$OBJECTUI_ROOT" merge-base --is-ancestor "$sha" origin/main 2>/dev/null || rc=$? @@ -310,12 +369,14 @@ if [[ "$OLD_SHA" == "$NEW_SHA" ]]; then exit 0 fi +# FIRST MUTATION OF THE WORKING TREE. Everything read out of the objectui commit +# was read above, and an unreadable commit already refused — nothing below this +# line can fail on a read of `$NEW_SHA` that has not already been attempted +# (#10797). Keep it that way: a new `git -C "$OBJECTUI_ROOT" …` added after this +# point re-opens exactly the half-applied write this ordering exists to prevent. echo "$NEW_SHA" > "${FRAMEWORK_ROOT}/.objectui-sha" echo "→ objectui pin: ${OLD_SHA:0:12} → ${NEW_SHA:0:12}${REACH_TAG}" -SHORT="${NEW_SHA:0:12}" -SUBJECT_LINE="$(git -C "$OBJECTUI_ROOT" log -1 --format=%s "$NEW_SHA")" - # --- Emit the @objectstack/console changeset for the frontend delta ---------- CS_FILE="" if [[ "$NO_CHANGESET" -eq 0 ]]; then diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index feed8aa4e1..1b304f1d95 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -2636,10 +2636,23 @@ function selfTest() { `STDOUT:${r4.stdout}\nSTDERR:${r4.stderr}`, ); - // R5 — STATE 3, cause 2: the object is absent. Two git behaviours make this - // reachable and make 128 the thing that must not be read as "no"; both are - // MEASURED here rather than asserted in prose, so a future git that changes - // either one fails this test instead of silently flipping the branch taken. + // R5 — the object is absent. Two git behaviours make this reachable and make + // 128 the thing that must not be read as "no"; both are MEASURED here rather + // than asserted in prose, so a future git that changes either one fails this + // test instead of silently flipping the branch taken. + // + // R5c CHANGED VERDICT under #10797 (triage ruling, 2026-08-21). It used to + // pin "reported as UNDETERMINED" — the same three-answer treatment R4 gets. + // That was #10495's reading, and #10495's warning-not-gate ruling is about a + // pin that is NOT ON MAIN: a real commit you can still meaningfully pin, + // where origin/main may just be stale and the judgement is the operator's. + // A pin whose object cannot be READ is a different question with no + // judgement in it — there is nothing to pin, and neither the changeset nor + // the commit message can be derived from it — so it REFUSES, before the + // first mutation of `.objectui-sha`. Reporting it was what let the pin file + // be rewritten and the run then die on `git log` (#10797). The case is + // rewritten rather than deleted: the fixture still pins the exact shape, + // only the verdict it demands moved. const uiMiss = mkUi('objectui-missing-object'); const missBase = uiMiss.c('chore: base'); uiMiss.gg('update-ref', 'refs/remotes/origin/main', missBase); @@ -2665,14 +2678,29 @@ function selfTest() { isAncestorMissing.status === 128, `status=${isAncestorMissing.status} stderr=${isAncestorMissing.stderr}`, ); - const r5 = runBump(mkFramework('fw-reach-missing-object', missBase), uiMiss.dir, ['--no-commit']); + const missFw = mkFramework('fw-reach-missing-object', missBase); + const missPinBefore = readFileSync(join(missFw, '.objectui-sha'), 'utf8'); + const r5 = runBump(missFw, uiMiss.dir, ['--no-commit']); check( - '#10495 R5c an absent object is reported as UNDETERMINED and says which failure it was', - r5.stderr.includes('COULD NOT DETERMINE') && - r5.stderr.includes('NOT PRESENT') && + '#10495/#10797 R5c an unreadable object REFUSES — it is not a verdict to report, there is nothing to pin', + r5.status !== 0 && + r5.stderr.includes('REFUSING to bump') && !r5.stderr.includes('is NOT reachable from origin/main'), `status=${r5.status}\nSTDERR:${r5.stderr}`, ); + check( + '#10797 R5d the refused run left NO half-applied state — .objectui-sha is byte-identical', + readFileSync(join(missFw, '.objectui-sha'), 'utf8') === missPinBefore && + r5.stderr.includes('NOTHING WAS WRITTEN'), + `before=${JSON.stringify(missPinBefore)} after=${JSON.stringify( + readFileSync(join(missFw, '.objectui-sha'), 'utf8'), + )}`, + ); + check( + '#10797 R5e the refusal names the remedy, so git\'s bare `fatal:` is not the whole explanation', + r5.stderr.includes('fetch origin') && r5.stderr.includes('cat-file -e'), + r5.stderr, + ); // R6 — the branch list is CAPPED, and a cap that fires says so with the real // count. A capped list that reads as complete is the same defect class as