-
Notifications
You must be signed in to change notification settings - Fork 0
1017 lines (981 loc) · 72.7 KB
/
Copy pathci.yml
File metadata and controls
1017 lines (981 loc) · 72.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
# One workflow for the whole workspace. Each package carried a near-identical ci.yml (Commitlint, Lint, Typecheck, Test, Smoke test, Release, and for the web UI a Pages deploy); those are consolidated here into one job per task, each running the task across the workspace through turbo rather than once per package. Release is a job in this workflow rather than a separate release.yml so it can keep the `needs: [...]` gate the per-package workflows had: the orchestrator publishes to npm and pushes tags, and gating that on the same run's checks is a direct dependency, not something to re-derive from a workflow_run event (which would also lose the elevated-token checkout the push to a protected main needs).
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
backfill_tags:
description: "Release tags (name@version) to backfill the SBOM/attestation, GitHub Packages mirror, and npm alias republish steps for, without re-running the release itself -- for a run whose post-release jobs never ran (see ExaDev/documents.js#835). Comma- or newline-separated. Leave empty for a normal manual CI run."
required: false
type: string
# A new push to a pull request supersedes that PR's in-flight run. A push to main never cancels: the release job publishes to npm and pushes tags mid-run, and the audit job polls a dispatched run to decide whether to merge its own fix -- cancelling either partway leaves real work half-done. The release and Pages jobs keep their own narrower groups below; this one only stops a PR racing itself.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
# On a pull request, restrict every turbo task to the packages the branch actually changed (and their dependents); on main, run the whole workspace so the caches the next run restores from are complete and the release gate covers everything. --affected compares against the base branch, so the checkouts below use fetch-depth: 0.
TURBO_FLAGS: ${{ github.event_name == 'pull_request' && '--affected' || '' }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
jobs:
commitlint:
name: Commitlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Validate the last commit with commitlint
if: github.event_name == 'push'
run: pnpm exec commitlint --last --verbose
- name: Find the PR commit-lint range start
if: github.event_name == 'pull_request'
id: lint-range
# Every package's history was imported into packages/<name> verbatim rather than rewritten (see the root README on the migration), so it was authored -- and validated by commitlint -- under that package's own, now-superseded standalone config, not this workspace's. fc476c76 is the last of the ten import merges, the commit after which every commit on main is new workspace-native history this repo's own commitlint.config.ts actually governed when it was written. An ordinary PR's base is always a descendant of that commit once the migration itself has landed on main, so this only ever widens the range for the one-time landing PR whose base predates the migration; every later PR computes the identical `from` its base.sha would have given directly.
run: |
MIGRATION_BOUNDARY=9b5cdad3dece5c84a48448eed2b2aafa277dd490
BASE=${{ github.event.pull_request.base.sha }}
if git merge-base --is-ancestor "$MIGRATION_BOUNDARY" "$BASE"; then
echo "from=$BASE" >> "$GITHUB_OUTPUT"
else
echo "from=$MIGRATION_BOUNDARY" >> "$GITHUB_OUTPUT"
fi
- name: Validate every PR commit with commitlint
if: github.event_name == 'pull_request'
run: pnpm exec commitlint --from ${{ steps.lint-range.outputs.from }} --to ${{ github.event.pull_request.head.sha }} --verbose
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: lint
command: pnpm lint $TURBO_FLAGS
typecheck:
name: Typecheck
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: typecheck
# _typecheck:attw (attw --pack per published package, checking each package's declared types resolve under every module resolution mode) is one of the tasks `pnpm typecheck` already runs through turbo, depending on that package's own _build the same way _test:smoke does -- no separate `pnpm build` step or raw shell loop needed to give it a dist/ to inspect. The web UI has no _typecheck:attw script at all, so turbo skips it there: it publishes nothing and exposes no types.
command: pnpm typecheck $TURBO_FLAGS
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: test
command: pnpm test:coverage $TURBO_FLAGS
- uses: actions/upload-artifact@v7
if: always()
with:
name: coverage-report
path: packages/*/coverage/
if-no-files-found: ignore
test-workers:
name: Test (workerd)
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# The install above already built the workerd binary (allowBuilds in pnpm-workspace.yaml). This suite runs the Worker-isomorphic packages' code inside the real Cloudflare Workers runtime, enforcing zero Node-only API usage on the tested paths at runtime rather than only at lint time.
- uses: ./.github/actions/setup-workspace
with:
task: test-workers
command: pnpm test:workers $TURBO_FLAGS
test-smoke:
name: Smoke test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: test-smoke
command: pnpm test:smoke $TURBO_FLAGS
test-e2e:
name: E2E test (web)
# Promoted to a required check (ExaDev/documents.js#1194): the suite has run unattended on every pull request and main push since it landed (ExaDev/documents.js#932), and its only observed red run tracked a regression on that pull request's own branch and cleared when the branch was fixed -- a genuine catch, not flakiness, which is exactly the run history gating merges on it requires. Requiredness itself lives in the repository ruleset ("main required checks"), not in this file; this job always runs on both triggers, so requiring it can never strand a pull request waiting for a check that does not report.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: test-e2e
# --filter=web, not a bare `pnpm exec`: the composite action's own command step runs from the repo root, and playwright's CLI binary is only linked into packages/web's own node_modules/.bin, not the root's -- a root-scoped `pnpm exec playwright` can't find it at all ([ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL] "playwright" not found).
#
# The google-chrome apt source GitHub's ubuntu images preinstall is removed first: playwright's --with-deps runs apt-get update, which fails the whole install whenever dl.google.com serves a Packages index whose hash mismatches the runner's cached InRelease (confirmed twice in a row on main, 2026-09-09: "Err:10 https://dl.google.com/linux/chrome-stable/deb stable/main amd64 Packages / Hash Sum mismatch", apt exit 100). That repo is irrelevant to playwright's own chromium build (it downloads its own; the deps it installs are Ubuntu system libraries), so dropping the source fixes the update without losing anything the browser needs. Both source spellings are removed: the runner image defines the repo in the deb822 .sources format as well as the legacy .list one, and removing only the legacy spelling left apt still fetching (and still failing on) the deb822 copy -- confirmed by a run of this very job with only the .list glob in place.
command: sudo rm -f /etc/apt/sources.list.d/google-chrome*.list /etc/apt/sources.list.d/google-chrome*.sources && pnpm --filter web exec playwright install --with-deps chromium && pnpm test:e2e $TURBO_FLAGS
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
path: packages/web/playwright-report/
if-no-files-found: ignore
knip:
name: Knip
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: knip
# Deliberately no $TURBO_FLAGS. knip resolves the import graph across every package at once to decide what is unreachable, so --affected would hand it a subset of the workspace and it would report every module the omitted packages import as unused. The analysis is whole-workspace or it is wrong.
command: pnpm knip
dependency-versions:
name: Dependency versions
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: dependency-versions
# Not a turbo task: syncpack reads every package.json in the workspace at once (syncpack.config.ts), not one package's own files, so there is nothing for --affected to scope against.
command: pnpm run deps:lint
npm-registration:
name: npm registration
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: npm-registration
# Not a turbo task -- the script does its own scoping directly from GITHUB_BASE_REF (set automatically on a pull_request event, absent on push/workflow_dispatch), checking only the packages a pull request's own diff touches and the whole workspace on a push to main. See the script's own top-of-file comment for why (a required check with no such scoping would let one still-unregistered package block every unrelated pull request) and for the OIDC chicken-and-egg mechanics this guards against.
command: node --experimental-strip-types .github/scripts/check-npm-registration.ts
audit:
name: Audit
runs-on: ubuntu-latest
# Generous because the fix-PR path below polls a dispatched CI run after the audit itself finishes.
timeout-minutes: 45
permissions:
contents: write # to push the fix branch
pull-requests: write # to open and merge the fix PR
actions: write # to dispatch CI on the fix branch
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Audit, applying an aged override where one exists and deferring the rest
uses: ./.github/actions/setup-workspace
with:
task: audit
command: node --experimental-strip-types .github/scripts/audit-autofix.ts
env:
AUDIT_LEVEL: high
# Only ever from main. A pull request run has nothing of its own to fix, and this keeps the fix branch out of branches this workflow does not own. Routing the fix through a PR rather than a direct push is what keeps a bad fix off main: the merge below happens only after a full run on the fix branch has passed.
#
# secrets.GITHUB_TOKEN deliberately, not the release job's GitHub App token. That token exists so the orchestrator can push to a protected main; an audit branch needs no bypass, and handing a maintenance job release credentials widens the blast radius for nothing.
- name: Open a pull request with the fixed overrides
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# The composite action declares no outputs, so the script reports through a file rather than a step output.
if [ "$(cat /tmp/audit-fix-fixed.txt 2>/dev/null)" != "true" ]; then
echo "Audit applied no override; nothing to open a pull request for."
exit 0
fi
BRANCH="audit-autofix/$(date -u +%Y%m%d%H%M%S)"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add pnpm-workspace.yaml pnpm-lock.yaml
git commit -m "fix(deps): apply aged security overrides
$(cat /tmp/audit-fix-commit-body.txt)"
git push origin "$BRANCH"
gh pr create --title "fix(deps): apply aged security overrides" --body-file /tmp/audit-fix-commit-body.txt --base main --head "$BRANCH"
# A push or PR authenticated with GITHUB_TOKEN never triggers another workflow run, so the fix branch's own checks have to be dispatched explicitly -- which is why workflow_dispatch is in this workflow's `on:` block.
gh workflow run ci.yml --ref "$BRANCH"
RUN_ID=""
for _ in 1 2 3 4 5; do
sleep 10
RUN_ID=$(gh run list --workflow=ci.yml --branch "$BRANCH" --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null)
if [ -n "$RUN_ID" ]; then break; fi
done
if [ -z "$RUN_ID" ]; then
echo "::error::Dispatched run for $BRANCH never appeared; the fix pull request is left open for review."
exit 0
fi
echo "Waiting for run $RUN_ID on $BRANCH..."
for i in $(seq 1 60); do
STATUS=$(gh run view "$RUN_ID" --json status,conclusion --jq '.status + ":" + (.conclusion // "pending")' 2>/dev/null || echo "query_failed")
if [ "$STATUS" = "completed:success" ]; then
echo "Green on $BRANCH; rebase-merging the fix pull request."
gh pr merge --rebase "$BRANCH"
exit 0
fi
if [ "$STATUS" = "completed:failure" ] || [ "$STATUS" = "completed:cancelled" ]; then
echo "::error::Run on $BRANCH concluded $STATUS; the fix pull request is left open so a broken fix cannot reach main."
exit 0
fi
echo "Run on $BRANCH still pending ($i/60)..."
sleep 20
done
echo "::error::Timed out waiting for run $RUN_ID on $BRANCH; the fix pull request is left open."
exit 0
release:
name: Release
needs:
[
commitlint,
lint,
typecheck,
test,
test-workers,
test-smoke,
knip,
dependency-versions,
npm-registration,
]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# Two pushes to main close together must never run this job at the same time. The orchestrator's own dependency-bump-and-release cascade pushes to main mid-run, and semantic-release's own stale-checkout guard (comparing this job's local HEAD against a fresh `git ls-remote` of the real repository on every per-package release) will correctly refuse to publish the instant it sees ANY commit land on main that this checkout doesn't have yet -- including a concurrent Release run's own pushes. One run racing another this way is how ooxml.js, pdf-codec, documents.js, document-cli, and document-mcp all silently skipped a release they needed (ExaDev/documents.js#735): every package after the point the two runs' pushes interleaved reported "no release", correctly by that check's own logic, but wrongly for what the cascade needed. `cancel-in-progress: false` queues the second run behind the first rather than cancelling either -- cancelling mid-release would leave whichever packages it had already published without their downstream dependents' bumps.
concurrency:
group: release-workspace
cancel-in-progress: false
runs-on: ubuntu-latest
# @semantic-release/github's success step queries GitHub's GraphQL search API to find every PR/issue
# linked to a released commit, then comments and labels each one -- one round of calls per package released in this run. A release cascade touching several packages at once (e.g. a backlog of merged PRs releasing together) can burn through GitHub's GraphQL rate limit, at which point Octokit's own retry logic waits out the reset window rather than failing -- correct behaviour, but confirmed to take over 20 minutes twice in a row (run 33664031010, both attempts cancelled at almost exactly 30:00, 19:25:34Z "Request quota exhausted for request POST /graphql" through 19:47:50Z's next successful call). 30 minutes has no headroom for that wait; 60 does.
timeout-minutes: 60
permissions:
contents: write # to push release and dependency-bump commits and tags, and create GitHub Releases
issues: write # to comment on released issues
pull-requests: write # to comment on released pull requests
id-token: write # OIDC identity for npm trusted publishing (no NPM_TOKEN)
outputs:
# JSON matrices for the post-release jobs below, listing exactly what this run released. Each is [] when nothing released, which each consuming job tests for in its own `if` -- an empty include list does not skip a matrix job, it makes the matrix invalid and fails the run.
github-packages-matrix: ${{ steps.released.outputs.github-packages-matrix }}
npm-alias-matrix: ${{ steps.released.outputs.npm-alias-matrix }}
attest-matrix: ${{ steps.released.outputs.attest-matrix }}
sea-matrix: ${{ steps.released.outputs.sea-matrix }}
container-matrix: ${{ steps.released.outputs.container-matrix }}
container-image-matrix: ${{ steps.released.outputs.container-image-matrix }}
steps:
# main requires status checks to merge, and the default GITHUB_TOKEN has no bypass for that -- the orchestrator's release commits, dependency-bump commits, and tags are direct pushes to main, so they need a token from an actor the branch ruleset explicitly allows to bypass (see ExaDev/.github and this repo's own ruleset). Everything else in this job (release notes, GitHub Release creation, issue/PR comments, OIDC npm publish) keeps using secrets.GITHUB_TOKEN below, unaffected -- only checkout's own git credentials need the elevated token, since that's what the later pushes inherit.
- name: Generate a token for the release pushes
id: app-token
uses: actions/create-github-app-token@v3
with:
client-id: Iv23liuX19EFyXndAmkL
private-key: ${{ secrets.AUTOMERGE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
# ref: main, not the bare event SHA. The orchestrator pushes each dependency-bump commit to the current branch by name as it goes, so a detached HEAD stops the run with a WorkspaceStateError rather than pushing HEAD:HEAD; naming the branch is what makes checkout attach HEAD to it. fetch-depth: 0 because each package's release range is analysed from its own last matching tag.
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1)
run: npm install -g npm@latest
- name: Record the tags present before the orchestrator runs
# The orchestrator creates each package's `name@version` tag in this same checkout as it releases, so diffing `git tag` across the release step is the exact record of what this run released -- the same source of truth the post-release jobs below consume. Written to RUNNER_TEMP, not the working tree: commitStrategy "single" discovers what it touched via `git status` and requires a clean tree to start from, so a scratch file living inside the checkout would fail that check every run.
run: git ls-remote --tags origin | sed 's|.*refs/tags/||' | grep -v '\^{}' | sort > "$RUNNER_TEMP/release-tags-before.txt"
- name: Release every package that changed, in dependency order
# One orchestrator run replaces the per-package release jobs: it discovers the workspace from pnpm-workspace.yaml, orders packages topologically, and runs semantic-release per package with commits path-filtered to that package's own directory and tags in `name@version` form. A package whose only change is a sibling's release still gets a patch release, because its published dependency range changed. HUSKY=0 so local git hooks never fire against the automated commits.
run: HUSKY=0 pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# npm publish provenance for every package the run publishes, matching each package.json's own publishConfig.provenance.
NPM_CONFIG_PROVENANCE: "true"
# Blanked, not omitted -- an inherited NPM_TOKEN/NODE_AUTH_TOKEN from a workflow-level env block, reusable workflow, or composite action would otherwise be used in preference to the OIDC exchange.
NPM_TOKEN: ""
NODE_AUTH_TOKEN: ""
- name: Collect the packages this run released
id: released
# Builds the matrices the post-release jobs fan out over: one attestation leg per released published package, one GitHub Packages leg per released package the map mirrors there, one npm-alias leg per (released package, alias) pair, one SEA-binary leg per (SEA-eligible released package, platform) pair, one container-image leg per (container-eligible released package, Linux architecture) pair, and one container-manifest leg per container-eligible released package. The version comes from each package's own package.json in this working tree -- the orchestrator has already committed the released version by the time this step runs, so it is the same version the tag names. The web UI is private and publishes nothing, so it is filtered out.
run: |
git ls-remote --tags origin | sed 's|.*refs/tags/||' | grep -v '\^{}' | sort > "$RUNNER_TEMP/release-tags-after.txt"
comm -13 "$RUNNER_TEMP/release-tags-before.txt" "$RUNNER_TEMP/release-tags-after.txt" > "$RUNNER_TEMP/release-tags-new.txt"
GITHUB_PACKAGES='[]'
NPM_ALIASES='[]'
ATTEST='[]'
SEA='[]'
CONTAINER='[]'
CONTAINER_IMAGES='[]'
while read -r TAG; do
[ -n "$TAG" ] || continue
NAME=${TAG%@*}
PKG=packages/$NAME
if [ ! -f "$PKG/package.json" ]; then
echo "::warning::tag $TAG has no package at $PKG; skipping every post-release step for it"
continue
fi
PRIVATE=$(jq -r '.private // false' "$PKG/package.json")
[ "$PRIVATE" = "true" ] && continue
VERSION=$(jq -r '.version' "$PKG/package.json")
ATTEST=$(echo "$ATTEST" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" \
'. + [{name: $name, version: $version, tag: $tag}]')
GHP_NAME=$(jq -r --arg name "$NAME" '.[$name].githubPackages // empty' .github/release-republish.json)
if [ -n "$GHP_NAME" ]; then
GITHUB_PACKAGES=$(echo "$GITHUB_PACKAGES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg scoped "@exadev/$GHP_NAME" \
'. + [{name: $name, version: $version, tag: $tag, scoped: $scoped}]')
fi
while read -r ALIAS; do
[ -n "$ALIAS" ] || continue
NPM_ALIASES=$(echo "$NPM_ALIASES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg alias "$ALIAS" \
'. + [{name: $name, version: $version, tag: $tag, alias: $alias}]')
done < <(jq -r --arg name "$NAME" '.[$name].aliases // [] | .[]' .github/release-republish.json)
# The only three packages this workspace builds a Node SEA (single-executable application) binary for -- see packages/document-cli, document-mcp, document-rest's own tsdown.config.ts. A hardcoded literal set, not a config-file entry like the alias/GitHub-Packages maps above: those vary per package and grow independently, where this is a small, closed set unlikely to grow the same way. Three architecture pairs share an OS (macos-latest/macos-26-intel are both darwin; ubuntu-latest/ubuntu-24.04-arm are both linux; windows-latest/windows-11-arm are both win32) -- all of them standard, non-large-runner images, no extra cost -- so build-sea-binary.ts's own binaryFileName disambiguates every leg by process.arch, not just by OS, for exactly this reason.
case "$NAME" in
document-cli | document-mcp | document-rest)
for OS in ubuntu-latest ubuntu-24.04-arm macos-latest macos-26-intel windows-latest windows-11-arm; do
SEA=$(echo "$SEA" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg os "$OS" \
'. + [{name: $name, version: $version, tag: $tag, os: $os}]')
done
;;
esac
# The two packages this workspace runs as long-lived servers rather than a one-shot CLI/TUI -- document-cli is deliberately excluded, per the root README's Releases section. This needs two shapes: one leg per (package, architecture) for build-container-images (its own per-arch build+push), and one leg per package for publish-container-manifests (which merges both architectures' images into one manifest) -- there's no way to dedupe the former into the latter inside a job's own `fromJSON(...)` matrix expression, so both are built here directly. isLatest is "true" here (a genuine release); collect-backfill-matrix's own copy of this block sets it "false", since a historical catch-up run must never regress the floating `latest` tag to an older version.
case "$NAME" in
document-mcp | document-rest)
IMAGE=""
[ "$NAME" = "document-rest" ] && IMAGE="documents.js"
[ "$NAME" = "document-mcp" ] && IMAGE="document-mcp"
CONTAINER_IMAGES=$(echo "$CONTAINER_IMAGES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg image "$IMAGE" \
'. + [{name: $name, version: $version, tag: $tag, image: $image, isLatest: "true"}]')
for OS in ubuntu-latest ubuntu-24.04-arm; do
case "$OS" in
ubuntu-latest) DOCKER_ARCH=amd64; NODE_ARCH=x64 ;;
ubuntu-24.04-arm) DOCKER_ARCH=arm64; NODE_ARCH=arm64 ;;
esac
CONTAINER=$(echo "$CONTAINER" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg image "$IMAGE" --arg os "$OS" --arg dockerArch "$DOCKER_ARCH" --arg nodeArch "$NODE_ARCH" \
'. + [{name: $name, version: $version, tag: $tag, image: $image, os: $os, dockerArch: $dockerArch, nodeArch: $nodeArch}]')
done
;;
esac
done < "$RUNNER_TEMP/release-tags-new.txt"
{
echo "github-packages-matrix=$GITHUB_PACKAGES"
echo "npm-alias-matrix=$NPM_ALIASES"
echo "attest-matrix=$ATTEST"
echo "sea-matrix=$SEA"
echo "container-matrix=$CONTAINER"
echo "container-image-matrix=$CONTAINER_IMAGES"
} >> "$GITHUB_OUTPUT"
# Everything the release job gates on (commitlint/lint/typecheck/test/test-workers/test-smoke/knip) validates the commit BEFORE release bumps any versions -- never the workspace state AFTER. The orchestrator's own release/dependency-bump commits are pushed with `[skip ci]` (deliberately: a push authenticated this way would otherwise re-trigger this same workflow and risk a concurrent release racing itself, per the release job's own comment above), so nothing else ever runs CI against post-release main either. A package released with a breaking (major) change is therefore never checked against its own workspace-local dependents until whichever of THEIR test suites next happens to run for an unrelated reason -- exactly what caught document-schema.js's 7.0.0 major landing with documents.js's own committed example dumps still pinned to 6.0.0 (ExaDev/documents.js, 2026-09-07): main was red from the moment that release commit landed, silently, until a later PR's own turbo --affected run (which doesn't even touch documents.js on an unrelated branch) happened not to catch it either. This job closes that gap: re-run every check that already exists above, against the real post-release commit on main, so a release that breaks a sibling package inside this same workspace fails LOUDLY on this same workflow run's own summary rather than staying invisible until someone else's unrelated work trips over it. It cannot un-publish a release that already went out -- nothing running after the fact can -- but a red run here is exactly the loud, immediate signal that should prompt a same-day follow-up fix, the way this file's own commit history already shows one landing for the 7.0.0 case.
verify-release:
name: Verify release (${{ matrix.task }})
needs: [release]
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: pnpm lint
- task: typecheck
command: pnpm typecheck
- task: test
command: pnpm test:coverage
- task: test-workers
command: pnpm test:workers
- task: test-smoke
command: pnpm test:smoke
- task: knip
command: pnpm knip
steps:
- uses: actions/checkout@v7
with:
# ref: main, not the bare event SHA -- the release job's own version-bump and dependency-bump commits landed on main AFTER the commit that triggered this workflow run, and validating that new state is the entire point of this job.
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: verify-release-${{ matrix.task }}
# No $TURBO_FLAGS: this job only ever runs from a push-to-main trigger (inherited via `needs: [release]`, itself gated the same way), where that variable is already empty -- the whole point here is checking the WHOLE workspace's post-release state, the identical scope its non-verify counterpart above already runs with on main.
command: ${{ matrix.command }}
# Backfills the three post-release jobs below for releases whose own run never reached the "Collect the packages this run released" step -- e.g. a release job cancelled by its own timeout after the per-package publish work had already completed (ExaDev/documents.js#835). A plain re-run can't recover this: that step diffs tags against a before/after snapshot taken within the same job run, so a later run sees every already-existing tag as pre-existing, not new. This job instead builds the identical matrices directly from an explicit tag list, without re-running the release itself.
collect-backfill-matrix:
name: Collect backfill matrix
if: github.event_name == 'workflow_dispatch' && inputs.backfill_tags != ''
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
github-packages-matrix: ${{ steps.collect.outputs.github-packages-matrix }}
npm-alias-matrix: ${{ steps.collect.outputs.npm-alias-matrix }}
attest-matrix: ${{ steps.collect.outputs.attest-matrix }}
sea-matrix: ${{ steps.collect.outputs.sea-matrix }}
container-matrix: ${{ steps.collect.outputs.container-matrix }}
container-image-matrix: ${{ steps.collect.outputs.container-image-matrix }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Build the post-release matrices from the given tags
id: collect
# Mirrors the release job's own "Collect the packages this run released" step, but sources name/version from each tag directly (tags are always `name@version`) instead of the current checkout's package.json -- the checkout here is just main at whatever commit is current, not necessarily the release commit each historical tag was cut from.
env:
# Passed through the environment rather than interpolated directly into the script below, so a tag list containing shell metacharacters is just data, never executed.
BACKFILL_TAGS: ${{ inputs.backfill_tags }}
run: |
GITHUB_PACKAGES='[]'
NPM_ALIASES='[]'
ATTEST='[]'
SEA='[]'
CONTAINER='[]'
CONTAINER_IMAGES='[]'
# Process substitution, not a pipe: a pipe would run the loop in a subshell, where GITHUB_PACKAGES/NPM_ALIASES/ATTEST/SEA/CONTAINER/CONTAINER_IMAGES's accumulated values never escape back to the `>> "$GITHUB_OUTPUT"` below.
while IFS= read -r RAW; do
TAG=$(echo "$RAW" | xargs)
[ -n "$TAG" ] || continue
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "::error::tag $TAG does not exist in this repository; aborting backfill"
exit 1
fi
NAME=${TAG%@*}
VERSION=${TAG##*@}
PKG=packages/$NAME
if ! git cat-file -e "$TAG:$PKG/package.json" 2>/dev/null; then
echo "::warning::tag $TAG has no package at $PKG; skipping every post-release step for it"
continue
fi
PRIVATE=$(git show "$TAG:$PKG/package.json" | jq -r '.private // false')
[ "$PRIVATE" = "true" ] && continue
ATTEST=$(echo "$ATTEST" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" \
'. + [{name: $name, version: $version, tag: $tag}]')
GHP_NAME=$(jq -r --arg name "$NAME" '.[$name].githubPackages // empty' .github/release-republish.json)
if [ -n "$GHP_NAME" ]; then
GITHUB_PACKAGES=$(echo "$GITHUB_PACKAGES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg scoped "@exadev/$GHP_NAME" \
'. + [{name: $name, version: $version, tag: $tag, scoped: $scoped}]')
fi
while read -r ALIAS; do
[ -n "$ALIAS" ] || continue
NPM_ALIASES=$(echo "$NPM_ALIASES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg alias "$ALIAS" \
'. + [{name: $name, version: $version, tag: $tag, alias: $alias}]')
done < <(jq -r --arg name "$NAME" '.[$name].aliases // [] | .[]' .github/release-republish.json)
# Mirrors the release job's own identical case statement -- see that step's own comment on why this is a hardcoded literal set rather than a release-republish.json entry, and on why three of the six legs share an OS with another leg.
case "$NAME" in
document-cli | document-mcp | document-rest)
for OS in ubuntu-latest ubuntu-24.04-arm macos-latest macos-26-intel windows-latest windows-11-arm; do
SEA=$(echo "$SEA" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg os "$OS" \
'. + [{name: $name, version: $version, tag: $tag, os: $os}]')
done
;;
esac
# Mirrors the release job's own identical container-matrix case statement, with one deliberate difference: isLatest is "false" here, never "true" -- a backfill exists specifically to catch up a possibly-old, already-superseded tag after the fact, so floating the registry's `latest` tag onto it would regress `latest` to an older version if newer releases have shipped since.
case "$NAME" in
document-mcp | document-rest)
IMAGE=""
[ "$NAME" = "document-rest" ] && IMAGE="documents.js"
[ "$NAME" = "document-mcp" ] && IMAGE="document-mcp"
CONTAINER_IMAGES=$(echo "$CONTAINER_IMAGES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg image "$IMAGE" \
'. + [{name: $name, version: $version, tag: $tag, image: $image, isLatest: "false"}]')
for OS in ubuntu-latest ubuntu-24.04-arm; do
case "$OS" in
ubuntu-latest) DOCKER_ARCH=amd64; NODE_ARCH=x64 ;;
ubuntu-24.04-arm) DOCKER_ARCH=arm64; NODE_ARCH=arm64 ;;
esac
CONTAINER=$(echo "$CONTAINER" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg image "$IMAGE" --arg os "$OS" --arg dockerArch "$DOCKER_ARCH" --arg nodeArch "$NODE_ARCH" \
'. + [{name: $name, version: $version, tag: $tag, image: $image, os: $os, dockerArch: $dockerArch, nodeArch: $nodeArch}]')
done
;;
esac
# printf '%s\n', not '%s': `tr` never adds a trailing newline of its own, and `read` silently drops the last line of a while-read loop when it hits EOF without one -- confirmed the hard way, the final tag in a comma-separated backfill_tags list was dropped from every matrix with no warning or error. The trailing \n here survives the comma-to-newline translation as the terminator the last tag needs.
done < <(printf '%s\n' "$BACKFILL_TAGS" | tr ',' '\n')
{
echo "github-packages-matrix=$GITHUB_PACKAGES"
echo "npm-alias-matrix=$NPM_ALIASES"
echo "sea-matrix=$SEA"
echo "attest-matrix=$ATTEST"
echo "container-matrix=$CONTAINER"
echo "container-image-matrix=$CONTAINER_IMAGES"
} >> "$GITHUB_OUTPUT"
# The three jobs below restore what the monorepo migration lost (ExaDev/documents.js#732): each standalone repository's own pipeline republished its package under alternate npm names and a @exadev/<name> scope on GitHub Packages, and signed an SPDX SBOM plus build-provenance attestation against the packed release tarball. They run after the release job (or, for a backfill, the collect-backfill-matrix job above) keyed off whichever of the two produced the matrices, and nothing depends on them -- a failure here can never block or fail the release itself or the Pages deploy.
#
# Each of the three reads directly from `needs.release`/`needs.collect-backfill-matrix` (never through an intermediate job) and gates with its own `always() && (...)` -- deliberately, after finding a real GitHub Actions platform quirk the hard way: an earlier design funnelled both through one intermediate `resolve-post-release-matrices` job so this `always()`-plus-mixed-`needs` gating logic (and the JSON matrix fallback) lived in one place instead of three. That job's own step correctly resolved and wrote the right matrices (confirmed directly via a `cat -A "$GITHUB_OUTPUT"` dump inside the step), but a *separate* downstream job reading `needs.resolve-post-release-matrices.outputs.*` always saw `'[]'`, regardless. The one thing unique to that intermediate job was combining `always()` with a `needs` array where one member is always skipped -- and that combination is exactly what breaks a job's own advertised `outputs` for whatever depends on *it* (its own step outputs and its own reads of *its* needs' outputs are unaffected, which is why the in-job debug dump looked correct). Reading `release`/`collect-backfill-matrix` directly here sidesteps it, since those two jobs never use `always()` themselves and nothing downstream needs outputs *from* a job that does.
notify-hive:
name: Notify novus-power/hive
needs: [release, collect-backfill-matrix]
# Same dual-path gating as the three jobs above, and for the identical reason -- see their shared comment. This job isn't restoring anything the monorepo migration lost, though: it's telling novus-power/hive, an external consumer with its own dependency on documents.js, that a release just published, the moment it does, rather than leaving it to Dependabot's weekly scan. hive's own instant-update receiver filters the dispatched matrix down to packages it actually depends on, so this fires the full matrix unfiltered rather than hardcoding which package(s) hive currently uses -- hive picking up a dependency on another family package later needs no change here.
if: always() && (needs.release.result == 'success' || needs.collect-backfill-matrix.result == 'success') && (needs.release.outputs.attest-matrix || needs.collect-backfill-matrix.outputs.attest-matrix) != '[]'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
# ExaSpot, unlike the org-wide 'exadev' App the release job uses, is installed specifically on novus-power/hive (a different org from ExaDev) rather than org-wide -- owner has to be given explicitly since the App's own installation context otherwise defaults to this workflow's own repository owner (ExaDev), not novus-power.
- name: Generate a token for cross-org dispatch to novus-power/hive
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: "4816274"
private-key: ${{ secrets.HIVE_DISPATCH_APP_PRIVATE_KEY }}
owner: novus-power
repositories: |
hive
- name: Dispatch a sibling-released event per released package
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ATTEST_MATRIX: ${{ needs.release.outputs.attest-matrix || needs.collect-backfill-matrix.outputs.attest-matrix }}
run: |
echo "$ATTEST_MATRIX" | jq -c '.[]' | while read -r pkg; do
name=$(echo "$pkg" | jq -r .name)
version=$(echo "$pkg" | jq -r .version)
gh api "repos/novus-power/hive/dispatches" \
-f event_type=sibling-released \
-F "client_payload[package]=$name" \
-F "client_payload[version]=$version"
done
publish-github-packages:
name: Mirror to GitHub Packages (${{ matrix.scoped }})
needs: [release, collect-backfill-matrix]
# Exactly one of the two ever runs for a given trigger (release only on a push to main, collect-backfill-matrix only on a workflow_dispatch carrying backfill_tags) -- always() waits on both regardless, and the matrix-emptiness check takes whichever produced output (the other's is the empty string when its job is skipped) and is load-bearing: `matrix: include: []` is not an empty matrix that skips the job, it is an invalid matrix, and the run concludes failed with no failed job to point at.
if: always() && (needs.release.result == 'success' || needs.collect-backfill-matrix.result == 'success') && (needs.release.outputs.github-packages-matrix || needs.collect-backfill-matrix.outputs.github-packages-matrix) != '[]'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: write
# Deliberately no id-token: write here -- see publish-npm-aliases' own comment for why this job is kept separate rather than folded into that matrix: GitHub Packages has no OIDC trusted-publishing exchange, and holding id-token: write makes pnpm attempt (and fail) that exchange anyway, which then breaks its fallback to the GITHUB_TOKEN this job actually authenticates with.
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.release.outputs.github-packages-matrix || needs.collect-backfill-matrix.outputs.github-packages-matrix) }}
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
# The tag, not main: a queued release run could have pushed further commits and tags to main between this run's release job finishing and this job starting, and each tag points at the exact release commit whose packages/<name> is the released state.
ref: ${{ matrix.tag }}
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
cache: pnpm
# Deliberately NOT setup-node's own registry-url/scope inputs (see ExaDev/documents.js#309): those write an @exadev:registry=https://npm.pkg.github.com/ *install-time* scope-to-registry mapping into .npmrc, and per setup-node's own docs an empty/omitted `scope` still falls back to the repository owner (ExaDev) when the target is GitHub Packages -- there is no way to get just the auth-token line out of that mechanism without also redirecting every @exadev-scoped *install* through it, which breaks `pnpm install` below: @exadev/eslint-config is a real devDependency published only to the default registry, never mirrored to GitHub Packages.
- run: pnpm install --frozen-lockfile
- name: Configure the GitHub Packages auth token for view and publish only
# Added after install so install never sees any @exadev scope mapping at all; the host-scoped credential alone determines auth, and publishConfig.registry (set explicitly below) determines the publish target.
run: echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
- name: Check whether this version is already mirrored
id: mirrored
# Re-running a completed release run re-runs these jobs for the same tags; republishing an existing version is rejected by GitHub Packages, so an already-present mirror is a skip, not a failure.
run: |
if npm view "${{ matrix.scoped }}@${{ matrix.version }}" version --registry https://npm.pkg.github.com 2>/dev/null | grep -qx "${{ matrix.version }}"; then
echo "already=true" >> "$GITHUB_OUTPUT"
echo "::notice::${{ matrix.scoped }}@${{ matrix.version }} is already mirrored on GitHub Packages; nothing to do."
fi
- name: Build the released package
if: steps.mirrored.outputs.already != 'true'
run: pnpm --dir "packages/${{ matrix.name }}" build
- name: Rewrite package name and registry for the GitHub Packages scope
# GitHub Packages requires the npm package name to be scoped to the repo owner. Rewriting the fields rather than keeping a second package.json means this mirror cannot drift away from the real package's metadata. publishConfig.registry has to be overridden too: it takes precedence over any registry the .npmrc sets, so without this the publish silently targets registry.npmjs.org instead -- confirmed failure mode in the standalone repositories this restores, not a hypothetical (404 on the npmjs.org registry, since GITHUB_TOKEN isn't a credential it recognises).
if: steps.mirrored.outputs.already != 'true'
working-directory: packages/${{ matrix.name }}
run: |
npm pkg set name="${{ matrix.scoped }}"
npm pkg set publishConfig.registry="https://npm.pkg.github.com"
- name: Publish the mirror
# A legacy mirror package's "Manage Actions access" list on github.com still names only the archived standalone repository that created it, so this repository's GITHUB_TOKEN is denied write_package (403 "Permission permission_denied: write_package") until a user adds ExaDev/documents.js to that package's access list once. That exact signature is the skip-with-instructions case below; every other failure is real and fails the leg.
if: steps.mirrored.outputs.already != 'true'
working-directory: packages/${{ matrix.name }}
run: |
set +e
OUTPUT=$(pnpm publish --access public --no-git-checks 2>&1)
STATUS=$?
set -e
echo "$OUTPUT"
[ "$STATUS" -eq 0 ] && exit 0
if printf '%s' "$OUTPUT" | grep -q 'permission_denied: write_package'; then
echo "::notice::Skipped ${{ matrix.scoped }}@${{ matrix.version }}: this repository is not on that GitHub package's Actions access list yet. Grant it once (a user with package admin): github.com -> Organization ExaDev -> Packages -> ${{ matrix.scoped }} -> Package settings -> Manage Actions access -> add Repository \"documents.js\" with Role \"Write\". The first release after that mirrors automatically; see the Releases section of the root README."
exit 0
fi
exit "$STATUS"
publish-npm-aliases:
name: Publish npm alias (${{ matrix.alias }})
needs: [release, collect-backfill-matrix]
# See the note on publish-github-packages: an empty include list is an invalid matrix, not a skip, and the always()-plus-direct-needs-read shape is deliberate, not incidental.
if: always() && (needs.release.result == 'success' || needs.collect-backfill-matrix.result == 'success') && (needs.release.outputs.npm-alias-matrix || needs.collect-backfill-matrix.outputs.npm-alias-matrix) != '[]'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write # OIDC identity for npm trusted publishing -- every leg in this job targets npmjs.org. The GitHub Packages mirror is a separate job above specifically so it never holds this permission: pnpm attempts an OIDC exchange whenever id-token: write is available regardless of target registry, and GitHub Packages has no such exchange to attempt -- confirmed the hard way in a sibling repo when this leg used to share that job and failed with "401 Unauthorized - authentication token not provided" even with GITHUB_TOKEN correctly set, because the failed OIDC attempt never fell through to it.
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.release.outputs.npm-alias-matrix || needs.collect-backfill-matrix.outputs.npm-alias-matrix) }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ matrix.tag }} # the release commit the orchestrator tagged, not whatever main has moved on to by now
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
cache: pnpm
# registry-url is deliberately absent. Setting it makes setup-node write an .npmrc containing an _authToken line, and that line wins over the OIDC exchange -- so the setting that looks like it configures the registry is exactly the one that would break trusted publishing.
- run: pnpm install --frozen-lockfile
- name: Check whether this alias version is already published
id: published
# Re-run idempotency, matching publish-github-packages above: republishing an existing version is rejected by npm, so an already-present version is a skip.
run: |
if npm view "${{ matrix.alias }}@${{ matrix.version }}" version 2>/dev/null | grep -qx "${{ matrix.version }}"; then
echo "already=true" >> "$GITHUB_OUTPUT"
echo "::notice::${{ matrix.alias }}@${{ matrix.version }} is already published; nothing to do."
fi
- name: Build the released package
if: steps.published.outputs.already != 'true'
run: pnpm --dir "packages/${{ matrix.name }}" build
- name: Rewrite package name for this alias
# Rewriting the checked-out package.json rather than keeping a package.json per alias means no alias can drift away from the real package's metadata -- each matrix leg gets its own fresh runner and checkout, so there is no cross-contamination between legs.
if: steps.published.outputs.already != 'true'
working-directory: packages/${{ matrix.name }}
run: npm pkg set name="${{ matrix.alias }}"
- name: Publish the alias
# Trusted publishing is registered per package name, so each alias needs its own one-time registration on npmjs.com against this repository and workflow file. Until that registration exists the publish fails two ways depending on where pnpm gives up: the OIDC exchange itself 404s (`oidc/token` or pnpm's `ERR_PNPM_AUTH_TOKEN_EXCHANGE` in the output), or the exchange is skipped and the bare PUT 404s on the unregistered name (`404 Not Found - PUT ... registry.npmjs.org/<alias>`). Both exact signatures are the fail-with-instructions case below (a deliberate signal, not a skip: the registration is a one-time user action, and a silent skip let every alias stay stranded from the monorepo migration until a manual audit found them -- a red leg names the missing registration the day a release runs); every other failure is real and fails the leg too.
if: steps.published.outputs.already != 'true'
working-directory: packages/${{ matrix.name }}
run: |
set +e
OUTPUT=$(pnpm publish --access public --no-git-checks 2>&1)
STATUS=$?
set -e
echo "$OUTPUT"
[ "$STATUS" -eq 0 ] && exit 0
if printf '%s' "$OUTPUT" | grep -qE 'oidc/token|ERR_PNPM_AUTH_TOKEN_EXCHANGE|404 Not Found - PUT .*registry\.npmjs\.org'; then
echo "::error::Failed ${{ matrix.alias }}@${{ matrix.version }}: no npm trusted publisher is registered for that package name against this repository and workflow yet. Register it once (a user with access to the package's npm settings): npmjs.com -> Packages -> ${{ matrix.alias }} -> Settings -> Trusted publisher -> GitHub Actions, with Organization \"ExaDev\", Repository \"documents.js\", Workflow filename \"ci.yml\", environment left empty, allowed action \"npm publish\". The first release after that publishes under the alias automatically; see the Releases section of the root README."
exit 1
fi
exit "$STATUS"
env:
# Blanked, not omitted -- an inherited NODE_AUTH_TOKEN would otherwise be used in preference to the OIDC exchange, matching the release job's own convention above.
NODE_AUTH_TOKEN: ""
attest-release-artifacts:
name: Attest SBOM and build provenance (${{ matrix.name }})
needs: [release, collect-backfill-matrix]
# See the note on publish-github-packages: an empty include list is an invalid matrix, not a skip, and the always()-plus-direct-needs-read shape is deliberate, not incidental.
if: always() && (needs.release.result == 'success' || needs.collect-backfill-matrix.result == 'success') && (needs.release.outputs.attest-matrix || needs.collect-backfill-matrix.outputs.attest-matrix) != '[]'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write # to attach the SBOM to the package's GitHub Release
id-token: write # signing identity for the attestations
attestations: write # to store the signed attestations against this repository
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.release.outputs.attest-matrix || needs.collect-backfill-matrix.outputs.attest-matrix) }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ matrix.tag }} # the release commit the orchestrator tagged, not whatever main has moved on to by now
fetch-depth: 0
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build the released package
run: pnpm --dir "packages/${{ matrix.name }}" build
# Pack into a directory of its own, separate from dist/ (tsdown's raw build output). The attestation subject has to be the artefact that actually ships -- attesting dist/ itself would mix in files that never leave the repo, producing digests that match nothing a consumer can download.
- name: Pack the release tarball
working-directory: packages/${{ matrix.name }}
run: pnpm pack --pack-destination release-artifact
- name: Generate the SPDX SBOM
working-directory: packages/${{ matrix.name }}
run: pnpm sbom --sbom-format spdx --prod > release-artifact/sbom.spdx.json
# Two steps (sbom-path present vs. absent) rather than one, since actions/attest's docs don't clearly state whether passing both together attests SBOM and provenance in a single call or SBOM only -- two steps is the unambiguous form.
- name: Attest SBOM
uses: actions/attest@v4
with:
subject-path: packages/${{ matrix.name }}/release-artifact/*.tgz
sbom-path: packages/${{ matrix.name }}/release-artifact/sbom.spdx.json
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-path: packages/${{ matrix.name }}/release-artifact/*.tgz
- name: Attach the SBOM to the GitHub Release
# The signed attestations live in the repository's attestation store, queryable by tarball digest; the raw SBOM additionally rides on the release itself so it is downloadable without tooling. --clobber so a re-run replaces rather than duplicates the asset.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ matrix.tag }}" --clobber "packages/${{ matrix.name }}/release-artifact/sbom.spdx.json"
# Surfaces the release on the org's own linked-artifacts page (Packages tab), pointing at the real npm registry location rather than only this repository's own GitHub Release assets. GITHUB_TOKEN cannot call this API: it needs the org-level "Artifact metadata" GitHub App permission, which the default Actions token has no equivalent for, so this mints a token from the documents-js app the release job already uses for its own elevated pushes.
- name: Generate a token for artifact metadata
id: artifact-metadata-token
uses: actions/create-github-app-token@v3
with:
client-id: Iv23liuX19EFyXndAmkL
private-key: ${{ secrets.AUTOMERGE_APP_PRIVATE_KEY }}
# Scoped down to only what this step needs, rather than inheriting every permission the documents-js app carries (contents/workflows/pull-requests/etc, needed by the release job's own use of the same app) -- least-privilege for a token that only ever calls one org-level endpoint.
permission-artifact-metadata: write
- name: Record the npm publish as a linked artifact
# The digest is the same tarball actions/attest signed above, hashed the same way (sha256 of the exact bytes at subject-path) -- this endpoint's own description says it attaches the record "on behalf of any artifact matching the provided digest and associated with a repository owned by the organization", which is the just-created build-provenance attestation for this tarball. github_repository is deliberately omitted: the API prefers the repository the provenance attestation itself names over this parameter.
env:
GH_TOKEN: ${{ steps.artifact-metadata-token.outputs.token }}
working-directory: packages/${{ matrix.name }}
run: |
DIGEST="sha256:$(sha256sum release-artifact/*.tgz | cut -d' ' -f1)"
gh api "orgs/${{ github.repository_owner }}/artifacts/metadata/storage-record" \
--method POST \
-f "name=${{ matrix.name }}" \
-f "version=${{ matrix.version }}" \
-f "digest=$DIGEST" \
-f "registry_url=https://registry.npmjs.org/" \
-f "repository=${{ matrix.name }}" \
-f "artifact_url=https://registry.npmjs.org/${{ matrix.name }}/-/${{ matrix.name }}-${{ matrix.version }}.tgz"
build-sea-binaries:
name: Build SEA binary (${{ matrix.name }}, ${{ matrix.os }})
needs: [release, collect-backfill-matrix]
# See the note on publish-github-packages: an empty include list is an invalid matrix, not a skip, and the always()-plus-direct-needs-read shape is deliberate, not incidental.
if: always() && (needs.release.result == 'success' || needs.collect-backfill-matrix.result == 'success') && (needs.release.outputs.sea-matrix || needs.collect-backfill-matrix.outputs.sea-matrix) != '[]'
runs-on: ${{ matrix.os }}
timeout-minutes: 20
permissions:
contents: write # to upload the binary as a release asset
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.release.outputs.sea-matrix || needs.collect-backfill-matrix.outputs.sea-matrix) }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ matrix.tag }} # the release commit the orchestrator tagged, not whatever main has moved on to by now
fetch-depth: 0
# build-sea-binary.ts and its shared tsdown config are workspace-level CI tooling, not part of any package's own versioned release -- unlike a package's own src/, they have no reason to stay frozen at whatever they looked like the moment a tag was cut. Checking out the tag alone would mean a script bug fixed on main today stays permanently unfixable for every already-tagged release, including a later backfill run against that same tag, since a backfill checks out the identical ref. Overlaying main's own current copies here is exactly what caught and needed fixing the first time this job ran for real (ExaDev/documents.js#1230, 2026-09-11): node --build-sea's own stdout corrupted this job's GITHUB_OUTPUT write, fixed on main, but every already-tagged release would still replay the old, broken script without this.
- name: Use the latest SEA build tooling rather than this tag's own frozen copy
run: git checkout origin/main -- .github/scripts/build-sea-binary.ts tsdown.sea.shared.ts
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .tool-versions
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build the released package's SEA bundle
run: pnpm --dir "packages/${{ matrix.name }}" build
# The workspace's own pinned Node (.tool-versions) predates the native ESM SEA mainFormat support document-cli's Ink-driven ESM bundle needs (see tsdown.sea.shared.ts and build-sea-binary.ts's own mainFormatFor), and a Homebrew-style distribution of even a new-enough Node can ship with the SEA feature itself compiled out. This second install, immediately before the injection step, targets one specific version confirmed locally to have SEA enabled, rather than "latest", so a future Node release can't silently break this step.
- name: Install a Node build with SEA and native ESM mainFormat support
uses: actions/setup-node@v7
with:
node-version: "26.8.1"
- name: Build the SEA binary
id: build
shell: bash
working-directory: packages/${{ matrix.name }}
run: |
BINARY_PATH=$(node --experimental-strip-types ../../.github/scripts/build-sea-binary.ts . dist-sea "${{ matrix.name }}")
echo "binary-path=packages/${{ matrix.name }}/$BINARY_PATH" >> "$GITHUB_OUTPUT"
- name: Upload the binary as a release asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ matrix.tag }}" --clobber "${{ steps.build.outputs.binary-path }}"
build-container-images:
name: Build container image (${{ matrix.image }}, ${{ matrix.dockerArch }})
needs: [release, collect-backfill-matrix, build-sea-binaries]
# build-sea-binaries' own result is checked for "attempted", not "success": its own matrix spans every SEA-eligible package and OS at once (including document-cli and every non-Linux leg), so an unrelated leg failing there must never block this job -- the gh release download step below fails loudly and specifically instead if the one Linux asset this leg actually needs wasn't uploaded.
if: always() && (needs.release.result == 'success' || needs.collect-backfill-matrix.result == 'success') && (needs.release.outputs.container-matrix || needs.collect-backfill-matrix.outputs.container-matrix) != '[]' && (needs.build-sea-binaries.result == 'success' || needs.build-sea-binaries.result == 'failure')
runs-on: ${{ matrix.os }}
timeout-minutes: 20
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.release.outputs.container-matrix || needs.collect-backfill-matrix.outputs.container-matrix) }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ matrix.tag }} # the release commit the orchestrator tagged, not whatever main has moved on to by now
fetch-depth: 0
- name: Download this leg's Linux SEA binary from the release
# Reuses the binary build-sea-binaries already built and uploaded for this exact (package, arch) pair rather than rebuilding it a second time here.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p "packages/${{ matrix.name }}/dist-sea"
gh release download "${{ matrix.tag }}" \
--pattern "${{ matrix.name }}-linux-${{ matrix.nodeArch }}" \
--dir "packages/${{ matrix.name }}/dist-sea" \
--clobber
mv "packages/${{ matrix.name }}/dist-sea/${{ matrix.name }}-linux-${{ matrix.nodeArch }}" \
"packages/${{ matrix.name }}/dist-sea/${{ matrix.name }}"
# The distroless base image this package's own Dockerfile builds FROM has no shell to chmod this with, so the execute bit has to already be set on disk before `docker build` runs -- COPY preserves it.
chmod +x "packages/${{ matrix.name }}/dist-sea/${{ matrix.name }}"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push this architecture's image
uses: docker/build-push-action@v6
with:
context: packages/${{ matrix.name }}
platforms: linux/${{ matrix.dockerArch }}
push: true
tags: ghcr.io/exadev/${{ matrix.image }}:${{ matrix.version }}-${{ matrix.dockerArch }}
publish-container-manifests:
name: Publish container manifest (${{ matrix.image }} ${{ matrix.version }})
needs: [release, collect-backfill-matrix, build-container-images]
# build-container-images' own result is likewise checked for "attempted": its own matrix can span both document-rest and document-mcp at once, so one package's arch leg failing must never block merging the other package's manifest -- imagetools create below fails specifically if either of *this* image's two per-arch tags doesn't actually exist.
if: always() && (needs.release.result == 'success' || needs.collect-backfill-matrix.result == 'success') && (needs.release.outputs.container-image-matrix || needs.collect-backfill-matrix.outputs.container-image-matrix) != '[]' && (needs.build-container-images.result == 'success' || needs.build-container-images.result == 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.release.outputs.container-image-matrix || needs.collect-backfill-matrix.outputs.container-image-matrix) }}
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Merge the per-architecture images into one multi-arch manifest
run: |
docker buildx imagetools create \
-t "ghcr.io/exadev/${{ matrix.image }}:${{ matrix.version }}" \
"ghcr.io/exadev/${{ matrix.image }}:${{ matrix.version }}-amd64" \
"ghcr.io/exadev/${{ matrix.image }}:${{ matrix.version }}-arm64"
- name: Float latest onto this manifest (never for a backfill run)
# A backfill run sets isLatest "false" specifically because it exists to catch up an old, already-superseded tag -- floating `latest` onto it would regress the tag if newer releases have shipped since.
if: matrix.isLatest == 'true'
run: |
docker buildx imagetools create \
-t "ghcr.io/exadev/${{ matrix.image }}:latest" \
"ghcr.io/exadev/${{ matrix.image }}:${{ matrix.version }}"
deploy-site:
name: Build and deploy the web UI to Pages
# After release, so the deploy is built from the release commit the orchestrator just pushed (it bumps packages/web/package.json and tags it). For a commit that releases nothing, no new tag is created and this builds the current tip of main. test-e2e is in the gate so a main-branch e2e failure blocks deploying the site the suite drives a real browser through, matching the required role e2e already plays on pull requests via the ruleset.
needs:
[
commitlint,
lint,
typecheck,
test,
test-workers,
test-smoke,
test-e2e,
release,
]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: deploy-pages
cancel-in-progress: false
permissions:
contents: read
pages: write
id-token: write
actions: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
outputs:
page_url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
with:
ref: main
fetch-depth: 0
- uses: ./.github/actions/setup-workspace
with:
task: build-site
# No $TURBO_FLAGS: this job only ever runs on main, where the flag is empty anyway, and the deploy must be built from the whole workspace rather than from an affected subset.
command: pnpm build
- uses: actions/configure-pages@v6
with:
enablement: true
# A failed deploy-pages step leaves this run's uploaded github-pages artifact behind; re-running would then try to upload a second one and fail ("Multiple artifacts named github-pages"). Clean up first.
- name: Remove prior github-pages artifacts from this run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
--jq '.artifacts[] | select(.name=="github-pages") | .id' |
while read -r id; do
[ -z "$id" ] && continue
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id"
done
- uses: actions/upload-pages-artifact@v5
with:
path: packages/web/dist
- id: deployment
uses: actions/deploy-pages@v5
verify-deployed-site:
name: Verify the deployed site's own assets actually resolve
# A 200 on the page URL alone doesn't prove the deploy works: the HTML can serve fine while every asset it references 404s (e.g. a GitHub Pages project-site base-path mismatch, the exact bug this job exists to catch). Fetch the real deployed HTML and confirm every script/stylesheet/icon/manifest it links to actually resolves.
needs: [deploy-site]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fetch the deployed page and verify its referenced assets resolve
env:
PAGE_URL: ${{ needs.deploy-site.outputs.page_url }}
run: |
set -euo pipefail
if [ -z "$PAGE_URL" ]; then
echo "::error::deploy-site's page_url output was empty -- actions/deploy-pages didn't report a URL, so there is nothing to verify."
exit 1
fi
html="$(curl -sf "$PAGE_URL")"
origin="$(printf '%s' "$PAGE_URL" | sed -E 's#^(https?://[^/]+).*#\1#')"