Repository navigation
1283 lines (1200 loc) · 58.9 KB
/
Copy pathrelease-pipeline.yml
File metadata and controls
1283 lines (1200 loc) · 58.9 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
# Release pipeline (bd-c6l13j79), as a REUSABLE workflow (bd-p4ljdp2e):
# web payloads → 6-target binary build → checksummed, signed artifacts →
# GitHub Release. Two callers:
#
# release.yml tag push / dispatch on an existing tag → channel: release
# nightly.yml schedule, when main has unreleased work → channel: nightly
#
# The callers own trigger, preflight/gate, concurrency and the repo
# guard; this file owns everything from "what ref + version" onward, so
# every gate below (placeholder-embed, no-glibc, asset-manifest parity,
# minisign against the pinned key) applies to nightlies and releases
# alike, from one copy. Inputs are documented under `on.workflow_call`.
#
# Nightly deltas, all keyed on `inputs.channel == 'nightly'`:
# - the binary is built with QUARTO_VERSION_OVERRIDE=<inputs.version>
# (quarto-util/src/version.rs): main carries the LAST released
# version and a nightly must not commit to it, so the version the
# nightly publishes under is injected at build time. The docs-embed
# host build gets the same override so `q2 docs llms` and the binary
# agree. NEVER export the variable EMPTY — `option_env!` then sees
# Some("") and the compile-time well-formedness assertion fails the
# build — hence the `[ -n ]` guards rather than a plain `env:` entry.
# - the release is a rolling PRERELEASE on the `nightly` tag: the
# previous nightly release + tag are deleted, the tag is re-pointed
# at inputs.ref, and the release is recreated. releases/latest never
# returns a prerelease, so the stable installers are unaffected.
# - the notes carry the nightly install one-liners instead of the
# stable ones.
#
# `inputs.publish: false` stops after signing and uploads the signed set
# as a `release-set` artifact instead: the dry-run mode for testing this
# file on a branch without touching any release or tag.
#
# Ported from cscheid/braid's release.yml (study copy in
# external-sources/braid). The artifact naming and checksum layout is a
# contract with install.sh (encoded by
# crates/quarto/tests/integration/bootstrap_sh.rs):
#
# q2-<version>-<platform>.tar.gz single member: q2
# q2-<version>-<platform>.tar.gz.sha256 "<sha256> <filename>"
# q2-<version>-<platform>.tar.gz.minisig minisign (Ed25519) signature;
# trusted comment = the archive
# filename (replay protection —
# install.sh compares it)
# checksums.sha256 all .sha256 lines, combined
#
# <version> has no "v" prefix in filenames; the tag does (v0.1.0 →
# q2-0.1.0-darwin_arm64.tar.gz).
#
# q2 deltas from braid:
#
# - A functional q2 binary embeds FOUR payloads via include_dir!:
# the hub MCP bundle (quarto-mcp-launcher), the preview SPA and the
# `--ui editor` hub-client bundle (both quarto-preview), and the
# trace viewer (quarto-trace-server). A
# build without them succeeds but ships placeholders (the 2026-05-20
# stale-embed incident class) — the per-target "verify binary" step
# fails the release if any placeholder is detected. The musl legs get
# a second gate on top of it ("assert the binary needs no glibc"),
# which runs the binary under alpine:latest — the runner has glibc, so
# only a glibc-less container can prove the static-linux promise
# (bd-3b47pxmm).
# - The `web-payloads` job builds the target-INDEPENDENT payloads once
# (WASM → preview SPA, trace viewer; the WASM toolchain is heavy and
# identical for every target). The MCP bundle is built per target:
# its keyring native addons must match the *user's* platform
# (KEYRING_PLATFORMS; see ts-packages/quarto-hub-mcp/scripts/
# stage-keyring.mjs).
# - The `hub-mcp-bundle` job ships the same MCP server as a standalone,
# downloadable tarball (quarto-hub-mcp-<version>.tar.gz) so people can
# run it directly (`node index.mjs`) without the q2 binary. ONE
# universal bundle: index.mjs is byte-identical across platforms and
# the keyring loader detects the platform at runtime, so every keyring
# addon co-stages in a single bundle. TEMPORARY/STOPGAP — the clean
# distribution is `npx @quarto/hub-mcp` (bd-3tak0lyy); this tarball
# exists only until the npm channel lands (bd-sca6g1tu; plan
# claude-notes/plans/2026-06-19-release-standalone-hub-mcp-bundle.md).
# - A `quarto-hub-client-wasm-<version>.tar.gz` asset (bd-m7oy2asc): the
# compiled crates/wasm-quarto-hub-client/pkg tree, packaged once in
# web-payloads with repo-relative paths, so Connect satellite editor
# builds (build-editor.sh in connect-satellites) can download the
# wasm-bindgen package for a pinned release instead of rebuilding it
# with a nightly Rust toolchain.
# - The bundled quarto-hub.com OAuth defaults (QUARTO_HUB_BUNDLED_*)
# are injected from repo secrets/vars into the cargo build env —
# release builds only; see crates/quarto-mcp-launcher/src/defaults.rs
# for why the Desktop-app client secret is safe to embed.
# - Linux targets are static musl — ONE artifact per arch that runs on
# every distro, Alpine included, with no glibc floor at all. (They
# were briefly gnu: rusty_v8, via deno_core → quarto-system-runtime,
# shipped no musl prebuilts and both musl legs 404'd in the v0.1.0
# dry-run, run 27449454203. That dependency was removed in
# bd-3e3sam51 and the switch back landed in bd-dofxhzaj.) Because the
# binary is static, the runner's own glibc is irrelevant, so these
# legs track ubuntu-latest rather than pinning an old image. No gnu
# artifact is published; anyone who needs a dynamically-linked build
# can use `install.sh --from-source`.
# - The linux legs still build with `--features vendored-openssl` so
# the binary has no runtime libssl dependency. openssl reaches q2
# only through samod's tungstenite/native-tls wiring (the upstream
# crate's `tungstenite` feature); bd-r7s13dfb tracks moving that to
# rustls, which would let this feature (and the openssl build
# entirely) go away.
#
# Signing key: MINISIGN_SECRET_KEY repo secret; the public half is pinned
# in install.sh (MINISIGN_PUBKEY) and README. The sign step verifies its
# output against the key extracted from install.sh, so a secret/pinned-key
# mismatch fails the release instead of shipping unverifiable artifacts.
#
# Plan: claude-notes/plans/2026-06-12-q2-github-releases-bundled-mcp.md
name: Release pipeline
on:
workflow_call:
inputs:
ref:
description: >-
What to build: the release tag (release.yml) or the exact commit
sha the gate selected (nightly.yml). Every job checks this out.
required: true
type: string
version:
description: >-
The bare version the artifacts are published under (no "v"):
"0.32.0" for a release, "0.33.0-nightly.20260919" for a nightly.
Goes into every filename and is what the verify gate expects
`q2 --version` to end with.
required: true
type: string
tag:
description: 'Git tag to release under: "vX.Y.Z" or "nightly".'
required: true
type: string
channel:
description: '"release" (default) or "nightly" — see header.'
required: false
type: string
default: release
publish:
description: >-
false = build, verify and sign, then upload the signed set as a
`release-set` artifact instead of creating the GitHub Release
(dry-run; no release or tag is touched).
required: false
type: boolean
default: true
notes_range:
description: >-
git log range for the "Changes" section (e.g. "v0.32.0..abc123").
Empty = derive from the previous tag as before.
required: false
type: string
default: ''
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
# Fail fast on a caller that passes an inconsistent channel/version pair
# before anything expensive runs. (Callers also validate; this is the
# belt to their braces.)
check-inputs:
name: Check pipeline inputs
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- shell: bash
env:
CHANNEL: ${{ inputs.channel }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
run: |
case "$CHANNEL" in
release)
[ "$TAG" = "v$VERSION" ] || { echo "::error::release tag $TAG must equal v$VERSION"; exit 1; } ;;
nightly)
[ "$TAG" = nightly ] || { echo "::error::nightly channel releases under the 'nightly' tag, got $TAG"; exit 1; }
case "$VERSION" in
*-nightly.*) ;;
*) echo "::error::nightly version must carry a -nightly.<date> prerelease, got $VERSION"; exit 1 ;;
esac ;;
*) echo "::error::unknown channel '$CHANNEL' (release|nightly)"; exit 1 ;;
esac
echo "channel=$CHANNEL tag=$TAG version=$VERSION ref=${{ inputs.ref }} publish=${{ inputs.publish }}"
# Target-independent embedded payloads, built once: the WASM module
# (wasm32, same bytes for every release target), the preview SPA that
# consumes it, the hub-client editor embed (`q2 preview --ui editor`),
# the trace viewer, and the `q2 docs llms` docs embed.
# Toolchain steps mirror hub-client-e2e.yml (the canonical WASM CI
# setup).
#
# The docs embed step compiles a host `q2` (to render `docs/`) on top
# of everything else, hence the wider timeout than the SPA builds
# alone needed — a cold rust-cache pays for the full q2 dependency
# tree here.
web-payloads:
name: Build web payloads (SPA + trace viewer + docs embed)
needs: check-inputs
runs-on: ubuntu-latest
timeout-minutes: 75
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Set up Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
targets: wasm32-unknown-unknown
components: rust-src
- name: Set up Clang
uses: egor-tensin/setup-clang@v2
with:
version: latest
- uses: Swatinem/rust-cache@v2
with:
key: release-web-payloads
cache-on-failure: true
# build-wasm.js verifies wasm-bindgen CLI matches the version
# pinned in Cargo.lock; install that exact version.
- name: Install wasm-bindgen-cli
run: |
VERSION=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/version = |"/, ""); print; exit}' Cargo.lock)
echo "Installing wasm-bindgen-cli $VERSION"
cargo install -f wasm-bindgen-cli --version "$VERSION"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- name: npm ci
run: npm ci
# wasm-opt (binaryen) is `build:wasm` step 3 (live-share payload
# plan Phase 1). Pinned to the version local dev uses (Homebrew
# binaryen 132) so CI and dev builds produce the same bytes.
- name: Install binaryen (wasm-opt)
run: npm install -g binaryen@132.0.0
- name: Build WASM (hub-client)
run: npm run build:wasm
working-directory: hub-client
# Release asset for Connect satellite editor builds (bd-m7oy2asc):
# the same wasm-bindgen pkg the build legs embed, tarred with
# repo-relative paths so `tar -xzf ... -C <q2 checkout>` drops it
# exactly where the committed hub-client/wasm-quarto-hub-client
# symlink points. Packaged once here because the wasm is
# target-independent; signed and published by the release job like
# every other asset. Tar the real directory, never the symlink
# (same rule as the web-payloads artifact staging below).
- name: Package hub-client WASM pkg (satellite editor builds)
shell: bash
run: |
PKG=crates/wasm-quarto-hub-client/pkg
for f in wasm_quarto_hub_client.js wasm_quarto_hub_client_bg.wasm \
wasm_quarto_hub_client.d.ts wasm_quarto_hub_client_bg.wasm.d.ts; do
test -s "$PKG/$f" || { echo "::error::$PKG/$f missing or empty"; exit 1; }
done
# Cheap syntax gate on the glue: a truncated .js fails here
# rather than in a satellite's vite build.
node --check --input-type=module < "$PKG/wasm_quarto_hub_client.js"
ASSET="quarto-hub-client-wasm-${{ inputs.version }}.tar.gz"
tar -czf "$ASSET" "$PKG"
sha256sum "$ASSET" > "$ASSET.sha256"
sha256sum -c "$ASSET.sha256"
tar -tzf "$ASSET"
- uses: actions/upload-artifact@v7
with:
name: hub-client-wasm-pkg
path: |
quarto-hub-client-wasm-${{ inputs.version }}.tar.gz
quarto-hub-client-wasm-${{ inputs.version }}.tar.gz.sha256
retention-days: 7
if-no-files-found: error
# Same npm invocations as `cargo xtask build-trace-viewer` /
# `build-q2-preview-spa`, without compiling xtask on this runner.
- name: Build trace-viewer
run: npm run build
working-directory: trace-viewer
- name: Build q2-preview-spa
run: npm run build
working-directory: q2-preview-spa
# The `q2 preview --ui editor` embed (live-share Phase 4,
# bd-jt1etjbn; GH #869 / bd-yx63o8wo): target-independent like the
# other SPA dists. Same npm invocation `cargo xtask
# build-hub-client-embed` wraps, without compiling xtask on this
# runner. The script re-runs `build:wasm`, which is a cargo no-op
# on the warm target dir from the WASM step above (plus a
# bindgen/wasm-opt re-pass), so this stays cheap.
- name: Build hub-client preview embed (--ui editor)
run: npm run build:preview-embed
working-directory: hub-client
# The MCP bundle's qmd parser (CAP-11): a wasm32 artifact like the
# others above — target-independent, so build it once here and let
# every bundle-building leg reuse it (their freshness stamps match
# the shared checkout, so `npm run bundle` skips rebuilding).
- name: Install wasm-pack
uses: taiki-e/install-action@wasm-pack
- name: Build wasm-qmd-parser (nodejs target)
run: node ts-packages/quarto-hub-mcp/scripts/build-qmd-parser.mjs
# Docs embed for `q2 docs llms` (bd-hwop1zii). Target-independent
# like the SPA dists, so it is staged once here rather than on
# every build leg — where cross-compiled targets could not run the
# `q2` that renders the docs anyway. `build-agents-docs` builds a
# host `q2` itself (once, dev profile, then runs it per render); a
# debug binary is fine, it only has to render.
# The xtask also stages `docs/examples/` (a gitignored resource the
# docs declare) first — on this fresh checkout nothing else does,
# and `q2 render docs` fails hard without it (v0.27.0 attempt 1).
#
# Nightly: the host `q2` that renders the docs is built with the
# same QUARTO_VERSION_OVERRIDE as the release binary, so the docs
# embed and `q2 --version` agree. Exported only when non-empty
# (an empty override fails the compile-time assertion; see header).
- name: Stage docs embed (q2 docs llms)
shell: bash
env:
NIGHTLY_VERSION: ${{ inputs.channel == 'nightly' && inputs.version || '' }}
run: |
if [ -n "$NIGHTLY_VERSION" ]; then
export QUARTO_VERSION_OVERRIDE="$NIGHTLY_VERSION"
echo "building the docs-render q2 as $QUARTO_VERSION_OVERRIDE"
fi
cargo xtask build-agents-docs
- uses: actions/upload-artifact@v7
with:
name: web-payloads
path: |
trace-viewer/dist
q2-preview-spa/dist
# `q2 preview --ui editor` embed. Quarto-preview's build.rs
# silently embeds a placeholder when this is missing
# (GH #869) — `if-no-files-found: error` below is the guard.
hub-client/dist-preview-embed
agents-docs-dist
# The wasm-pack artifacts (glue JS + .wasm) themselves: the
# per-target build legs and the hub-mcp-bundle job run
# `npm run bundle`, whose buildWasmHost refuses to bundle
# without them — and rebuilding them here would mean the
# whole wasm32/wasm-bindgen/binaryen toolchain on every leg.
#
# Stage the real output directory, never the committed
# hub-client/wasm-quarto-hub-client symlink that points at
# it: on a checkout that has not built WASM the symlink
# dangles (Linux/macOS) or is a plain text file (Windows,
# no core.symlinks), and download-artifact cannot create a
# directory under either — every downstream job then fails
# with "Artifact download failed after 5 retries" (nightly
# 2026-10-14). Glob-ing the symlink here uploads the files
# under the symlink path, which is exactly what breaks.
crates/wasm-quarto-hub-client/pkg
# The qmd parser wasm (CAP-11), built once here — the
# hub-mcp-bundle job stages it without rebuilding.
crates/wasm-qmd-parser/pkg-nodejs
retention-days: 7
if-no-files-found: error
# pkg-nodejs/.build-stamp is a dotfile, and upload-artifact
# drops hidden files by default. Without the stamp, every
# downstream `npm run bundle` sees the pkg as stale and tries
# to rebuild it with wasm-pack, which only this job installs
# (dry-run nightly 38625518489). None of the other staged
# dirs contain hidden files.
include-hidden-files: true
# Standalone hub MCP server bundle (bd-sca6g1tu): the same
# ts-packages/quarto-hub-mcp dist-bundle that gets embedded into q2,
# published as its own downloadable tarball so people can run the MCP
# server directly with an ambient Node.js instead of installing q2.
#
# TEMPORARY/STOPGAP: the clean distribution is `npx @quarto/hub-mcp`
# (bd-3tak0lyy); we ship this tarball only until the npm channel lands
# (we don't want to wire up npm publish credentials yet). Expected
# lifetime ~a couple of months — when bd-3tak0lyy ships, reassess
# whether to drop this job. Plan:
# claude-notes/plans/2026-06-19-release-standalone-hub-mcp-bundle.md.
#
# ONE universal bundle (vs. the per-target binaries): index.mjs is
# byte-identical across platforms; only the @napi-rs/keyring .node
# addons differ, and the keyring loader does runtime platform/arch/libc
# detection, so all platforms co-stage in one bundle. KEYRING_PLATFORMS
# carries every target; non-host addon packages are fetched via
# `npm pack` at the loader's locked version (the path the per-target
# build already relies on — see stage-keyring.mjs).
hub-mcp-bundle:
name: Build standalone hub MCP bundle
# web-payloads: the bundle build needs the wasm-pack artifacts
# (crates/wasm-quarto-hub-client/pkg/) for the WASM render host, and
# the qmd parser pkg (crates/wasm-qmd-parser/pkg-nodejs, CAP-11).
needs: [check-inputs, web-payloads]
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Every keyring platform the q2 release matrix covers, co-staged
# into the one universal bundle. Keep in sync with the per-target
# `keyring:` matrix values in the `build` job below.
KEYRING_PLATFORMS: darwin-x64,darwin-arm64,linux-x64-gnu,linux-x64-musl,linux-arm64-gnu,linux-arm64-musl,win32-x64-msvc,win32-arm64-msvc
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- name: npm ci
run: npm ci
# Target-independent payloads from the web-payloads job, placed
# where the bundle build looks for them — the wasm-pack artifacts
# (crates/wasm-quarto-hub-client/pkg/) are required: buildWasmHost
# refuses to ship a stub host. The qmd parser wasm (CAP-11) also
# comes from web-payloads — this job has no Rust toolchain, and
# with the pkg present and fresh (same checkout) `npm run bundle`
# stages it without rebuilding.
- uses: actions/download-artifact@v8
with:
name: web-payloads
path: .
# esbuild compiles the @quarto/* workspace deps from TS source (the
# `source` condition in scripts/bundle.mjs), so no prior workspace
# build is needed here.
- name: Build universal hub MCP bundle
run: npm run bundle -w ts-packages/quarto-hub-mcp
# Fail closed: a real bundle (not the empty/placeholder case), every
# requested keyring addon staged, the qmd parser staged AND loading,
# and the module graph actually loads under node (the linux-x64
# addon resolves on this runner).
- name: Verify bundle
shell: bash
run: |
DIR=ts-packages/quarto-hub-mcp/dist-bundle
# wasm-host.mjs + the .wasm: the standalone bundle has no
# shared crate to draw the render WASM from, so it must ship.
for f in index.mjs build-info.json wasm-host.mjs wasm_quarto_hub_client_bg.wasm; do
test -f "$DIR/$f" || { echo "::error::$DIR/$f missing"; exit 1; }
done
IFS=',' read -ra PLATFORMS <<< "$KEYRING_PLATFORMS"
for P in "${PLATFORMS[@]}"; do
test -d "$DIR/node_modules/@napi-rs/keyring-$P" \
|| { echo "::error::keyring addon for $P not staged"; exit 1; }
done
for f in package.json wasm_qmd_parser.js wasm_qmd_parser_bg.wasm; do
test -f "$DIR/node_modules/wasm-qmd-parser/$f" \
|| { echo "::error::qmd parser $f not staged"; exit 1; }
done
node --input-type=module -e \
"const m = await import('./$DIR/node_modules/wasm-qmd-parser/wasm_qmd_parser.js'); \
const r = JSON.parse(m.parse_qmd('# ok\n', 'true')); \
if (r.success !== true) { console.error('qmd parser smoke failed'); process.exit(1); }"
node "$DIR/index.mjs" --help >/dev/null
# Build a copy named for the version so the tarball extracts into a
# self-describing directory, drop in README + NOTICE, then archive.
- name: Package standalone bundle (README + NOTICE + tarball)
shell: bash
env:
VERSION: ${{ inputs.version }}
run: |
PKG="quarto-hub-mcp-${VERSION}"
cp -r ts-packages/quarto-hub-mcp/dist-bundle "$PKG"
cat > "$PKG/README.md" <<EOF
# Quarto Hub MCP server — standalone bundle (v${VERSION})
The Quarto Hub MCP server (\`@quarto/hub-mcp\`), bundled as one
self-contained ES module. It gives an MCP-capable AI agent read/write
access to files in Quarto Hub projects over Automerge sync, speaking
the Model Context Protocol over stdio.
## Requirements
- **Node.js 24 or newer.** This bundle targets node24 and is NOT
version-guarded (unlike \`q2 mcp\`, which enforces the floor before
launching); older Node may fail in non-obvious ways.
## Run
node index.mjs --help
node index.mjs --server wss://quarto-hub.com/ws
Point your MCP client's server command at \`node /path/to/index.mjs\`.
## Authentication
Unlike the \`q2 mcp\` launcher, this standalone bundle does **not**
embed quarto-hub.com OAuth client credentials. Supply your own via
the environment: \`QUARTO_HUB_MCP_CLIENT_ID\`,
\`QUARTO_HUB_MCP_CLIENT_SECRET\`, and (optionally)
\`QUARTO_HUB_MCP_ISSUER\`. The sync server defaults to
\`wss://quarto-hub.com/ws\` (override with \`--server\`).
## Contents
- \`index.mjs\` the bundled server
- \`build-info.json\` source git commit + build timestamp
- \`node_modules/@napi-rs/\` the OS-keyring native addon (all platforms)
## Status
Experimental, and a temporary distribution channel: the intended
long-term path is \`npx @quarto/hub-mcp\`. See \`build-info.json\` for
the exact source commit this was built from.
EOF
cat > "$PKG/NOTICE" <<EOF
Quarto Hub MCP server — standalone bundle
Copyright (c) Posit, PBC. MIT licensed.
This bundle inlines code from the following third-party packages at
build time (esbuild). Each is distributed under its own license
(all MIT at the versions bundled); consult each project for full
terms:
- @modelcontextprotocol/sdk (MIT)
- jose (MIT)
- oauth4webapi (MIT)
- @automerge/automerge (MIT)
- ws (MIT)
- @napi-rs/keyring (MIT) native addon, node_modules/@napi-rs/
EOF
tar -czf "$PKG.tar.gz" "$PKG"
sha256sum "$PKG.tar.gz" > "$PKG.tar.gz.sha256"
sha256sum -c "$PKG.tar.gz.sha256"
- uses: actions/upload-artifact@v7
with:
name: hub-mcp-bundle
path: |
quarto-hub-mcp-${{ inputs.version }}.tar.gz
quarto-hub-mcp-${{ inputs.version }}.tar.gz.sha256
retention-days: 7
if-no-files-found: error
build:
name: Build (${{ matrix.platform }})
needs: [check-inputs, web-payloads]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
# keyring: the @napi-rs/keyring platform addons staged into
# the MCP bundle. They must match the *user's node* on the
# target platform — both libc flavors on linux (glibc + Alpine
# node), both mac archs (Rosetta mismatch: x64 q2 under an
# arm64 node and vice versa), both win archs (arm64 Windows
# can run either q2 under either node: x64 binaries run there
# under emulation, and older releases ship only the x64 one).
# NOTE the keyring lists stay libc-plural even though q2 itself
# is now musl-only: the addon must match the user's *node*, and
# an Alpine user runs a musl node while a Debian user runs a
# glibc one. That is independent of how q2 was linked.
# cargo_flags: linux legs vendor openssl (no runtime libssl
# dep); macOS/Windows TLS is security-framework/schannel, no
# openssl involved. Both linux legs build NATIVELY (no
# cross-compilation), so musl-tools' musl-gcc is the right
# compiler on each runner.
- platform: linux_amd64
target: x86_64-unknown-linux-musl
os: ubuntu-latest
ext: tar.gz
keyring: linux-x64-gnu,linux-x64-musl
cargo_flags: --features vendored-openssl
- platform: linux_arm64
target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
ext: tar.gz
keyring: linux-arm64-gnu,linux-arm64-musl
cargo_flags: --features vendored-openssl
- platform: darwin_amd64
target: x86_64-apple-darwin
os: macos-15 # arm64 runner; the x86_64 binary runs under Rosetta
ext: tar.gz
keyring: darwin-x64,darwin-arm64
cargo_flags: ''
- platform: darwin_arm64
target: aarch64-apple-darwin
os: macos-15
ext: tar.gz
keyring: darwin-arm64,darwin-x64
cargo_flags: ''
- platform: windows_amd64
target: x86_64-pc-windows-msvc
os: windows-latest
ext: zip
keyring: win32-x64-msvc,win32-arm64-msvc
cargo_flags: ''
# Native ARM64 build (bd-windows-arm64-nightly-xms5p652). Built
# on an arm runner rather than cross-compiled from
# windows-latest: an x64 Windows host cannot execute an ARM64
# binary, and the verify steps below must run it.
- platform: windows_arm64
target: aarch64-pc-windows-msvc
os: windows-11-arm
ext: zip
keyring: win32-arm64-msvc,win32-x64-msvc
cargo_flags: ''
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
- name: Set up Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
targets: ${{ matrix.target }}
# The toolchain action adds the target to the *latest* nightly,
# but cargo resolves the dated nightly pinned in
# rust-toolchain.toml (bd-at72) — which auto-installs with only
# its own declared targets. Add the matrix target to the pinned
# toolchain explicitly, or every cross/musl build dies with
# E0463 "can't find crate for core" (v0.1.0 dry-run, run
# 27448388974).
- name: Add ${{ matrix.target }} to the pinned toolchain
shell: bash
run: rustup target add ${{ matrix.target }}
# musl-gcc, needed by the C-building deps on the linux legs
# (openssl-src under vendored-openssl, aws-lc-sys, tree-sitter,
# mlua). The `cc` crate selects it automatically for
# *-linux-musl targets.
- name: Install musl-tools
if: contains(matrix.target, 'musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
- uses: Swatinem/rust-cache@v2
with:
key: release-${{ matrix.target }}
# Defender real-time scanning inspects every object/rlib/exe the
# compiler writes; excluding the workspace cuts the MSVC build
# time markedly.
- name: Exclude workspace from Defender scanning
if: runner.os == 'Windows'
shell: pwsh
run: Add-MpPreference -ExclusionPath "${{ github.workspace }}"
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- name: npm ci
run: npm ci
# Target-independent payloads from the web-payloads job, placed
# where the include_dir! build scripts look for them.
- uses: actions/download-artifact@v8
with:
name: web-payloads
path: .
# Per-target payload: the MCP bundle, with keyring addons for the
# *target's* users (esbuild compiles workspace TS from source;
# missing addon packages are fetched via npm pack at the loader's
# locked version).
- name: Build hub MCP bundle
shell: bash
env:
KEYRING_PLATFORMS: ${{ matrix.keyring }}
run: npm run bundle -w ts-packages/quarto-hub-mcp
# The bundled quarto-hub.com defaults. Secrets here are masked in
# logs; they are public-client credentials (RFC 8252) kept out of
# the repo only to avoid GOCSPX- scanners — see
# crates/quarto-mcp-launcher/src/defaults.rs.
#
# Nightly: QUARTO_VERSION_OVERRIDE=<inputs.version> makes the binary
# report the nightly version (the "one version string" invariant —
# the verify step below then checks it). Exported only when
# non-empty; see the header for why.
- name: Build release binary
shell: bash
env:
QUARTO_HUB_BUNDLED_CLIENT_ID: ${{ secrets.QUARTO_HUB_MCP_CLIENT_ID }}
QUARTO_HUB_BUNDLED_CLIENT_SECRET: ${{ secrets.QUARTO_HUB_MCP_CLIENT_SECRET }}
QUARTO_HUB_BUNDLED_SERVER: ${{ vars.QUARTO_HUB_SERVER }}
NIGHTLY_VERSION: ${{ inputs.channel == 'nightly' && inputs.version || '' }}
# aarch64 only: Ubuntu's gcc defaults to -moutline-atomics,
# whose __aarch64_* helpers musl-gcc does not link, so every
# atomics probe in jemalloc's configure fails to link and the
# build dies with "Don't have atomics implemented on this
# platform". jemalloc comes in via pagefind, which pulls in
# tikv-jemallocator on every 64-bit musl target (dry-run
# nightly 38634938445). Inline atomics are fine on armv8.0.
CFLAGS_aarch64_unknown_linux_musl: ${{ matrix.target == 'aarch64-unknown-linux-musl' && '-mno-outline-atomics' || '' }}
run: |
for VAR in QUARTO_HUB_BUNDLED_CLIENT_ID QUARTO_HUB_BUNDLED_CLIENT_SECRET QUARTO_HUB_BUNDLED_SERVER; do
if [ -z "${!VAR}" ]; then
echo "::error::$VAR is empty — release builds must carry the bundled hub defaults (repo secrets/vars)"
exit 1
fi
done
if [ -n "$NIGHTLY_VERSION" ]; then
export QUARTO_VERSION_OVERRIDE="$NIGHTLY_VERSION"
echo "building as $QUARTO_VERSION_OVERRIDE (nightly override)"
fi
cargo build --release --locked --target ${{ matrix.target }} -p quarto ${{ matrix.cargo_flags }}
# Every target in the matrix can execute on its runner (musl
# binaries are static; darwin x86_64 runs under Rosetta; both
# windows legs run natively), so these checks are unconditional. `shell: bash`
# uses Git Bash on the windows runner.
#
# Beyond braid's version check, this is the anti-stale-embed /
# anti-placeholder gate: a release binary must carry a real MCP
# bundle (with the target's keyring addons), real bundled hub
# defaults, and real SPA payloads.
- name: Verify binary (version, embeds, bundled defaults)
shell: bash
run: |
BIN="./target/${{ matrix.target }}/release/q2"
[ "$RUNNER_OS" = "Windows" ] && BIN="$BIN.exe"
RAW=$("$BIN" --version)
ACTUAL="${RAW##* }"
if [ "$ACTUAL" != "${{ inputs.version }}" ]; then
echo "::error::binary reports '$RAW', expected version ${{ inputs.version }}"
exit 1
fi
INFO=$("$BIN" mcp --launcher-info)
printf '%s\n' "$INFO"
FAIL=0
if printf '%s' "$INFO" | grep -q "PLACEHOLDER"; then
echo "::error::release binary embeds the placeholder MCP bundle"
FAIL=1
fi
for VAR in QUARTO_HUB_MCP_CLIENT_ID QUARTO_HUB_MCP_CLIENT_SECRET QUARTO_HUB_SERVER; do
if ! printf '%s' "$INFO" | grep -q "default $VAR: bundled"; then
echo "::error::$VAR is not reported as bundled in the release binary"
FAIL=1
fi
done
IFS=',' read -ra PLATFORMS <<< "${{ matrix.keyring }}"
for P in "${PLATFORMS[@]}"; do
if ! printf '%s' "$INFO" | grep -q "keyring-$P"; then
echo "::error::MCP bundle is missing keyring addon for $P"
FAIL=1
fi
done
# Docs embed for `q2 docs llms` (bd-hwop1zii). A release binary
# must carry real docs, built from this tag's source — a
# placeholder ships a command that only knows how to say it is
# missing, and a foreign commit means the docs describe a
# different Quarto than the binary implements.
DOCS=$("$BIN" docs llms --embed-info)
printf '%s\n' "$DOCS"
if ! printf '%s' "$DOCS" | grep -q '^source: real$'; then
echo "::error::release binary embeds placeholder docs for \`q2 docs llms\`"
FAIL=1
fi
DOCS_COMMIT_LINE=$(printf '%s' "$DOCS" | grep '^commit: ' || true)
DOCS_SHA=${DOCS_COMMIT_LINE#commit: }
DOCS_SHA=${DOCS_SHA% (dirty)}
EXPECTED_SHA=$(git rev-parse HEAD)
if [ "$DOCS_SHA" != "$EXPECTED_SHA" ]; then
echo "::error::embedded docs were staged from $DOCS_SHA, expected this tag's $EXPECTED_SHA"
FAIL=1
fi
# Dirtiness is a diagnostic, not a correctness failure: the
# docs still came from this commit. Surface it rather than
# blocking the release, since it means something modified
# tracked files inside the web-payloads job.
case "$DOCS_COMMIT_LINE" in
*"(dirty)") echo "::warning::embedded docs were staged from a dirty tree at $EXPECTED_SHA" ;;
esac
# `q2 docs serve` (bd-x248xpyh) reads the same embed; prove the
# binary can decode it and serve the site's home page. Skipped
# on Windows, where backgrounding and killing a native process
# from Git Bash is unreliable; the embed-info gate above still
# covers the Windows embed.
if [ "$RUNNER_OS" != "Windows" ]; then
"$BIN" docs serve --no-browser --port 0 > docs-serve.out 2>&1 &
SERVE_PID=$!
DOCS_URL=""
for _ in $(seq 1 50); do
DOCS_URL=$(grep -o 'http://127\.0\.0\.1:[0-9]*/' docs-serve.out | head -n 1 || true)
[ -n "$DOCS_URL" ] && break
sleep 0.2
done
if [ -z "$DOCS_URL" ] || ! curl -fsS "$DOCS_URL" | grep -q 'Quarto 2'; then
echo "::error::\`q2 docs serve\` did not serve the embedded docs home page"
cat docs-serve.out
FAIL=1
fi
kill "$SERVE_PID" 2>/dev/null || true
fi
# Embedded bundler: build a tiny extension with the release binary.
# It runs the embedded rollup driver under the runner's Node 24 (the
# same Node TS engine extensions need), so this is where the musl and
# Windows binaries' build path is exercised.
mkdir -p verify-ext/src
printf 'title: T\nversion: 1.0.0\ncontributes:\n engines:\n - path: dist/e.js\n' > verify-ext/_extension.yml
echo 'export default { name: "verify-ext" };' > verify-ext/src/e.ts
if ! "$BIN" call build-ts-extension verify-ext || ! grep -q 'verify-ext' verify-ext/dist/e.js; then
echo "::error::release binary cannot build a TS extension (embedded rollup driver or Node failed)"
FAIL=1
fi
exit $FAIL
# Live-share local asset serving (plan Phase 2, bd-ee2fqm95): a
# --join guest serves SPA assets locally iff its embedded manifest
# hash equals the host's. That is only true cross-platform if every
# release binary embeds byte-identical dists — the web-payloads
# artifact makes that true by construction, and this step is the
# tripwire: record each platform's embedded hashes so the
# asset-manifest-check job can fail the release on drift. A missing
# viewer hash means the placeholder SPA snuck in; a missing editor
# hash means the `--ui editor` embed did (GH #869). Fail on either.
- name: Record embedded asset manifest hashes
shell: bash
run: |
BIN="./target/${{ matrix.target }}/release/q2"
[ "$RUNNER_OS" = "Windows" ] && BIN="$BIN.exe"
OUT="asset-manifest-hashes-${{ matrix.platform }}.txt"
"$BIN" preview --print-asset-manifest-hashes | tee "$OUT"
if ! grep -Eq '^viewer: [0-9a-f]{64}$' "$OUT"; then
echo "::error::release binary embeds no viewer asset manifest (placeholder SPA?)"
exit 1
fi
if ! grep -Eq '^editor: [0-9a-f]{64}$' "$OUT"; then
echo "::error::release binary embeds no editor asset manifest (placeholder --ui editor?)"
exit 1
fi
- uses: actions/upload-artifact@v7
with:
name: asset-manifest-hashes-${{ matrix.platform }}
path: asset-manifest-hashes-${{ matrix.platform }}.txt
retention-days: 7
if-no-files-found: error
# The no-glibc gate (bd-3b47pxmm). The step above proves the binary
# runs on the RUNNER, which has glibc — it cannot detect an
# accidental dynamic link against libc, so it cannot defend the
# headline promise of the musl switch ("static, any distro"). Nothing
# in the build pins staticness: crt-static is a *default* of the
# *-unknown-linux-musl targets, and a future RUSTFLAGS / cargo config
# / build-script link flag could flip it while every other gate stays
# green. alpine:latest ships musl and NO glibc at all, so a
# dynamically-linked binary cannot even exec there.
#
# This replaces the manual post-release check the runbook used to
# ask for (§6): it needed Docker on the maintainer's machine and, on
# Apple Silicon, would only ever have exercised one arch. Here each
# linux leg runs its own arch's container NATIVELY (amd64 on
# ubuntu-latest, arm64 on ubuntu-24.04-arm) — no qemu, no
# --platform. Docker is present on both runner images; the
# bd-dofxhzaj spike ran this exact invocation green on both.
#
# Two traps that spike paid for, do not "simplify" them away:
# * `file` describes the arches DIFFERENTLY — x86_64 comes out
# "static-pie linked", aarch64 plain "statically linked".
# Matching one spelling passes on one arch and fails on the other.
# * `ldd` says "not a dynamic executable" on a static binary but
# EXITS NON-ZERO, so it can be logged and never asserted on.
#
# `q2 mcp --launcher-info` is deliberately not run in the container:
# it spawns Node, which alpine:latest does not have. That check
# belongs on the runner, where it already is.
- name: Assert the binary needs no glibc (Alpine)
if: contains(matrix.target, 'musl')
shell: bash
run: |
BIN="target/${{ matrix.target }}/release/q2"
# Cheap and fast-failing: localizes a dynamic link to one clear
# message instead of an opaque container exec failure.
file "$BIN"
file "$BIN" | grep -Eq 'static-pie linked|statically linked' || {
echo "::error::$BIN is not statically linked — it will not run without glibc"
exit 1
}
ldd "$BIN" 2>&1 || true # informational only; see above
# The real proof.
docker run --rm \
-v "$PWD:/work" -w /work \
-e BIN="$BIN" \
-e EXPECTED="${{ inputs.version }}" \
alpine:latest \
sh -eux -c '
cat /etc/alpine-release
RAW=$("$BIN" --version)
echo "$RAW"
[ "${RAW##* }" = "$EXPECTED" ]
' || {
echo "::error::$BIN failed to run under alpine:latest (no glibc present)"
exit 1
}
# Unix targets ship a .tar.gz of the `q2` binary.
- name: Package archive + checksum (tar.gz)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
ARCHIVE="q2-${{ inputs.version }}-${{ matrix.platform }}.tar.gz"
tar -czf "$ARCHIVE" -C "target/${{ matrix.target }}/release" q2
if command -v sha256sum >/dev/null; then
sha256sum "$ARCHIVE" > "$ARCHIVE.sha256"
sha256sum -c "$ARCHIVE.sha256"
else
shasum -a 256 "$ARCHIVE" > "$ARCHIVE.sha256"
shasum -a 256 -c "$ARCHIVE.sha256"
fi
# Windows ships a .zip of q2.exe. The .sha256 is written in GNU
# coreutils format ("<lowercase-hash> <file>", LF-terminated, no
# BOM) so the ubuntu combine job's `sha256sum -c` accepts it
# alongside the unix lines.
- name: Package archive + checksum (zip)
if: matrix.ext == 'zip'
shell: pwsh
run: |
$version = "${{ inputs.version }}"
$archive = "q2-$version-${{ matrix.platform }}.zip"
Compress-Archive -Path "target/${{ matrix.target }}/release/q2.exe" -DestinationPath $archive
$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower()
[System.IO.File]::WriteAllText((Join-Path (Get-Location) "$archive.sha256"), "$hash $archive`n")
# Signing happens in the release job, NOT here: centralizing the
# signature step means the secret key is touched by exactly one
# job and signs the exact bytes being published, after their
# artifact round-trip. (The original reason was narrower — the
# then-jammy linux runners had no minisign apt package; v0.1.0
# dry-run iteration 3, run 27450999322. The linux legs are no
# longer on jammy, but the macOS/Windows build runners still have
# no minisign, and the one-job-touches-the-key property is the
# part actually worth keeping.)
- uses: actions/upload-artifact@v7
if: matrix.ext == 'tar.gz'
with:
name: q2-${{ matrix.platform }}
path: |
q2-${{ inputs.version }}-${{ matrix.platform }}.tar.gz
q2-${{ inputs.version }}-${{ matrix.platform }}.tar.gz.sha256
retention-days: 7
if-no-files-found: error
- uses: actions/upload-artifact@v7
if: matrix.ext == 'zip'
with:
name: q2-${{ matrix.platform }}
path: |
q2-${{ inputs.version }}-${{ matrix.platform }}.zip
q2-${{ inputs.version }}-${{ matrix.platform }}.zip.sha256
retention-days: 7
if-no-files-found: error
# Live-share plan Phase 2 (bd-ee2fqm95): every per-platform binary
# must embed SPA bundles with identical manifest hashes, or a
# cross-platform host/guest pair silently loses local asset serving
# (the runtime fallback is safe — full tunneling — but invisible).
# The web-payloads artifact already makes the viewer dist identical
# by construction; this job is the tripwire for that and for the
# per-platform build.rs editor-manifest generation.
asset-manifest-check:
name: Asset manifests identical across platforms
needs: [build]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/download-artifact@v8
with:
pattern: asset-manifest-hashes-*
path: asset-hashes
merge-multiple: true
- name: Compare hashes across platforms
run: |
cd asset-hashes