Repository navigation
1148 lines (1066 loc) · 52.3 KB
/
Copy pathrelease.yml
File metadata and controls
1148 lines (1066 loc) · 52.3 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*.*.*'
# Tag vX.Y.Z → publishes codeam-cli to npm, the VS Code extension to the VS
# Code Marketplace and Open VSX, and the JetBrains plugin to JetBrains
# Marketplace (stable channel). Drafts a GitHub Release with all three
# artifacts attached. Pre-release tags (vX.Y.Z-rc.N) skip the JetBrains
# marketplace push.
#
# ── RELEASE GATE (codeagent-nczt.6) ──────────────────────────────────────────
# Nothing reaches users until the journey canary passes on THIS version:
#
# publish-cli / publish-shared → npm dist-tag `next` (never `latest`)
# publish-box → ghcr codeam-box:<version> (never `:latest`)
# gate → `next` := <version> (npm + image), dispatch
# codeagent-mobile journey-canary.yml against
# DEV (its fleet host runs `:next` — env
# CODEAM_FLEET_BOX_TAG=next) and wait; the
# canary requires the Box session to report
# this exact version.
# green → `latest` := <version> (npm + image) → prebuild refresh, VS Code,
# JetBrains, GitHub Release, changelog.
# red → `latest` never moves, `next` goes back to `latest`, Discord alert.
#
# Why `latest` is the thing to protect: every Box/host self-updates hourly from
# npm `latest` (host/self-update.ts), new Boxes and the codespace prebuild boot
# `codeam-box:latest`, and codespaces install `codeam-cli@latest`.
#
# Break-glass: repo variable CLI_RELEASE_GATE=bypass skips the canary (the
# promotion still runs and says so). Delete the variable afterwards.
#
# Topology: `setup` validates the tag; `publish-cli` / `publish-shared` fan out
# in parallel; `publish-box` builds the image; `gate` promotes; the IDE
# plugins and the `release` job wait for the gate.
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
# Opt into Node.js 24 runtime for GitHub JavaScript actions (silences the
# deprecation warning from Node 20 default — will be removed 2026-09-16).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
setup:
name: Validate tag and expose version
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
steps:
- name: Extract version from tag
id: version
run: |
VERSION="${GITHUB_REF_NAME#v}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match vMAJOR.MINOR.PATCH[-prerelease]"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
if [[ "$VERSION" == *-* ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
fi
echo "Publishing version: $VERSION"
# Publishes @codeam/shared to npm. Runs in PARALLEL with publish-cli —
# publish-cli deliberately does NOT depend on this job, because the CLI
# and the IDE plugins bundle the shared package's SOURCE at build time
# and never resolve the published artifact. A failure here must never
# block a client release.
#
# ⚠️ If this job fails on `npm publish`, the most likely cause is that
# the NPM_TOKEN repo secret lacks publish rights on the @codeam npm
# scope (historically it only published the unscoped `codeam-cli`).
# Fix: mint an Automation token with publish access to the @codeam
# scope (it already owns @codeam/ide-*), update the NPM_TOKEN secret,
# and re-run this job — the version guard makes retries idempotent.
publish-shared:
name: Publish @codeam/shared to npm
needs: setup
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Patch shared version
working-directory: packages/shared
run: npm version "${{ needs.setup.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Install workspace dependencies
run: npm ci
- name: Shared typecheck
working-directory: packages/shared
run: npm run typecheck
- name: Shared tests
working-directory: packages/shared
run: npm run test
- name: Build shared
working-directory: packages/shared
run: npm run build
- name: Publish @codeam/shared to npm
working-directory: packages/shared
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.setup.outputs.version }}
run: |
# Idempotent publish — same guard as the CLI job: skip when this
# exact version already exists on the registry so retries
# (re-tagged release, hotfix re-push) stay green.
if npm view "@codeam/shared@$VERSION" version >/dev/null 2>&1; then
echo "::notice::@codeam/shared@$VERSION already published — skipping npm publish"
exit 0
fi
# `next`, never `latest`: the `gate` job promotes it with the CLI.
npm publish --access public --tag next
publish-cli:
name: Publish CLI to npm
needs: setup
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Patch CLI version
working-directory: apps/cli
run: npm version "${{ needs.setup.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Install workspace dependencies
run: npm ci
- name: CLI typecheck
working-directory: apps/cli
run: npm run typecheck
- name: CLI tests
working-directory: apps/cli
run: npm run test
- name: Build CLI
working-directory: apps/cli
env:
# PostHog ingestion key baked at build time via tsup's
# `define` (apps/cli/tsup.config.ts). Public-by-design — same
# write-key shape mobile + landing bake. Leave the GitHub
# secret blank to disable telemetry across published builds.
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
run: npm run build
- name: Pack CLI tarball (for GitHub Release artifact)
working-directory: apps/cli
run: npm pack
- name: Publish CLI to npm
working-directory: apps/cli
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.setup.outputs.version }}
# `npm publish` triggers `prepublishOnly: "npm run build"`,
# which re-runs tsup. Without these env vars present the
# second build's `define` substitutes `__POSTHOG_API_KEY__`
# with `""` and overwrites the dist/ from the "Build CLI"
# step — telemetry silently disabled in every published
# build. (#192 root cause: PostHog dashboard showed zero
# CLI events for 60 days despite the secret being set on
# the repo since 2026-05-24.)
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
run: |
# Idempotent publish — skip if this exact version already
# exists on the registry. npm itself returns 403 on republish,
# which is correct semantics but turns retries (re-tagged
# release, hotfix re-push, anything) into red runs. With the
# guard, downstream jobs (VS Code, JetBrains, GitHub Release
# changelog) still run on retry without us first having to
# `git tag -d`.
if npm view "codeam-cli@$VERSION" version >/dev/null 2>&1; then
echo "::notice::codeam-cli@$VERSION already published — skipping npm publish"
exit 0
fi
# ⚠️ `next`, NEVER `latest` (npm's default). `latest` is what every
# Box/host self-updates to within the hour; only the `gate` job moves
# it, after the journey canary passed on this version.
npm publish --access public --tag next
- name: Upload CLI tarball artifact
uses: actions/upload-artifact@v7
with:
name: cli-tgz
path: apps/cli/codeam-cli-${{ needs.setup.outputs.version }}.tgz
# The files live on in the GitHub Release; Actions storage is billed.
retention-days: 7
# Builds + pushes the CodeAgent Box runtime image (apps/box) to GHCR on every
# stable tag. Previously a MANUAL `docker build`, which is exactly how the box
# drifted a node major behind the codespace bootstrap (2026-07-16: the box was
# still node:20-slim while the ACP adapter had moved to engines.node ">=22").
# Automating it here keeps the image's node + pinned codeam-cli in lockstep
# with the release, so a rescued user never boots on a stale runtime.
#
# Depends on publish-cli: the image does `npm install -g codeam-cli@<version>`,
# so that version must already be live on npm (under `next` — an exact
# version installs whatever its dist-tag).
#
# Gated on verify-published (up to 7.5 min of registry replica lag), not just
# publish-cli: v2.76.29's first attempt died here because the 200 s wait below
# ran out before npm served the version — same race as codeagent-itqg — and a
# failed image build also holds the release gate.
publish-box:
name: Build and publish CodeAgent Box image
needs: [setup, publish-cli, verify-published]
if: always() && needs.setup.outputs.is_prerelease != 'true' && needs.publish-cli.result == 'success' && needs.verify-published.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Wait for codeam-cli on npm
env:
VERSION: ${{ needs.setup.outputs.version }}
run: |
for i in $(seq 1 40); do
if npm view "codeam-cli@${VERSION}" version >/dev/null 2>&1; then
echo "codeam-cli@${VERSION} is live on npm"; exit 0
fi
echo "waiting for codeam-cli@${VERSION} on npm (attempt ${i})..."; sleep 5
done
echo "::error::codeam-cli@${VERSION} never appeared on npm — cannot build the box image against it"
exit 1
# Only the version tag. `:next` / `:latest` are moved by the `gate` job
# with `imagetools create` (a manifest copy, no rebuild), so the image the
# canary walked is byte-for-byte the one prod boxes get.
- name: Build and push box image (node 22 base, pinned CLI)
env:
VERSION: ${{ needs.setup.outputs.version }}
run: |
IMAGE="ghcr.io/codeagentmobile/codeam-box"
docker build \
--build-arg "CODEAM_CLI_VERSION=${VERSION}" \
-t "${IMAGE}:${VERSION}" \
apps/box
docker push "${IMAGE}:${VERSION}"
# ── The release gate: next → journey canary on dev → latest ─────────────
# ONE job (and one concurrency group across tags) so arming `next`, the
# canary and the promotion/rollback of two tags in flight can't interleave.
# Re-running it is safe: it re-arms `next` to this version first.
gate:
name: 'Release gate: next → journey canary (dev) → latest'
needs: [setup, publish-box, verify-published]
if: needs.setup.outputs.is_prerelease != 'true'
runs-on: ubuntu-latest
timeout-minutes: 75
concurrency:
group: clients-release-gate
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
VERSION: ${{ needs.setup.outputs.version }}
IMAGE: ghcr.io/codeagentmobile/codeam-box
CANARY_REPO: CodeAgentMobile/codeagent-mobile
steps:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
# The dev fleet host creates/wakes Boxes from `codeam-box:next`
# (CODEAM_FLEET_BOX_TAG=next on fleet-1's codeam-fleet-dev unit); npm
# `next` is the channel for anyone who wants to try the candidate.
- name: Arm `next` with this version (npm + Box image)
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
npm dist-tag add "codeam-cli@${VERSION}" next
npm dist-tag add "@codeam/shared@${VERSION}" next
docker buildx imagetools create -t "${IMAGE}:next" "${IMAGE}:${VERSION}"
echo "next → ${VERSION} (npm codeam-cli + @codeam/shared, ${IMAGE}:next)"
- name: Journey canary on dev with codeam-cli@${{ needs.setup.outputs.version }}
id: canary
if: vars.CLI_RELEASE_GATE != 'bypass'
env:
# Classic PAT with `repo` on codeagent-mobile (same one the
# api-v2-integration dispatch uses): dispatch + read its runs.
GH_TOKEN: ${{ secrets.CROSS_REPO_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN}" ]; then
echo "::error::CROSS_REPO_PAT is not set — the release gate cannot dispatch the journey canary."
exit 1
fi
# The nonce is how we find OUR run: journey-canary.yml puts it in
# its run-name. Attempt-scoped so a re-run never adopts an old run.
NONCE="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
gh api "repos/${CANARY_REPO}/dispatches" \
-f event_type=cli-release-gate \
-f "client_payload[cliVersion]=${VERSION}" \
-f "client_payload[nonce]=${NONCE}" >/dev/null
echo "dispatched journey-canary (cli-release-gate ${VERSION}, nonce ${NONCE})"
RUN_ID=""
for _ in $(seq 1 30); do
RUN_ID=$(gh run list -R "${CANARY_REPO}" --workflow journey-canary.yml \
--event repository_dispatch --limit 30 --json databaseId,displayTitle \
--jq ".[] | select(.displayTitle | contains(\"${NONCE}\")) | .databaseId" | head -1)
[ -n "${RUN_ID}" ] && break
sleep 10
done
if [ -z "${RUN_ID}" ]; then
echo "::error::the journey canary run for nonce ${NONCE} never appeared in ${CANARY_REPO}"
exit 1
fi
URL="https://github.com/${CANARY_REPO}/actions/runs/${RUN_ID}"
echo "url=${URL}" >> "$GITHUB_OUTPUT"
echo "journey canary: ${URL}"
# A fresh Box pulls the ~6 GB image on fleet-1: give it 60 min, well
# under the job timeout so a slow canary fails HERE (and rolls `next`
# back) instead of the runner killing the job.
DEADLINE=$((SECONDS + 60 * 60))
while :; do
STATE=$(gh run view "${RUN_ID}" -R "${CANARY_REPO}" --json status,conclusion \
--jq '"\(.status) \(.conclusion)"' || echo "unknown ")
case "${STATE}" in completed\ *) break ;; esac
if [ "${SECONDS}" -ge "${DEADLINE}" ]; then
echo "::error::journey canary still '${STATE}' after 60 min: ${URL}"
exit 1
fi
sleep 30
done
CONCLUSION="${STATE#completed }"
echo "### Journey canary (dev) for codeam-cli@${VERSION}: ${CONCLUSION} — ${URL}" >> "$GITHUB_STEP_SUMMARY"
if [ "${CONCLUSION}" != "success" ]; then
echo "::error::journey canary ${CONCLUSION} for codeam-cli@${VERSION} — \`latest\` is NOT moved: ${URL}"
exit 1
fi
- name: Canary red → `next` back to `latest` (dev runs what prod runs)
if: failure()
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -uo pipefail
CLI_LATEST=$(npm view codeam-cli dist-tags.latest)
SHARED_LATEST=$(npm view @codeam/shared dist-tags.latest)
npm dist-tag add "codeam-cli@${CLI_LATEST}" next
npm dist-tag add "@codeam/shared@${SHARED_LATEST}" next
docker buildx imagetools create -t "${IMAGE}:next" "${IMAGE}:latest"
echo "### ❌ Gate failed — \`latest\` stays ${CLI_LATEST}; \`next\` reset to it (npm + ${IMAGE}:next)" >> "$GITHUB_STEP_SUMMARY"
- name: Promote to `latest` (npm + Box image)
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
BYPASS: ${{ vars.CLI_RELEASE_GATE == 'bypass' }}
run: |
set -euo pipefail
if [ "${BYPASS}" = "true" ]; then
echo "::warning::CLI_RELEASE_GATE=bypass — promoting codeam-cli@${VERSION} WITHOUT the journey canary. Delete the repo variable once the emergency is over."
echo "### ⚠️ Gate BYPASSED (CLI_RELEASE_GATE=bypass) — promoted without the canary" >> "$GITHUB_STEP_SUMMARY"
fi
npm dist-tag add "codeam-cli@${VERSION}" latest
npm dist-tag add "@codeam/shared@${VERSION}" latest
docker buildx imagetools create -t "${IMAGE}:latest" "${IMAGE}:${VERSION}"
# Same replica lag verify-published allows for: dist-tag reads can trail.
for ATTEMPT in $(seq 1 15); do
SEEN=$(npm view codeam-cli dist-tags.latest 2>/dev/null || true)
[ "${SEEN}" = "${VERSION}" ] && break
echo "… npm still reports latest=${SEEN:-?} (attempt ${ATTEMPT}/15)"
sleep 30
done
if [ "${SEEN}" != "${VERSION}" ]; then
echo "::error::npm dist-tag latest is ${SEEN:-?}, expected ${VERSION}"
exit 1
fi
echo "### ✅ codeam-cli@${VERSION} promoted: npm latest + ${IMAGE}:latest" >> "$GITHUB_STEP_SUMMARY"
# Keeps the wrapper repo's Codespaces prebuild on the PROMOTED image — it
# re-pulls `codeam-box:latest`, so it must run after the gate moved it.
refresh-prebuild:
name: Refresh the wrapper-repo Codespaces prebuild
needs: [setup, gate]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# Keep the wrapper repo's Codespaces PREBUILD in sync with the image we just
# pushed — WITHOUT ever leaving a window with no valid prebuild.
#
# ⚠️ This step used to bump a `// prebuild-refresh` marker inside
# `.devcontainer/devcontainer.json`. GitHub keys a prebuild to the dev
# container CONFIGURATION ("Prebuild ready … for a particular dev container
# configuration file"), so every release INVALIDATED the live prebuild until
# the new one finished (~19 min, e.g. deb91f49 12:01→12:20), and when GitHub
# never started the new one (2.76.8, 50eac85f, 2026-09-30) codespaces booted
# without a prebuild for ~26 h: 72–143 s to Available, 8 deploys timed out.
#
# Now: devcontainer.json is NEVER touched by a release. The release bumps the
# NON-config file `PREBUILD_VERSION` at the repo root. With the wrapper repo's
# prebuild trigger set to "Every push", that push starts a rebuild (which
# re-pulls `codeam-box:latest`) while the CURRENT prebuild stays valid —
# same configuration — until the new one replaces it. Freshness between
# rebuilds is covered by the host-agent's own hourly self-update.
#
# Safety net: confirm GitHub actually started a prebuild for the pushed SHA.
# If not (trigger not "Every push", or GitHub skipped it), nudge once, then
# fail this step loudly. Either way the live prebuild is never invalidated.
# Best-effort (the image is already published); needs a PAT with
# `contents:write` on CodeAgentMobile/codeam-codespace (secret WRAPPER_REPO_PAT).
- name: Refresh the wrapper-repo Codespaces prebuild
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.WRAPPER_REPO_PAT }}
VERSION: ${{ needs.setup.outputs.version }}
run: |
if [ -z "${GH_TOKEN}" ]; then
echo "::warning::WRAPPER_REPO_PAT not set — skipping prebuild refresh (codespaces keep the current prebuild; the host-agent self-updates)"
exit 0
fi
git clone --depth 1 \
"https://x-access-token:${GH_TOKEN}@github.com/CodeAgentMobile/codeam-codespace.git" wrapper
cd wrapper
git config user.name "codeam-release"
git config user.email "release@codeagent-mobile.com"
# 0 = a prebuild run exists for the SHA, 1 = none after 5 min,
# 2 = the Actions API itself failed (auth/permission) — never confuse
# an API error with "no prebuild", and never retry-commit on one.
wait_for_prebuild() {
local sha="$1" n out
for _ in $(seq 1 15); do
if ! out=$(gh api "repos/CodeAgentMobile/codeam-codespace/actions/runs?head_sha=${sha}" --jq '.total_count' 2>&1); then
echo "::error::Actions API error while checking the prebuild for ${sha} (does WRAPPER_REPO_PAT have actions:read on CodeAgentMobile/codeam-codespace?): ${out}"
return 2
fi
n="${out}"
if [ "${n:-0}" -gt 0 ]; then
echo "Prebuild run started for ${sha}"
return 0
fi
sleep 20
done
return 1
}
# ⚠️ No `[skip ci]` in these commits: prebuilds ARE Actions workflows,
# and with the "Every push" trigger the tag could suppress the very run
# this push exists to start.
bump() {
printf '%s\n' "$1" > PREBUILD_VERSION
git add PREBUILD_VERSION
git commit -m "chore: refresh prebuild → codeam-box:latest ($1)"
git push
}
bump "codeam-cli ${VERSION}"
SHA=$(git rev-parse HEAD)
rc=0; wait_for_prebuild "$SHA" || rc=$?
if [ "$rc" -eq 0 ]; then exit 0; fi
if [ "$rc" -eq 2 ]; then exit 1; fi
echo "::warning::No Codespaces prebuild run for ${SHA} after 5 min — nudging once"
bump "codeam-cli ${VERSION} (retry)"
SHA=$(git rev-parse HEAD)
rc=0; wait_for_prebuild "$SHA" || rc=$?
if [ "$rc" -eq 0 ]; then exit 0; fi
if [ "$rc" -eq 2 ]; then exit 1; fi
echo "::error::No Codespaces prebuild started for CodeAgentMobile/codeam-codespace@${SHA}. The CURRENT prebuild is still valid (devcontainer.json unchanged), so deploys stay fast, but it carries an older image. Check that the repo's prebuild trigger is \"Every push\" (Settings → Codespaces)."
echo "### ⚠️ Wrapper prebuild did not start for ${SHA} — current prebuild still serves; check the prebuild trigger" >> "$GITHUB_STEP_SUMMARY"
exit 1
trigger-api-integration:
# Cross-repo regression gate: as soon as codeam-cli@<version>
# is live on npm, dispatch the api-v2-integration workflow in
# `codeagent-mobile` so its docker-e2e installs THIS exact
# version inside the surrogate container and runs the full
# api ↔ CLI integration suite. Without this, CLI tag pushes
# never exercised the e2e — verified empirically when v2.23.13
# / .14 / .15 each shipped api ↔ CLI breakage that nobody
# caught until prod traffic surfaced it.
#
# `repository_dispatch` is the right tool here (`gh workflow
# run` would need a PAT with `actions:write` on the cross-repo;
# the dispatch event accepts a `client_payload` and works with
# the same `repo` scope on the target's secret).
#
# Non-blocking by design: this job runs only once the version is
# live on npm but doesn't block the release flow's `release`
# finalisation. A failed e2e shows up as a red dispatch run on
# the api-v2 side and notifies the maintainer; we don't
# un-publish the npm tag retroactively. The "fail loud" is the
# GitHub UI surfacing the integration run as a Check.
#
# Gated on verify-published, NOT publish-cli: a finished `npm publish`
# is not a version the registry serves yet. v2.76.24 (2026-10-12) was
# dispatched 6 s after publish-cli, verify-published only saw the
# version 3.7 min later, and the e2e's `npm install` died ETARGET
# (codeagent-itqg). verify-published polls `npm view` for up to 7.5 min.
name: Trigger api-v2 integration tests
needs: [setup, verify-published]
runs-on: ubuntu-latest
if: ${{ needs.setup.outputs.is_prerelease != 'true' && needs.verify-published.result == 'success' }}
permissions:
contents: read
steps:
- name: Dispatch api-v2-integration
env:
# Same PAT the issue-forwarding workflow uses. Needs `repo`
# scope on codeagent-mobile (private repo doesn't accept a
# bare `GITHUB_TOKEN` from this workflow).
DISPATCH_TOKEN: ${{ secrets.CROSS_REPO_PAT }}
VERSION: ${{ needs.setup.outputs.version }}
run: |
if [ -z "$DISPATCH_TOKEN" ]; then
echo "::warning::CROSS_REPO_PAT not set — skipping api-v2-integration trigger. Add a PAT (repo scope on codeagent-mobile) to enable the regression gate."
exit 0
fi
curl -fsSL \
-X POST \
-H "Authorization: Bearer $DISPATCH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/CodeAgentMobile/codeagent-mobile/dispatches \
-d "{\"event_type\":\"cli-released\",\"client_payload\":{\"cliVersion\":\"$VERSION\"}}"
echo "::notice::Dispatched api-v2-integration for codeam-cli@$VERSION"
# The IDE plugins ship the same release as the CLI, so they wait for the gate
# too (a pre-release has no gate and publishes the VS Code build as before).
publish-vsc:
name: Publish VS Code plugin
needs: [setup, gate]
if: ${{ always() && needs.setup.result == 'success' && (needs.gate.result == 'success' || needs.setup.outputs.is_prerelease == 'true') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Patch VS Code plugin version
working-directory: apps/vsc-plugin
run: npm version "${{ needs.setup.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Install workspace dependencies
run: npm ci
- name: Build VS Code plugin
working-directory: apps/vsc-plugin
env:
# PostHog ingestion key baked at build time via esbuild's
# `define` (apps/vsc-plugin/esbuild.js). Same write-key
# shape the CLI bakes. Without this env block esbuild
# inlines an empty string and telemetry is silently
# disabled in published builds.
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
run: npm run build
- name: Package VS Code plugin (.vsix)
working-directory: apps/vsc-plugin
run: |
npx --no-install @vscode/vsce package --no-dependencies --out "codeagent-mobile-${{ needs.setup.outputs.version }}.vsix"
# ⚠️ Los dos publicados toleran «ya existe», y eso es load-bearing para
# los REINTENTOS. Son dos registros independientes en un mismo job: el
# 2026-09-17 Open VSX devolvio un 503 suyo con el Marketplace ya
# publicado, y al reintentar el job murio en el PRIMER paso
# (`v2.75.5 already exists`) sin llegar nunca al segundo — la version se
# quedo fuera de Open VSX hasta el siguiente release. Un reintento tiene
# que poder avanzar por los pasos ya hechos.
- name: Publish VS Code plugin to Marketplace
working-directory: apps/vsc-plugin
run: |
set +e
OUT=$(npx --no-install @vscode/vsce publish --no-dependencies --pat "$VSCE_PAT" \
--packagePath "codeagent-mobile-${{ needs.setup.outputs.version }}.vsix" 2>&1)
CODE=$?
echo "$OUT"
if [ $CODE -ne 0 ]; then
echo "$OUT" | grep -qi 'already exists' || exit $CODE
echo "→ ya publicado en el Marketplace; sigo."
fi
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
- name: Publish VS Code plugin to Open VSX
working-directory: apps/vsc-plugin
run: |
set +e
OUT=$(npx ovsx publish --no-dependencies --pat "$OVSX_TOKEN" \
"codeagent-mobile-${{ needs.setup.outputs.version }}.vsix" 2>&1)
CODE=$?
echo "$OUT"
if [ $CODE -ne 0 ]; then
echo "$OUT" | grep -qi 'already exists' || exit $CODE
echo "→ ya publicado en Open VSX; sigo."
fi
env:
OVSX_TOKEN: ${{ secrets.OVSX_TOKEN }}
- name: Upload VSIX artifact
uses: actions/upload-artifact@v7
with:
name: vsc-vsix
path: apps/vsc-plugin/codeagent-mobile-${{ needs.setup.outputs.version }}.vsix
if-no-files-found: error
# The files live on in the GitHub Release; Actions storage is billed.
retention-days: 7
publish-jetbrains:
name: Publish JetBrains plugin
needs: [setup, gate]
if: ${{ always() && needs.setup.result == 'success' && needs.gate.result == 'success' && !contains(needs.setup.outputs.version, '-') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
# The Gradle build + IntelliJ Platform SDK + the verifier's IDE
# distributions (several GB each) no longer fit next to the
# runner's preinstalled toolchains — v2.52.4 died twice with
# "No space left on device", which kills the WORKER itself (so
# verifyPlugin's continue-on-error can't save the job). Drop the
# big toolchains we never use here (~25 GB freed).
- name: Free runner disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force > /dev/null 2>&1 || true
df -h /
- name: Setup Java
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 17
- name: Patch JetBrains plugin version
run: |
VERSION="${{ needs.setup.outputs.version }}"
sed -i -E "s/^version = \"[^\"]+\"/version = \"$VERSION\"/" apps/jetbrains-plugin/build.gradle.kts
sed -i -E "s|<version>[^<]+</version>|<version>$VERSION</version>|" apps/jetbrains-plugin/src/main/resources/META-INF/plugin.xml
- name: Build JetBrains plugin
working-directory: apps/jetbrains-plugin
env:
# PostHog ingestion key baked at build time via the gradle
# task in build.gradle.kts (writes GeneratedBuildConfig.kt
# with the key as a const). Without this env block gradle's
# `providers.environmentVariable("POSTHOG_API_KEY")` resolves
# to empty and telemetry is silently disabled in the
# published plugin.
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
run: ./gradlew clean buildPlugin
- name: Verify JetBrains plugin
working-directory: apps/jetbrains-plugin
# Best-effort: the IntelliJ Platform Plugin Verifier has been
# crashing internally on a subset of the IDE matrix (Worker
# finishes with an exception unrelated to our plugin shape).
# Run as a diagnostic but never fail the release on it — the
# publish step itself is the authoritative gate, and the
# marketplace runs its own server-side validation on upload.
run: ./gradlew verifyPlugin || echo "::warning::verifyPlugin failed; continuing release"
continue-on-error: true
- name: Validate JetBrains Marketplace token before publish
# Tokens generated at the WRONG location (personal
# `/author/me/tokens` rather than the vendor's
# `/author/<vendor>/tokens`) fail mid-publish with an unhelpful
# "Marketplace service scope is missing" error AFTER gradle
# has already burned ~2 min building the plugin. Pre-flight
# against `/api/users/me` so we fail fast with the exact
# remediation surfaced from JetBrains' own response body.
#
# Two bugs in the prior preflight (fixed here):
# 1. URL was `/api/auth/me` which doesn't exist on
# plugins.jetbrains.com → curl returned 404 even for
# valid tokens. The marketplace user endpoint is
# `/api/users/me`.
# 2. `-fsS` (fail on HTTP 4xx) combined with `-w '%{http_code}'`
# AND `|| echo "000"` concatenated both writers to STDOUT
# on any 4xx response — the captured `STATUS` came out as
# `404000` (real status code + fallback) instead of `404`.
# Drop `-f`: curl writes the body even on 4xx, `-w` writes
# the actual status, and `|| echo "000"` only fires when
# curl ITSELF fails (network error / DNS / cert).
env:
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }}
run: |
if [ -z "$PUBLISH_TOKEN" ]; then
echo "::error::JETBRAINS_MARKETPLACE_TOKEN is not set. Generate one from the CodeAgent-Mobile vendor's tokens page (Vendors → CodeAgent-Mobile → Tokens — NOT the personal /author/me/tokens page, those lack Marketplace scope)."
exit 1
fi
STATUS=$(curl -sS -o /tmp/jb_auth.json -w '%{http_code}' \
-H "Authorization: Bearer $PUBLISH_TOKEN" \
https://plugins.jetbrains.com/api/users/me \
|| echo "000")
if [ "$STATUS" != "200" ]; then
BODY=$(cat /tmp/jb_auth.json 2>/dev/null || echo '')
echo "::error::JetBrains Marketplace token auth failed (HTTP $STATUS). Body: ${BODY}. The 'Marketplace service scope is missing' message means the token was created on the user's personal tokens page; publishing needs a token from the vendor's tokens page (Vendors → CodeAgent-Mobile → Tokens). Update the JETBRAINS_MARKETPLACE_TOKEN secret with the new vendor-scoped token."
exit 1
fi
- name: Publish JetBrains plugin to Marketplace
working-directory: apps/jetbrains-plugin
run: ./gradlew publishPlugin
env:
PUBLISH_TOKEN: ${{ secrets.JETBRAINS_MARKETPLACE_TOKEN }}
# `publishPlugin` depends on `buildPlugin`, so gradle will
# re-bake `GeneratedBuildConfig.kt` if the up-to-date check
# invalidates. Mirror the build step's env so we never ship
# a publish with the key stripped out — same root cause as
# the CLI's `prepublishOnly` regression that hid CLI events
# from PostHog for 60 days.
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
- name: Locate JetBrains plugin archive
id: jb
run: |
ZIP=$(ls apps/jetbrains-plugin/build/distributions/*.zip | head -n 1)
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
- name: Upload JetBrains plugin archive
uses: actions/upload-artifact@v7
with:
name: jb-zip
path: ${{ steps.jb.outputs.zip }}
if-no-files-found: error
# The files live on in the GitHub Release; Actions storage is billed.
retention-days: 7
release:
name: Draft GitHub Release + commit changelog
needs: [setup, publish-cli, publish-vsc, publish-jetbrains]
if: |
always() &&
needs.publish-cli.result == 'success' &&
needs.publish-vsc.result == 'success' &&
(needs.publish-jetbrains.result == 'success' || needs.publish-jetbrains.result == 'skipped')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout (full history for git-cliff)
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Download CLI tarball
uses: actions/download-artifact@v8
with:
name: cli-tgz
path: dist/cli
- name: Download VSIX
uses: actions/download-artifact@v8
with:
name: vsc-vsix
path: dist/vsc
- name: Download JetBrains plugin archive
if: ${{ needs.publish-jetbrains.result == 'success' }}
uses: actions/download-artifact@v8
with:
name: jb-zip
path: dist/jb
- name: Resolve JetBrains plugin archive path
id: jb
if: ${{ needs.publish-jetbrains.result == 'success' }}
run: |
ZIP=$(ls dist/jb/*.zip | head -n 1)
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
- name: Generate release notes from commits (git-cliff)
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --current --strip all
env:
OUTPUT: RELEASE_NOTES.md
- name: Commit updated CHANGELOGs directly to main
run: |
set -euo pipefail
if [ ! -s RELEASE_NOTES.md ]; then
echo "git-cliff produced no release notes (no conventional commits between tags). Nothing to commit."
exit 0
fi
# The tag checkout has no in-flight version bumps in this job (those
# happen in publish-* jobs which don't push). Switch to main cleanly.
git fetch origin main
git checkout main
for APP in cli vsc-plugin jetbrains-plugin; do
FILE="apps/$APP/CHANGELOG.md"
awk -v notes_file="RELEASE_NOTES.md" '
BEGIN { inserted = 0 }
/^## \[/ && !inserted {
while ((getline line < notes_file) > 0) print line
print ""
inserted = 1
}
{ print }
' "$FILE" > "$FILE.new"
mv "$FILE.new" "$FILE"
done
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add apps/*/CHANGELOG.md
if git diff --cached --quiet; then
echo "Nothing new to add to CHANGELOGs — maybe all commits between tags were skippable chores."
exit 0
fi
git commit -m "chore(changelog): notes for v${{ needs.setup.outputs.version }} [skip ci]"
git push origin main
- name: Create GitHub Release
# Run even if the changelog commit step failed — the registries already
# received the release, so we always want the GitHub Release to exist
# with the built artifacts attached.
if: always() && needs.setup.outputs.version != ''
uses: softprops/action-gh-release@v3
with:
name: v${{ needs.setup.outputs.version }}
body_path: RELEASE_NOTES.md
append_body: true
files: |
dist/cli/codeam-cli-${{ needs.setup.outputs.version }}.tgz
dist/vsc/codeagent-mobile-${{ needs.setup.outputs.version }}.vsix
${{ steps.jb.outputs.zip }}
# ── Did npm ACTUALLY get it? ──────────────────────────────────────────────
# 2026-08-14→18: four consecutive releases (v2.65.8 … v2.65.11) failed to
# reach npm because the granular NPM_TOKEN had expired. The publish jobs went
# red, nobody was watching the Actions tab, and npm stayed on 2.65.7 for four
# days. This job does not trust a job status — it re-reads the REGISTRY and
# fails if either package is missing at this tag's version, so "release is
# green" can only mean "npm serves it".
verify-published:
name: Verify npm serves this version
needs: [setup, publish-shared, publish-cli]
if: always() && needs.setup.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: npm view codeam-cli + @codeam/shared
env:
VERSION: ${{ needs.setup.outputs.version }}
run: |
set -uo pipefail
MISSING=""
for PKG in codeam-cli @codeam/shared; do
FOUND=0
# The registry's read replicas lag a publish — v2.75.1 (2026-09-12)
# took > 2 min to become visible, so a 6×20 s window called a landed
# publish "missing" and skipped the release notes. Since v2.76.38
# (801 versions, a huge packument) codeam-cli takes 10–25 min to show up,
# so 7.5 min failed every release (v2.76.39/40, 2026-10-30): 60×30 s (30 min).
# A real miss still fails the run.
for ATTEMPT in $(seq 1 60); do
if npm view "$PKG@$VERSION" version >/dev/null 2>&1; then
FOUND=1
break
fi
echo "… $PKG@$VERSION not on the registry yet (attempt $ATTEMPT/60)"
sleep 30
done
if [ "$FOUND" = "1" ]; then
echo "✓ $PKG@$VERSION is live on npm"
else
echo "::error::$PKG@$VERSION is NOT on npm — the publish did not land."
MISSING="$MISSING $PKG"
fi
done
if [ -n "$MISSING" ]; then
echo "::error::Release ${GITHUB_REF_NAME} is INCOMPLETE — missing on npm:$MISSING." \
"Most likely cause: the NPM_TOKEN repo secret expired (granular npm" \
"tokens expire after 90 days). Mint a new Automation token with publish" \
"rights on codeam-cli AND the @codeam scope, update NPM_TOKEN, then" \
"re-run the failed jobs — the publish guards make retries idempotent."
exit 1
fi
# ── Make a failed release impossible to miss ──────────────────────────────
# Runs only when something upstream failed. Posts the tag + which jobs failed
# to Discord so an expired token / broken publish surfaces the same day
# instead of four releases later.
notify-failure:
name: Alert on release failure
needs:
[setup, publish-shared, publish-cli, publish-box, gate, publish-vsc, publish-jetbrains, release, verify-published]
if: failure()
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Post to Discord
env:
WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
TAG: ${{ github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
R_SHARED: ${{ needs.publish-shared.result }}
R_CLI: ${{ needs.publish-cli.result }}
R_BOX: ${{ needs.publish-box.result }}
R_GATE: ${{ needs.gate.result }}
R_VSC: ${{ needs.publish-vsc.result }}
R_JB: ${{ needs.publish-jetbrains.result }}
R_RELEASE: ${{ needs.release.result }}
R_VERIFY: ${{ needs.verify-published.result }}
run: |
set -uo pipefail
FAILED=""
add() { [ "$2" = "failure" ] && FAILED="$FAILED $1"; return 0; }
add "publish-shared" "$R_SHARED"
add "publish-cli" "$R_CLI"
add "publish-box" "$R_BOX"
add "gate" "$R_GATE"
add "publish-vsc" "$R_VSC"
add "publish-jetbrains" "$R_JB"
add "release" "$R_RELEASE"
add "verify-published" "$R_VERIFY"
[ -n "$FAILED" ] || FAILED=" (see the run)"
HINT="If a publish job failed, check NPM_TOKEN expiry first (granular npm tokens last 90 days)."
if [ "$R_GATE" = "failure" ]; then
HINT="Release gate RED: the journey canary failed on dev with this version — npm \`latest\` and codeam-box:latest were NOT moved (\`next\` was reset to latest). Fix forward with the next tag, or re-run the gate job once dev is healthy."
fi
MSG=$(printf '%s\n%s\n%s' \
"🚨 **Release $TAG FAILED** — failing jobs:$FAILED" \
"$HINT" \
"$RUN_URL")
echo "$MSG"
if [ -z "${WEBHOOK:-}" ]; then