-
Notifications
You must be signed in to change notification settings - Fork 10
970 lines (904 loc) · 60.1 KB
/
Copy pathcut-rc.yml
File metadata and controls
970 lines (904 loc) · 60.1 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
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
name: Cut RC
# ══════════════════════════════════════════════════════════════════════════════
# ONE DISPATCH, ONE SNAPSHOT, ZERO FREEZES. (#7447)
# ══════════════════════════════════════════════════════════════════════════════
#
# Read `release.yml`'s header first — its 2026-08-07 ruling ("版本发布必须是人工的")
# and its lane split are binding context for this file, and this lane preserves
# both. Nothing here weakens them:
#
# - `workflow_dispatch` is the ONLY trigger. No push, bot, merge-queue landing
# or schedule can synthesise the event; a human types the exact version.
# - `environment: release` gates it exactly as the publish lane is gated.
# - The publish still only ever ships a commit that is ALREADY on main
# (#6170): this lane PUSHES the version commit to main first and publishes
# from it second. It never publishes from a ref that only exists in the run.
#
# WHAT THIS SOLVES THAT `release.yml` CANNOT
# ------------------------------------------
# The standing "Version Packages" PR (#6208) is force-refreshed on every push to
# main, so its CI can never converge while main is busy — on a repo with ~18
# merges a working day, cutting through it means asking two repositories to hold
# still. rc.6 was chased across four pin-bump laps
# (8aad9fd -> 9b9fa49 -> cfeb378 -> 1b6188d -> bcd3e02), every lap overtaken
# before its CI finished, and finishing would have needed ~40 minutes of
# coordinated freezes.
#
# The fix is not more discipline, it is a SNAPSHOT: this lane records the
# objectstack `main` sha it checked out and does every downstream step against
# that one frozen value. main may keep moving freely for the whole run.
#
# THE objectui PIN IS AN INPUT HERE, NEVER A DECISION (#10134)
# ------------------------------------------------------------
# Maintainer ruling, 2026-08-20, verbatim and untranslated:
#
# > objectui 每次 pin 的时候,对应的 changeset 应该带过来,但是发版本的时候不需要去
# > 扫描 objectui 仓库的最新版
# >
# > 更新到哪个版本是 objectstack 的 issue 自己决定的
#
# So this lane READS `.objectui-sha` and builds against it. It does not resolve
# objectui `main`, does not compare the pin to anything in another repo, and does
# not move the pin. The pin moves only on a manual bump PR whose target revision
# was decided in an objectstack issue (#10129 is the pattern).
#
# ⛔ Do not re-add a currency check here or anywhere on a release path. It is not
# that liveness is expensive — it is that "the pin is behind objectui main" is
# not a defect. #3340's actual invariant is "everything published is covered by
# the changeset record", and `scripts/bump-objectui.sh` +
# `scripts/objectui-changeset-digest.mjs` deliver it at BUMP time by carrying
# objectui's own changesets across OLD..NEW. That carry-over is the mechanism;
# scanning objectui mid-release was a second, redundant attempt at the same goal
# that additionally reached into another repo to second-guess a decision.
#
# CONSEQUENCE, STATED PLAINLY: an rc ships whatever console the committed pin
# names. If the pin is old, the rc's frontend is old, and nothing here will warn
# about it. That is the design, not an oversight — the pin is a decision.
#
# SCOPE — rc PRERELEASES ONLY
# ---------------------------
# The guard step below refuses anything that is not `X.Y.Z-rc.N`, and refuses to
# run at all unless `.changeset/pre.json` is in `mode: pre` with `tag: rc`. GA cuts
# keep the `release.yml` + Version-PR flow, where board-clearing, the #7275-A cut
# precondition and human changelog review live. See `docs/releases-maintenance.md`.
#
# THE RUNTIME IMAGE IS DELIBERATELY NOT BUILT HERE
# ------------------------------------------------
# This lane publishes to npm and pushes tags; it does not call `docker-publish.yml`.
# That is not an oversight and not a gap that stays open: `release.yml`'s
# `release-integrity` lane runs on EVERY push to main, and once this lane's version
# is on npm the next main push finds the image missing and requests the build — the
# #4900 repair path, doing exactly the job it was written for. On a repo with ~18
# merges a working day that is minutes, not days. For an image immediately, dispatch
# `docker-publish.yml` with the version. Duplicating the job here would be a second
# copy of a repair path that already exists.
on:
# The human lane, and the only one. Deliberately no `push:`, no `schedule:`.
workflow_dispatch:
inputs:
version:
description: >-
The EXPECTED resulting version, e.g. 17.0.0-rc.6 — what the version pass
should compute from the changesets pending on main, not what main carries
now. Typing it is the human confirmation the 2026-08-07 ruling requires:
the run fails before anything irreversible if the computed version differs.
required: true
type: string
dry_run:
description: >-
Stop after building the version commit locally and upload it as a patch
artifact. Nothing is pushed and nothing is published. Run this first —
it means the first REAL dispatch is not this workflow's first execution.
required: false
type: boolean
default: false
# ONE group for the whole lane, so two cuts can never interleave two snapshots
# over one main. Note the eviction property `release.yml` documents: GitHub keeps
# at most one PENDING run per group, so dispatching a third cut while one runs and
# one waits silently drops the middle one. That is acceptable here and nowhere near
# the hazard it would be on a shared group — every run of this workflow is a
# deliberate human act, and a dropped one is visibly absent from the Actions list.
# No `cancel-in-progress`: a cut that has begun publishing must never be killed.
concurrency:
group: cut-rc
cancel-in-progress: false
permissions:
contents: read
jobs:
cut:
name: Cut an rc from one snapshot
runs-on: ubuntu-latest
# ⚠️ An environment with NO protection rules passes AUTOMATICALLY and silently
# — the same one-time setup note `release.yml`'s header carries applies here
# verbatim (Settings -> Environments -> release -> Required reviewers). The
# load-bearing guarantee until then is the `workflow_dispatch` trigger itself.
environment: release
permissions:
# For `git push origin main` and the tag push. See "THE ADMIN PREREQUISITE"
# on the checkout step: this permission is necessary and NOT sufficient when
# main carries a protection ruleset.
contents: write
timeout-minutes: 120
steps:
# ────────────────────────────────────────────────────────────────────────
# GUARDS THAT NEED NO CHECKOUT. Cheapest possible failure for the mistakes
# that are most likely: wrong event, wrong branch, wrong version shape.
# ────────────────────────────────────────────────────────────────────────
- name: Guard the dispatch (event, ref, version shape)
env:
# Human-supplied text: read through env, never interpolated into the
# shell. Same rule as `release.yml`'s guard step.
REQUESTED: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
# Belt and braces against a FUTURE edit adding a second trigger to this
# file. Today `workflow_dispatch` is the only one, so this cannot fire;
# the day someone adds `push:` for convenience, it fires instead of
# publishing. A structural guard is cheap; re-earning #6170 is not.
if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then
echo "::error::cut-rc ran on event '${GITHUB_EVENT_NAME}'. This lane publishes to npm and may ONLY be started by a human dispatch (#6170, 2026-08-07 ruling). Remove whatever trigger produced this event."
exit 1
fi
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
echo "::error::dispatch cut-rc from main (got ${GITHUB_REF}). This workflow snapshots and pushes main; running it from another ref would execute that branch's workflow file against main's code."
exit 1
fi
# rc ONLY. A GA cut goes through release.yml + the Version Packages PR,
# where the human changelog review and the #7275-A precondition live.
if ! printf '%s' "$REQUESTED" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$'; then
echo "::error::'${REQUESTED}' is not an rc version. cut-rc cuts PRERELEASES only and expects the exact shape X.Y.Z-rc.N (e.g. 17.0.0-rc.6). For a GA release use the Release workflow and the 'chore: version packages' PR — see docs/releases-maintenance.md."
exit 1
fi
echo "Requested: ${REQUESTED} (dry_run=${DRY_RUN}), dispatched by ${GITHUB_ACTOR}."
# ────────────────────────────────────────────────────────────────────────
# THE SNAPSHOT, HALF 1 — objectstack.
#
# `ref: main` is explicit rather than inherited: the guard above already
# requires the dispatch ref to be main, and pinning it here means the two
# can never drift apart in a later edit.
#
# `fetch-depth: 0` is required, not tidiness: the changeset gates below diff
# against the snapshot sha, and `changesets` reads history.
#
# ⚠️ THE ADMIN PREREQUISITE — ONE-TIME, MAINTAINER-ONLY, AND THIS LANE
# CANNOT CREATE IT FOR ITSELF.
# This job pushes the version commit straight to `main`. If main carries a
# protection ruleset (it does), the pushing identity must be on that
# ruleset's BYPASS list, and `contents: write` alone does not put it there.
# Either of these satisfies it:
#
# (a) add the GitHub Actions app to the ruleset's bypass list, ideally
# scoped to this workflow, and leave RELEASE_PUSH_TOKEN unset; or
# (b) create a fine-grained PAT with `contents: write` on this repository
# for an account that IS on the bypass list, and store it as the
# repository secret RELEASE_PUSH_TOKEN.
#
# The token below picks (b) when the secret exists and (a) when it does not,
# so configuring either one is enough and neither needs a workflow edit. The
# push step names this prerequisite in its failure message, because a
# rejected push is otherwise indistinguishable from a network fault.
#
# Whichever route is taken, the credential is persisted on `origin` here so
# the version-commit push AND `release-publish.sh`'s atomic tag push both use
# it — one credential, no second remote to keep in step.
# ────────────────────────────────────────────────────────────────────────
- name: Checkout main (full history)
uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
token: ${{ secrets.RELEASE_PUSH_TOKEN || github.token }}
- name: Record the objectstack snapshot + guard the release train
id: snapshot
env:
REQUESTED: ${{ inputs.version }}
# PRESENCE, not the value: `!= ''` is evaluated by the expression engine
# and yields 'true'/'false', so the secret itself never reaches the
# shell. Interpolating `secrets.X` into a `run:` block would put it in
# the process's argv — masked in the log, but still the pattern
# release.yml refuses ("read through env, never interpolated").
HAS_PUSH_TOKEN: ${{ secrets.RELEASE_PUSH_TOKEN != '' }}
run: |
SNAPSHOT_SHA="$(git rev-parse HEAD)"
echo "SNAPSHOT_SHA=${SNAPSHOT_SHA}" >> "$GITHUB_ENV"
echo "snapshot-sha=${SNAPSHOT_SHA}" >> "$GITHUB_OUTPUT"
# Report credential state on EVERY run, before anything can early-exit.
# Otherwise a repo with the secret and one without look identical until a
# push happens to be rejected, and "is it configured?" stays unanswerable
# (the pattern cross-repo-issue-closer.yml uses). Presence only — the
# value is never read into the log.
if [ "$HAS_PUSH_TOKEN" = "true" ]; then
echo "push credential: RELEASE_PUSH_TOKEN is configured (route b)."
else
echo "push credential: RELEASE_PUSH_TOKEN is NOT set — pushing as the Actions app (route a). That app must be on main's ruleset bypass list or the push below is rejected."
fi
# rc-only, asserted against committed state rather than trusted from the
# version string. Pre mode is what makes `changeset version` compute
# `-rc.N` at all; outside it this lane would silently cut a GA release
# through a path that has none of GA's review steps.
MODE="$(jq -r '.mode // empty' .changeset/pre.json 2>/dev/null || true)"
TAG="$(jq -r '.tag // empty' .changeset/pre.json 2>/dev/null || true)"
if [ "$MODE" != "pre" ] || [ "$TAG" != "rc" ]; then
echo "::error::.changeset/pre.json is mode='${MODE:-<absent>}' tag='${TAG:-<absent>}', but cut-rc requires mode='pre' tag='rc'. Outside pre mode 'pnpm run version' computes a FINAL version, and this lane has none of the GA review steps. Enter pre mode ('changeset pre enter rc') or use the Release workflow."
exit 1
fi
CURRENT="$(jq -r '.version' packages/cli/package.json)"
if [ "$CURRENT" = "$REQUESTED" ]; then
echo "::error::main already carries @objectstack/cli@${CURRENT}. cut-rc computes the NEXT version from the pending changesets — asking it for the version main already has means the cut has already happened. To (re)publish an existing version, use the Release workflow."
exit 1
fi
echo "objectstack snapshot: ${SNAPSHOT_SHA}"
echo "main currently carries @objectstack/cli@${CURRENT}; this run must compute ${REQUESTED}."
# ────────────────────────────────────────────────────────────────────────
# THE objectui HALF — READ FROM THE COMMITTED PIN, NOT RESOLVED (#10134).
#
# `.objectui-sha` is the whole input. Everything downstream reads
# $OBJECTUI_SHA, which is that file and nothing else, so objectui may merge
# freely for the rest of the run and this cut neither notices nor cares —
# not because a snapshot froze a moving target, but because the target was
# never objectui `main`. See "THE objectui PIN IS AN INPUT HERE" up top.
# ────────────────────────────────────────────────────────────────────────
- name: Read the committed objectui pin
run: |
# Shape-checked, not trusted: `.objectui-sha` is hand-edited by
# bump-objectui.sh and by humans, and a truncated or empty file would
# otherwise surface much later as an unhelpful clone/build failure.
OBJECTUI_SHA="$(tr -d '[:space:]' < .objectui-sha)"
if ! printf '%s' "$OBJECTUI_SHA" | grep -qE '^[0-9a-f]{40}$'; then
echo "::error::.objectui-sha does not contain a 40-hex commit id (got '${OBJECTUI_SHA}'). Refusing to cut against an unreadable frontend revision."
exit 1
fi
echo "OBJECTUI_SHA=${OBJECTUI_SHA}" >> "$GITHUB_ENV"
echo "objectui pin (committed, NOT resolved): ${OBJECTUI_SHA}. This cut ships the console at that revision, however old it is — moving the pin is a separate, deliberate PR."
- name: Clone objectui at full depth, at the pin
run: |
OBJECTUI_ROOT="${RUNNER_TEMP}/objectui"
# FULL clone, not shallow, and still a measured requirement rather than
# caution. Two reasons, both surviving #10134:
# - the check below asks whether the PIN is reachable from objectui
# main; a tip-only shallow clone cannot answer that and would answer
# "no" for every pin older than the tip. Necessary, not sufficient:
# a full clone carries every branch, main's and otherwise, so it
# makes the question answerable without answering it;
# - build-console.sh builds from THIS clone (it honours
# $OBJECTUI_ROOT) by adding a worktree at the pin, which needs the
# pin's tree present.
# The #4731 lesson — a degraded changeset record and a complete one look
# alike, so never let history quietly go missing — now applies on the
# bump PR, which is where objectui-changeset-digest.mjs walks OLD..NEW.
git clone --no-tags https://github.com/objectstack-ai/objectui.git "$OBJECTUI_ROOT"
echo "OBJECTUI_ROOT=${OBJECTUI_ROOT}" >> "$GITHUB_ENV"
# THE PIN MUST BE ON objectui MAIN — WHICH IS NOT WHAT OBJECT PRESENCE
# ANSWERS (#9450). This was one `cat-file -e`, and the sentence it
# printed on failure — "not reachable from main (unmerged branch, or
# main was rewritten)" — named a case the test cannot see. Measured on
# a fresh `--no-tags` clone of objectui, 2026-08-21: 291 commits across
# 118 branch tips are present and NOT reachable from main, and
# `cat-file -e` says yes to every one of them — including the "branch
# that never merged" the message claimed to catch. The clone being FULL
# is what OPENS that gap rather than closing it: `git clone` fetches
# every branch head, so the more complete the clone, the more non-main
# revisions it can vouch for.
#
# Nothing upstream closes it either — bump-objectui.sh pins
# `git rev-parse HEAD` of a local objectui checkout without asking which
# branch that is. "The operator happened to be on main" is the whole of
# the protection, so ask the question here rather than assume it.
#
# Three questions, three exits, in this order because the later ones
# cannot be asked until the earlier ones hold: `merge-base --is-ancestor`
# exits 128 on an absent object — an error, not a verdict — and with no
# origin/main it would report "the pin left main", which is this block's
# own overclaim wearing a new message.
if ! git -C "$OBJECTUI_ROOT" rev-parse --verify --quiet origin/main >/dev/null; then
echo "::error::the objectui clone has no origin/main ref, so \"is the pin on main\" cannot be answered in it. Refusing to cut rather than assuming the answer."
exit 1
fi
if ! git -C "$OBJECTUI_ROOT" cat-file -e "${OBJECTUI_SHA}^{commit}" 2>/dev/null; then
echo "::error::the committed pin ${OBJECTUI_SHA} is not present in a fresh full clone of objectui at all — no branch carries it. It was never pushed, its branch was deleted, or main was rewritten. Fix .objectui-sha in its own PR; do not cut against it."
exit 1
fi
if ! git -C "$OBJECTUI_ROOT" merge-base --is-ancestor "$OBJECTUI_SHA" origin/main; then
echo "::error::the committed pin ${OBJECTUI_SHA} exists in objectui but is NOT reachable from objectui main — it names a revision on a branch that never merged. Cutting against it would publish @objectstack/console built from code that is not on main. Fix .objectui-sha in its own PR; do not cut against it."
exit 1
fi
echo "objectui pin ${OBJECTUI_SHA:0:12}: present in the clone, and reachable from objectui main."
# Detach the clone AT THE PIN so nothing downstream can accidentally
# read a working tree that is objectui main. build-console.sh builds
# from its own worktree at the pin regardless; this makes the intent
# true of the clone itself rather than incidental.
git -C "$OBJECTUI_ROOT" checkout --detach --quiet "$OBJECTUI_SHA"
echo "objectui clone detached at ${OBJECTUI_SHA:0:12}."
# ────────────────────────────────────────────────────────────────────────
# Toolchain. Mirrors release.yml's publish job step for step.
# ────────────────────────────────────────────────────────────────────────
- name: Setup Node.js
uses: actions/setup-node@v7
with:
# Cannot go below 22 — the downstream hotcrm smoke below clones
# hotcrm@v1.2.0, whose manifest pins engines.node >=22.
node-version: '22'
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-v3-
# Restore-only, like ci.yml's Console Pin Gate: this workflow runs a handful
# of times a month so its own namespace is almost always cold, and the
# build-core fallbacks seeded from main are the ones that actually hit.
# Saving is lint.yml's job.
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-
${{ runner.os }}-turbo-build-core-${{ github.ref_name }}-
${{ runner.os }}-turbo-build-core-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# A fresh workspace must build local dists BEFORE the console build: the
# console's vite alias table (objectui#4103) resolves @objectstack/client to
# this tree's packages/client/dist, whose DTS needs @objectstack/core built,
# and build-console.sh's own fallback is a bare `pnpm build` inside
# packages/client that tsup cannot finish on its own. @objectstack/spec is
# named explicitly because generated-artifact reads need it even where the
# client closure would already have pulled it in. Same step, same reason, as
# ci.yml's Console Pin Gate and showcase-smoke.yml.
- name: Build the @objectstack/client and @objectstack/spec closures
run: pnpm exec turbo run build --filter=@objectstack/client... --filter=@objectstack/spec... --concurrency=4
# ────────────────────────────────────────────────────────────────────────
# ⛔ NO PIN BUMP HAPPENS HERE (#10134).
#
# This lane used to run `bash scripts/bump-objectui.sh "$OBJECTUI_SHA"` and
# then assert `.objectui-sha` equalled the sha it had just written — an
# assertion that was trivially true, and that existed only because the sha
# came from outside the repo. Both are gone: the pin is committed input, so
# there is nothing to bump to and nothing to reconcile.
#
# The `@objectstack/console` changeset for a pin range is emitted by the
# manual bump PR, which is the only place the pin moves. A cut therefore
# publishes a console the release record already describes, or it publishes
# the same console as last time — never a console nobody wrote a record for.
# ────────────────────────────────────────────────────────────────────────
- name: Build the vendored Console SPA at the committed pin
run: pnpm objectui:build
- name: Verify the Console dist stamp matches the pin
run: pnpm check:console-sha
# ────────────────────────────────────────────────────────────────────────
# ADR-0082 D4 declaration-parity ratchet — the mandatory SECOND HALF of
# every pin move (#5960), run here against the pin as COMMITTED.
#
# ⚠️ Since #10134 this lane no longer moves the pin, so this is no longer
# "the second half of a move this run performed" — the move, and the
# ratchet that belongs to it, happen on the manual bump PR. Kept anyway,
# and deliberately: the ratchet is an on-demand gate by decision (ADR-0082
# addendum), which means "unrun" is its live failure mode, and a bump PR
# that skipped it leaves a divergence that reaches a release unnoticed.
# This is the last place before publish that can still say so. It writes to
# a gitignored path and asserts against the committed baseline, so it adds
# no file to the version commit; on a pin that already ratcheted it simply
# passes.
#
# Installing a Playwright browser is fine HERE. The #5960 ruling that keeps
# the ratchet off CI is about not putting an objectui build plus a browser
# download on every matching PR; this workflow runs a handful of times a
# month, on purpose, and has already built the console two steps above.
# ────────────────────────────────────────────────────────────────────────
- name: Install a Playwright browser for the manifest dump
run: |
# The dump runs INSIDE objectui's build tree (gen-sdui-manifest.sh does
# `pushd $BUILD_ROOT` and drives objectui's own playwright), so the
# browser must be installed against THAT workspace. `pnpm exec
# playwright` from the framework root resolves nothing — playwright is
# not a framework dependency.
BUILD_ROOT=".cache/objectui-${OBJECTUI_SHA:0:12}"
if [ ! -d "$BUILD_ROOT" ]; then
echo "::error::expected objectui build tree at ${BUILD_ROOT} (created by 'pnpm objectui:build'). Cannot install the browser the ratchet needs."
exit 1
fi
pnpm --dir "$BUILD_ROOT" exec playwright install chromium-headless-shell
- name: 'Declaration-parity ratchet at the committed pin (ADR-0082 D4)'
run: pnpm sdui:manifest
# ────────────────────────────────────────────────────────────────────────
# PRE-VERSION GATES. Every one of these reads `.changeset/*`, so all of them
# must run BEFORE `pnpm run version` consumes it.
#
# ON THE `--base` VALUE: these three are FORWARD-ONLY DIFF gates — they judge
# what a change INTRODUCES, never the standing stock (their `--list` modes
# are audits that always exit 0, and `--audit-stock` says in its own header
# that a non-zero exit there would make it a gate, which it is not). So the
# honest base here is $SNAPSHOT_SHA, and the diff they judge is this run's
# OWN contribution.
#
# ⚠️ Since #10134 that contribution is EMPTY by design — the lane no longer
# bumps the pin, so it emits no @objectstack/console changeset of its own,
# and these three assert exactly that. They are kept rather than deleted for
# two reasons: "this cut introduces nothing before versioning" is a real
# invariant to hold (it is what makes the allowlist assertion below a
# statement about `pnpm run version` alone), and each still runs its
# `--self-test`, so a broken gate is caught here rather than on the PR that
# broke it. The risk they used to cover on this lane — an objectui `major`
# promoting all ~70 packages, or a digest bug emitting empty frontmatter and
# stalling the release silently and greenly (#4898) — now lands on the
# manual bump PR, which is where the digest runs and where these same gates
# judge a non-empty diff.
#
# $SNAPSHOT_SHA is a pinned sha, which #6129 warns against — but that warning
# is about pr-automation.yml's FROZEN `base.sha`, a value that goes stale as
# main moves under a PR. Here the snapshot is by construction the checkout
# this run is judging, so it IS the merge base and cannot go stale.
# ────────────────────────────────────────────────────────────────────────
- name: Gate — Changesets "fixed" group covers every public package
run: node scripts/check-changeset-fixed.mjs
- name: Gate — no control bytes anywhere in the tree
run: pnpm check:nul-bytes
- name: Gate — this cut introduces no empty-frontmatter changeset
run: |
node scripts/check-empty-changeset.mjs --self-test
node scripts/check-empty-changeset.mjs --base "$SNAPSHOT_SHA"
- name: Gate — this cut introduces no unregistered breaking change (ADR-0087)
run: |
node scripts/check-adr-0087-registration.mjs --self-test
node scripts/check-adr-0087-registration.mjs --base "$SNAPSHOT_SHA"
- name: Gate — this cut introduces no major bump
run: |
node scripts/check-changeset-no-major.mjs --self-test
node scripts/check-changeset-no-major.mjs --base "$SNAPSHOT_SHA"
# ────────────────────────────────────────────────────────────────────────
# VERSION. `pnpm run version`, never a bare `changeset version`: the repo
# script is `changeset version && sync-protocol-version.mjs &&
# sync-template-versions.mjs`, and the two sync steps are what keep
# PROTOCOL_VERSION and the scaffolder templates in lockstep with the number
# being cut. Pre mode (`.changeset/pre.json`, tag rc) is committed state and
# applies automatically — the guard step already proved it is active.
# ────────────────────────────────────────────────────────────────────────
- name: Version the workspace
run: pnpm run version
- name: Assert the computed version is the one that was dispatched
env:
REQUESTED: ${{ inputs.version }}
run: |
COMPUTED="$(jq -r '.version' packages/cli/package.json)"
if [ "$COMPUTED" != "$REQUESTED" ]; then
echo "::error::you dispatched '${REQUESTED}' but the pending changesets compute @objectstack/cli@${COMPUTED}. Nothing has been pushed or published. Re-dispatch with ${COMPUTED} if that is the intended cut, or find the changeset that moved the number."
exit 1
fi
echo "computed version ${COMPUTED} matches the dispatch."
# ────────────────────────────────────────────────────────────────────────
# THE SINGLE VERSION COMMIT.
#
# Squashed onto $SNAPSHOT_SHA so main receives exactly one commit for the
# cut: the pin bump and the version output are one atomic unit, and there is
# never a main commit carrying a bumped pin but an unversioned tree.
#
# WHAT MAY BE IN IT — re-measured under @changesets/cli v3 on the
# 17.0.0 -> 17.1.0-rc.0 train (a full local `pnpm run version` in a throwaway
# clone over 209 pending changesets), not assumed. 365 staged paths:
# 76 modified package.json, 76 modified CHANGELOG.md, 209 changesets MOVED
# into `.changeset/pre/`, `.changeset/pre.json`, and the 3 doc surfaces
# below. `.changeset/pre.json` is UNTRACKED on the first cut of a train
# (`changeset pre enter` just created it) and modified on later ones;
# `git add -A` covers both.
# The 3 major-boundary paths below did NOT appear on this train and are
# allowed for the train that does move the major — as is the blank
# template's own package.json, which is why the v2 measurement this block
# used to carry counted 77 package.json against this one's 76: that train
# was 17.0.0 -> 18.0.0-rc.0, so `sync-template-versions.mjs` had to rewrite
# the template's `^17.0.0` pins. On a minor train it logs "already pins" and
# writes nothing. The counts move with the SHAPE of the train; the
# allowlist below is what does not have to.
#
# THE CONSUMED CHANGESETS MOVE — new in v3 (changesets#2190), and the one
# thing about a cut that this file's prose used to get wrong. v2 left every
# consumed `.changeset/*.md` on disk and recorded it in `pre.json`; v3 moves
# it to `.changeset/pre/NAME.md` VERBATIM (measured: a moved file diffs
# identical to its original, and git reports all 209 as `R100` renames), and
# `pre.json` is `{"mode","tag"}` only — `changeset version` never rewrites
# it. So in the worktree a cut is 209 deletions plus a new untracked
# `.changeset/pre/` directory, and the claim this block used to make — "a
# cut never races a lane PR over a `.changeset/*.md` file it wants to keep"
# — is FALSE under v3. The cut moves tracked changeset paths, so a lane PR
# that edits or deletes one of them conflicts; the rebase guard further down
# is what catches it, and it already names `.changeset` in its conflict
# surface. What does NOT change is this allowlist: `^\.changeset/` covers
# `.changeset/pre/…` and `git add -A -- … .changeset …` stages the deletions
# and the new directory together (measured: all 365 staged paths accepted,
# nothing tracked left unstaged).
# Three more paths can appear at a major boundary and are allowed for that
# reason, all written by `sync-protocol-version.mjs` /
# `sync-template-versions.mjs`: packages/spec/src/kernel/protocol-version.ts
# and, per bundled template, objectstack.config.ts (engines.protocol) and
# objectstack.manifest.json (specVersion). protocol-version.ts is named
# literally because that one file IS the whole of its surface. The template
# paths are not, and the reason is the one the doc half states below.
#
# THE DOC SURFACES ARE READ, NOT RESTATED. `pnpm run version` ends with
# `sync-docs-image-tags.mjs`, which rewrites the doc surfaces that pin a
# concrete image tag / CLI version. That rewriter does not own the list — it
# imports `SURFACES` from `scripts/check-docs-image-tag.mjs`, the gate whose
# findings it exists to clear, on the stated principle "one list, two
# consumers". This step is the THIRD consumer and joins on the same terms:
# it resolves `SURFACES` at run time rather than copying the paths, because a
# fourth literal is a fourth contract and the two drifting apart is exactly
# the defect that reached this assertion in the first place — the rewriter
# joined the `version` script while this allowlist kept describing a train
# measured before it existed, and every cut since would have refused to push.
# Note what this deliberately is NOT: a wildcard over `content/docs/**`. Only
# the paths a reviewed declaration names are allowed, so a version pass that
# writes anywhere else — a new sync script, or this rewriter growing an
# output outside `SURFACES` — still fails loudly here, which is the whole
# point of the assertion. Onboarding a surface stays a one-line edit to
# `SURFACES` that a reviewer sees in the diff.
#
# THE TEMPLATE SURFACES ARE READ ON THE SAME TERMS. They used to be two
# literals, both hard-coding the template name `blank`, and this block used
# to record why: `sync-template-versions.mjs` declared its targets (a
# `TEXT_STAMPS` table plus a walk of `src/templates/`) but exported none of
# them and ran the sync at module scope, so importing it to ask "which files
# does the version pass stamp?" would have rewritten the templates instead of
# answering. #9648 gave it the export and the entry-point guard this block
# asked for, so the restatement is retired here rather than re-seeded:
# `stampedPaths()` derives its answer from the same walk and the same table
# the stamper's own `main()` uses.
#
# This is a COLLAPSE OF TWO COPIES OF ONE LIST, not a widening. The literals
# were never a deliberately narrower allowlist — they were the same set,
# spelled by hand, and they equal it only while `blank` is the only bundled
# template. `findTemplateDirs()` exists BECAUSE the template set is not
# curated ("a template added tomorrow is covered on the day it lands"), so
# the day a second template ships, the walk stamps it, the literal pair does
# not cover it, and the unstaged-files assertion below refuses the cut — with
# nothing red until someone attempts a release. `stampedPaths()` also reports
# each template's own package.json, which `'*package.json'` already permits,
# so the resolved list is a superset of the two literals and never a
# narrowing.
#
# The allowlist is ENFORCED rather than described. Only allowlisted paths are
# staged, and then every staged path is re-checked against the same rule and
# the worktree is asserted clean. If `pnpm run version` ever grows a new
# output, this fails loudly here instead of pushing a surprise to main — the
# "disjoint from lane PRs by construction" claim is only worth anything if
# something actually checks it.
# ────────────────────────────────────────────────────────────────────────
- name: Build the single version commit
id: commit
env:
REQUESTED: ${{ inputs.version }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
# The committer identity. It used to be set by the pin-bump step, which
# #10134 deleted; `actions/checkout` does not set one, so `git commit`
# below would fail with "empty ident name" without this.
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
# Structural guarantee that the version commit's parent is the snapshot.
# Since #10134 no step before this one commits anything, so this is a
# no-op in the normal case — kept because it is what makes "one commit
# on top of the snapshot" true by construction rather than by review.
git reset --soft "$SNAPSHOT_SHA"
# The doc surfaces `sync-docs-image-tags.mjs` rewrites, read from the same
# declaration the rewriter reads. Import-safe by construction: the module
# carries an entry-point guard added for exactly this kind of consumer.
SURFACE_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-docs-image-tag-surfaces.txt"
if ! node --input-type=module \
-e 'import { SURFACES } from "./scripts/check-docs-image-tag.mjs"; for (const s of SURFACES) console.log(s.file);' \
> "$SURFACE_LIST"; then
echo "::error::could not resolve SURFACES from scripts/check-docs-image-tag.mjs, so the doc half of the release file surface is unknown. Refusing to push."
exit 1
fi
# An empty list would silently re-open the hole this replaced: the docs the
# version pass rewrites would go unstaged and the assertion below would
# blame them. Unknown is a failure, never an empty allowlist.
if [ ! -s "$SURFACE_LIST" ]; then
echo "::error::SURFACES in scripts/check-docs-image-tag.mjs resolved EMPTY, so no doc surface would be staged even though the version pass rewrites them. Refusing to push."
exit 1
fi
mapfile -t DOCS_SURFACES < "$SURFACE_LIST"
echo "doc surfaces declared by SURFACES (${#DOCS_SURFACES[@]}):"
sed 's/^/ /' "$SURFACE_LIST"
# The template surfaces `sync-template-versions.mjs` stamps, read from the
# same walk and the same table the stamper itself uses. Import-safe by
# construction: that module carries the entry-point guard and the exports
# added for exactly this consumer.
TEMPLATE_LIST="${RUNNER_TEMP:-/tmp}/cut-rc-template-version-surfaces.txt"
if ! node --input-type=module \
-e 'import { stampedPaths } from "./scripts/sync-template-versions.mjs"; for (const p of stampedPaths()) console.log(p);' \
> "$TEMPLATE_LIST"; then
echo "::error::could not resolve stampedPaths() from scripts/sync-template-versions.mjs, so the template half of the release file surface is unknown. Refusing to push."
exit 1
fi
# Same rule as the doc half: unknown is a failure, never an empty
# allowlist. `stampedPaths()` THROWS rather than returning [] on a moved
# or empty template directory, so an empty file here means a resolution
# that reported nothing while still exiting 0 — which would stage no
# template path at all and then blame the files it left unstaged.
if [ ! -s "$TEMPLATE_LIST" ]; then
echo "::error::stampedPaths() in scripts/sync-template-versions.mjs resolved EMPTY, so no template surface would be staged even though the version pass stamps them. Refusing to push."
exit 1
fi
mapfile -t TEMPLATE_SURFACES < "$TEMPLATE_LIST"
echo "template surfaces declared by stampedPaths() (${#TEMPLATE_SURFACES[@]}):"
sed 's/^/ /' "$TEMPLATE_LIST"
git add -A -- \
'*package.json' \
'*CHANGELOG.md' \
.changeset \
.objectui-sha \
packages/spec/src/kernel/protocol-version.ts \
"${TEMPLATE_SURFACES[@]}" \
"${DOCS_SURFACES[@]}"
STAGED="$(git diff --cached --name-only)"
if [ -z "$STAGED" ]; then
echo "::error::the version pass produced no changes to commit. Nothing to cut."
exit 1
fi
# Re-check every staged path against the allowlist. The pathspec above is
# convenience; THIS is the guarantee. Three filters, same allowlist the
# pathspec used: the fixed release paths by pattern, then the declared
# template and doc surfaces by WHOLE-LINE EXACT match (`-xF`) against the
# very lists that were staged — so neither derived filter can accept a path
# its declaration does not name, and neither needs regex-escaping of the
# paths to stay exact.
BAD="$(printf '%s\n' "$STAGED" \
| grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^\.objectui-sha$|^packages/spec/src/kernel/protocol-version\.ts$' \
| grep -vxF -f "$TEMPLATE_LIST" \
| grep -vxF -f "$SURFACE_LIST" || true)"
if [ -n "$BAD" ]; then
echo "::error::the version commit would carry paths outside the release file surface. Refusing to push. Offending paths follow; if the version pass legitimately grew a new output, widen the allowlist in this workflow deliberately."
printf '%s\n' "$BAD" | sed 's/^/::error:: unexpected: /'
exit 1
fi
# Nothing tracked may be left behind: a modified-but-unstaged file means
# the version pass wrote somewhere the allowlist does not cover, and the
# commit would silently ship a partial cut.
LEFT="$(git diff --name-only)"
if [ -n "$LEFT" ]; then
echo "::error::tracked files were modified but fall outside the release file surface, so the version commit would be incomplete. Refusing to push."
printf '%s\n' "$LEFT" | sed 's/^/::error:: unstaged: /'
exit 1
fi
# Separate -m flags rather than one multi-line string: inside a YAML
# block scalar every continuation line carries the block's indentation,
# which would land verbatim in the commit body.
git commit -q \
-m "chore(release): ${REQUESTED}" \
-m "objectui pin (unchanged by this cut): ${OBJECTUI_SHA}" \
-m "cut from ${SNAPSHOT_SHA} by ${GITHUB_ACTOR} via cut-rc (#7447)"
COMMIT_SHA="$(git rev-parse HEAD)"
COUNT="$(printf '%s\n' "$STAGED" | wc -l | tr -d ' ')"
echo "commit-sha=${COMMIT_SHA}" >> "$GITHUB_OUTPUT"
echo "file-count=${COUNT}" >> "$GITHUB_OUTPUT"
echo "built ${COMMIT_SHA} on top of ${SNAPSHOT_SHA}: ${COUNT} file(s)."
{
echo "## Cut ${REQUESTED}"
echo
echo "| | |"
echo "|---|---|"
echo "| objectstack snapshot | \`${SNAPSHOT_SHA}\` |"
echo "| objectui pin (committed) | \`${OBJECTUI_SHA}\` |"
echo "| version commit | \`${COMMIT_SHA}\` |"
echo "| files in commit | ${COUNT} |"
echo "| dry run | ${DRY_RUN} |"
} >> "$GITHUB_STEP_SUMMARY"
# ────────────────────────────────────────────────────────────────────────
# DRY RUN STOPS HERE.
#
# It stops AFTER the local commit rather than after the version step, which
# is strictly more of the pipeline than #7447's acceptance criterion asks
# for: the squash, the allowlist enforcement and the version assertion have
# all really executed, and the artifact is the exact commit a real run would
# push. Everything downstream of this point is irreversible.
# ────────────────────────────────────────────────────────────────────────
- name: Export the would-be version commit (dry run)
if: ${{ inputs.dry_run }}
env:
REQUESTED: ${{ inputs.version }}
run: |
mkdir -p "${RUNNER_TEMP}/cut-rc"
git format-patch -1 --stdout > "${RUNNER_TEMP}/cut-rc/version-commit.patch"
git show --stat --oneline HEAD > "${RUNNER_TEMP}/cut-rc/version-commit.stat.txt"
{
echo "# cut-rc dry run — ${REQUESTED}"
echo
echo "- objectstack snapshot: ${SNAPSHOT_SHA}"
echo "- objectui pin: ${OBJECTUI_SHA} (committed, unchanged)"
echo "- version commit: $(git rev-parse HEAD)"
echo
echo "Nothing was pushed and nothing was published."
echo "version-commit.patch is byte-for-byte what a real dispatch would push to main."
} > "${RUNNER_TEMP}/cut-rc/README.md"
- name: Upload the dry-run artifact
if: ${{ inputs.dry_run }}
uses: actions/upload-artifact@v7
with:
name: cut-rc-${{ inputs.version }}-dry-run
path: ${{ runner.temp }}/cut-rc
if-no-files-found: error
- name: Stop (dry run)
if: ${{ inputs.dry_run }}
run: |
echo "::notice::dry run complete — the version commit was built and verified locally, then discarded. Nothing was pushed, nothing was published. Re-dispatch with dry_run unchecked to cut for real."
echo "- **dry run**: stopped before the push. Nothing was published." >> "$GITHUB_STEP_SUMMARY"
# ────────────────────────────────────────────────────────────────────────
# LAND ON MAIN FIRST, PUBLISH SECOND (#6170).
#
# rc.3 and rc.4 tagged commits that lived only on `changeset-release/main`,
# so main kept a stale version and every later release recomputed an
# npm-occupied number. The ordering below is the structural fix: by the time
# anything reaches npm, the exact commit it was built from is on main.
#
# THE DRY-RUN GUARD IS WRITTEN TWICE, ON PURPOSE. The step-level `if:` is the
# primary control; the in-shell assert is there because a dropped `if:` in a
# future edit would otherwise publish from a run a human asked NOT to
# publish. Two languages, one decision — the same belt-and-braces reasoning
# release.yml applies to its own publish path.
# ────────────────────────────────────────────────────────────────────────
- name: Push the version commit to main
if: ${{ !inputs.dry_run }}
env:
DRY_RUN: ${{ inputs.dry_run }}
REQUESTED: ${{ inputs.version }}
run: |
if [ "$DRY_RUN" = "true" ]; then
echo "::error::refusing to push: this run was dispatched as a dry run. A step-level 'if:' has been dropped in an edit to this workflow."
exit 1
fi
# Bounded fetch-rebase-retry. main is busy by assumption — that is the
# premise of this whole workflow — so losing the push race is expected,
# not exceptional.
ATTEMPTS=5
for attempt in $(seq 1 "$ATTEMPTS"); do
if git push origin HEAD:main; then
echo "pushed $(git rev-parse HEAD) to main on attempt ${attempt}."
echo "PUSHED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
exit 0
fi
if [ "$attempt" -eq "$ATTEMPTS" ]; then
echo "::error::could not push the version commit to main after ${ATTEMPTS} attempts. Nothing has been published, so this is safe to retry."
echo "::error::If the rejection was 'protected branch' / 'refusing to allow ... to update', this is the ONE-TIME ADMIN PREREQUISITE and no re-run will clear it: the pushing identity must be on main's ruleset BYPASS list. Either add the GitHub Actions app to the ruleset bypass list (ideally scoped to this workflow), or create a fine-grained PAT with contents:write for an account already on that list and store it as the repository secret RELEASE_PUSH_TOKEN. See this workflow's checkout step for the full note."
exit 1
fi
echo "push rejected (attempt ${attempt}/${ATTEMPTS}) — refreshing main and rebasing the version commit."
git fetch origin main
if ! git rebase origin/main; then
git rebase --abort || true
echo "::error::the version commit conflicts with main. Nothing has been published. A commit landing during the cut touched the release file surface (package.json / CHANGELOG.md / .changeset / .objectui-sha) — resolve it and re-dispatch."
exit 1
fi
# The rebase moved the commit onto newer main, so the version
# assertion must be re-earned rather than inherited. If another
# version commit landed underneath us the number is now wrong, and
# that must stop the cut before npm, not after.
COMPUTED="$(jq -r '.version' packages/cli/package.json)"
if [ "$COMPUTED" != "$REQUESTED" ]; then
echo "::error::after rebasing onto refreshed main the tree carries @objectstack/cli@${COMPUTED}, not the dispatched ${REQUESTED}. Something else versioned main during this cut. Nothing has been published."
exit 1
fi
sleep $(( attempt * 5 ))
done
# From here the commit IS on main, so everything published is a commit main
# already carries — #6170's invariant, kept to the letter. If the loop above
# rebased, main also carries whatever landed during the cut; those commits
# passed their own CI, and the publish below builds from this exact tree, so
# the artifacts and the main commit still describe each other exactly.
- name: Confirm the pushed commit is main
if: ${{ !inputs.dry_run }}
run: |
git fetch origin main
REMOTE="$(git rev-parse origin/main)"
LOCAL="$(git rev-parse HEAD)"
if [ "$REMOTE" != "$LOCAL" ]; then
echo "::warning::main has already moved past the version commit (${LOCAL} -> ${REMOTE}). That is normal on a busy repository; the version commit itself is on main, which is what the publish requires."
if ! git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "::error::the version commit ${LOCAL} is NOT an ancestor of origin/main. Refusing to publish a commit main does not carry (#6170)."
exit 1
fi
fi
echo "@objectstack/cli@$(jq -r '.version' packages/cli/package.json) is on main at ${LOCAL}."
# Pre-publish gate #2035, kept in step with release.yml's publish job rather
# than dropped for speed. In pre mode (which the guard step proved is active,
# so ALWAYS on this lane) the #3600 amendment makes it advisory: a major
# train exists to ship deliberate surface removals, and a migrated hotcrm
# cannot exist until the rc artifacts it would migrate against are published.
# It reports; it cannot block.
- name: Downstream backward-compat smoke (live hotcrm, advisory in pre mode)
if: ${{ !inputs.dry_run }}
env:
HOTCRM_REF: v2.1.0
run: |
if bash scripts/downstream-smoke.sh; then
echo "::notice::hotcrm@${HOTCRM_REF} is still compatible with the pre-release train."
else
echo "::warning::hotcrm@${HOTCRM_REF} is incompatible with the pre-release train — expected for this window's deliberate removals. Ship a migrated hotcrm release and bump HOTCRM_REF in release.yml before 'changeset pre exit' re-arms that gate."
fi
# ────────────────────────────────────────────────────────────────────────
# PUBLISH. `pnpm run release` = build + build-console + release-publish.sh,
# which is `changeset publish` followed by ONE atomic `git push origin --tags`
# (#2191: the changesets action's concurrent per-tag pushes raced GitHub's
# ref backend and lost ~half the tags).
#
# changesets/action is NOT used, exactly as in release.yml: handed a
# workspace with pending changesets it would take the VERSION path and mint a
# commit. `changeset publish` can only publish what the checked-out
# package.json files already declare — which is what main now carries.
# ────────────────────────────────────────────────────────────────────────
- name: Publish to npm + push version tags
id: publish
if: ${{ !inputs.dry_run }}
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
VERSION: ${{ inputs.version }}
run: |
if [ "$DRY_RUN" = "true" ]; then
echo "::error::refusing to publish: this run was dispatched as a dry run. A step-level 'if:' has been dropped in an edit to this workflow."
exit 1
fi
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" >> "$HOME/.npmrc"
pnpm run release
# `changeset publish` skips versions already on the registry, so a
# re-dispatch over a partially-published release is a repair rather than
# a duplicate. What is NOT optional is that the version is on npm when
# this step ends.
if ! npm view "@objectstack/cli@$VERSION" version >/dev/null 2>&1; then
echo "::error::publish ran but @objectstack/cli@$VERSION is still not on npm"
exit 1
fi
echo "published=true" >> "$GITHUB_OUTPUT"
- name: Create GitHub Releases (bodies truncated to the API limit)
# `!cancelled()` rather than the implicit success(): npm is already public
# by the time this runs, so a failure upstream must not be the reason the
# release record stays empty (#4900).
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The fixed group releases every public package at one version, so the
# version alone drives the whole publishable workspace.
RELEASE_VERSION: ${{ inputs.version }}
run: node scripts/release-github-releases.mjs
- name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4)
# Ordering is load-bearing: `gh release upload` needs the Release the step
# above created.
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ inputs.version }}
run: bash scripts/release-spec-changes.sh
- name: Summarise the cut
if: ${{ !cancelled() && steps.publish.outputs.published == 'true' }}
env:
REQUESTED: ${{ inputs.version }}
run: |
{
echo
echo "**${REQUESTED} is published.**"
echo
echo "- npm: every package in the fixed group, dist-tag \`rc\`"
echo "- git: version tags pushed atomically"
echo "- main: carries the version commit (nothing was published that main does not have)"
echo
echo "### Two things that are expected, not defects"
echo
echo "1. **The standing \`chore: version packages (rc)\` PR (#6208) may look stale.**"
echo " Its changesets were consumed by this cut and moved to \`.changeset/pre/\` (v3)."
echo " Whether it refreshes now depends on the push credential: pushes made with the"
echo " Actions \`GITHUB_TOKEN\` trigger no workflow runs (GitHub's recursion guard), so"
echo " \`version-pr\` does not fire until some later push to main; with a"
echo " \`RELEASE_PUSH_TOKEN\` PAT it fires now and the PR self-corrects. **Do not fix it"
echo " by hand** — see docs/releases-maintenance.md, 'What happens to the standing"
echo " Version Packages PR'."
echo "2. **No runtime image yet.** This lane does not build one; \`release-integrity\` in"
echo " \`release.yml\` requests it on a later push to main, or dispatch"
echo " \`docker-publish.yml\` with version \`${REQUESTED}\` for one immediately."
} >> "$GITHUB_STEP_SUMMARY"