-
Notifications
You must be signed in to change notification settings - Fork 5
419 lines (396 loc) · 23.5 KB
/
Copy pathrelease.yml
File metadata and controls
419 lines (396 loc) · 23.5 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
# Cuts a GitHub Release on every v* tag: goreleaser cross-builds the darwin and
# linux arm64+amd64 tarballs, writes checksums.txt, publishes the release, and
# bumps the homebrew-tap formula. Runs on macOS so the darwin binaries are built
# natively (CGO_ENABLED=0, so the linux tarballs cross-compile on the same host).
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
# The journey-cost ledger is a SIBLING of the goreleaser job, not a
# pre-goreleaser gate, so a missing producer run can never block the cut. It is
# authored before goreleaser in this file because the document-order guard
# (internal/release/workflow_exec_guard_test.go) parses steps job-unaware and
# requires the builder to precede the goreleaser action in text. It carries the
# ONE-WAY edge `needs: goreleaser`: `gh release upload` needs the Release to
# exist, and only goreleaser creates it, so this job waits for goreleaser —
# goreleaser does NOT wait for this job, so the cut proceeds regardless.
journey-ledger:
needs: goreleaser
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.22"
# Pull journey-metrics from EVERY successful Runtime Live E2E run (PR or
# main) since the immediately-prior release tag of any kind (stable or
# -pre) — an incremental window, so a -pre release does not re-fold runs an
# earlier -pre in the same cycle already covered. A run's inclusion does
# not depend on its triggering PR's eventual merge state: the observation
# is about boot behavior on that commit at that time. When no run exists in
# the window this is a NON-FATAL skip (`exit 0`, found=false) — a fresh cut
# routinely has no producer run, and blocking the ledger here must not fail
# anything. The emitted `found` output gates the downstream Build/Publish
# steps so they SKIP (job green) rather than run `journey-costs` over an
# empty dir and RED the job.
- name: Download latest journey metrics artifacts
id: download_metrics
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/runtime-live-artifacts" "$RUNNER_TEMP/journey-metrics"
# `|| true` degrades a gh error (or a missing gh) to the epoch fallback
# so a query failure never aborts the best-effort ledger under set -e.
# jq reads the tag via $ENV (not bash interpolation) so this survives
# set -u even when GITHUB_REF_NAME is unset.
since="$(gh release list --limit 100 --json tagName,createdAt --jq \
'[sort_by(.createdAt)[]] as $r | ($r | map(.tagName) | index($ENV.GITHUB_REF_NAME)) as $i | if $i == null or $i == 0 then "" else $r[$i-1].createdAt end' \
|| true)"
if [ -z "$since" ]; then
since="1970-01-01T00:00:00Z"
fi
echo "journey ledger aggregation window: runs created after $since" >&2
# `|| true` degrades a gh error (or a missing gh) to an empty run list so
# the no-run skip branch below fires (found=false, exit 0) instead of
# aborting under set -e. The journey ledger is best-effort; a query
# failure must never RED the cut. No --branch filter: PR-triggered runs
# count in the window exactly like main runs.
run_ids="$(gh run list --workflow "Runtime Live E2E" --status success --created ">$since" --limit 100 --json databaseId --jq '.[].databaseId' || true)"
if [ -z "$run_ids" ]; then
echo "::warning::no successful Runtime Live E2E run found since $since; skipping journey ledger" >&2
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Each run's metrics land in their OWN subdirectory, never flat-copied
# into one: journeymetrics.recordFilename has no run-distinguishing
# component, so flattening N runs' same-named scenario/model files into
# one directory silently collapses them back to one observation via
# overwrite. journeymetrics.ReadRecordsDir already walks metrics-dir
# recursively, so no Go-side change is needed to consume this layout.
for run_id in $run_ids; do
run_dir="$RUNNER_TEMP/journey-metrics/$run_id"
mkdir -p "$run_dir"
gh run download "$run_id" --dir "$RUNNER_TEMP/runtime-live-artifacts/$run_id"
find "$RUNNER_TEMP/runtime-live-artifacts/$run_id" -path '*/journey-metrics/*.json' -type f -exec cp {} "$run_dir/" \;
done
if [ -z "$(find "$RUNNER_TEMP/journey-metrics" -name '*.json' -type f -print -quit)" ]; then
echo "::warning::downloaded Runtime Live E2E artifacts contained no journey metrics JSON; skipping journey ledger" >&2
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "found=true" >> "$GITHUB_OUTPUT"
- name: Build journey cost ledger
if: steps.download_metrics.outputs.found == 'true'
run: |
set -euo pipefail
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
go run ./cmd/spacedock-release journey-costs "$RELEASE_VERSION" \
--metrics-dir "$RUNNER_TEMP/journey-metrics" \
--out "$RUNNER_TEMP/journey-costs-v${RELEASE_VERSION}.json"
test -s "$RUNNER_TEMP/journey-costs-v${RELEASE_VERSION}.json"
- name: Publish journey cost ledger
if: steps.download_metrics.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
gh release upload "$GITHUB_REF_NAME" "$RUNNER_TEMP/journey-costs-v${RELEASE_VERSION}.json" --clobber
# Release-time precondition: the cut may not proceed unless the live e2e
# matrix ran green for the EXACT commit being released. The job resolves the
# tagged commit SHA and asks `spacedock-release e2e-gate` whether a
# `conclusion: success` Runtime Live E2E run exists for it — a green run means
# every live lane was approved and passed (the secret-free offline-only run is
# never `success`). goreleaser `needs:` this job, so a missing/parked live run
# blocks the cut. SPACEDOCK_E2E_GATE_WAIVER (set with a reason) is the
# auditable captain-waiver escape hatch for an emergency cut; the reason is
# recorded to the step summary.
e2e-gate:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# Full history + tags so `git rev-list -1 "$GITHUB_REF_NAME"` resolves
# the tagged commit SHA the gate binds the green-run match to.
fetch-depth: 0
fetch-tags: true
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.22"
- name: Gate the cut on a green live e2e for the release commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Set this to a non-empty reason to WAIVE the gate for an emergency cut.
# The reason is recorded to $GITHUB_STEP_SUMMARY so the bypass is auditable.
SPACEDOCK_E2E_GATE_WAIVER: ${{ vars.SPACEDOCK_E2E_GATE_WAIVER }}
run: |
set -euo pipefail
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
echo "release commit for $GITHUB_REF_NAME: $RELEASE_COMMIT"
go run ./cmd/spacedock-release e2e-gate "$RELEASE_COMMIT"
# Assert the tagged commit's plugin manifests AND the FO shared-core prose
# already carry the tag's semver/minor — the stamp-then-tag ordering
# docs/releasing.md documents. The checkout above is at the tagged commit,
# so these are the tagged commit's files. A mismatch (the v0.20.0 pre-stamp
# inversion: tag 0.20.0, manifest 0.19.9; or a forgotten prose stamp) exits
# non-zero and blocks the cut, since goreleaser needs: this job. The `if`
# skips pre-releases (a vX.Y.Z-pre.N tag legitimately differs from its
# X.Y.Z manifest/prose), matching the stamp step's pre-release carve-out
# below.
- name: Gate the cut on the tagged manifest/prose matching the tag semver
if: "!contains(github.ref, '-')"
run: |
set -euo pipefail
go run ./cmd/spacedock-release manifest-tag-gate "$GITHUB_REF_NAME" .claude-plugin/plugin.json .codex-plugin/plugin.json skills/first-officer/references/first-officer-shared-core.md
goreleaser:
needs: e2e-gate
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# Full history + tags so `git describe --tags` resolves the version
# goreleaser stamps into internal/cli.Version. fetch-tags: true is
# required so annotated-tag bodies (the release notes consumed by the
# next step's `git tag -l --format='%(contents:body)'`) reach the
# runner — actions/checkout@v5's default is false, which leaves the
# tag ref but drops the tag object.
fetch-depth: 0
fetch-tags: true
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.22"
# The release notes are authored LOCALLY (via `spacedock-release notes`)
# and carried in the annotated tag's message body, so CI never runs
# `claude` — it only reads the tag. `%(contents:body)` strips the tag
# subject and yields just the notes body. Guard against an empty body
# (a lightweight tag, or one cut outside the notes flow) so we fail loudly
# rather than publish an empty Release; this step MUST precede goreleaser,
# which consumes the file via --release-notes.
- name: Extract release notes from the tag body
run: |
set -euo pipefail
# Re-fetch the tag annotation explicitly — actions/checkout@v5 sometimes
# drops the tag object even with fetch-tags: true, leaving %(contents:body)
# empty. A bare git fetch of the ref forces the annotation through.
git fetch origin "+refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" --force
# Read the tag object directly via cat-file (subject + blank line + body)
# and extract everything after the first blank line. This is more robust
# than %(contents:body), which has surfaced as empty on macos runners.
# Write outside the worktree so goreleaser's dirty-check stays clean.
git cat-file tag "$GITHUB_REF_NAME" | awk 'p{print; next} /^$/{p=1}' > "$RUNNER_TEMP/release-notes.txt"
# Guard against an empty body (a lightweight tag, or one cut outside the
# notes flow) so we fail loudly rather than publish an empty Release.
if [ -z "$(tr -d '[:space:]' < "$RUNNER_TEMP/release-notes.txt")" ]; then
echo "::error::tag $GITHUB_REF_NAME has an empty annotated-tag body; cut it via 'spacedock-release notes <version>'" >&2
exit 1
fi
- name: Run goreleaser
uses: goreleaser/goreleaser-action@v7
with:
version: "~> v2"
args: release --clean --release-notes ${{ runner.temp }}/release-notes.txt
env:
# GITHUB_TOKEN creates the release on this repo. HOMEBREW_TAP_TOKEN is a
# PAT with write access to spacedock-dev/homebrew-tap so the
# homebrew_casks block can push the cask bump cross-repo (the default
# GITHUB_TOKEN cannot write to another repository).
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
# Defensive pin of the version goreleaser stamps into the binary. On the
# auto-cut vX.(Y+1).0-pre0 run the commit carries BOTH that pre0 tag and
# the stable vX.Y.0 tag; goreleaser 2.16 already picks the highest-semver
# tag pointing at HEAD (the pre0 tag) with no override, so this only makes
# the choice explicit — harmless on a single-tag run, deterministic on the
# dual-tagged one. Not load-bearing (measured with AND without in the CI
# dry-run spike); belt-and-suspenders against a future resolution change.
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
# AC-4: stamp the plugin manifests' `version` to the release so the host
# plugin panel (`claude plugin list --json`) displays a version that tracks
# the binary instead of the frozen placeholder. release.yml triggers ONLY on
# a `push: tags: ['v*']` cut, which carries no channel signal and has no edge
# `v*`-tag scheme — so exactly ONE thing reaches this step: a STABLE release.
# The stable channel's plugin lives on `main` (the stable binary stamps
# devBranch=main), so the displayed version must be stamped+pushed to `main`,
# not the edge `next` branch. This is a single-target stamp, not a
# per-channel branch; the edge channel's version-display rides the unchanged
# next-publish.yml, with no involvement here. The marketplace-entry calendar
# key is a DIFFERENT file (marketplace.json) and is left untouched here; it is
# the moving-branch re-pull key (AC-2d), pj's surface.
- name: Stamp plugin manifests to the release version
# A hyphenated tag is a pre-release (v0.23.0-pre); it ships binary +
# content as a GitHub pre-release but must stay OUT of the stable channel,
# so skip both the manifest stamp and the stable-ref advance below. A final
# vX.Y.Z has no hyphen and still runs, advancing stable as before.
if: "!contains(github.ref, '-')"
run: |
set -euo pipefail
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
FO_PROSE=skills/first-officer/references/first-officer-shared-core.md
# The tagged commit is the SHA the e2e-gate greened and the manifest-tag
# gate matched. Resolve it BEFORE switching to main, so the stable ref
# below advances to exactly THIS commit, not whatever main HEAD happens to
# be at push time.
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
git fetch origin main
git switch main
go run ./cmd/spacedock-release stamp-version "$RELEASE_VERSION" \
.claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
if git diff --quiet -- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"; then
echo "plugin manifests and FO prose already at $RELEASE_VERSION; nothing to commit"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "release: stamp plugin manifests and FO prose to $RELEASE_VERSION" \
-- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
git push origin main
fi
# Advance the stable channel ref to the TAGGED commit. The
# spacedock-dev/marketplace stable entry pins source.ref=stable (a moving
# branch, not a per-release tag), so a fresh `spacedock@spacedock` install
# resolves whatever this branch points at — this push is what publishes the
# release to the stable channel, replacing a hand-edit of the marketplace
# repo. Pushing $RELEASE_COMMIT (the tagged SHA), not `main`, keeps stable
# and the tag the SAME commit even if main advanced after the tag fired.
# The tagged commit is on main's history, so this fast-forwards. Same-repo
# push, so the default GITHUB_TOKEN suffices (no cross-repo PAT, unlike the
# homebrew-tap push).
git push origin "$RELEASE_COMMIT:refs/heads/stable"
# Advances `next` — the branch `spacedock-edge` resolves — on EVERY tag, not
# just stable. A SIBLING of goreleaser (`needs: goreleaser`, mirroring the
# journey-ledger job's isolation): a rare conflict here must not block or unwind
# a release that already published. See docs/releasing.md "Advancing the Edge
# Line". The reconcile is a merge favoring the release, never a reset or force —
# the pre-merge `next` tip stays a first-parent ancestor of the pushed commit,
# so `git push edge-advance:next` is a plain fast-forward.
edge-advance:
needs: goreleaser
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# Full history + tags so `git rev-list -1 "$GITHUB_REF_NAME"` resolves
# the tagged commit the reconcile merges into `next`.
fetch-depth: 0
fetch-tags: true
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.22"
# Decide whether this tag advances the edge line AT ALL, gating the whole
# job. edge-advance-decision computes the tag's target edge version
# (dev-preversion for a stable tag; the tag's own version for a -pre tag)
# and prints `advance` only when that is STRICTLY greater than origin/next's
# current manifest version. An old-line / patch tag prints `skip`, and EVERY
# downstream step gates on this output, so the whole job no-ops: no
# `-X theirs` clobber of next's newer content, no manifest/gate-line rewind,
# no marketplace calendar re-pull, and no colliding pre0 auto-tag.
- name: Decide whether this tag advances the edge line
id: decision
run: |
set -euo pipefail
git fetch origin next
NEXT_PLUGIN="$(mktemp)"
git show origin/next:.claude-plugin/plugin.json > "$NEXT_PLUGIN"
if [ "$(go run ./cmd/spacedock-release edge-advance-decision "$GITHUB_REF_NAME" "$NEXT_PLUGIN")" = "advance" ]; then
echo "advance=true" >> "$GITHUB_OUTPUT"
else
echo "advance=false" >> "$GITHUB_OUTPUT"
echo "::notice::edge-advance SKIPPED for $GITHUB_REF_NAME: its target edge version is not strictly greater than next's current manifest version (an old-line/patch tag); next's tip is left untouched"
fi
# Prerelease (`-pre`) tag: reconcile `next` to the tagged commit's content,
# favoring the release over whatever `next` drifted to. The manifests inherit
# the tag's version; the calendar bump below makes installers re-pull.
- name: Reconcile the edge line to the prerelease commit
if: "contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
run: |
set -euo pipefail
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
git fetch origin next
git switch -c edge-advance origin/next
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git merge -X theirs --no-edit "$RELEASE_COMMIT" \
-m "next: reconcile edge line to $GITHUB_REF_NAME"
# Stable (`vX.Y.Z`) tag: reconcile the same way, then stamp the manifests
# PAST the release to the post-release dev pre-version (X.(Y+1).0-pre1), so
# the edge line never masquerades as the stable version it just shipped.
- name: Reconcile the edge line past the stable release
if: "!contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
run: |
set -euo pipefail
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
DEV_VERSION="$(go run ./cmd/spacedock-release dev-preversion "$RELEASE_VERSION")"
git fetch origin next
git switch -c edge-advance origin/next
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git merge -X theirs --no-edit "$RELEASE_COMMIT" \
-m "next: reconcile edge line to $GITHUB_REF_NAME"
FO_PROSE=skills/first-officer/references/first-officer-shared-core.md
go run ./cmd/spacedock-release stamp-version "$DEV_VERSION" \
.claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
git commit -m "next: bump dev pre-version to $DEV_VERSION" \
-- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
# On an advancing tag both reconcile paths end here: bump the marketplace
# calendar key (the moving-branch re-pull key `claude plugin update` /
# `codex` read) and fast-forward `next`. Exactly one reconcile step above
# ran, so `edge-advance` already carries the merge (plus, on a stable tag,
# the dev-preversion stamp). Gated on the same advance decision: an old-line
# / patch tag skips this bump too, so the calendar key — the thing every edge
# installer polls — is left untouched and no re-pull is triggered (AC-2d).
- name: Bump the marketplace calendar key and push the edge line
if: "steps.decision.outputs.advance == 'true'"
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
go run ./cmd/spacedock-release bump-calendar .claude-plugin/marketplace.json
git commit -m "next: bump marketplace calendar version" \
-- .claude-plugin/marketplace.json
git push origin edge-advance:next
# Always-cut-pre0 (latest-line stable tag only, same advance gate): auto-cut
# an ANNOTATED vX.(Y+1).0-pre0 tag ON THE GREENED RELEASE COMMIT (the SHA
# e2e-gate already greened and the stable stamp resolved) and push it via the
# re-triggering PAT. That tag's own release run resolves the SAME green e2e
# run (same commit), builds+publishes the X.(Y+1)-minor edge binary, and
# bumps the spacedock@next cask — closing the post-stable-cut window in
# minutes instead of waiting for the next hand-cut prerelease. The tag MUST be
# annotated with a non-empty body: the release-notes extraction step
# (above, in goreleaser) hard-errors on a lightweight/empty-body tag before
# the binary builds. The push MUST use the PAT: a push authenticated with the
# default GITHUB_TOKEN deliberately does NOT trigger further workflow runs, so
# the pre0 build would never start. Recursion terminates at one level — the
# pre0 tag routes to the prerelease path, whose edge-advance-decision skips
# (pre0 < next's pre1), so it neither re-tags nor rewinds `next`.
- name: Auto-cut the edge prerelease tag on the greened release commit
if: "!contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
env:
# PAT (HOMEBREW_TAP_TOKEN-class) so the tag push RE-TRIGGERS release.yml;
# the default GITHUB_TOKEN cannot fire a workflow from within a run.
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
set -euo pipefail
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
PRE0_VERSION="$(go run ./cmd/spacedock-release edge-pre0-version "$RELEASE_VERSION")"
PRE0_TAG="v$PRE0_VERSION"
PRE0_BODY="Edge prerelease auto-cut with stable $GITHUB_REF_NAME: publishes the ${PRE0_VERSION%.*}-line edge binary so spacedock@next realigns with next's post-release skills. No standalone changes."
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$PRE0_TAG" "$RELEASE_COMMIT" -m "$PRE0_BODY"
git push "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$PRE0_TAG"