Repository navigation
1348 lines (1185 loc) · 54 KB
/
Copy pathrelease.yml
File metadata and controls
1348 lines (1185 loc) · 54 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: Release
on:
push:
tags:
- 'v*'
pull_request:
branches:
- main
workflow_dispatch:
inputs:
dry-run:
description: Package only; disable only when dispatching a v* tag to publish
type: boolean
default: true
permissions:
contents: read
jobs:
# A release tag must identify a commit already contained in main. This runs
# independently of the build graph so an invalid tag cannot attest, publish,
# or create a GitHub release even if the build itself succeeds.
release-eligibility:
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs['dry-run'] == 'false'))
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Require release commit to be on main
shell: bash
run: |
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
release_commit="$(git rev-parse "${GITHUB_REF}^{commit}")"
if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then
echo "Release tag ${GITHUB_REF_NAME} points to ${release_commit}, which is not contained in main." >&2
exit 1
fi
- name: Require tag to match release manifests
env:
RELEASE_TAG: ${{ github.ref_name }}
run: node scripts/check-release-version.mjs --tag "$RELEASE_TAG"
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
no-cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate Pi extension shared copies
run: bash apps/pi-extension/vendor.sh
- name: Type check
run: bun run typecheck
- name: Run tests
run: bun test
build:
needs: test
runs-on: ubuntu-latest
# Build job has NO id-token / attestations permissions. Compilation
# itself doesn't need OIDC minting — those capabilities live in the
# separate `attest` job below, which only runs on tag pushes. This
# ensures PR dry-runs (which exercise `bun install` + compile) never
# have OIDC minting available, closing the narrow-but-real
# "trusted-contributor compromise lets a malicious build step mint
# a repo-identity OIDC token" attack surface.
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
no-cache: true
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build UI
run: |
bun run build:review
bun run build:hook
- name: Compile binaries (cross-compile all targets)
run: |
VERSION=$(jq -r .version package.json)
# macOS ARM64
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-darwin-arm64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-darwin-arm64
sha256sum plannotator-darwin-arm64 > plannotator-darwin-arm64.sha256
# macOS x64
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-darwin-x64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-darwin-x64
sha256sum plannotator-darwin-x64 > plannotator-darwin-x64.sha256
# Linux x64 (baseline: CPUID-dispatched SIMD, so it still runs on
# pre-AVX2 CPUs). Bun's default x64 target is x86-64-v3 and SIGILLs
# on Nehalem/Ivy Bridge class hardware. Proven by the emulated-CPU
# gate in `smoke-binaries`, which is itself proven by the
# default-target control binary compiled below.
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-linux-x64-baseline --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-linux-x64
sha256sum plannotator-linux-x64 > plannotator-linux-x64.sha256
# Linux ARM64
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-linux-arm64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-linux-arm64
sha256sum plannotator-linux-arm64 > plannotator-linux-arm64.sha256
# Windows x64 (baseline, same pre-AVX2 rationale as Linux x64 above:
# Windows 10 is supported on plenty of pre-Haswell hardware). There is
# no cheap way to run an emulated old CPU on a GitHub Windows runner,
# so this target is covered by the Bun target contract and the ordinary
# Windows smoke leg, not by an old-CPU gate. macOS x64 deliberately
# stays on the default target: every Mac that can run a supported
# macOS is Haswell or newer.
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-windows-x64-baseline --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-win32-x64.exe
sha256sum plannotator-win32-x64.exe > plannotator-win32-x64.exe.sha256
# Windows ARM64 (native, via bun-windows-arm64 — stable since Bun v1.3.10)
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-windows-arm64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile plannotator-win32-arm64.exe
sha256sum plannotator-win32-arm64.exe > plannotator-win32-arm64.exe.sha256
# Paste service binaries
bun build apps/paste-service/targets/bun.ts --compile --no-compile-autoload-bunfig --target=bun-darwin-arm64 --outfile plannotator-paste-darwin-arm64
sha256sum plannotator-paste-darwin-arm64 > plannotator-paste-darwin-arm64.sha256
bun build apps/paste-service/targets/bun.ts --compile --no-compile-autoload-bunfig --target=bun-darwin-x64 --outfile plannotator-paste-darwin-x64
sha256sum plannotator-paste-darwin-x64 > plannotator-paste-darwin-x64.sha256
bun build apps/paste-service/targets/bun.ts --compile --no-compile-autoload-bunfig --target=bun-linux-x64-baseline --outfile plannotator-paste-linux-x64
sha256sum plannotator-paste-linux-x64 > plannotator-paste-linux-x64.sha256
bun build apps/paste-service/targets/bun.ts --compile --no-compile-autoload-bunfig --target=bun-linux-arm64 --outfile plannotator-paste-linux-arm64
sha256sum plannotator-paste-linux-arm64 > plannotator-paste-linux-arm64.sha256
bun build apps/paste-service/targets/bun.ts --compile --no-compile-autoload-bunfig --target=bun-windows-x64-baseline --outfile plannotator-paste-win32-x64.exe
sha256sum plannotator-paste-win32-x64.exe > plannotator-paste-win32-x64.exe.sha256
bun build apps/paste-service/targets/bun.ts --compile --no-compile-autoload-bunfig --target=bun-windows-arm64 --outfile plannotator-paste-win32-arm64.exe
sha256sum plannotator-paste-win32-arm64.exe > plannotator-paste-win32-arm64.exe.sha256
# Control subject for the emulated-CPU gate in `smoke-binaries`. Same
# source, same Bun, same flags as the shipped Linux x64 binary — only the
# target differs (bun-linux-x64 is x86-64-v3/AVX2). The gate asserts this
# one dies with SIGILL on the emulated pre-AVX2 CPU, which is what makes
# "the baseline binary ran there" a falsifiable claim. Uploaded under its
# own artifact name so it can never reach the `binaries` artifact, the
# attestation subjects, or the GitHub Release (both of which resolve
# `binaries` by name).
- name: Compile baseline-gate control binary (default x64 target)
run: |
VERSION=$(jq -r .version package.json)
mkdir -p baseline-control
bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --target=bun-linux-x64 --define "__CLI_VERSION__=\"$VERSION\"" --outfile baseline-control/control-cli-linux-x64-default
- name: Upload baseline-gate control binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: baseline-gate-control
path: baseline-control/control-cli-linux-x64-default
if-no-files-found: error
retention-days: 1
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries
path: |
plannotator-*
!*.ts
smoke-binaries:
needs: build
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
binary: plannotator-linux-x64
- os: windows-latest
binary: plannotator-win32-x64.exe
# macOS leg exists so a Bun cross-compile signing regression (the
# 1.3.12 linker-signature loss, #541: binaries SIGKILLed on launch)
# can never ship silently again. Executing the binary at all is the
# assertion; the env smoke below rides along.
- os: macos-latest
binary: plannotator-darwin-arm64
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries
path: artifacts
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Install QEMU for the older-CPU gate
if: runner.os == 'Linux'
timeout-minutes: 5
run: |
set -euo pipefail
# Retried: a transient apt mirror failure must not fail a release.
for attempt in 1 2 3; do
if sudo apt-get update && sudo apt-get install --no-install-recommends -y qemu-user; then
qemu-x86_64 -version | head -n 1
exit 0
fi
echo "apt-get attempt ${attempt}/3 failed; retrying in 5s" >&2
sleep 5
done
echo "FAIL: could not install qemu-user after 3 attempts" >&2
exit 1
- name: Download baseline-gate control binary
if: runner.os == 'Linux'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: baseline-gate-control
path: control
# Control for the gate below. Without it, "the baseline binary ran under
# -cpu Nehalem" is unfalsifiable: a qemu that quietly emulated AVX2, an
# ignored -cpu flag, or a gate that never really executed the artifact
# would all look exactly like a pass. This runs the SAME source built with
# Bun's default x64 target (x86-64-v3/AVX2) through the SAME qemu
# invocation and requires it to die, so a green gate means the gate can
# actually tell the two builds apart.
- name: Control - default-target x64 binary must SIGILL on the emulated CPU
if: runner.os == 'Linux'
timeout-minutes: 5
env:
CONTROL_BINARY: control/control-cli-linux-x64-default
CPU_MODEL: Nehalem
RUN_TIMEOUT: 120s
run: |
set -euo pipefail
ulimit -c 0
chmod +x "$CONTROL_BINARY"
status=0
timeout --kill-after=5s "$RUN_TIMEOUT" qemu-x86_64 -cpu "$CPU_MODEL" "$CONTROL_BINARY" --version \
> control.log 2>&1 || status=$?
cat control.log || true
# 132 == 128 + SIGILL(4); qemu-user re-raises the guest signal and
# timeout propagates signal deaths the same way.
if [ "$status" -ne 132 ]; then
echo "FAIL: control binary $CONTROL_BINARY (built with bun-linux-x64, AVX2) exited ${status} under qemu-x86_64 -cpu ${CPU_MODEL}; expected 132 (SIGILL)." >&2
echo "The older-CPU gate cannot distinguish a baseline build from a non-baseline one on this runner, so its result would be meaningless. Fix the harness before trusting the gate." >&2
exit 1
fi
echo "OK: default-target control binary died with SIGILL on ${CPU_MODEL}; the gate below can fail."
- name: Smoke-test Linux x64 baseline CPU compatibility
if: runner.os == 'Linux'
timeout-minutes: 8
env:
BINARY: artifacts/plannotator-linux-x64
PASTE_BINARY: artifacts/plannotator-paste-linux-x64
CPU_MODEL: Nehalem
# Ceiling for any single emulated run. Deliberately ABOVE the paste
# startup poll ceiling below, so the poll loop — which reports which
# binary and which CPU model — is what decides, not this timeout.
RUN_TIMEOUT: 120s
STARTUP_TIMEOUT_SECONDS: 90
STARTUP_POLL_SECONDS: 2
run: |
set -euo pipefail
ulimit -c 0
chmod +x "$BINARY" "$PASTE_BINARY"
fail() {
echo "FAIL: $1" >&2
echo " binary: $2" >&2
echo " emulated CPU: qemu-x86_64 -cpu ${CPU_MODEL}" >&2
exit 1
}
# Nehalem has SSE4.2 but no AVX, AVX2 or BMI instructions. Execute the
# downloaded release artifacts, not a separately compiled probe. The
# control step above proves a non-baseline build fails right here.
timeout --kill-after=5s "$RUN_TIMEOUT" qemu-x86_64 -cpu "$CPU_MODEL" "$BINARY" --help \
|| fail "CLI --help did not run on the emulated CPU" "$BINARY"
expected="plannotator $(jq -r .version package.json)"
actual="$(timeout --kill-after=5s "$RUN_TIMEOUT" qemu-x86_64 -cpu "$CPU_MODEL" "$BINARY" --version)" \
|| fail "CLI --version did not run on the emulated CPU" "$BINARY"
if [ "$actual" != "$expected" ]; then
fail "expected version '$expected', got '$actual'" "$BINARY"
fi
# Port zero lets the OS choose a free port. The startup message is
# emitted only after Bun.serve succeeds; no public health API is needed.
paste_dir="$(mktemp -d)"
PASTE_PORT=0 PASTE_DATA_DIR="$paste_dir/data" \
timeout --kill-after=5s "$RUN_TIMEOUT" qemu-x86_64 -cpu "$CPU_MODEL" "$PASTE_BINARY" > "$paste_dir/server.log" 2>&1 &
paste_pid=$!
trap '
kill "$paste_pid" 2>/dev/null || true
wait "$paste_pid" 2>/dev/null || true
cat "$paste_dir/server.log"
rm -rf "$paste_dir"
' EXIT
# Emulated startup is slow, so sample often against a generous
# ceiling: 45 samples over ${STARTUP_TIMEOUT_SECONDS}s.
attempts=$(( STARTUP_TIMEOUT_SECONDS / STARTUP_POLL_SECONDS ))
for _ in $(seq 1 "$attempts"); do
# Read the log before checking liveness: a server that printed its
# banner and then exited has still started on this CPU.
if grep -q '^Plannotator paste service running on ' "$paste_dir/server.log"; then
echo "OK: Linux x64 CLI and paste service started under qemu-x86_64 -cpu ${CPU_MODEL}"
exit 0
fi
if ! kill -0 "$paste_pid" 2>/dev/null; then
fail "paste service exited before reporting startup" "$PASTE_BINARY"
fi
sleep "$STARTUP_POLL_SECONDS"
done
fail "paste service did not report startup within ${STARTUP_TIMEOUT_SECONDS}s" "$PASTE_BINARY"
- name: Smoke-test binary
if: runner.os != 'Windows'
env:
BINARY: artifacts/${{ matrix.binary }}
BROWSER: true
run: |
set -euo pipefail
chmod +x "$BINARY"
# 1. --help from a hostile caller cwd proves the binary loads,
# arg parsing works, and Bun did not autoload caller bunfig.toml.
hostile_cwd="$(mktemp -d)"
printf 'preload = ["./does-not-exist.ts"]\n' > "$hostile_cwd/bunfig.toml"
(
cd "$hostile_cwd"
"$GITHUB_WORKSPACE/$BINARY" --help
)
smoke_test_server() {
local label="$1" port="$2" endpoint="$3"
shift 3
PLANNOTATOR_PORT="$port" "$@" &
local pid=$!
local ok=0
for _ in $(seq 1 60); do
if curl -sf "http://127.0.0.1:${port}${endpoint}" -o /dev/null 2>/dev/null; then
ok=1
break
fi
sleep 0.5
done
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
if [ "$ok" = "0" ]; then
echo "FAIL: ${label} did not respond on :${port}${endpoint}"
exit 1
fi
echo "OK: ${label} responded on :${port}${endpoint}"
}
smoke_test_agent_terminal() {
local port="19502"
local data_dir
data_dir="$(mktemp -d)"
PLANNOTATOR_DATA_DIR="$data_dir" "$BINARY" install-runtime agent-terminal
PLANNOTATOR_DATA_DIR="$data_dir" PLANNOTATOR_PORT="$port" "$BINARY" annotate README.md &
local pid=$!
local ok=0
for _ in $(seq 1 60); do
if curl -sf "http://127.0.0.1:${port}/api/plan" -o /dev/null 2>/dev/null; then
ok=1
break
fi
sleep 0.5
done
if [ "$ok" = "0" ]; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
echo "FAIL: plannotator annotate terminal did not respond on :${port}/api/plan"
exit 1
fi
local runtime_dir="${data_dir}/vendor/agent-terminal/webtui-0.1.0"
local smoke_script="${runtime_dir}/terminal-smoke.mjs"
cat > "$smoke_script" <<'NODE'
import WebSocket from "ws";
const base = process.env.PLANNOTATOR_SMOKE_URL;
const plan = await fetch(`${base}/api/plan`).then((res) => res.json());
const terminal = plan.agentTerminal;
if (!terminal?.enabled) {
throw new Error(`agent terminal disabled: ${JSON.stringify(terminal)}`);
}
if (!terminal.wsPath?.startsWith("/api/agent-terminal/pty/")) {
throw new Error(`agent terminal returned non-tokenized path: ${terminal.wsPath}`);
}
await new Promise((resolve, reject) => {
let ws;
const timer = setTimeout(() => {
ws?.close();
reject(new Error("timed out waiting for terminal response"));
}, 5000);
ws = new WebSocket(`${base.replace(/^http/, "ws")}${terminal.wsPath}`);
ws.on("open", () => {
ws.send(JSON.stringify({ type: "spawn", requestId: "missing-agent", options: {} }));
});
ws.on("message", (raw) => {
clearTimeout(timer);
ws.close();
const message = JSON.parse(String(raw));
if (message.type !== "error" || message.message !== "Agent terminal requires a built-in WebTUI agent.") {
reject(new Error(`unexpected terminal response: ${JSON.stringify(message)}`));
return;
}
resolve();
});
ws.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
NODE
local result=0
(cd "$runtime_dir" && PLANNOTATOR_SMOKE_URL="http://127.0.0.1:${port}" node "$smoke_script") || result=$?
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
if [ "$result" != "0" ]; then
exit "$result"
fi
echo "OK: plannotator annotate terminal WebSocket reached sidecar"
}
# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.
smoke_test_server "plannotator review" 19500 "/api/diff" \
"$BINARY" review
# 3. annotate: exercises annotate server startup with a real file.
smoke_test_server "plannotator annotate" 19501 "/api/plan" \
"$BINARY" annotate README.md
# 4. terminal: exercises managed WebTUI runtime + compiled sidecar import.
smoke_test_agent_terminal
# 5. #1249 regression: env vars must load even when a cwd ancestor
# is unreadable (the OS-sandbox case: the project dir is granted but
# an ancestor is not). Bun <= 1.3.11 loaded an EMPTY process.env
# there, silently ignoring every PLANNOTATOR_* variable. The server
# responding on the fixed PLANNOTATOR_PORT proves the variable was
# actually read from inside the guarded cwd.
guard_root="$(mktemp -d)"
mkdir -p "$guard_root/parent/child"
printf '# env smoke\n' > "$guard_root/parent/child/doc.md"
chmod 311 "$guard_root/parent"
(
cd "$guard_root/parent/child"
smoke_test_server "annotate under unreadable ancestor" 19503 "/api/plan" \
"$GITHUB_WORKSPACE/$BINARY" annotate doc.md
)
chmod 755 "$guard_root/parent"
- name: Smoke-test binary
if: runner.os == 'Windows'
shell: pwsh
env:
BINARY: artifacts/${{ matrix.binary }}
BROWSER: true
run: |
$ErrorActionPreference = "Stop"
$binary = (Resolve-Path $env:BINARY).Path
# 1. --help from a hostile caller cwd proves the binary loads,
# arg parsing works, and Bun did not autoload caller bunfig.toml.
$hostileCwd = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()))
Set-Content -Path (Join-Path $hostileCwd.FullName "bunfig.toml") -Value 'preload = ["./does-not-exist.ts"]'
Push-Location $hostileCwd.FullName
try {
& $binary --help
} finally {
Pop-Location
}
function Test-PlannotatorServer {
param(
[string] $Label,
[string] $Port,
[string] $Endpoint,
[string[]] $Arguments
)
$env:PLANNOTATOR_PORT = $Port
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$process = Start-Process `
-FilePath $binary `
-ArgumentList $Arguments `
-PassThru `
-NoNewWindow `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
$ok = $false
try {
for ($i = 0; $i -lt 60; $i++) {
try {
Invoke-WebRequest -Uri "http://127.0.0.1:$Port$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null
$ok = $true
break
} catch {
if ($process.HasExited) {
break
}
Start-Sleep -Milliseconds 500
}
}
} finally {
if (-not $process.HasExited) {
Stop-Process -Id $process.Id -Force
Wait-Process -Id $process.Id -ErrorAction SilentlyContinue
}
Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue
}
if (-not $ok) {
Write-Host "stdout:"
Get-Content $stdout -ErrorAction SilentlyContinue
Write-Host "stderr:"
Get-Content $stderr -ErrorAction SilentlyContinue
throw "FAIL: $Label did not respond on :$Port$Endpoint"
}
Write-Host "OK: $Label responded on :$Port$Endpoint"
}
function Test-AgentTerminal {
$port = "19502"
$dataDir = Join-Path ([System.IO.Path]::GetTempPath()) "plannotator-terminal-$([System.Guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
$env:PLANNOTATOR_DATA_DIR = $dataDir
& $binary install-runtime agent-terminal
if ($LASTEXITCODE -ne 0) {
throw "FAIL: install-runtime agent-terminal failed"
}
$env:PLANNOTATOR_PORT = $port
$stdout = New-TemporaryFile
$stderr = New-TemporaryFile
$process = Start-Process `
-FilePath $binary `
-ArgumentList @("annotate", "README.md") `
-PassThru `
-NoNewWindow `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
try {
$ok = $false
for ($i = 0; $i -lt 60; $i++) {
try {
Invoke-WebRequest -Uri "http://127.0.0.1:$Port/api/plan" -UseBasicParsing -TimeoutSec 1 | Out-Null
$ok = $true
break
} catch {
if ($process.HasExited) {
break
}
Start-Sleep -Milliseconds 500
}
}
if (-not $ok) {
throw "FAIL: plannotator annotate terminal did not respond on :$Port/api/plan"
}
$runtimeDir = Join-Path $dataDir "vendor\agent-terminal\webtui-0.1.0"
$smokeScript = Join-Path $runtimeDir "terminal-smoke.mjs"
Set-Content -Path $smokeScript -Encoding UTF8 -Value @'
import WebSocket from "ws";
const base = process.env.PLANNOTATOR_SMOKE_URL;
const plan = await fetch(`${base}/api/plan`).then((res) => res.json());
const terminal = plan.agentTerminal;
if (!terminal?.enabled) {
throw new Error(`agent terminal disabled: ${JSON.stringify(terminal)}`);
}
if (!terminal.wsPath?.startsWith("/api/agent-terminal/pty/")) {
throw new Error(`agent terminal returned non-tokenized path: ${terminal.wsPath}`);
}
await new Promise((resolve, reject) => {
let ws;
const timer = setTimeout(() => {
ws?.close();
reject(new Error("timed out waiting for terminal response"));
}, 5000);
ws = new WebSocket(`${base.replace(/^http/, "ws")}${terminal.wsPath}`);
ws.on("open", () => {
ws.send(JSON.stringify({ type: "spawn", requestId: "missing-agent", options: {} }));
});
ws.on("message", (raw) => {
clearTimeout(timer);
ws.close();
const message = JSON.parse(String(raw));
if (message.type !== "error" || message.message !== "Agent terminal requires a built-in WebTUI agent.") {
reject(new Error(`unexpected terminal response: ${JSON.stringify(message)}`));
return;
}
resolve();
});
ws.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
'@
Push-Location $runtimeDir
try {
$env:PLANNOTATOR_SMOKE_URL = "http://127.0.0.1:$Port"
node $smokeScript
} finally {
Pop-Location
Remove-Item Env:\PLANNOTATOR_SMOKE_URL -ErrorAction SilentlyContinue
}
Write-Host "OK: plannotator annotate terminal WebSocket reached sidecar"
} finally {
if (-not $process.HasExited) {
Stop-Process -Id $process.Id -Force
Wait-Process -Id $process.Id -ErrorAction SilentlyContinue
}
Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue
Remove-Item Env:\PLANNOTATOR_DATA_DIR -ErrorAction SilentlyContinue
}
}
# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.
Test-PlannotatorServer "plannotator review" "19500" "/api/diff" @("review")
# 3. annotate: exercises annotate server startup with a real file.
Test-PlannotatorServer "plannotator annotate" "19501" "/api/plan" @("annotate", "README.md")
# 4. terminal: exercises managed WebTUI runtime + compiled sidecar import.
Test-AgentTerminal
- name: Smoke-test uninstall lifecycle
if: runner.os == 'Windows'
shell: pwsh
env:
BINARY: artifacts/${{ matrix.binary }}
run: |
$ErrorActionPreference = "Stop"
# This disposable GitHub-hosted VM is the real Windows QA boundary.
# The smoke temporarily adds one unique RUNNER_TEMP path to HKCU PATH,
# restores the original value in finally, and the VM is discarded.
$sourceBinary = (Resolve-Path $env:BINARY).Path
$runnerTemp = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP)
$testRoot = [System.IO.Path]::GetFullPath((Join-Path $runnerTemp "plannotator-uninstall-$([System.Guid]::NewGuid().ToString('N'))"))
$expectedPrefix = $runnerTemp.TrimEnd('\') + '\'
if (-not $testRoot.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing unsafe uninstall smoke root: $testRoot"
}
$environmentNames = @(
"USERPROFILE",
"LOCALAPPDATA",
"APPDATA",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"CLAUDE_CONFIG_DIR",
"CODEX_HOME",
"FACTORY_CONFIG_DIR",
"COPILOT_HOME",
"PI_CODING_AGENT_DIR",
"PLANNOTATOR_DATA_DIR"
)
$savedEnvironment = @{}
foreach ($name in $environmentNames) {
$savedEnvironment[$name] = [System.Environment]::GetEnvironmentVariable($name, "Process")
}
$originalUserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
function Wait-UntilMissing {
param([string] $Path)
for ($i = 0; $i -lt 80; $i++) {
if (-not (Test-Path -LiteralPath $Path)) { return }
Start-Sleep -Milliseconds 250
}
throw "Timed out waiting for uninstall to remove $Path"
}
function Set-IsolatedUninstallEnvironment {
param([string] $CaseRoot)
$isolatedHome = Join-Path $CaseRoot "home"
$localAppData = Join-Path $isolatedHome "AppData\Local"
$values = @{
USERPROFILE = $isolatedHome
LOCALAPPDATA = $localAppData
APPDATA = (Join-Path $isolatedHome "AppData\Roaming")
XDG_CONFIG_HOME = (Join-Path $isolatedHome ".config")
XDG_CACHE_HOME = (Join-Path $isolatedHome ".cache")
CLAUDE_CONFIG_DIR = (Join-Path $isolatedHome ".claude")
CODEX_HOME = (Join-Path $isolatedHome ".codex")
FACTORY_CONFIG_DIR = (Join-Path $isolatedHome ".factory")
COPILOT_HOME = (Join-Path $isolatedHome ".copilot")
PI_CODING_AGENT_DIR = (Join-Path $isolatedHome ".pi\agent")
PLANNOTATOR_DATA_DIR = (Join-Path $isolatedHome ".plannotator")
}
foreach ($entry in $values.GetEnumerator()) {
[System.Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process")
}
return $values
}
function Add-TestUserPathEntry {
param([string] $InstallDirectory)
$current = [System.Environment]::GetEnvironmentVariable("Path", "User")
$next = if ([string]::IsNullOrWhiteSpace($current)) {
";$InstallDirectory;"
} else {
"$current;;$InstallDirectory;"
}
[System.Environment]::SetEnvironmentVariable("Path", $next, "User")
if ([string]::IsNullOrWhiteSpace($current)) { return ";" }
return "$current;;"
}
function Assert-TestUserPathEntryRemoved {
param(
[string] $InstallDirectory,
[string] $ExpectedPath
)
$current = [System.Environment]::GetEnvironmentVariable("Path", "User")
$target = $InstallDirectory.Trim().TrimEnd('\')
$matches = @($current -split ';' | Where-Object {
$_ -and $_.Trim().TrimEnd('\') -ieq $target
})
if ($matches.Count -ne 0) {
throw "Uninstall left its user PATH entry behind: $InstallDirectory"
}
if ($current -cne $ExpectedPath) {
throw "Uninstall changed unrelated user PATH bytes. Expected '$ExpectedPath', got '$current'"
}
}
function Invoke-UninstallSmoke {
param(
[string] $Name,
[bool] $Purge
)
$caseRoot = Join-Path $testRoot $Name
$values = Set-IsolatedUninstallEnvironment $caseRoot
$installDirectory = Join-Path $values.LOCALAPPDATA "plannotator"
$testBinary = Join-Path $installDirectory "plannotator.exe"
$dataDirectory = $values.PLANNOTATOR_DATA_DIR
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
Copy-Item -LiteralPath $sourceBinary -Destination $testBinary
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "plans") | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "vendor\sem\v-test") | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "vendor\agent-terminal\v-test") | Out-Null
Set-Content -LiteralPath (Join-Path $dataDirectory "plans\keep.md") -Value "# keep"
Set-Content -LiteralPath (Join-Path $dataDirectory "config.json") -Value '{}'
Set-Content -LiteralPath (Join-Path $dataDirectory "vendor\sem\v-test\sem.exe") -Value "fixture"
Set-Content -LiteralPath (Join-Path $dataDirectory "vendor\agent-terminal\v-test\server.js") -Value "fixture"
$expectedUserPath = Add-TestUserPathEntry $installDirectory
$arguments = @("uninstall", "--yes")
if ($Purge) { $arguments += "--purge" }
& $testBinary @arguments
if ($LASTEXITCODE -ne 0) {
throw "Uninstall smoke '$Name' exited $LASTEXITCODE"
}
Wait-UntilMissing $testBinary
Wait-UntilMissing $installDirectory
Assert-TestUserPathEntryRemoved $installDirectory $expectedUserPath
if ($Purge) {
if (Test-Path -LiteralPath $dataDirectory) {
throw "Purge smoke left the data directory behind: $dataDirectory"
}
} else {
if (-not (Test-Path -LiteralPath (Join-Path $dataDirectory "plans\keep.md"))) {
throw "Default uninstall removed preserved plan data"
}
if (-not (Test-Path -LiteralPath (Join-Path $dataDirectory "config.json"))) {
throw "Default uninstall removed preserved configuration"
}
if (Test-Path -LiteralPath (Join-Path $dataDirectory "vendor\sem")) {
throw "Default uninstall left the sem sidecar behind"
}
if (Test-Path -LiteralPath (Join-Path $dataDirectory "vendor\agent-terminal")) {
throw "Default uninstall left the agent-terminal runtime behind"
}
}
}
try {
Invoke-UninstallSmoke "preserve" $false
Invoke-UninstallSmoke "purge" $true
Write-Host "OK: Windows uninstall preserve-data, purge, PATH, and self-delete verified"
} finally {
[System.Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User")
foreach ($name in $environmentNames) {
[System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process")
}
if (Test-Path -LiteralPath $testRoot) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
}
pi-extension-ai-runtime-windows:
needs: test
# Exercises the Pi extension's Node/jiti server mirror on Windows with an
# npm-style `pi` shim pair. The binary smoke above covers the compiled Bun
# CLI, but the published Pi extension uses this separate Node path.
name: Pi extension AI runtime (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
no-cache: true
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
package-manager-cache: false
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate Pi extension shared copies
shell: bash
run: bash apps/pi-extension/vendor.sh
- name: Build Pi AI runtime smoke
run: bun build scripts/smoke-pi-extension-ai-runtime.ts --target=node --outfile "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"
- name: Run Pi AI runtime smoke
run: node "$env:RUNNER_TEMP/pi-ai-runtime-smoke.mjs"
install-script-smoke:
needs: build
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
artifact: plannotator-linux-x64
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries
path: artifacts
- name: Verify installer writes Codex hook config
env:
ARTIFACT_NAME: ${{ matrix.artifact }}
run: |
set -euo pipefail
tmp_home="$(mktemp -d)"
fake_bin="$(mktemp -d)"
artifact="$PWD/artifacts/$ARTIFACT_NAME"
cat > "$fake_bin/codex" <<'SH'
#!/usr/bin/env bash
echo "codex stub"
SH
chmod +x "$fake_bin/codex"
cat > "$fake_bin/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
out=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o|--output)
out="$2"
shift 2
;;
-*)
shift
;;
*)
url="$1"
shift
;;
esac
done
if [[ "$url" == *.sha256 ]]; then
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$ARTIFACT"
else
shasum -a 256 "$ARTIFACT"
fi
exit 0
fi
if [ -n "$out" ]; then
cp "$ARTIFACT" "$out"
else
cat "$ARTIFACT"
fi
SH
chmod +x "$fake_bin/curl"
# --skip-skills: this smoke installs a SYNTHETIC v9.9.9. The fake curl
# above serves the freshly built binary for any URL, but the skills and
# slash commands come from `git clone --branch v9.9.9` against real
# github.com, where that tag does not and cannot exist. The clone has
# always failed here; it only went unnoticed while a broken guard let
# the installer exit 0 anyway. This job checks the Codex hook config,
# not the skills, so opt out of the checkout rather than teach the test
# to ignore a real error. Both call sites below run through this
# function, so the flag applies to each of them.
run_installer() {
HOME="$tmp_home" \
PATH="$fake_bin:$PATH" \
SHELL=/bin/bash \
ARTIFACT="$artifact" \
bash scripts/install.sh --version v9.9.9 --skip-attestation --skip-skills
}
run_installer
test -x "$tmp_home/.local/bin/plannotator"
grep -q 'hooks = true' "$tmp_home/.codex/config.toml"
HOME="$tmp_home" node <<'NODE'
const fs = require("fs");
const path = require("path");
const home = process.env.HOME;
const hooksPath = path.join(home, ".codex", "hooks.json");
const hooks = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
const command = hooks?.hooks?.Stop?.[0]?.hooks?.[0]?.command;
const timeout = hooks?.hooks?.Stop?.[0]?.hooks?.[0]?.timeout;
const expected = path.join(home, ".local", "bin", "plannotator");