Repository navigation
1168 lines (1148 loc) · 64.7 KB
/
Copy pathci.yml
File metadata and controls
1168 lines (1148 loc) · 64.7 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
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
inputs:
rehearsal:
description: "Run the exact tag build/verify graph, the non-tag main graph, or publish an immutable nightly prerelease after the full source graph passes. Manual nightlies release dev; scheduled nightlies release main."
required: true
type: choice
options: [tag-build-verify, main-nontag, nightly-release]
# Least privilege by default: only `publish` needs write, and it declares its
# own job-level `contents: write` override. This keeps rehearsal dispatches
# (and every build/verify job) on a read-scoped GITHUB_TOKEN.
permissions:
contents: read
concurrency:
# Real stable and nightly publication share one non-cancelling cross-channel
# lane so npm dist-tags and closed evidence cannot race each other.
group: ${{ (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'nightly-release')) && 'gajae-npm-release' || format('ci-{0}-{1}', github.ref, github.event_name == 'workflow_dispatch' && inputs.rehearsal || 'event') }}
cancel-in-progress: ${{ !(startsWith(github.ref, 'refs/tags/v') || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'nightly-release')) }}
jobs:
release_metadata:
if: ${{ startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'tag-build-verify') || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'nightly-release') }}
runs-on: ubuntu-22.04
timeout-minutes: 5
outputs:
channel: ${{ steps.release.outputs.channel }}
version: ${{ steps.release.outputs.version }}
nightly_version: ${{ steps.release.outputs.nightly_version }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Resolve immutable release identity
id: release
shell: bash
env:
IS_NIGHTLY: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'nightly-release')) && '1' || '0' }}
EVENT_NAME: ${{ github.event_name }}
SOURCE_SHA: ${{ github.sha }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
if [ "$IS_NIGHTLY" = 1 ]; then
expected_ref=refs/heads/dev
if [ "$EVENT_NAME" = schedule ]; then
expected_ref=refs/heads/main
fi
if [ "$GITHUB_REF" != "$expected_ref" ]; then
echo "Nightly release for $EVENT_NAME must run from $expected_ref, not $GITHUB_REF" >&2
exit 1
fi
timestamp="$(git show -s --format=%cI "$SOURCE_SHA")"
version="$(bun scripts/nightly-release.ts version --timestamp "$timestamp" --run-id "$RUN_ID" --source-sha "$SOURCE_SHA")"
{
echo "channel=nightly"
echo "version=$version"
echo "nightly_version=$version"
echo "tag_name=v$version"
} >> "$GITHUB_OUTPUT"
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
version="${GITHUB_REF_NAME#v}"
if ! [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "Stable release tag must be exact vX.Y.Z, received $GITHUB_REF_NAME" >&2
exit 1
fi
manifest_version="$(bun -e 'const manifest = await Bun.file("packages/coding-agent/package.json").json(); process.stdout.write(manifest.version)')"
if [ "$manifest_version" != "$version" ]; then
echo "Stable release tag $GITHUB_REF_NAME does not match package version $manifest_version" >&2
exit 1
fi
{
echo "channel=stable"
echo "version=$version"
echo "nightly_version="
echo "tag_name=$GITHUB_REF_NAME"
} >> "$GITHUB_OUTPUT"
else
{
echo "channel=rehearsal"
echo "version="
echo "nightly_version="
echo "tag_name="
} >> "$GITHUB_OUTPUT"
fi
- name: Validate stable tag protected-main provenance
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
env:
SOURCE_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
# A stable tag workflow is evaluated from the tagged revision and
# later grants repository write plus the OIDC identity token, so
# the tagged commit must provably come from the protected main
# line: fetch
# the live default branch from origin and require ancestry.
# This check alone cannot stop an actor allowed to create tags
# from pointing one at a side-branch commit with modified
# workflow YAML; closing that half requires the owner-controlled
# repository settings documented on the publish job below.
git fetch --quiet --tags --prune --unshallow origin +refs/heads/main:refs/remotes/origin/main \
|| git fetch --quiet --tags --prune origin +refs/heads/main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$SOURCE_SHA" refs/remotes/origin/main; then
echo "Stable release tag $GITHUB_REF_NAME resolves to $SOURCE_SHA, which is not an ancestor of origin/main; refusing release" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# PR + main branch: lint/typecheck (native-free) and the test suite.
# These never run on tags — a tag is cut from an already-green main.
# ---------------------------------------------------------------------------
check:
if: ${{ !startsWith(github.ref, 'refs/tags/v') && (github.event_name != 'workflow_dispatch' || inputs.rehearsal == 'main-nontag' || inputs.rehearsal == 'nightly-release') }}
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Cache bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-1.4.0-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Lint and type check (native-free)
run: bun run ci:check:full
# ---------------------------------------------------------------------------
# PR + main branch: sharded full test suite. Unlike dev CI (changed-path
# affected), Main CI runs the COMPLETE task union via CI_FORCE_FULL. Long-tail
# tasks are sub-split (coding-agent tests 16-way, rust-test 4 nextest
# partitions) so no single shard dominates wall-time. The `test` aggregate job
# keeps the stable branch-protection status name.
# ---------------------------------------------------------------------------
main_plan:
if: ${{ !startsWith(github.ref, 'refs/tags/v') && (github.event_name != 'workflow_dispatch' || inputs.rehearsal == 'main-nontag' || inputs.rehearsal == 'nightly-release') }}
runs-on: ubuntu-22.04
timeout-minutes: 10
env:
CI_FORCE_FULL: "1"
CI_CODING_AGENT_TEST_SHARDS: "16"
CI_RUST_TEST_PARTITIONS: "4"
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
has_tasks: ${{ steps.plan.outputs.has_tasks }}
has_native: ${{ steps.plan.outputs.has_native }}
has_python: ${{ steps.plan.outputs.has_python }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Compute full Main CI task matrix
id: plan
run: bun scripts/ci-dev-affected.ts --matrix-json
main_native:
if: ${{ !startsWith(github.ref, 'refs/tags/v') && (github.event_name != 'workflow_dispatch' || inputs.rehearsal == 'main-nontag' || inputs.rehearsal == 'nightly-release') && needs.main_plan.outputs.has_native == 'true' }}
needs: [main_plan]
runs-on: ubuntu-22.04
timeout-minutes: 30
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Cache bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-1.4.0-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Build native addon (linux-x64 baseline + modern)
env:
TARGET_PLATFORM: linux
TARGET_ARCH: x64
TARGET_VARIANTS: baseline modern
run: bun run ci:build:native
- name: Verify required native addon variants
run: |
test -f packages/natives/native/pi_natives.linux-x64-baseline.node
test -f packages/natives/native/pi_natives.linux-x64-modern.node
- name: Verify producer provenance sidecars
shell: bash
run: |
set -euo pipefail
test -f packages/natives/native/pi_natives.linux-x64-baseline.node.provenance.json
test -f packages/natives/native/pi_natives.linux-x64-modern.node.provenance.json
- name: Upload native addon(s)
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: main-native-${{ github.run_id }}
path: |
packages/natives/native/pi_natives.linux-x64-baseline.node
packages/natives/native/pi_natives.linux-x64-modern.node
packages/natives/native/pi_natives.linux-x64-baseline.node.provenance.json
packages/natives/native/pi_natives.linux-x64-modern.node.provenance.json
if-no-files-found: error
retention-days: 1
overwrite: true
main_shards:
name: test-shard / ${{ matrix.key }}
if: ${{ always() && !startsWith(github.ref, 'refs/tags/v') && (github.event_name != 'workflow_dispatch' || inputs.rehearsal == 'main-nontag' || inputs.rehearsal == 'nightly-release') && needs.main_plan.outputs.has_tasks == 'true' && needs.main_native.result != 'failure' && needs.main_native.result != 'cancelled' }}
needs: [main_plan, main_native]
runs-on: ubuntu-22.04
timeout-minutes: ${{ matrix.rust && 90 || 60 }}
strategy:
fail-fast: false
max-parallel: 16
matrix: ${{ fromJSON(needs.main_plan.outputs.matrix) }}
env:
CI_FORCE_FULL: "1"
CI_CODING_AGENT_TEST_SHARDS: "16"
CI_RUST_TEST_PARTITIONS: "4"
AFFECTED_TASK_KEY: ${{ matrix.key }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly
if: ${{ matrix.rust }}
with:
toolchain: nightly-2026-08-12
- uses: taiki-e/install-action@56545b37b57562edd73171cb6c62cc509db4c34e # v2
if: ${{ matrix.nextest }}
with:
tool: nextest@0.9.137
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
if: ${{ matrix.rust }}
with:
shared-key: main-rust-linux-x64
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-workspace-crates: true
- name: Cache bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-1.4.0-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Restore native addon(s) from this run
id: native_restore
if: ${{ matrix.native }}
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: main-native-${{ github.run_id }}
path: packages/natives/native
- name: Rebuild native addon(s) when this run's artifact has expired
if: ${{ matrix.native && steps.native_restore.outcome == 'failure' }}
env:
TARGET_PLATFORM: linux
TARGET_ARCH: x64
TARGET_VARIANTS: baseline modern
run: |
# `main-native-<run_id>` is retained for one day because a normal run
# consumes it inside that same run. Re-running a run older than that
# finds no artifact to download, which is not a code failure: rebuild
# the same variants from the same source rather than failing every
# native shard on an expired artifact (#5715).
echo "::notice::main-native-${{ github.run_id }} is no longer retained; rebuilding the native addon from source for this re-run."
bun run ci:build:native
- name: Rebuild and verify transferred native provenance
if: ${{ matrix.native }}
shell: bash
run: |
set -euo pipefail
bun scripts/verify-diagnostic-artifact-provenance.ts --rebuild-from-sidecars packages/natives/native
bun scripts/verify-diagnostic-artifact-provenance.ts --verify packages/natives/native
- name: Run task shard
env:
GITHUB_ACTIONS: ""
run: bun scripts/ci-dev-affected.ts --task="$AFFECTED_TASK_KEY"
acp_conformance:
if: ${{ always() && !startsWith(github.ref, 'refs/tags/v') && (github.event_name != 'workflow_dispatch' || inputs.rehearsal == 'main-nontag' || inputs.rehearsal == 'nightly-release') && needs.main_plan.outputs.has_tasks == 'true' && needs.main_native.result != 'failure' && needs.main_native.result != 'cancelled' }}
needs: [main_plan, main_native]
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Cache bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-1.4.0-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Restore native addon(s) from this run
id: native_restore
continue-on-error: true
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: main-native-${{ github.run_id }}
path: packages/natives/native
- name: Rebuild native addon(s) when this run's artifact has expired
if: ${{ steps.native_restore.outcome == 'failure' }}
env:
TARGET_PLATFORM: linux
TARGET_ARCH: x64
TARGET_VARIANTS: baseline modern
run: |
# Same recovery as the shard lane: an expired run-scoped artifact on a
# re-run is not a code failure (#5715).
echo "::notice::main-native-${{ github.run_id }} is no longer retained; rebuilding the native addon from source for this re-run."
bun run ci:build:native
- name: Rebuild and verify transferred native provenance
shell: bash
run: |
set -euo pipefail
bun scripts/verify-diagnostic-artifact-provenance.ts --rebuild-from-sidecars packages/natives/native
bun scripts/verify-diagnostic-artifact-provenance.ts --verify packages/natives/native
- name: Run ACP conformance
run: |
# Real (non-symlinked) seeded scratch workspace, matching the promoted baseline:
# the ACP client enforces its session cwd root against the resolved path.
scratch="$(mktemp -d "$RUNNER_TEMP/acp-conformance-XXXXXX")"
scratch="$(cd "$scratch" && pwd -P)"
printf 'acpx conformance workspace\n' > "$scratch/README.md"
bun run conformance:run -- \
--agent-command "bun packages/coding-agent/scripts/acp-conformance-agent.ts" \
--format json \
--report "$RUNNER_TEMP/acp-conformance/report.json" \
--cwd "$scratch"
- name: Upload ACP conformance report
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: acp-conformance-report-${{ github.run_id }}
path: ${{ runner.temp }}/acp-conformance/report.json
if-no-files-found: warn
retention-days: 7
overwrite: true
# Branch protection must keep requiring this stable aggregate status.
test:
if: ${{ always() && !startsWith(github.ref, 'refs/tags/v') && (github.event_name != 'workflow_dispatch' || inputs.rehearsal == 'main-nontag' || inputs.rehearsal == 'nightly-release') }}
needs: [main_plan, main_native, main_shards, acp_conformance]
runs-on: ubuntu-22.04
timeout-minutes: 5
steps:
- name: Fail closed unless every shard succeeded
run: |
plan='${{ needs.main_plan.result }}'
native='${{ needs.main_native.result }}'
shards='${{ needs.main_shards.result }}'
conformance='${{ needs.acp_conformance.result }}'
echo "main_plan=$plan main_native=$native main_shards=$shards acp_conformance=$conformance"
test "$plan" = success
case "$native" in success|skipped) ;; *) echo "native gate failed"; exit 1;; esac
test "$shards" = success
test "$conformance" = success
nightly_gate:
if: ${{ always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'nightly-release')) }}
needs: [check, test]
runs-on: ubuntu-22.04
timeout-minutes: 5
steps:
- name: Require the complete main verification graph
run: |
test '${{ needs.check.result }}' = success
test '${{ needs.test.result }}' = success
# ---------------------------------------------------------------------------
# Stable tags and verified nightly runs build native addons for every published
# platform, then standalone binaries. Rehearsals stop after binary verification;
# nightly publication waits for `nightly_gate` before touching npm or GitHub.
# ---------------------------------------------------------------------------
native:
if: ${{ always() && needs.release_metadata.result == 'success' && (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'tag-build-verify') || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'nightly-release')) }}
needs: [release_metadata]
timeout-minutes: 90
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-22.04, platform: linux, arch: x64, variant: baseline, rust_checks: true }
- { os: ubuntu-22.04, platform: linux, arch: x64, variant: modern }
- { os: ubuntu-22.04, platform: linux, arch: arm64, target: aarch64-unknown-linux-gnu }
- { os: macos-14, platform: darwin, arch: arm64 }
- { os: macos-15-intel, platform: darwin, arch: x64, variant: baseline }
- { os: windows-latest, platform: win32, arch: x64, variant: baseline }
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/build-native
with:
hash: ${{ github.sha }}
platform: ${{ matrix.platform }}
arch: ${{ matrix.arch }}
variant: ${{ matrix.variant }}
target: ${{ matrix.target }}
rust_checks: ${{ matrix.rust_checks && 'true' || 'false' }}
save_cache: "true"
nightly_version: ${{ needs.release_metadata.outputs.nightly_version }}
binaries:
if: ${{ always() && needs.release_metadata.result == 'success' && needs.native.result == 'success' && (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'tag-build-verify') || github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.rehearsal == 'nightly-release')) }}
needs: [native, release_metadata]
timeout-minutes: 60
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-22.04, platform: linux, arch: x64, target_id: linux-x64, binary_path: packages/coding-agent/binaries/gjc-linux-x64 }
- { os: ubuntu-24.04-arm, platform: linux, arch: arm64, target_id: linux-arm64, binary_path: packages/coding-agent/binaries/gjc-linux-arm64 }
- { os: macos-14, platform: darwin, arch: arm64, target_id: darwin-arm64, binary_path: packages/coding-agent/binaries/gjc-darwin-arm64 }
- { os: macos-15-intel, platform: darwin, arch: x64, target_id: darwin-x64, binary_path: packages/coding-agent/binaries/gjc-darwin-x64 }
- { os: windows-latest, platform: win32, arch: x64, target_id: win32-x64, binary_path: packages/coding-agent/binaries/gjc-windows-x64.exe }
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Cache bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-1.4.0-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Stage nightly release version
if: ${{ needs.release_metadata.outputs.nightly_version != '' }}
run: bun scripts/nightly-release.ts stage --version "${{ needs.release_metadata.outputs.nightly_version }}" --source-sha "${{ github.sha }}"
- name: Download native addon(s)
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: pi-natives-${{ matrix.platform }}-${{ matrix.arch }}*
path: packages/natives/native
merge-multiple: true
- name: Rebuild the diagnostic artifact provenance record
# The binaries matrix includes a Windows runner, whose default shell is
# PowerShell; this body is Bash.
shell: bash
run: |
set -euo pipefail
bun scripts/verify-diagnostic-artifact-provenance.ts --rebuild-from-sidecars packages/natives/native
bun scripts/verify-diagnostic-artifact-provenance.ts --verify packages/natives/native
- name: Verify memory-guard native loader export
run: bun test packages/natives/test/memory-guard-native.test.ts
- name: Build release binary
env:
RELEASE_TARGETS: ${{ matrix.target_id }}
run: bun run ci:release:build-binaries
- name: Verify removed PDF references
run: bun scripts/verify-agpl-removal.ts --active-refs
- name: Verify packaged PDF payloads
shell: bash
run: |
set -euo pipefail
package_dir="$RUNNER_TEMP/coding-agent-package"
mkdir -p "$package_dir"
(cd packages/coding-agent && bun pm pack --destination "$package_dir")
# `bun pm pack` on Windows records non-POSIX member modes, which the
# strict release tar reader rejects. The published tarball is packed on
# Linux; for this scan-only copy, repack the identical member bytes with
# uniform POSIX modes via Bun.Archive (no shell tar, no drive-letter paths).
if [ "$RUNNER_OS" = "Windows" ]; then
bun -e 'const [p] = await Array.fromAsync(new Bun.Glob("*.tgz").scan({ cwd: process.argv[1], absolute: true })); const files = await new Bun.Archive(await Bun.file(p).bytes()).files(); await Bun.write(p, await new Bun.Archive(Object.fromEntries(files), { compress: "gzip" }).bytes());' "$package_dir"
fi
bun scripts/verify-agpl-removal.ts --payload --tarball "$package_dir"/*.tgz --binary "${{ matrix.binary_path }}" --addon packages/natives/native/*.node --prose-root .
- name: Smoke memory-guard native route (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$json = & "${{ matrix.binary_path }}" internal memory-guard-native-smoke --json
$data = $json | ConvertFrom-Json
if ($data.api -ne "memory_guard_windows_job_probe_v1") {
throw "unexpected api: $($data.api)"
}
if ($data.source -ne "pi_natives") {
throw "unexpected source: $($data.source)"
}
if ($data.result.platform -ne "win32") {
throw "unexpected platform: $($data.result.platform)"
}
if (@("job_snapshot", "not_in_job", "api_error") -notcontains $data.result.kind) {
throw "unexpected result kind: $($data.result.kind)"
}
- name: Smoke release binary
if: runner.os != 'Windows'
run: |
runtime_dir="$(mktemp -d)"
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" "${{ matrix.binary_path }}" --version
HOME="$runtime_dir/home" XDG_DATA_HOME="$runtime_dir/xdg" "${{ matrix.binary_path }}" --smoke-test
- name: Smoke release binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$runtimeDir = Join-Path $env:TEMP ("gjc-runtime-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null
$env:HOME = Join-Path $runtimeDir "home"
$env:XDG_DATA_HOME = Join-Path $runtimeDir "xdg"
& "${{ matrix.binary_path }}" --version
& "${{ matrix.binary_path }}" --smoke-test
- name: Upload release binary artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: gjc-binary-${{ matrix.target_id }}
path: ${{ matrix.binary_path }}
# ---------------------------------------------------------------------------
# Per-release RSS checkpoint (#5941). Every stable tag and nightly records a
# whole-process-tree RSS checkpoint for the exact linux-x64 release binary, so
# regressions stay bisectable release-to-release. The compare against the
# previous published stable release is ADVISORY (docs/perf-profiling-corpus.md
# threshold promotion stage 1): both binaries are measured in this job on the
# same runner so host noise cancels, and regressions are reported, never
# gating. The job has no write scope; checkpoints ship as a run artifact.
# ---------------------------------------------------------------------------
rss_checkpoint:
if: ${{ always() && needs.release_metadata.result == 'success' && needs.binaries.result == 'success' }}
needs: [release_metadata, binaries]
runs-on: ubuntu-22.04
timeout-minutes: 60
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Cache bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-1.4.0-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Download release binary
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: gjc-binary-linux-x64
path: ${{ runner.temp }}/rss/current
- name: Resolve previous published stable release
id: previous
env:
GH_TOKEN: ${{ github.token }}
CURRENT_TAG: ${{ needs.release_metadata.outputs.tag_name }}
shell: bash
run: |
set -euo pipefail
# Newest published, non-draft, non-prerelease release other than this one.
# Paginated: daily nightly prereleases can push every stable release off
# the first page. The API lists newest first, so the first match wins.
# A missing predecessor is not an error: the checkpoint is still written.
previous="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
--jq ".[] | select(.draft == false and .prerelease == false and .tag_name != \"$CURRENT_TAG\") | .tag_name" \
| sed -n 1p)"
echo "tag=$previous" >> "$GITHUB_OUTPUT"
if [ -n "$previous" ]; then
git fetch --quiet --force origin "refs/tags/$previous:refs/tags/$previous"
echo "commit=$(git rev-parse "$previous^{commit}")" >> "$GITHUB_OUTPUT"
gh release download "$previous" --pattern gjc-linux-x64 --dir "$RUNNER_TEMP/rss/previous"
chmod +x "$RUNNER_TEMP/rss/previous/gjc-linux-x64"
fi
- name: Install GNU time
run: test -x /usr/bin/time || { sudo apt-get update -qq && sudo apt-get install -y -qq time; }
- name: Write RSS checkpoints with advisory compare
env:
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
PREVIOUS_COMMIT: ${{ steps.previous.outputs.commit }}
CURRENT_COMMIT: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
out="$RUNNER_TEMP/rss/checkpoints"
current="$RUNNER_TEMP/rss/current/gjc-linux-x64"
chmod +x "$current"
if [ -z "$PREVIOUS_TAG" ]; then
bun scripts/verify-rss-checkpoints.ts --all --write-baseline --matrix release \
--binary "$current" --commit "$CURRENT_COMMIT" --output-dir "$out"
exit 0
fi
bun scripts/verify-rss-checkpoints.ts --all --write-baseline --matrix "release-previous,$PREVIOUS_TAG" \
--binary "$RUNNER_TEMP/rss/previous/gjc-linux-x64" --commit "$PREVIOUS_COMMIT" --output-dir "$out"
# One measurement of the current binary both writes its checkpoint and
# compares it; --advisory reports regressions without failing the job.
bun scripts/verify-rss-checkpoints.ts --all --write-baseline --compare --advisory --allow-baseline-drift \
--matrix release --baseline "$out/$PREVIOUS_COMMIT.json" \
--binary "$current" --commit "$CURRENT_COMMIT" --output-dir "$out" 2>&1 | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload RSS checkpoints
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: rss-checkpoint-${{ needs.release_metadata.outputs.version }}
path: ${{ runner.temp }}/rss/checkpoints
if-no-files-found: error
retention-days: 90
release_prepare:
if: ${{ always() && needs.native.result == 'success' && needs.binaries.result == 'success' && ((needs.release_metadata.outputs.channel == 'stable' && startsWith(github.ref, 'refs/tags/v') && github.event_name != 'workflow_dispatch') || (needs.release_metadata.outputs.channel == 'nightly' && needs.nightly_gate.result == 'success')) }}
needs: [native, binaries, release_metadata, nightly_gate]
timeout-minutes: 45
runs-on: ubuntu-22.04
permissions:
contents: read
# Release-note attribution enumerates the pull requests behind each
# shipped commit. This job holds no write scope and no OIDC identity
# token: everything untrusted (dependency installs, repository
# scripts, GitHub API reads) happens here, before any credential that
# can publish exists.
pull-requests: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Release notes need the full history and the tag list.
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.0"
- name: Cache bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.bun/install/cache
key: bun-1.4.0-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
- run: bun install --frozen-lockfile
- name: Stage nightly release version
if: ${{ needs.release_metadata.outputs.nightly_version != '' }}
run: bun scripts/nightly-release.ts stage --version "${{ needs.release_metadata.outputs.nightly_version }}" --source-sha "${{ github.sha }}"
- name: Download all native addons
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: pi-natives-*
path: packages/natives/native
merge-multiple: true
- name: Rebuild the diagnostic artifact provenance record
run: |
set -euo pipefail
bun scripts/verify-diagnostic-artifact-provenance.ts --rebuild-from-sidecars packages/natives/native
bun scripts/verify-diagnostic-artifact-provenance.ts --verify packages/natives/native
- name: Prepare immutable package evidence
env:
RELEASE_CHANNEL: ${{ needs.release_metadata.outputs.channel }}
run: |
set -euo pipefail
evidence_dir="$RUNNER_TEMP/release-evidence"
rm -rf "$evidence_dir"
bun scripts/ci-release-publish.ts --prepare-evidence --evidence-dir "$evidence_dir" --release-channel "$RELEASE_CHANNEL"
- name: Persist pre-publication package evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-evidence-${{ needs.release_metadata.outputs.version }}
path: ${{ runner.temp }}/release-evidence
if-no-files-found: error
retention-days: 30
overwrite: true
- name: Reject pre-existing release tag or release
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.release_metadata.outputs.tag_name }}
SOURCE_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
# Reject any pre-existing tag that does not peel to this run's source.
# Lightweight tags report the commit directly; annotated tags require
# the peeled ^{} ref. ls-remote --tags emits both lines; we match the
# peeled line when present and fall back to the direct ref otherwise.
raw_remote="$(git ls-remote --tags origin "refs/tags/$TAG_NAME" "refs/tags/$TAG_NAME^{}")"
if [ -n "$raw_remote" ]; then
peeled="$(printf '%s\n' "$raw_remote" | awk -F '\t' '$2 ~ /\^\{\}$/ { print $1; exit }')"
if [ -z "$peeled" ]; then
peeled="$(printf '%s\n' "$raw_remote" | awk -F '\t' '$2 !~ /\^\{\}$/ { print $1; exit }')"
fi
if [ "$peeled" != "$SOURCE_SHA" ]; then
echo "Release tag $TAG_NAME already exists and peels to ${peeled:-<empty>}, not $SOURCE_SHA; refusing upsert" >&2
exit 1
fi
fi
status="$(curl --silent --show-error --output "$RUNNER_TEMP/release-preflight.json" --write-out '%{http_code}' \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/$TAG_NAME")"
case "$status" in
404) ;;
200) echo "Release $TAG_NAME already exists; refusing upsert" >&2; exit 1 ;;
*) echo "Cannot verify release absence: GitHub API returned HTTP $status" >&2; exit 1 ;;
esac
- name: Derive and validate release notes
# A curated release is assembled by cherry-picking onto main, so
# generate_release_notes has no merged pull requests to describe and
# emits a body containing only a compare link (v0.14.1 shipped that).
# The body is resolved and validated HERE, in the credential-free
# preparation job, before the publish job performs the irreversible
# npm publication: a fetch, API, parse, or generation failure aborts
# the run while nothing immutable has shipped yet.
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.release_metadata.outputs.tag_name }}
SOURCE_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
git fetch --quiet --tags --prune
notes_dir="$RUNNER_TEMP/release-notes"
mkdir -p "$notes_dir"
notes="$notes_dir/release-notes.md"
# Only exact stable vX.Y.Z tags that are ancestors of this release
# head may anchor the range; nightly/RC or unrelated-branch tags
# would derive notes from the wrong base. The loop is pipe-free so
# a no-candidate outcome reaches the documented empty-notes
# fallback instead of aborting under pipefail.
#
# A tag whose GitHub Release was never published is skipped too. A
# stable cut that fails after tagging (v0.15.1 died in this very
# step, before any package reached npm) leaves an immutable tag
# that shipped nothing, and anchoring on it would hide every
# change users never received from the release that finally
# carries them. Only a published, non-draft release counts as
# "already shipped".
previous=""
while IFS= read -r tag; do
[ "$tag" = "$TAG_NAME" ] && continue
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || continue
if ! git merge-base --is-ancestor "$tag" "$SOURCE_SHA"; then
continue
fi
# Fail closed on anything but a definitive "no such release":
# an auth/transport/5xx failure must not silently widen the
# range to an older base.
lookup_error="$notes_dir/release-lookup.err"
release_status=0
release_draft="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq '.draft' 2>"$lookup_error")" || release_status=$?
if [ "$release_status" -eq 0 ]; then
if [ "$release_draft" = "false" ]; then
previous="$tag"
break
fi
echo "Skipping $tag: its GitHub Release is still a draft" >&2
continue
fi
if grep -qiE 'HTTP 404|Not Found' "$lookup_error"; then
echo "Skipping $tag: tagged but never published as a GitHub Release" >&2
continue
fi
echo "Release lookup for $tag failed: $(cat "$lookup_error")" >&2
exit 1
done < <(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname)
if [ -z "$previous" ]; then
echo "No previous stable tag to compare against; leaving notes empty" >&2
: > "$notes"
else
bun scripts/release-notes.ts \
--repo "$GITHUB_REPOSITORY" \
--base "$previous" \
--head "$SOURCE_SHA" \
--head-tag "$TAG_NAME" \
--out "$notes"
# The release cannot be redone once packages are immutable, so
# the persisted body must be deterministic: a second derivation
# has to reproduce it byte-for-byte.
repeat="$notes_dir/release-notes-repeat.md"
bun scripts/release-notes.ts \
--repo "$GITHUB_REPOSITORY" \
--base "$previous" \
--head "$SOURCE_SHA" \
--head-tag "$TAG_NAME" \
--out "$repeat"
cmp --silent "$notes" "$repeat" || { echo "Release notes derivation is not deterministic" >&2; exit 1; }
rm -f "$repeat"
fi
(cd "$notes_dir" && sha256sum release-notes.md > release-notes.sha256)
- name: Persist validated release notes
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-notes-${{ needs.release_metadata.outputs.version }}
path: ${{ runner.temp }}/release-notes
if-no-files-found: error
retention-days: 30
overwrite: true
release_approval:
# Approval gate BEFORE the credential boundary, STABLE releases only:
# scheduled and manual nightlies are the documented unattended lane and
# must never wait on a reviewer. The environment's protection rules
# (required reviewers, restricted deployment refs) are owner-controlled
# repository settings and are NOT evaluated from the tagged revision.
# This job holds no permissions and no OIDC token, so placing the
# environment HERE — not on publish — keeps the publish job's OIDC
# subject ref-scoped (repo:...:ref:refs/tags/v*), the identity the npm
# trusted-publisher registrations and the shipped v0.14.1 exchange
# already prove. Owner prerequisites (documented blocker, verified
# absent at review time): configure the npm-release environment with
# required non-self reviewers and no administrator bypass, restrict its
# deployment refs to the release refs, and protect `main` plus
# `refs/tags/v*` creation with rulesets. Until then this gate is a hook.
# `always() &&` is load-bearing, not decoration: `nightly_gate` is an
# ANCESTOR of release_prepare and is skipped on every stable tag, and
# GitHub propagates a skipped ancestor to each descendant whose condition
# omits always(). Without it this job — and publish and release_finalize
# behind it — skip with zero steps on every stable tag while every build
# is green, which is exactly how v0.15.0 and v0.15.1 tagged and published
# nothing. release_prepare already carries always() for the same reason.
if: ${{ always() && needs.release_prepare.result == 'success' && needs.release_metadata.outputs.channel == 'stable' }}
needs: [release_prepare, release_metadata]
timeout-minutes: 30
runs-on: ubuntu-22.04
permissions: {}
environment: npm-release
steps:
- name: Record release approval
run: echo "Stable release approved for ${{ github.ref }} via the npm-release environment gate"
publish:
# Fixed publisher boundary: the ONLY job holding the OIDC identity
# token, and it holds nothing else — no contents scope, no checkout, no
# bun, no repository code. A malicious tagged revision cannot influence
# what executes here: the steps are pinned actions plus this workflow
# shell, and the payload is the sha512-sealed tarball set produced by
# release_prepare. Publication itself is `npm publish` from the exact,
# integrity-verified npm CLI against the sealed bytes only; the sealed
# sha512 is re-verified at this boundary before every publish, registry
# state is reconciled by integrity, and the per-package receipt feeds
# the finalize job's evidence.
#
# The OIDC subject stays ref-scoped (repo:...:ref:refs/tags/v*) — the
# identity the npm trusted-publisher registrations and the shipped
# v0.14.1 exchange already prove. Owner-controlled release
# prerequisites that cannot be set from this repository (documented
# blocker, verified absent at review time): a ruleset protecting
# `main`, a tag ruleset for `refs/tags/v*` restricting creation to
# maintainers, and the npm-release environment on release_approval
# with required non-self reviewers and no admin bypass.
# always(): see release_approval — the skipped nightly_gate ancestor
# otherwise skips this job on every stable tag. The gate itself is
# unchanged: nightly publishes on channel, stable only behind approval.
if: ${{ always() && needs.release_prepare.result == 'success' && (needs.release_metadata.outputs.channel == 'nightly' || needs.release_approval.result == 'success') }}
needs: [release_prepare, release_approval, release_metadata]
# 13 sequential publishes; each package gets a 10-minute integrity and a
# 5-minute dist-tag window of wall time, but uncached observation normally
# settles in seconds.
# 0.17.4 took 43 minutes and 0.17.5 timed out at 45 before uncached
# observation; 90 keeps a slow-registry day from stranding a half-published
# release without letting a hung publish hold the OIDC token indefinitely.
timeout-minutes: 90
runs-on: ubuntu-22.04
permissions:
# npm trusted publishing (OIDC): npm >= 11.5.1 exchanges this job's
# GitHub OIDC token for a short-lived registry credential, so no
# long-lived NPM_TOKEN is involved and nothing expires under us.
id-token: write
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
# Exact patch pin: this job holds the OIDC identity token, so the
# bootstrap runtime must not float across Node releases.
node-version: "24.19.0"
- name: Pin npm for OIDC trusted publishing
# Trusted publishing requires npm >= 11.5.1; do not rely on whatever
# npm the Node distribution happens to bundle. The pin is exact and
# integrity-verifiable: a mutable range inside this credential-
# bearing job would let a drifting or compromised CLI publish.
env:
NPM_PIN_VERSION: "11.5.1"
# sha512 of https://registry.npmjs.org/npm/-/npm-11.5.1.tgz
# (matches the registry dist.integrity for npm@11.5.1).
NPM_TARBALL_SHA512: "232e6f5d9e799bcb486920b3e9ba907fdf96e576cf7e8c9446c8162e33a416096a1d37a9e910d9a918f6b1f606791c99bc6bb61ee2569b496ec74af13d0dbd95"
shell: bash
run: |
set -euo pipefail
tmp="$(mktemp -d "$RUNNER_TEMP/npm-pin.XXXXXX")"
npm pack "npm@${NPM_PIN_VERSION}" --pack-destination "$tmp"
echo "$NPM_TARBALL_SHA512 $tmp/npm-${NPM_PIN_VERSION}.tgz" | sha512sum --check --strict -
npm install -g "$tmp/npm-${NPM_PIN_VERSION}.tgz"
test "$(npm --version)" = "$NPM_PIN_VERSION"
- name: Download pre-publication package evidence
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: release-evidence-${{ needs.release_metadata.outputs.version }}
path: ${{ runner.temp }}/release-evidence
- name: Publish sealed tarballs to npm
env:
RELEASE_CHANNEL: ${{ needs.release_metadata.outputs.channel }}
RELEASE_VERSION: ${{ needs.release_metadata.outputs.version }}
shell: bash
run: |
set -euo pipefail
evidence_dir="$RUNNER_TEMP/release-evidence"
expected="$evidence_dir/gajae-release-packages-expected-v1.json"
registry="https://registry.npmjs.org/"
case "$RELEASE_CHANNEL" in
stable) npm_tag=latest ;;
nightly) npm_tag=nightly ;;
*) echo "Unknown release channel $RELEASE_CHANNEL" >&2; exit 1 ;;
esac
# Observe the registry through its uncached endpoints. The npm CLI's
# view command reads the full packument, which the registry CDN serves with
# max-age=300, so a fresh publish stayed invisible for up to five
# minutes per package and serialized ~13 packages into ~45 minutes.
# The per-version document and the dist-tags endpoint are served
# uncached (cf-cache-status: DYNAMIC) and reflect the publish at once.
registry_path() { local name="$1"; printf '%s' "${name/\//%2f}"; }
registry_integrity() {
curl --silent --fail --max-time 30 "${registry}$(registry_path "$1")/$2" 2>/dev/null \
| jq -r '.dist.integrity // empty' 2>/dev/null || true
}
registry_dist_tag() {
curl --silent --fail --max-time 30 "${registry}-/package/$(registry_path "$1")/dist-tags" 2>/dev/null \
| jq -r --arg tag "$2" '.[$tag] // empty' 2>/dev/null || true
}
records_dir="$RUNNER_TEMP/receipt-records"
rm -rf "$records_dir" && mkdir -p "$records_dir"
order_file="$evidence_dir/gajae-release-publish-order-v2.json"
# The sealed plan groups packages into dependency waves: nothing in a
# wave depends on anything in the same or a later wave. It must cover
# exactly the expected package set, each package exactly once.
if [ "$(jq -r '.waves[][]' "$order_file" | sort)" != "$(jq -r '.packages[].name' "$expected" | sort)" ]; then
echo "Publish order file does not cover exactly the expected package set" >&2
exit 1
fi
# Publish one sealed package and wait until the registry exposes its
# integrity and dist-tag. Runs in a subshell per package, so `exit`
# fails only that package; the wave barrier below fails the job.
publish_one() {
local name="$1" index="$2" record version sha expected_sri tarball published observed output raced tagged deadline
record="$(jq -c --arg name "$name" '.packages[] | select(.name == $name)' "$expected")"
if [ -z "$record" ]; then
echo "Publish order references unknown package $name" >&2
exit 1
fi
version="$(jq -r '.version' <<<"$record")"
sha="$(jq -r '.tarball_sha512' <<<"$record")"
expected_sri="$(jq -r '.expected_sri' <<<"$record")"
tarball="$evidence_dir/tarballs/$sha.tgz"
# Re-verify the sealed bytes at the credential boundary.
echo "$sha $tarball" | sha512sum --check --strict -
published=false
observed="$(registry_integrity "$name" "$version")"
if [ "$observed" = "$expected_sri" ]; then
echo "Skipping $name@$version (registry already carries the sealed bytes)"
else
if ! output="$(npm publish "$tarball" --access public --registry="$registry" --tag="$npm_tag" 2>&1)"; then
raced="$(registry_integrity "$name" "$version")"
if [ "$raced" != "$expected_sri" ]; then
echo "npm publish failed for $name@$version: $output" >&2
exit 1
fi
echo "Skipping $name@$version (concurrent exact publication)"
else
published=true
echo "Published $name@$version"
fi
# npm is read-after-write eventually consistent: re-observe
# until the sealed integrity is visible. 0.15.4 proved 60s is
# not enough (agent-core became visible only after the window
# elapsed, failing an otherwise clean publish), and v0.18.3
# proved 600s is not enough either: the 110MB win32-x64
# native was published at 09:47:26 but only became visible at
# 09:59:40, blowing the window by 2m11s AFTER five sibling
# packages had already gone immutable, which stranded the
# release. Size this for the slowest plausible replica (large
# Windows natives) rather than the median package, and keep
# the window bounded so a truly stalled registry still fails.
deadline=$((SECONDS + 1800))
while :; do
observed="$(registry_integrity "$name" "$version")"
[ "$observed" = "$expected_sri" ] && break
if [ "$SECONDS" -ge "$deadline" ]; then
echo "$name@$version did not become visible with the sealed integrity" >&2
exit 1
fi
sleep 5
done