-
Notifications
You must be signed in to change notification settings - Fork 5
729 lines (668 loc) · 35.8 KB
/
Copy pathruntime-live-e2e.yml
File metadata and controls
729 lines (668 loc) · 35.8 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
# SECURITY MODEL — read before editing.
#
# Triggers: workflow_dispatch (a maintainer triggers it manually) and
# pull_request on `main`. It uses pull_request, NOT pull_request_target, so it
# runs the PR-head version of the workflow and GitHub withholds repo secrets from
# FORK PRs — untrusted fork code cannot exfiltrate live API keys because the
# secrets are simply absent for forks. Workflow-level permissions are read-only.
#
# The offline job carries NO environment and NO secret: it builds + runs the
# default Go suite (the live-tagged test compiles out) as the secret-free gate,
# and runs unconditionally on every PR. Only live jobs declare an `environment:`
# (a required-reviewer approval gate, reviewer = clkao) and read only the
# host-specific secret they need: ANTHROPIC_API_KEY for Claude, OPENAI_API_KEY
# for Codex and Pi. The Claude live job is a matrix over two variants: sonnet
# (the Python-land floor) on CI-E2E and claude-opus-4-8 on CI-E2E-OPUS — each its
# own separately-approved deployment. So a live PR run needs
# same-repo-or-no-secrets + the per-variant environment approval; each variant
# pauses in `waiting` until a maintainer approves its environment, so the
# API-spending dispatch cannot start unapproved.
name: Runtime Live E2E
on:
workflow_dispatch:
inputs:
claude_version:
description: "Pin Claude Code to a specific version (e.g. 2.1.107, stable, latest). Empty = installer default."
required: false
type: string
default: ""
codex_version:
description: "Pin @openai/codex to a specific version (e.g. 0.136.0). Empty = npm latest."
required: false
type: string
default: ""
effort:
description: "Effort hint for the live run (low, high, xhigh). Default low."
required: false
type: string
default: "low"
# pull_request (NOT pull_request_target): runs the PR-head version of this
# workflow, and withholds repo secrets from FORK PRs — a fork's malicious code
# cannot exfiltrate live API keys because the secrets are simply absent.
# Same-repo PRs get the secret; the live job stays per-variant environment-gated.
pull_request:
branches: [main]
permissions:
contents: read
jobs:
# Gate job: the secret-free offline suite must pass before the live job burns
# env-approval. NO environment, NO secret — the live-tagged test compiles out of
# the default `go test ./...` surface, so this job is the AC-3 secret-free gate.
offline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.22"
- name: Build
run: go build ./...
# The offline suite includes the AC tests that exercise the live steps'
# clean-log + jsonl-archive shape over a fixture; they invoke the pinned
# gotestsum, so install it here (sha256-verified) to guarantee those tests
# RUN — they would otherwise self-skip when gotestsum is absent.
- name: Install gotestsum
run: bash .github/scripts/install-gotestsum.sh
- name: Run offline test suite
run: go test ./...
# Live job: a matrix over two separately-approved variants. sonnet is the
# Python-land floor (CI-E2E); claude-opus-4-8 is the upper tier (CI-E2E-OPUS).
# Each leg pins its own model and its own approval-gated environment, so each
# spends ANTHROPIC_API_KEY only after a maintainer approves THAT deployment.
# fail-fast is off so one model's red does not cancel the other's run.
claude-live:
needs: offline
# Live, secret-bearing job. Every PR to `main` (and every manual
# workflow_dispatch) queues the live matrix; the offline gate above runs
# unconditionally per-PR (no secrets). Each variant then pauses in `waiting`
# on its CI-E2E* / CI-E2E-OPUS environment until a maintainer approves THAT
# deployment, so a live PR run needs same-repo-or-no-secrets + the
# per-variant environment approval.
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- model: sonnet
environment: CI-E2E
- model: claude-opus-4-8
environment: CI-E2E-OPUS
environment:
name: ${{ matrix.environment }}
env:
# Freeze the installed version for the run's duration: the Install step below
# resolves installer-`latest` ONCE, and this keeps that resolved binary from
# auto-updating mid-run (a multi-minute test must not drift to a second
# version partway through — the "Show tool versions" step records the exact
# version that ran). It no longer holds a PIN: the live lane floats to current
# Claude (the merged-team floor #396 ships on), so CI tests what users run
# rather than the retired team-tool-capable 2.1.177 (#395, now retired).
DISABLE_AUTOUPDATER: "1"
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1"
# Per-leg artifact root the shared Claude scenario runner writes its
# stream/final-message artifacts under (claudeLiveArtifactDir reads it). The
# /${{ matrix.model }} segment keeps the two matrix legs from colliding.
SPACEDOCK_LIVE_ARTIFACT_DIR: ${{ github.workspace }}/live-artifacts/claude/${{ matrix.model }}
SPACEDOCK_JOURNEY_METRICS_DIR: ${{ github.workspace }}/live-artifacts/journey-metrics/claude/${{ matrix.model }}
steps:
- name: Check required secret
run: |
if [ -z "${ANTHROPIC_API_KEY}" ]; then
echo "ANTHROPIC_API_KEY is required for claude-live." >&2
exit 1
fi
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.22"
- uses: actions/setup-node@v6
with:
node-version: "20"
# tmux is the pty/tmux team-mode harness's transport (it drives a real
# interactive claude session in a detached tmux pane). ubuntu-latest does not
# ship tmux, so install it before the pty team-mode step below.
- name: Install tmux
run: |
sudo apt-get update
sudo apt-get install -y tmux
tmux -V
- name: Install Claude Code
env:
CLAUDE_VERSION: ${{ inputs.claude_version }}
run: |
# Empty workflow_dispatch input floats to installer-`latest` — the live
# lane tests the version users actually run (the merged-team floor #396
# ships on), not a frozen pin. A maintainer can still override with an
# explicit version or `stable` via the claude_version dispatch input
# (the escape hatch to pin a known-good version for a release cut).
VERSION="${CLAUDE_VERSION:-latest}"
echo "Installing Claude Code version: $VERSION"
curl -fsSL https://claude.ai/install.sh | bash -s -- "$VERSION"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build spacedock binary
run: |
go build -o ./spacedock ./cmd/spacedock
echo "SPACEDOCK_BIN=$(pwd)/spacedock" >> "$GITHUB_ENV"
echo "$(pwd)" >> "$GITHUB_PATH"
# gotestsum renders the live test run as a CLEAN step log (a per-package
# progress view + an `=== Failed` recap listing each failing test with
# file:line) while writing the full `-json` event stream to a `--jsonfile`
# archive, from a SINGLE test run — so an FO/ensign reads the small step log
# for triage and fetches the archived `*-detail.jsonl` only for root cause.
# It is PINNED to a fixed release and the downloaded prebuilt binary is
# sha256-verified before use (no `@latest`, no unverified download, and no
# Go-toolchain change — the prebuilt is decoupled from the build toolchain).
- name: Install gotestsum
run: bash .github/scripts/install-gotestsum.sh
- name: Configure git identity
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global init.defaultBranch main
- name: Show tool versions
run: |
# No pin to enforce: the live lane floats to current Claude. Record the
# resolved version (the install step froze it for the run) so a failure
# is always attributable to a known version, and surface it in the step
# summary for the regression-sensor read.
echo "claude version (frozen for this run): $(claude --version)"
go version
echo "### Tool versions" >> "$GITHUB_STEP_SUMMARY"
echo "- \`claude --version\`: \`$(claude --version)\`" >> "$GITHUB_STEP_SUMMARY"
echo "- \`go version\`: \`$(go version)\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Model: \`${{ matrix.model }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Effort: \`${{ inputs.effort }}\`" >> "$GITHUB_STEP_SUMMARY"
# Archivable per-job config dir under $RUNNER_TEMP, exported to later steps
# via $GITHUB_ENV. $RUNNER_TEMP is the runtime shell variable (the `runner`
# CONTEXT is NOT available in a job-level `env:` — the runner is unassigned
# until the job starts, so referencing it there is a parse-time startup
# failure). The child claude writes its session jsonl to
# projects/<slug>/<session>.jsonl HERE (the test honors this via
# isolatedClaudeEnv), so the Upload step below grabs the structured
# transcript even on a failed/killed run. The /${{ matrix.model }} segment
# keeps the two matrix legs from sharing a config dir.
- name: Set archivable CLAUDE_CONFIG_DIR
run: echo "CLAUDE_CONFIG_DIR=$RUNNER_TEMP/spacedock-claude-config/${{ matrix.model }}" >> "$GITHUB_ENV"
# TestLiveEnsignCycle is the full lifecycle canary, TEAM-AGNOSTIC: it gates on
# dispatch-close + the on-disk terminal end-state, so it greens whether the
# headless `-p` FO drove team or bare (no team-vs-bare coin). Headless `-p` goes
# bare per the captain's go-bare determination, so this step carries no
# forced-team test; live team end-to-end (roster injection, the bounded teardown
# marker) runs in the dedicated pty/tmux team-mode step below.
# TestLiveDefaultHeadlessStopsAtGate is AC-a: default `-p` with NO conn drives to
# a gate:true stage and EXITS reporting gate status, without resolving the gate.
# TestLiveZeroDiscoverReportsAndStops is the cheap boot-only lean-boot guard (a
# zero-`status --discover` fixture: the FO reports no workflow found and STOPS,
# taking no broad filesystem sweep). All are boot/front-door drives, so they
# share this step and `-run` alternation rather than a second job.
# Output discipline: gotestsum runs the suite ONCE, prints a CLEAN step log
# (per-package progress + an `=== Failed` recap listing each failing test
# with file:line — no per-test === RUN / --- PASS firehose) and writes the
# full `-json` event stream to the `--jsonfile` archive for root-cause
# retrieval. gotestsum preserves the underlying `go test` exit code, so the
# step stays red on failure.
- name: Run live ensign cycle
env:
SPACEDOCK_LIVE_MODEL: ${{ matrix.model }}
run: |
set -o pipefail
mkdir -p "$SPACEDOCK_LIVE_ARTIFACT_DIR"
gotestsum --jsonfile live-e2e-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 40m -run 'TestLiveEnsignCycle|TestLiveDefaultHeadlessStopsAtGate|TestLiveZeroDiscoverReportsAndStops' ./internal/ensigncycle/
# The shared scenario suite: the Claude lane runs the SAME host-neutral
# gate/rejection/merge-hook scenarios the codex-live lane runs, through the
# spacedock claude front door. This is the AC-4 proof that both runtime lanes
# exercise the shared scenario table, not separate per-host smokes.
#
# `-timeout 40m` is a LOOSE BACKSTOP only — sized above the full 4-scenario
# serial-suite wall-time (~27m opus: rejection-flow 8.98m measured + the
# heavier 3-cycle escalation + gate + merge-hook). The REAL guard is the
# per-stage no-progress quiet budget (the streamWatcher's quietBudgetDefault,
# 60s) in the runners: it resets on every stream line and kills a hang at 60s
# of stream SILENCE. This ceiling never fires in a healthy run, it only bounds
# a pathological progressing-but-runaway loop.
# if: !cancelled() runs this step even when an EARLIER step (the ensign cycle)
# failed, so a flake there does not skip the shared-scenario proof — each step's
# result surfaces independently. It still skips on a real workflow cancellation.
- name: Run live Claude shared scenarios
if: ${{ !cancelled() }}
env:
SPACEDOCK_LIVE_MODEL: ${{ matrix.model }}
run: |
set -o pipefail
gotestsum --jsonfile claude-shared-scenarios-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 40m -run TestLiveClaudeSharedScenarios ./internal/ensigncycle/
# The pty/tmux team-mode harness: a REAL interactive claude session (not -p)
# where the native team tools are exposed and the session stays resident while
# teammates work. TestLivePtyStandingResidencyInjectsCommOfficer asserts the
# comm-officer standing teammate lands in the team config.json members[];
# TestLivePtyEnsignCycleTeamTeardown drives a real TeamCreate->dispatch->
# terminalize->teardown and grades the bounded TERMINAL_TEARDOWN_BOUNDED marker
# with the same grader the offline fixture suite uses. These are the live team
# end-to-end coverage headless `-p` cannot carry. The per-stage no-progress
# quiet budget is the real guard; -timeout is a loose backstop.
#
# if: !cancelled() decouples this step from a failure in the earlier ensign-cycle
# step (e.g. an unrelated TestLiveZeroDiscover flake), so the AC-3/4 pty proof
# always runs and reports its own pass/fail. It still skips on a real cancellation.
- name: Run live pty team-mode harness
if: ${{ !cancelled() }}
env:
SPACEDOCK_LIVE_MODEL: ${{ matrix.model }}
run: |
set -o pipefail
gotestsum --jsonfile pty-team-mode-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 40m -run 'TestLivePtyStandingResidencyInjectsCommOfficer|TestLivePtyEnsignCycleTeamTeardown' ./internal/ensigncycle/
# The MERGED team-mode lane: a HEADLESS `claude -p` FO (no tmux) on the
# current/unpinned merged host (>=2.1.178) drives the workflow as a named
# BACKGROUND Agent dispatch (no TeamCreate) — the in-process team shape #396
# committed. TestLiveMergedTeamModeDispatch asserts no TeamCreate (init tools +
# no tool_use), a merged Agent dispatch (name + run_in_background + no
# team_name), the on-disk subagents meta member (agentType=spacedock:ensign),
# reconcile (no --team-name) exit 0, and the team-lead completion target. This
# is the second live lane: the legacy pty lane above SKIPs on a merged host
# (native team tools gone), this lane is the regression sensor for the merged
# floor. No tmux needed — it is a `-p` launch like the shared-scenario step.
#
# if: !cancelled() decouples it from an earlier step's failure so it always
# runs and reports its own pass/fail; it still skips on a real cancellation.
- name: Run live merged team-mode lane
if: ${{ !cancelled() }}
env:
SPACEDOCK_LIVE_MODEL: ${{ matrix.model }}
run: |
set -o pipefail
gotestsum --jsonfile merged-team-mode-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 40m -run TestLiveMergedTeamModeDispatch ./internal/ensigncycle/
- name: Upload live artifacts
if: always()
uses: actions/upload-artifact@v5
with:
name: runtime-live-e2e-claude-live-${{ matrix.model }}
path: |
./spacedock
./live-e2e-detail.jsonl
./claude-shared-scenarios-detail.jsonl
./pty-team-mode-detail.jsonl
./merged-team-mode-detail.jsonl
${{ runner.temp }}/spacedock-claude-config/${{ matrix.model }}/projects
${{ runner.temp }}/spacedock-claude-config/${{ matrix.model }}/*/projects
live-artifacts/claude/**
live-artifacts/journey-metrics/**
if-no-files-found: warn
codex-live:
needs: offline
runs-on: ubuntu-latest
environment:
name: CI-E2E-CODEX
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SPACEDOCK_CODEX_LIVE_REQUIRED: "1"
SPACEDOCK_LIVE_ARTIFACT_DIR: ${{ github.workspace }}/live-artifacts/codex
SPACEDOCK_JOURNEY_METRICS_DIR: ${{ github.workspace }}/live-artifacts/journey-metrics/codex
steps:
- name: Check required secret
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "OPENAI_API_KEY is required for codex-live after CI-E2E-CODEX approval." >&2
exit 1
fi
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.22"
- uses: actions/setup-node@v6
with:
node-version: "20"
- name: Install Codex CLI
env:
CODEX_VERSION: ${{ inputs.codex_version }}
run: |
if [ -n "$CODEX_VERSION" ]; then
npm install -g "@openai/codex@$CODEX_VERSION"
else
npm install -g @openai/codex
fi
- name: Login to Codex
run: |
printf '%s\n' "$OPENAI_API_KEY" | codex login --with-api-key
codex login status
- name: Build spacedock binary
run: |
go build -o ./spacedock ./cmd/spacedock
echo "SPACEDOCK_BIN=$(pwd)/spacedock" >> "$GITHUB_ENV"
echo "SPACEDOCK_REPO_ROOT=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
# Pinned, sha256-verified gotestsum for the clean log + `-json` archive (see
# the claude-live job for the rationale).
- name: Install gotestsum
run: bash .github/scripts/install-gotestsum.sh
- name: Configure git identity
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global init.defaultBranch main
- name: Install current checkout as Codex plugin
run: |
mkdir -p "$SPACEDOCK_LIVE_ARTIFACT_DIR"
marketplace="$RUNNER_TEMP/spacedock-codex-marketplace"
mkdir -p "$marketplace/.agents/plugins" "$marketplace/plugins"
ln -s "$GITHUB_WORKSPACE" "$marketplace/plugins/spacedock"
cat > "$marketplace/.agents/plugins/marketplace.json" <<'JSON'
{
"name": "spacedock",
"plugins": [
{
"name": "spacedock",
"source": {
"source": "local",
"path": "./plugins/spacedock"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "workflow"
}
]
}
JSON
codex plugin marketplace add "$marketplace"
codex plugin add spacedock@spacedock
codex plugin list | tee "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-plugin-list.txt"
if grep -q 'github.com\|ref `next`' "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-plugin-list.txt"; then
echo "codex-live must not install spacedock from remote next." >&2
exit 1
fi
if ! grep -Fq "$marketplace/plugins/spacedock" "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-plugin-list.txt"; then
echo "codex-live did not list the current checkout plugin path." >&2
exit 1
fi
# AC-1: the EDGE channel install end-to-end on the real codex CLI. Codex
# enforces that a marketplace entry's name equals the plugin's own plugin.json
# `name` (`spacedock`). The OLD channel encoding put the channel in the ENTRY
# name (`spacedock-edge`), so `codex plugin add spacedock-edge@spacedock` was
# rejected — the failing-today baseline. The fix puts the channel in the
# marketplace NAME (`spacedock-edge`) with the entry still `spacedock`, so
# `codex plugin add spacedock@spacedock-edge` passes. This step proves BOTH on
# the live host: the old shape still fails (baseline moves the wrong way) and
# the new shape succeeds (the fix flips it).
- name: Codex edge channel install (name-match baseline → fix)
run: |
# Baseline: the OLD shape — entry named `spacedock-edge` in a marketplace
# named `spacedock` — must FAIL codex's entry-name vs plugin.json name
# check. (The plugin's own plugin.json name is `spacedock`.) The baseline's
# marketplace is ALSO named `spacedock`, which collides with the real
# `spacedock` marketplace this job already registered ("marketplace
# 'spacedock' is already added from a different source"). Run the baseline
# under its OWN isolated CODEX_HOME (a subshell export, scoped to these
# commands) so the old-shape add can't collide; the fix portion below runs
# in the job's default CODEX_HOME and integrates with the rest of the job.
(
baseline_home="$RUNNER_TEMP/spacedock-codex-baseline-home"
mkdir -p "$baseline_home"
export CODEX_HOME="$baseline_home"
oldmkt="$RUNNER_TEMP/spacedock-codex-oldshape"
mkdir -p "$oldmkt/.agents/plugins" "$oldmkt/plugins"
ln -s "$GITHUB_WORKSPACE" "$oldmkt/plugins/spacedock"
cat > "$oldmkt/.agents/plugins/marketplace.json" <<'JSON'
{
"name": "spacedock",
"plugins": [
{
"name": "spacedock-edge",
"source": { "source": "local", "path": "./plugins/spacedock" },
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
"category": "workflow"
}
]
}
JSON
codex plugin marketplace add "$oldmkt"
if codex plugin add spacedock-edge@spacedock 2> "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-baseline-err.txt"; then
echo "BASELINE REGRESSION: codex accepted the old entry-name shape spacedock-edge@spacedock; the name-match constraint this AC depends on no longer holds." >&2
cat "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-baseline-err.txt" >&2
exit 1
fi
if ! grep -Fq "does not match" "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-baseline-err.txt"; then
echo "BASELINE add failed, but NOT with the expected name-mismatch error (e.g. a marketplace collision); the baseline must fail for the name-match reason." >&2
cat "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-baseline-err.txt" >&2
exit 1
fi
echo "Baseline confirmed: codex rejects spacedock-edge@spacedock (entry name != plugin.json name)." | tee -a "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-baseline-err.txt"
)
# Fix: the NEW shape — entry still `spacedock`, marketplace named
# `spacedock-edge` — must SUCCEED. This is the channelMarketplace fix the
# binary now emits (spacedock@spacedock-edge). It runs in the job's default
# CODEX_HOME; the `spacedock-edge` marketplace name is distinct from the
# already-registered `spacedock`, so no collision.
edgemkt="$RUNNER_TEMP/spacedock-codex-edge-marketplace"
mkdir -p "$edgemkt/.agents/plugins" "$edgemkt/plugins"
ln -s "$GITHUB_WORKSPACE" "$edgemkt/plugins/spacedock"
cat > "$edgemkt/.agents/plugins/marketplace.json" <<'JSON'
{
"name": "spacedock-edge",
"plugins": [
{
"name": "spacedock",
"source": { "source": "local", "path": "./plugins/spacedock" },
"policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
"category": "workflow"
}
]
}
JSON
codex plugin marketplace add "$edgemkt"
codex plugin add spacedock@spacedock-edge
codex plugin list | tee "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-plugin-list.txt"
if ! grep -Fq "spacedock@spacedock-edge" "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-plugin-list.txt"; then
echo "codex-live edge install did not list spacedock@spacedock-edge after a successful add." >&2
exit 1
fi
if ! grep -Fq "$edgemkt/plugins/spacedock" "$SPACEDOCK_LIVE_ARTIFACT_DIR/codex-edge-plugin-list.txt"; then
echo "codex-live edge install did not list the current checkout plugin path." >&2
exit 1
fi
echo "Fix confirmed: codex installed spacedock@spacedock-edge from the spacedock-edge marketplace name."
- name: Verify Codex resolver against installed plugin
run: go test ./internal/cli -run TestCodexResolveManifestAgainstInstalledHost
- name: Show tool versions
run: |
codex --version
go version
echo "### Codex live tool versions" >> "$GITHUB_STEP_SUMMARY"
echo "- \`codex --version\`: \`$(codex --version)\`" >> "$GITHUB_STEP_SUMMARY"
echo "- \`go version\`: \`$(go version)\`" >> "$GITHUB_STEP_SUMMARY"
# Clean step log + archived `-json` detail from one run (see the claude-live
# job for the rationale).
- name: Run live Codex shared scenarios
run: |
set -o pipefail
gotestsum --jsonfile codex-shared-scenarios-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 40m -run TestLiveCodexSharedScenarios ./internal/ensigncycle
- name: Upload live artifacts
if: always()
uses: actions/upload-artifact@v5
with:
name: runtime-live-e2e-codex-live
path: |
./spacedock
./codex-shared-scenarios-detail.jsonl
live-artifacts/codex/**
live-artifacts/journey-metrics/**
if-no-files-found: warn
pi-live:
needs: offline
runs-on: ubuntu-latest
environment:
name: CI-E2E-PI
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SPACEDOCK_PI_LIVE_REQUIRED: "1"
SPACEDOCK_LIVE_ARTIFACT_DIR: ${{ github.workspace }}/live-artifacts/pi
SPACEDOCK_JOURNEY_METRICS_DIR: ${{ github.workspace }}/live-artifacts/journey-metrics/pi
PI_OFFLINE: "1"
steps:
- name: Check required secret
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "OPENAI_API_KEY is required for pi-live after CI-E2E-PI approval." >&2
exit 1
fi
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.22"
- uses: actions/setup-node@v6
with:
node-version: "20"
- name: Install Pi CLI and substrates
run: |
npm --version
NPM_BEFORE="$(node -e 'console.log(new Date(Date.now() - 24*60*60*1000).toISOString())')"
echo "Using npm --before age gate for pi-live installs: $NPM_BEFORE"
npm install -g @earendil-works/pi-coding-agent --before="$NPM_BEFORE" --ignore-scripts --no-audit --no-fund --omit=dev
command -v pi
test -x "$(command -v pi)"
pi --version
global_npm_root="$(npm root -g)"
node -e "const p=require('$global_npm_root/@earendil-works/pi-coding-agent/package.json'); if (p.name !== '@earendil-works/pi-coding-agent') throw new Error('unexpected Pi package name '+p.name); if (!p.bin || p.bin.pi !== 'dist/cli.js') throw new Error('unexpected Pi bin '+JSON.stringify(p.bin)); console.log('verified '+p.name+'@'+p.version+' bin pi='+p.bin.pi)"
pi_npm_root="$HOME/.pi/agent/npm"
mkdir -p "$pi_npm_root"
npm install --prefix "$pi_npm_root" \
pi-subagents \
pi-intercom \
--before="$NPM_BEFORE" --ignore-scripts --no-audit --no-fund --omit=dev
node -e "const p=require('$pi_npm_root/node_modules/pi-subagents/package.json'); if (p.name !== 'pi-subagents') throw new Error('unexpected pi-subagents package name '+p.name); console.log('verified '+p.name+'@'+p.version)"
node -e "const p=require('$pi_npm_root/node_modules/pi-intercom/package.json'); if (p.name !== 'pi-intercom') throw new Error('unexpected pi-intercom package name '+p.name); console.log('verified '+p.name+'@'+p.version)"
test -f "$pi_npm_root/node_modules/pi-subagents/src/extension/index.ts"
test -f "$pi_npm_root/node_modules/pi-subagents/skills/pi-subagents/SKILL.md"
test -f "$pi_npm_root/node_modules/pi-subagents/src/intercom/intercom-bridge.ts"
test -f "$pi_npm_root/node_modules/pi-intercom/skills/pi-intercom/SKILL.md"
echo "PI_SUBAGENTS_PACKAGE_ROOT=$pi_npm_root/node_modules/pi-subagents" >> "$GITHUB_ENV"
echo "PI_INTERCOM_PACKAGE_ROOT=$pi_npm_root/node_modules/pi-intercom" >> "$GITHUB_ENV"
- name: Build spacedock binary
run: |
go build -o ./spacedock ./cmd/spacedock
echo "SPACEDOCK_BIN=$(pwd)/spacedock" >> "$GITHUB_ENV"
echo "SPACEDOCK_REPO_ROOT=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
echo "$(pwd)" >> "$GITHUB_PATH"
# Pinned, sha256-verified gotestsum for the clean log + `-json` archive (see
# the claude-live job for the rationale).
- name: Install gotestsum
run: bash .github/scripts/install-gotestsum.sh
- name: Configure git identity
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --global init.defaultBranch main
- name: Verify Pi current-checkout setup
run: |
mkdir -p "$SPACEDOCK_LIVE_ARTIFACT_DIR"
spacedock doctor --host pi --plugin-dir "$GITHUB_WORKSPACE" | tee "$SPACEDOCK_LIVE_ARTIFACT_DIR/pi-doctor.txt"
test -f "$GITHUB_WORKSPACE/skills/first-officer/references/pi-first-officer-runtime.md"
test -f "$GITHUB_WORKSPACE/skills/ensign/references/pi-ensign-runtime.md"
test -f "$PI_SUBAGENTS_PACKAGE_ROOT/src/extension/index.ts"
test -f "$PI_SUBAGENTS_PACKAGE_ROOT/skills/pi-subagents/SKILL.md"
test -f "$PI_SUBAGENTS_PACKAGE_ROOT/src/intercom/intercom-bridge.ts"
test -f "$PI_INTERCOM_PACKAGE_ROOT/skills/pi-intercom/SKILL.md"
- name: Show tool versions
run: |
pi --version
go version
echo "### Pi live tool versions" >> "$GITHUB_STEP_SUMMARY"
echo "- \`pi --version\`: \`$(pi --version)\`" >> "$GITHUB_STEP_SUMMARY"
echo "- \`go version\`: \`$(go version)\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Effort: \`${{ inputs.effort }}\`" >> "$GITHUB_STEP_SUMMARY"
# Clean step log + archived `-json` detail from one run per step (see the
# claude-live job for the rationale).
- name: Run Pi shared scenario coverage guard
run: |
set -o pipefail
gotestsum --jsonfile pi-coverage-detail.jsonl --format pkgname -- -tags live -count=1 -run 'TestSharedScenarioRunnerCoverage|TestPiSharedScenarioCoverage' ./internal/ensigncycle
- name: Run live Pi front-door smoke
run: |
set -o pipefail
gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -run TestLivePiFrontDoorSmoke ./internal/ensigncycle
- name: Upload live artifacts
if: always()
uses: actions/upload-artifact@v5
with:
name: runtime-live-e2e-pi-live
path: |
./spacedock
./pi-coverage-detail.jsonl
./pi-front-door-smoke-detail.jsonl
live-artifacts/pi/**
live-artifacts/journey-metrics/**
if-no-files-found: warn
# AC-3: posts a per-PR delta of this run's journey-cost observations against
# the latest published release ledger, as a single sticky-updating PR comment
# (`gh pr comment --edit-last`, so repeated pushes update ONE comment instead
# of appending a new one). PR-only — a workflow_dispatch run has no PR to
# comment on. needs: claude-live with no explicit `if:` on that edge means
# this job runs only once claude-live's matrix completed successfully;
# shallow-boot-window (like every scenario this comments on) is Claude-only.
# A job-level permissions block grants pull-requests: write beyond the
# workflow-level contents: read default; specifying any job-level permissions
# replaces (not merges with) the workflow default, so contents/actions read
# are restated here for checkout and cross-job artifact download.
journey-delta-comment:
needs: claude-live
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
pull-requests: write
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.22"
- name: Download this run's Claude journey metrics
uses: actions/download-artifact@v5
with:
pattern: runtime-live-e2e-claude-live-*
path: ${{ runner.temp }}/current-run-artifacts
merge-multiple: true
# Best-effort: no previously published release (or one with no
# journey-costs asset) means no baseline to diff against. `|| true` /
# explicit exit-0 branches degrade either gap to a non-fatal skip so a
# missing baseline never REDs the PR.
- name: Download the latest published release's journey-cost ledger
id: download_previous_ledger
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
previous_tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' || true)"
if [ -z "$previous_tag" ] || [ "$previous_tag" = "null" ]; then
echo "::warning::no published release found; skipping journey-cost delta comment" >&2
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! gh release download "$previous_tag" -p 'journey-costs-*.json' -O "$RUNNER_TEMP/previous-journey-costs.json"; then
echo "::warning::published release $previous_tag has no journey-costs asset; skipping journey-cost delta comment" >&2
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "found=true" >> "$GITHUB_OUTPUT"
# download-artifact's merge-multiple flat-copies every matched artifact's
# files into current-run-artifacts, but PRESERVES each artifact's own
# internal path prefix (verified against a real run's downloaded zip:
# the journey-metrics JSON lands several directories deep, not directly
# under "live-artifacts/journey-metrics/"). A hardcoded exact subpath is
# brittle to that prefix; find + cp (release.yml's own pattern for the
# same class of problem) is layout-agnostic — it locates the metrics
# files wherever they actually landed.
- name: Locate this run's journey metrics
if: steps.download_previous_ledger.outputs.found == 'true'
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/current-run-metrics"
find "$RUNNER_TEMP/current-run-artifacts" -path '*/journey-metrics/*.json' -type f -exec cp {} "$RUNNER_TEMP/current-run-metrics/" \;
- name: Post the journey-cost delta PR comment
if: steps.download_previous_ledger.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
go run ./cmd/spacedock-release journey-delta "$RUNNER_TEMP/previous-journey-costs.json" \
--metrics-dir "$RUNNER_TEMP/current-run-metrics" \
--pr "${{ github.event.pull_request.number }}"