-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathbump-objectui.sh
More file actions
executable file
·764 lines (728 loc) · 42.8 KB
/
Copy pathbump-objectui.sh
File metadata and controls
executable file
·764 lines (728 loc) · 42.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
#!/usr/bin/env bash
# Bump the objectui SHA the framework workspace pins against.
#
# Usage:
# scripts/bump-objectui.sh # bump to current HEAD of ../objectui
# scripts/bump-objectui.sh <sha> # bump to an explicit SHA (or ref)
# scripts/bump-objectui.sh --no-commit # update files only, don't commit
# scripts/bump-objectui.sh --no-changeset # skip the @objectstack/console changeset
#
# After the bump — the second half of the pin-update procedure (#5960, #12924):
# node scripts/gen-sdui-manifest-node.mjs \
# --objectui-version {the @object-ui version the new pin ships}
#
# That call rewrites the two TRACKED files, and nothing else writes them: the
# repo-root `sdui.manifest.json` and its provenance record
# `scripts/sdui-manifest.record.json`. The required lint job reds the bump PR until
# it has run — `scripts/check-sdui-manifest.mjs` asserts the record's pin equals
# `.objectui-sha` — and ADR-0082 D4's spec↔registry declaration-parity ratchet reads
# that same tracked artefact on every PR. No browser and no objectui build; it
# installs the published @object-ui packages into a temp dir. This script prints the
# reminder on its way out.
#
# ⛔ `pnpm sdui:manifest` is a DIFFERENT command and does not clear that gate: it
# dumps objectui's registry from a real browser to the gitignored
# `packages/console/dist/sdui.manifest.json` and ratchets against that untracked
# copy. An independent read of the registry, worth running when you hold an objectui
# checkout and a Playwright chromium — but it writes neither tracked file.
#
# The pin must name a commit that is on objectui MAIN. This script WARNS — it
# does not refuse — when the revision being pinned is not reachable from the
# objectui checkout's `origin/main`, and names the branch(es) it IS on. It never
# fetches for you: `origin/main` is only as fresh as your last fetch and this
# script stays usable offline. The release cut re-asks the same question against
# 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).
#
# It also refuses — before writing anything, same invariant — when the OLD..NEW
# range cannot be walked in the objectui checkout and a deepen does not repair
# it. The changeset's bump LEVEL comes from what objectui declared over that
# range; with the range unreadable there is nothing to declare, and the degraded
# entry this used to emit carried the default level (`patch`) into published
# CHANGELOG text as if it were one (#14178). Deepen the checkout and re-run, or
# pass `--no-changeset` to move the pin while claiming nothing about the range.
#
# It ALSO refuses — same invariant, one input further in — when the range
# WALKS COMPLETELY but deriving the changeset from it still fails: the digest
# reads git BLOBS the walk above never touches, and a changeset blob unreadable
# at both `to` and the commit that added it throws. A level was derivable in
# principle there, so publishing the default level would be a guess wearing a
# declaration's clothes — the same #14178 shape, one state further in (#14393).
# The initial pin is unaffected: it has no previous SHA and no range to have
# walked, so it keeps its degraded (tip-subject-only) entry.
#
# Env:
# CONSOLE_BUMP=major|minor|patch # force the changeset bump type (default: auto —
# # the HIGHEST level objectui itself declared in the
# # changesets added over the range; see #4731)
# CONSOLE_CHANGES_MAX=<n> # cap the rendered list (default 100). A cap that
# # fires says so, with the real count — never silently.
# OBJECTUI_NO_DEEPEN=1 # do NOT run 'git fetch --unshallow' on the objectui
# # checkout when the pin range cannot be walked in it —
# # whether an endpoint is absent or the history stops
# # inside the range (#9408 / #14178, one remedy for both).
# # Default is to deepen: measured on objectui the fetch
# # costs ~6s and ~4MB and turns a 110-commit walk into
# # the true 191 (#9408). Set this offline, or when the
# # checkout must not be touched — the bump then REFUSES
# # and says why, rather than deriving a weaker record.
#
# Assumes sibling layout:
# ~/work/objectui
# ~/work/objectstack ← run from here
# --help ends here
#
# ^ SENTINEL, not prose — `--help` prints from the shebang down to the line above
# and stops there, so the terminator travels with the text it terminates. Add or
# remove header lines freely; no line number tracks this block any more (#6425).
# Spell it exactly: the --help branch below refuses to run without it. Everything
# from here down is internal rationale and is NOT user-facing help.
#
# objectui ships @object-ui/console as a static SPA. The framework
# release pipeline reads .objectui-sha, clones objectui at that commit,
# builds @object-ui/console, and copies dist/ into
# packages/console/ so @objectstack/console publishes a frozen,
# version-matched build alongside the rest of the framework.
#
# The frontend is a version-locked package too, but a SHA bump alone left no
# trace in the release history — @objectstack/console's CHANGELOG stayed empty
# across frontend-only updates. So this bump also emits a changeset summarizing
# the objectui commit range, routing the frontend delta through the SAME
# changesets pipeline as the backend: it lands in @objectstack/console's
# CHANGELOG and rolls up into the platform version + the curated release notes.
#
# WHAT GOES IN THE LIST — DECLARED, NOT GUESSED (#4731)
# The list used to be a GUESS off the commit subject (`grep -iE '^- (feat|fix)'`
# + `head -40`), and the bump level another (`grep -ciE '^feat'`). Both were
# measured wrong on one real range: every `refactor(...)!` — the BREAKING class,
# the one that must never vanish from a release record — was structurally unable
# to appear, `head -40` truncated in silence, and `fix(ci)` commits that release
# nothing were pulled in. objectui already DECLARES which commits ship: every
# releasing PR carries a `.changeset/*.md`, and an empty frontmatter block is
# 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, plus a case pinning the #14393 refusal above: a range that WALKS
# COMPLETELY but whose changeset blob is unreadable at both revisions also
# refuses with the pin untouched. The first four cases stay offline, no node,
# ~1s; the #14393 case needs node (it drives the real digest derivation through
# a throwaway objectui with a real changeset commit) and a copy of
# `objectui-changeset-digest.mjs` + `invoked-as.mjs` alongside the script under
# test. Reordering anything between the reads above the first mutation and that
# mutation is what this file exists to catch.
set -euo pipefail
FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OBJECTUI_ROOT="${OBJECTUI_ROOT:-$(cd "${FRAMEWORK_ROOT}/../objectui" 2>/dev/null && pwd || true)}"
NO_COMMIT=0
NO_CHANGESET=0
EXPLICIT_SHA=""
for arg in "$@"; do
case "$arg" in
--no-commit) NO_COMMIT=1 ;;
--no-changeset) NO_CHANGESET=1 ;;
-h|--help)
# The header block above IS the help text, and the `# --help ends here`
# sentinel is what ends it — no line range, so growing the header can no
# longer truncate the help (#6425; #5960 grew it and PR #6421 had to move a
# hand-kept `2,26p`). The leading `2` addresses the shebang, whose position
# is fixed by execve rather than by the header's content, so it cannot drift.
#
# A missing sentinel EXITS 1 rather than running on to EOF: a truncated help
# and a complete one both exit 0 and both print something, which is precisely
# why the old coupling could fail in silence — same lesson as the `head -40`
# this script used to truncate its changeset list with (#4731). Guarded here,
# not at startup: a deleted comment must never stop an actual pin bump.
if ! grep -qxF '# --help ends here' "$0"; then
echo "✗ ${0##*/}: the '# --help ends here' sentinel is missing — cannot tell" >&2
echo " where the help text ends. Restore it at the end of the header block." >&2
exit 1
fi
sed -n '2,/^# --help ends here$/p' "$0" \
| grep -vxF '# --help ends here' \
| sed 's/^# \{0,1\}//'
exit 0
;;
*) EXPLICIT_SHA="$arg" ;;
esac
done
# `-e`, not `-d`: in a git WORKTREE `.git` is a regular file holding a `gitdir:`
# pointer, so a `-d` test rejected every linked worktree — and AGENTS.md requires
# one per task, so this rejected the mandated workflow and only ever worked from a
# primary clone.
if [[ -z "${OBJECTUI_ROOT}" || ! -e "${OBJECTUI_ROOT}/.git" ]]; then
if [[ -n "${OBJECTUI_ROOT}" ]]; then
echo "✗ ${OBJECTUI_ROOT} is not a git checkout (no .git)"
else
echo "✗ Cannot find objectui checkout at ${FRAMEWORK_ROOT}/../objectui"
fi
echo " Override with: OBJECTUI_ROOT=/path/to/objectui scripts/bump-objectui.sh"
exit 1
fi
if [[ -n "$EXPLICIT_SHA" ]]; then
NEW_SHA="$(git -C "$OBJECTUI_ROOT" rev-parse "$EXPLICIT_SHA^{commit}")"
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
# a revision that is not on main, and this script used to report success.
# Measured on a fresh `--no-tags` clone of objectui, 2026-08-21: 941 remote
# branches, 118 branch tips not reachable from `main`, 291 commits present and
# not on main. Any of those is one `rev-parse HEAD` away from being the pin.
#
# A WARNING, NOT A GATE — deliberately, and the split is with #9450 / PR #10494:
# `.github/workflows/cut-rc.yml` now runs this same predicate against a fresh
# full clone and refuses to cut. That is the chokepoint that must fail closed.
# Here it must not: `origin/main` in a local checkout is only as fresh as the
# last fetch, so hard-failing would reject a legitimately-just-merged commit,
# and this script is deliberately usable offline and on a machine that cannot
# run the full procedure (same rationale as `print_sdui_next_step`). So: say it
# loudly, name the branch it IS on, and let the operator decide. Never fetch on
# the operator's behalf — unlike the `--unshallow` deepen below, which repairs
# an input this script is about to DERIVE from, a fetch here would only change
# the answer to a question the operator is being asked to judge.
#
# THREE ANSWERS, NOT TWO. `merge-base --is-ancestor` exits 0 (ancestor), 1 (not
# an ancestor) and **128 on an absent object** — an error, not a verdict.
# Measured, git 2.43.0:
# git merge-base --is-ancestor <absent-sha> origin/main
# fatal: Not a valid commit name <absent-sha> -> exit 128
# Reading 128 as "not an ancestor" invents a false alarm; reading anything that
# is not 1 as "fine" reinstates exactly the silent pass this check exists to
# remove. A checkout with no `origin/main` cannot answer the question at all.
# So the exits are split three ways, and the third says what it could not
# determine and why — it never borrows the wording of either verdict.
#
# 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 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
# Render at most 10 ref names. A cap that fires SAYS SO, with the real count —
# a capped list that reads as complete is this file's other standing lesson
# (CONSOLE_CHANGES_MAX, #4731).
reach_ref_list() {
local n="$#"
if (( n == 0 )); then printf 'none'; return 0; fi
local out
out="$(printf '%s, ' "${@:1:10}")"
out="${out%, }"
if (( n > 10 )); then out="${out} … (+$(( n - 10 )) more; ${n} total)"; fi
printf '%s' "$out"
}
report_objectui_reachability() {
local sha="$1"
local short="${sha:0:12}"
local rc=0
# Q1 — can this checkout answer the question AT ALL? Asked first because the
# later questions are meaningless without it: with no origin/main,
# `--is-ancestor` would report "the pin left main", which is this check's own
# overclaim wearing a new message.
if ! git -C "$OBJECTUI_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
REACH_TAG=" (reachability UNKNOWN)"
REACH_RECALL=" — ⚠️ pin reachability UNKNOWN, see above"
{
echo
echo "⚠️ COULD NOT DETERMINE whether ${short} is on objectui main."
echo " ${OBJECTUI_ROOT} has no 'origin/main' ref, so the question cannot be asked"
echo " in this checkout at all."
echo " This is NOT 'the pin is fine' and NOT 'the pin left main'. It is unanswered,"
echo " and the pin is being written anyway."
echo " To make it answerable here: git -C ${OBJECTUI_ROOT} fetch origin main"
echo " (not run for you — this script stays usable offline.)"
echo " The release cut re-asks it against a fresh full clone and fails closed (#9450)."
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=$?
if [[ "$rc" -eq 0 ]]; then
# Reachable. No banner — but the line that prints anyway says so, so that
# "checked, and fine" is stated rather than inferred from silence.
REACH_TAG=" (on origin/main)"
return 0
fi
if [[ "$rc" -ne 1 ]]; then
REACH_TAG=" (reachability UNKNOWN)"
REACH_RECALL=" — ⚠️ pin reachability UNKNOWN, see above"
{
echo
echo "⚠️ COULD NOT DETERMINE whether ${short} is on objectui main."
echo " 'git merge-base --is-ancestor ${short} origin/main' exited ${rc}; only 0"
echo " (is an ancestor) and 1 (is not) are verdicts. Anything else is an error,"
echo " and is being reported as one rather than folded into either answer."
echo " This is NOT 'the pin is fine' and NOT 'the pin left main'. It is unanswered."
echo
} >&2
return 0
fi
# rc == 1: a real verdict, and the one this card exists for.
REACH_TAG=" (NOT on origin/main)"
REACH_RECALL=" — ⚠️ pin is NOT on objectui origin/main, see above"
# Enumerated only on this branch: with ~941 branches a --contains walk is not
# free, and the healthy path must not pay for it.
#
# READ LOOP, NOT `mapfile` — THE BASH 3.2 FLOOR. `mapfile`/`readarray` are
# bash 4 builtins, and `/usr/bin/env bash` is bash 3.2.57 on macOS (Apple has
# not shipped bash 4+ for licensing reasons). This script is run BY HAND, BY
# AN OPERATOR, on a laptop — the pin-bump procedure in
# `docs/releases-maintenance.md` has no CI path — so a bash-4 builtin here
# does not fail on a fringe host, it fails on the ordinary one. And it fails
# on exactly the branch that must not fail: `mapfile` is only ever reached
# once the verdict is "NOT on origin/main", i.e. the #10495 warning is the
# single thing this shell cannot deliver. Measured with `mapfile` disabled:
# `mapfile: command not found`, then `set -e` kills the run at status
# 127 BEFORE the pin is written, and the operator is handed a bare builtin
# error where the warning should have been — an error whose obvious remedy
# (edit `.objectui-sha` by hand) walks around every guard in this file.
# CI runs bash 5, so neither the defect nor this repair is observable in a
# normal CI run; the digest self-test pins it with the builtin disabled
# (`enable -n mapfile readarray` via `BASH_ENV`) plus a static scan.
# Keep this loop bash-3.2-clean: no `mapfile`, no `readarray`, no
# `declare -A`, no `${x^^}`/`${x,,}`. The `if` (rather than `[[ … ]] &&`) is
# load-bearing under `set -e` — but NOT on an empty ref list: an all-empty
# read leaves the loop body unexecuted and the `while` exits 0 either way
# (measured, bash 5.2.21). The real trap is a NON-EMPTY read whose LAST
# line fails the `[[ -n … ]]` test, and only once that loop is the LAST
# command of a function: the `&&`-list's false status becomes the loop's
# exit status, which becomes the function's return, which `set -e` then
# kills the caller on. Measured over all four combinations (empty vs.
# non-empty-with-failing-last-line × loop-is-fn's-last-command vs. not):
# only that one combination dies (exit 1); the other three exit 0.
local -a local_refs=() remote_refs=()
local ref_line=''
while IFS= read -r ref_line; do
if [[ -n "$ref_line" ]]; then local_refs+=("$ref_line"); fi
done < <(
git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/heads 2>/dev/null || true
)
while IFS= read -r ref_line; do
if [[ -n "$ref_line" ]]; then remote_refs+=("$ref_line"); fi
done < <(
git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/remotes 2>/dev/null || true
)
local main_tip
main_tip="$(git -C "$OBJECTUI_ROOT" log -1 --format='%h, committed %cr' origin/main 2>/dev/null || echo 'unknown')"
{
echo
echo "⚠️ objectui pin ${short} is NOT reachable from origin/main in"
echo " ${OBJECTUI_ROOT} — it names a revision that is not on objectui main."
echo " on local branches : $(reach_ref_list ${local_refs[@]+"${local_refs[@]}"})"
echo " on remote branches: $(reach_ref_list ${remote_refs[@]+"${remote_refs[@]}"})"
# Which of the two situations this is, said out loud, so the operator does
# not have to go and look: a branch that never merged and a commit that was
# never pushed take different remedies.
if (( ${#remote_refs[@]} > 0 )); then
echo " → It IS pushed, but only onto branch(es) that have not merged into main."
elif (( ${#local_refs[@]} > 0 )); then
echo " → It is on a LOCAL branch only — this commit has not been pushed to objectui."
else
echo " → No branch in this checkout contains it (detached HEAD on a discarded commit?)."
fi
echo " Or your origin/main is simply STALE: it points at ${main_tip}."
echo " Refresh it with: git -C ${OBJECTUI_ROOT} fetch origin main"
echo " (not run for you — this script stays usable offline, and the answer to"
echo " this question is yours to judge.)"
echo " Pinning it anyway. Downstream: the release cut re-asks this against a fresh"
echo " full clone and REFUSES to cut (#9450), so a bad pin surfaces at RC time at"
echo " the earliest; until then 'pnpm sdui:manifest' would ratchet spec↔registry"
echo " declaration parity against a tree that is not on main (ADR-0082 D4)."
echo
} >&2
return 0
}
report_objectui_reachability "$NEW_SHA"
OLD_SHA="$(cat "${FRAMEWORK_ROOT}/.objectui-sha" 2>/dev/null | tr -d '[:space:]' || echo '<none>')"
if [[ "$OLD_SHA" == "$NEW_SHA" ]]; then
echo "→ Already at ${NEW_SHA:0:12}${REACH_TAG}, nothing to do."
exit 0
fi
# --- CAN THE OLD..NEW RANGE BE WALKED? Asked BEFORE the first mutation --------
# (#9408 for the question, #14178 for where it is asked and what a "no" costs.)
#
# THE TEST IS WALK COMPLETENESS, NOT OBJECT PRESENCE (#9408). It used to be
# `git cat-file -e OLD_SHA` — "does the OLD endpoint exist" — which is a
# different question, and the gap between them is measured: on the bump that
# landed `.changeset/console-82a94170c405.md` that test PASSED against a history
# truncated at commit 110 of 191, so the guard reported a walkable range, the
# degraded path never fired, and the digest exited 0 on a record crediting 36 of
# its 119 entries to one commit that adds exactly one. A truncated history is
# worse than an absent endpoint precisely because it ANSWERS: git shows its
# oldest visible commit as parentless, diffs it against the empty tree, and that
# one commit absorbs a whole batch.
#
# The question is asked IN THE DIGEST (`--check-walkable`) so there is one
# implementation of the rule rather than a shell copy that can drift from the
# thing it guards — see `findRangeTruncation` and `findAbsentEndpoints`. Exit
# 2 = an endpoint is missing, 3 = the endpoints are here but the history stops
# inside the range.
#
# ONE FAILURE, TWO CAUSES, ONE REMEDY SHAPE (#14178). 2 used to skip the deepen
# entirely: the digest's endpoint guard returned before `findRangeTruncation`
# ran, so a shallow clone whose boundary sits AFTER the old pin — what
# `git clone --depth N` produces — never reached the fetch that would have
# repaired it. Both causes now take the same path: deepen, then RE-ASK.
#
# AND A "no" AFTER THAT REFUSES. Measured on a shallow checkout, same range and
# same command before and after `git fetch --unshallow`: the degraded record
# carried `patch` where the range declared `minor`, 15 declared-breaking entries
# were invisible in it, the ADR-0087 disposition prompt never fired (it keys on a
# declared-breaking changeset, and the degraded one declares none), and the run
# exited 0 having written and offered to commit it. That level publishes into
# @objectstack/console's CHANGELOG and the curated release notes, where it reads
# as a declaration. No record beats a wrong one (triage ruling, 2026-09-01), so
# the unwalkable range is now a REFUSAL rather than a fourth kind of artifact.
#
# ASKED HERE, above the pin write, for the #10797 invariant: a run that refuses
# must leave `.objectui-sha` byte-identical. The deepen this block may perform is
# additive to the OBJECTUI checkout (it adds objects and drops `.git/shallow`; it
# moves no branch and touches no working tree) and writes nothing in the
# framework tree, so nothing here can leave half-applied state behind.
RANGE_OK=0
if [[ "$NO_CHANGESET" -eq 0 && "$OLD_SHA" != "<none>" ]]; then
range_walkable() {
node "${FRAMEWORK_ROOT}/scripts/objectui-changeset-digest.mjs" \
--objectui-root "$OBJECTUI_ROOT" --from "$1" --to "$2" --check-walkable
}
WALK_RC=0
range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$?
DEEPEN_NOTE=""
if [[ "$WALK_RC" -ne 0 ]]; then
if [[ "$WALK_RC" -ne 2 && "$WALK_RC" -ne 3 ]]; then
# The probe did not ANSWER. 2 and 3 are its two verdicts; anything else
# means it never reached one (no node, a syntax error, a killed process).
# Deepening would be a remedy applied to a diagnosis nobody made, so it is
# not attempted — and the refusal below says which of the two this was.
DEEPEN_NOTE=" no deepen was attempted: the probe never returned a verdict, so there is nothing to repair yet."
elif [[ "${OBJECTUI_NO_DEEPEN:-0}" == "1" ]]; then
echo "→ the objectui pin range cannot be walked here; OBJECTUI_NO_DEEPEN=1, not deepening." >&2
DEEPEN_NOTE=" OBJECTUI_NO_DEEPEN=1 was set for this run, so no deepen was attempted."
elif [[ "$(git -C "$OBJECTUI_ROOT" rev-parse --is-shallow-repository 2>/dev/null)" != "true" ]]; then
# Not shallow, yet the range does not walk: an object that was never
# fetched, a graft, a `git replace`, or unrelated histories. `--unshallow`
# cannot repair those and errors out on a complete repository, so do not
# pretend it might.
echo "→ the objectui pin range cannot be walked here, and the clone is NOT shallow" >&2
echo " (an unfetched object, a graft, git replace, or unrelated histories) —" >&2
echo " 'fetch --unshallow' cannot repair that." >&2
DEEPEN_NOTE=" the clone is NOT shallow, so 'fetch --unshallow' is not the repair here."
else
# REPAIR THE INPUT BEFORE DERIVING FROM IT. A console changeset becomes
# published CHANGELOG text, so a bad record is permanent — while the
# correct history is one fetch away and cheap: measured on objectui,
# `fetch --unshallow` costs ~6s and ~4MB and takes the walk from 110
# commits to the true 191. The fetch is ADDITIVE by construction, which is
# what makes doing it on the operator's checkout defensible rather than
# presumptuous. Announced before and after, and skippable with
# OBJECTUI_NO_DEEPEN=1 for an offline run.
#
# RE-CHECK, never trust the fetch's exit code. Measured: `git fetch
# --unshallow` in a checkout with no remote configured exits 0 and changes
# nothing at all, so a status-only test would have declared the range
# walkable on a still-broken tree — this card's failure shape, one layer
# further in.
echo "→ objectui is a shallow clone and the pin range cannot be walked in it — deepening…"
DEEPEN_RC=0
git -C "$OBJECTUI_ROOT" fetch --unshallow || DEEPEN_RC=$?
if [[ "$DEEPEN_RC" -eq 0 ]]; then
WALK_RC=0
range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$?
if [[ "$WALK_RC" -eq 0 ]]; then
echo "✓ deepened — the range walks completely now."
else
DEEPEN_NOTE=" 'git fetch --unshallow' exited 0, but the RE-CHECK still refuses — the re-check decides, never the fetch's status."
fi
else
echo "✗ 'git fetch --unshallow' failed (exit ${DEEPEN_RC})." >&2
DEEPEN_NOTE=" 'git fetch --unshallow' failed (exit ${DEEPEN_RC}), so the input was not repaired."
fi
fi
fi
if [[ "$WALK_RC" -eq 0 ]]; then
RANGE_OK=1
else
case "$WALK_RC" in
2) WALK_CAUSE="an endpoint of the range is not present as a commit object in that checkout" ;;
3) WALK_CAUSE="both endpoints are present but the history STOPS INSIDE the range (objectstack#9408)" ;;
*) WALK_CAUSE="the walkability probe itself did not answer (exit ${WALK_RC}); its verdicts are 2 and 3" ;;
esac
{
echo "✗ REFUSING to bump: the objectui range ${OLD_SHA:0:12}..${NEW_SHA:0:12} cannot be walked in"
echo " ${OBJECTUI_ROOT} — ${WALK_CAUSE}."
echo " (the digest's own diagnostic is above.)"
# An `[[ … ]] && echo` here would be a top-level AND-list whose status is
# the test's — false when there is no note — and `set -e` kills the run on
# exactly that, mid-refusal. Same trap the #12071 loop below documents.
if [[ -n "$DEEPEN_NOTE" ]]; then echo "$DEEPEN_NOTE"; fi
echo " The @objectstack/console changeset takes BOTH its list and its bump level from the"
echo " changesets objectui declared over this range. Neither can be derived here. This bump"
echo " used to emit a degraded entry carrying the default level instead — measured on a"
echo " shallow checkout: \`patch\` for a range that declared \`minor\`, every declared-breaking"
echo " entry invisible, the ADR-0087 disposition prompt silently skipped, exit 0"
echo " (objectstack#14178). That level publishes into @objectstack/console's CHANGELOG and"
echo " the release notes, where it reads as a declaration — so this refuses instead."
echo " NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin."
echo " Fix it, then re-run this bump:"
echo " git -C ${OBJECTUI_ROOT} fetch --unshallow # a shallow clone (the usual case)"
echo " git -C ${OBJECTUI_ROOT} fetch origin # an object that was never fetched"
echo " Or move the pin without a release record at all: scripts/bump-objectui.sh --no-changeset"
echo " (the pin moves, nothing is derived, and nothing is claimed about the range)."
} >&2
exit 1
fi
fi
# --- Derive the @objectstack/console changeset BEFORE the pin write ----------
# `RANGE_OK` was settled above, before the pin write — walkability is a
# PRECONDITION of this whole section, and an unwalkable range already refused
# (#14178). Reaching here means one of exactly two things: the range walks
# (derive), or this is the initial pin with no previous SHA to walk from
# (degrade after the pin write, below). There is no third input state to test.
#
# THE DERIVATION ITSELF CAN STILL FAIL even though the range WALKS COMPLETELY:
# `objectui-changeset-digest.mjs` reads git BLOBS (`readAt`) that the commit/tree
# walk above never touches, and the reachable failure is a changeset blob that
# cannot be read at EITHER `to` or the commit that added it — its own
# both-reads-failed diagnostic, then a thrown exception, non-zero exit.
#
# ASKED HERE, above the pin write, for the SAME #10797 invariant the range walk
# above already keeps: a run that refuses must leave `.objectui-sha`
# byte-identical. Deriving AFTER the pin write (as this bump used to) refused
# the level correctly but left the pin already moved — the half-applied write
# #10797 exists to prevent, one input further in (#14393).
#
# WRITTEN STRAIGHT INTO `CS_FILE`, not a temp path moved into place afterward:
# the digest CLI only calls `writeFileSync(out, …)` once the WHOLE digest
# object has built successfully — a `readAt` failure throws INSIDE that build,
# before the write is ever reached, so a failing derivation leaves `CS_FILE`
# unwritten on its own. There is no partial-write case left for a temp file to
# guard against.
CS_FILE=""
DIGEST_OK=0
BUMP=""
if [[ "$NO_CHANGESET" -eq 0 ]]; then
CS_FILE="${FRAMEWORK_ROOT}/.changeset/console-${SHORT}.md"
if [[ "$RANGE_OK" -eq 1 ]]; then
# The digest reads objectui's OWN declarations (.changeset/*.md added over
# the range) — inclusion and level both come from there, nothing is guessed
# off a commit subject. It writes the whole changeset file and echoes the
# resolved bump level.
if BUMP="$(node "${FRAMEWORK_ROOT}/scripts/objectui-changeset-digest.mjs" \
--objectui-root "$OBJECTUI_ROOT" \
--framework-root "$FRAMEWORK_ROOT" \
--from "$OLD_SHA" --to "$NEW_SHA" \
--max "${CONSOLE_CHANGES_MAX:-100}" \
--bump-override "${CONSOLE_BUMP:-}" \
--out "$CS_FILE")"; then
DIGEST_OK=1
else
# `RANGE_OK=1` implies `OLD_SHA != "<none>"` (the walk-check above only
# runs when there IS a previous SHA), so reaching here is always THIS
# card's state: the range walked, so a level WAS derivable in principle,
# and the derivation failed reading a changeset blob. Declaring `patch`
# here used to be a guess wearing a declaration's clothes, published into
# @objectstack/console's CHANGELOG and the curated release notes. No
# record beats a wrong one (#14178 triage ruling, restated for this state
# #14393) — refuse instead, same as the unwalkable-range case above and
# for the same reason.
{
echo "✗ REFUSING to bump: the objectui range ${OLD_SHA:0:12}...${NEW_SHA:0:12} walks"
echo " completely in ${OBJECTUI_ROOT}, but deriving the @objectstack/console changeset"
echo " from it failed — its diagnostic is in this run's output, above."
echo " A level was derivable in principle here; the derivation just failed reading a"
echo " changeset blob. This bump used to emit a degraded entry carrying the default"
echo " level instead (\`patch\`) as if it were a declaration nobody made, publishing"
echo " into @objectstack/console's CHANGELOG and the curated release notes. No record"
echo " beats a wrong one (objectstack#14178 triage ruling), so this refuses instead."
echo " NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin."
echo " Repair the objectui object store and re-run this bump, or move the pin without"
echo " a release record at all: scripts/bump-objectui.sh --no-changeset"
echo " (the pin moves, nothing is derived, and nothing is claimed about the range)."
} >&2
exit 1
fi
fi
fi
# NOT "the first mutation" any more — on the SUCCESS path the digest above
# already wrote `CS_FILE` (`--out`), so the changeset file is the first
# working-tree mutation and this pin write is the second. That is fine: the
# digest only calls `writeFileSync(out, …)` once the WHOLE digest has built
# successfully, so a refusal never leaves `CS_FILE` behind for this write to
# follow — either both land, in this order, or neither does.
#
# THE INVARIANT THAT MUST HOLD HERE (#10797): no read of objectui — git or the
# digest — happens BELOW this point. Everything that can fail on such a read
# (the commit read above, the range walk, the changeset derivation) already
# ran and already refused above. Keep it that way: a new `git -C
# "$OBJECTUI_ROOT" …` or `node .../objectui-changeset-digest.mjs` call added
# after this point reopens exactly the half-applied write this ordering exists
# to prevent — a mutation already made (the changeset, or this pin) sitting
# next to a read that still might fail.
echo "$NEW_SHA" > "${FRAMEWORK_ROOT}/.objectui-sha"
echo "→ objectui pin: ${OLD_SHA:0:12} → ${NEW_SHA:0:12}${REACH_TAG}"
# --- Emit the degraded @objectstack/console changeset for the initial pin ----
if [[ -n "$CS_FILE" && "$DIGEST_OK" -eq 0 ]]; then
# Reaching here with NO_CHANGESET=0 and DIGEST_OK=0 means RANGE_OK was 0 above
# — the ONLY input state left, now that the walkable-range/digest-failed
# state refuses above instead of degrading (#14393). RANGE_OK is 0 here only
# when OLD_SHA == "<none>" (the initial pin: no previous SHA, no range,
# nothing guessed about a walk) — a genuinely different fact from state 2,
# and it keeps its degraded (tip-subject-only) entry, labelled as such
# (#4731) so the reader can tell this list from a derived one.
BUMP="${CONSOLE_BUMP:-patch}"
RANGE_LABEL="(initial pin) → ${NEW_SHA:0:12}"
WHY="this is the initial pin, so there is no previous SHA to walk from"
cat > "$CS_FILE" <<EOF
---
"@objectstack/console": ${BUMP}
---
Console (objectui) refreshed to \`${SHORT}\`. Frontend changes in this range:
⚠️ **Degraded list** — ${WHY}, so this entry could not be derived from the
changesets objectui declared. It names the tip commit only and is NOT a
complete account of the range:
- ${SUBJECT_LINE}
objectui range: \`${RANGE_LABEL}\`
EOF
fi
if [[ -n "$CS_FILE" ]]; then
echo "→ wrote changeset $(basename "$CS_FILE") (@objectstack/console: ${BUMP})"
fi
# --- The other half of the pin-update procedure (#5960, #12924) --------------
# The pin bump is what makes the committed manifest stale, and regenerating it is
# what this prints. Which command writes what is the whole of it:
#
# node scripts/gen-sdui-manifest-node.mjs -> sdui.manifest.json (repo root) and
# scripts/sdui-manifest.record.json
# — both TRACKED, and gated per PR
# pnpm sdui:manifest (gen-sdui-manifest.sh) -> packages/console/dist/sdui.manifest.json
# — GITIGNORED, and gated nowhere
#
# So only the first one can turn the bump PR green. `scripts/check-sdui-manifest.mjs`
# in the required lint job reds while the record's pin trails `.objectui-sha`, and
# ADR-0082 D4's declaration-parity ratchet runs `--strict` against the tracked
# manifest on every PR (#12924 checked it in; before that the ratchet had no
# automatic run at all, which is the world the reminder here used to describe).
#
# Producing the BROWSER dump in this repo's per-PR CI was considered and REJECTED
# (#5960) — a full objectui build plus a chromium download on every matching PR —
# and that ruling stands; what changed is the ratchet's input, not the ruling.
#
# ⚠️ Corrected 2026-08-30 (#13091): this comment used to read "measured on
# origin/main, no workflow runs `pnpm sdui:manifest`, no workflow installs
# Playwright for it". Both halves stopped being true on 2026-08-10, when
# `.github/workflows/cut-rc.yml` landed doing both, one step apart, as the last
# check before publish — a `workflow_dispatch`-only lane a human starts by typing
# the version. "No workflow" was an absolute about the whole workflow set, and that
# is the shape that rotted. Read a workflow's `on:` block rather than a file count.
# ADR-0082 addendum 2 carries the full correction.
#
# Deliberately a REMINDER, not a hard gate: a machine that cannot reach npm must
# still be able to move the pin, and hard-failing here would be CI cost wearing a
# local disguise. Nothing is lost by that, because the gate cannot go falsely green
# — since #4690 a missing or unusable manifest exits 1 instead of skipping, and
# since #12924 the bump PR itself reds until the regeneration lands. Printed on
# BOTH exits below: --no-commit still moved the pin.
print_sdui_next_step() {
echo
echo "→ NEXT STEP — regenerate the committed SDUI manifest (required lint gate):"
echo " node scripts/gen-sdui-manifest-node.mjs \\"
echo " --objectui-version {the @object-ui version the new pin ships}"
echo " Read that version from the objectui checkout's packages/core/package.json."
echo " It rewrites the tracked sdui.manifest.json and scripts/sdui-manifest.record.json;"
echo " scripts/check-sdui-manifest.mjs reds this bump PR until it does, and ADR-0082 D4's"
echo " declaration-parity ratchet reads the same tracked artefact. No browser needed."
echo " ⛔ 'pnpm sdui:manifest' does NOT write those files — it is the separate browser"
echo " dump to the gitignored packages/console/dist/ (needs Playwright chromium)."
echo " Procedure: docs/releases-maintenance.md → 'After the pin moves'."
}
if [[ "$NO_COMMIT" -eq 1 ]]; then
echo "→ --no-commit: leaving files unstaged${REACH_RECALL}."
print_sdui_next_step
exit 0
fi
git -C "$FRAMEWORK_ROOT" add .objectui-sha
[[ -n "$CS_FILE" ]] && git -C "$FRAMEWORK_ROOT" add "$CS_FILE"
git -C "$FRAMEWORK_ROOT" commit -m "chore: bump objectui to ${SHORT}
${SUBJECT_LINE}
objectui@${NEW_SHA}" -- .objectui-sha ${CS_FILE:+"$CS_FILE"}
echo "✓ Committed${REACH_RECALL}. Push with: git push"
print_sdui_next_step