Skip to content

Commit 70bcbf1

Browse files
clkaoSpike Testclaude
authored
release: close the edge-channel stable-cut window with a line-ordering guard + always-cut-pre0 (#482)
The release flow deterministically broke the edge channel from every latest-line stable cut until the first later prerelease tag: edge-advance stamps next's manifests + FO gate line PAST the release to X.(Y+1).0-pre1, but the edge binary (spacedock@next cask) only publishes the tag's own X.Y.0 build, so the same-minor boot gate aborted every edge boot in the window. Two mechanisms: - A job-level line-ordering decision gates the WHOLE edge-advance job. A new edge-advance-decision subcommand computes the tag's target edge version and emits advance only when it is STRICTLY greater than origin/next's manifest; both reconcile steps, the calendar bump, and the auto-pre0 step gate on it. An old-line/patch tag skips the whole job, so next's tip stays byte-identical (no -X theirs clobber, no manifest/gate-line rewind, no calendar re-pull, no colliding pre0 tag). Ordering uses a new prerelease-aware ComparePreVersion (contract.semverCompare is dotted-int only and cannot order pre0 < pre1). - Always-cut-pre0: a latest-line stable cut auto-creates an ANNOTATED vX.(Y+1).0-pre0 tag on the greened RELEASE_COMMIT and pushes it via the re-triggering PAT. That run reuses the greened commit's e2e-gate pass and publishes the X.(Y+1)-minor edge binary + cask, closing the window in minutes. GORELEASER_CURRENT_TAG is added as a defensive pin (goreleaser 2.16 already picks the highest tag at HEAD; verified in a CI dry-run spike). Guards land with adversarial twins: the decision table incl. three equality skip-cases, ComparePreVersion ordering, the AC-1 pre0/required-minor algebra, the AC-3 byte-identical-tip fixture, and release.yml structure guards (decision-gating, annotated-body, greened-SHA, recursion, PAT). Existing assertEdgeAdvanceWiring matchers now tolerate the decision-gate conjunct and the bump step is no longer asserted unconditional. Co-authored-by: Spike Test <spike@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c6e3ec commit 70bcbf1

10 files changed

Lines changed: 1230 additions & 50 deletions

.github/workflows/release.yml

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@ jobs:
224224
# GITHUB_TOKEN cannot write to another repository).
225225
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
226226
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
227+
# Defensive pin of the version goreleaser stamps into the binary. On the
228+
# auto-cut vX.(Y+1).0-pre0 run the commit carries BOTH that pre0 tag and
229+
# the stable vX.Y.0 tag; goreleaser 2.16 already picks the highest-semver
230+
# tag pointing at HEAD (the pre0 tag) with no override, so this only makes
231+
# the choice explicit — harmless on a single-tag run, deterministic on the
232+
# dual-tagged one. Not load-bearing (measured with AND without in the CI
233+
# dry-run spike); belt-and-suspenders against a future resolution change.
234+
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
227235

228236
# AC-4: stamp the plugin manifests' `version` to the release so the host
229237
# plugin panel (`claude plugin list --json`) displays a version that tracks
@@ -301,11 +309,33 @@ jobs:
301309
with:
302310
go-version: "1.22"
303311

312+
# Decide whether this tag advances the edge line AT ALL, gating the whole
313+
# job. edge-advance-decision computes the tag's target edge version
314+
# (dev-preversion for a stable tag; the tag's own version for a -pre tag)
315+
# and prints `advance` only when that is STRICTLY greater than origin/next's
316+
# current manifest version. An old-line / patch tag prints `skip`, and EVERY
317+
# downstream step gates on this output, so the whole job no-ops: no
318+
# `-X theirs` clobber of next's newer content, no manifest/gate-line rewind,
319+
# no marketplace calendar re-pull, and no colliding pre0 auto-tag.
320+
- name: Decide whether this tag advances the edge line
321+
id: decision
322+
run: |
323+
set -euo pipefail
324+
git fetch origin next
325+
NEXT_PLUGIN="$(mktemp)"
326+
git show origin/next:.claude-plugin/plugin.json > "$NEXT_PLUGIN"
327+
if [ "$(go run ./cmd/spacedock-release edge-advance-decision "$GITHUB_REF_NAME" "$NEXT_PLUGIN")" = "advance" ]; then
328+
echo "advance=true" >> "$GITHUB_OUTPUT"
329+
else
330+
echo "advance=false" >> "$GITHUB_OUTPUT"
331+
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"
332+
fi
333+
304334
# Prerelease (`-pre`) tag: reconcile `next` to the tagged commit's content,
305335
# favoring the release over whatever `next` drifted to. The manifests inherit
306336
# the tag's version; the calendar bump below makes installers re-pull.
307337
- name: Reconcile the edge line to the prerelease commit
308-
if: "contains(github.ref, '-')"
338+
if: "contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
309339
run: |
310340
set -euo pipefail
311341
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
@@ -320,7 +350,7 @@ jobs:
320350
# PAST the release to the post-release dev pre-version (X.(Y+1).0-pre1), so
321351
# the edge line never masquerades as the stable version it just shipped.
322352
- name: Reconcile the edge line past the stable release
323-
if: "!contains(github.ref, '-')"
353+
if: "!contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
324354
run: |
325355
set -euo pipefail
326356
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
@@ -338,11 +368,15 @@ jobs:
338368
git commit -m "next: bump dev pre-version to $DEV_VERSION" \
339369
-- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
340370
341-
# Both paths end here: bump the marketplace calendar key (the moving-branch
342-
# re-pull key `claude plugin update` / `codex` read) and fast-forward `next`.
343-
# Exactly one reconcile step above ran, so `edge-advance` already carries the
344-
# merge (plus, on a stable tag, the dev-preversion stamp).
371+
# On an advancing tag both reconcile paths end here: bump the marketplace
372+
# calendar key (the moving-branch re-pull key `claude plugin update` /
373+
# `codex` read) and fast-forward `next`. Exactly one reconcile step above
374+
# ran, so `edge-advance` already carries the merge (plus, on a stable tag,
375+
# the dev-preversion stamp). Gated on the same advance decision: an old-line
376+
# / patch tag skips this bump too, so the calendar key — the thing every edge
377+
# installer polls — is left untouched and no re-pull is triggered (AC-2d).
345378
- name: Bump the marketplace calendar key and push the edge line
379+
if: "steps.decision.outputs.advance == 'true'"
346380
run: |
347381
set -euo pipefail
348382
git config user.name "github-actions[bot]"
@@ -351,3 +385,35 @@ jobs:
351385
git commit -m "next: bump marketplace calendar version" \
352386
-- .claude-plugin/marketplace.json
353387
git push origin edge-advance:next
388+
389+
# Always-cut-pre0 (latest-line stable tag only, same advance gate): auto-cut
390+
# an ANNOTATED vX.(Y+1).0-pre0 tag ON THE GREENED RELEASE COMMIT (the SHA
391+
# e2e-gate already greened and the stable stamp resolved) and push it via the
392+
# re-triggering PAT. That tag's own release run resolves the SAME green e2e
393+
# run (same commit), builds+publishes the X.(Y+1)-minor edge binary, and
394+
# bumps the spacedock@next cask — closing the post-stable-cut window in
395+
# minutes instead of waiting for the next hand-cut prerelease. The tag MUST be
396+
# annotated with a non-empty body: the release-notes extraction step
397+
# (above, in goreleaser) hard-errors on a lightweight/empty-body tag before
398+
# the binary builds. The push MUST use the PAT: a push authenticated with the
399+
# default GITHUB_TOKEN deliberately does NOT trigger further workflow runs, so
400+
# the pre0 build would never start. Recursion terminates at one level — the
401+
# pre0 tag routes to the prerelease path, whose edge-advance-decision skips
402+
# (pre0 < next's pre1), so it neither re-tags nor rewinds `next`.
403+
- name: Auto-cut the edge prerelease tag on the greened release commit
404+
if: "!contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
405+
env:
406+
# PAT (HOMEBREW_TAP_TOKEN-class) so the tag push RE-TRIGGERS release.yml;
407+
# the default GITHUB_TOKEN cannot fire a workflow from within a run.
408+
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
409+
run: |
410+
set -euo pipefail
411+
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
412+
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
413+
PRE0_VERSION="$(go run ./cmd/spacedock-release edge-pre0-version "$RELEASE_VERSION")"
414+
PRE0_TAG="v$PRE0_VERSION"
415+
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."
416+
git config user.name "github-actions[bot]"
417+
git config user.email "github-actions[bot]@users.noreply.github.com"
418+
git tag -a "$PRE0_TAG" "$RELEASE_COMMIT" -m "$PRE0_BODY"
419+
git push "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$PRE0_TAG"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// ABOUTME: `edge-advance-decision` prints advance/skip gating the whole edge-advance
2+
// ABOUTME: job; `edge-pre0-version` prints the auto-cut edge prerelease version.
3+
package main
4+
5+
import (
6+
"fmt"
7+
"os"
8+
9+
"github.com/spacedock-dev/spacedock/internal/release"
10+
)
11+
12+
// edgeAdvanceDecision decides whether a tag advances the edge line or skips the
13+
// whole edge-advance job, given `next`'s current manifest. It prints `advance`
14+
// or `skip` to stdout (exit 0 for BOTH — a skip is a normal outcome, not an
15+
// error) so release.yml's decision step can write advance=true|false to
16+
// $GITHUB_OUTPUT and gate every downstream step on it. A missing/unparseable
17+
// manifest or a malformed tag/version is a real error (non-zero), so a miswiring
18+
// fails loud rather than silently skipping and leaving the edge channel broken.
19+
func edgeAdvanceDecision(args []string) int {
20+
if len(args) != 2 {
21+
fmt.Fprintln(os.Stderr, "spacedock-release edge-advance-decision: need <tag> <next-plugin.json>")
22+
return 2
23+
}
24+
tag, manifestPath := args[0], args[1]
25+
data, err := os.ReadFile(manifestPath)
26+
if err != nil {
27+
fmt.Fprintf(os.Stderr, "read %s: %v\n", manifestPath, err)
28+
return 1
29+
}
30+
nextVersion, err := release.ManifestVersion(data)
31+
if err != nil {
32+
fmt.Fprintf(os.Stderr, "parse %s: %v\n", manifestPath, err)
33+
return 1
34+
}
35+
if nextVersion == "" {
36+
fmt.Fprintf(os.Stderr, "edge-advance-decision: %s carries no top-level version\n", manifestPath)
37+
return 1
38+
}
39+
advance, target, err := release.EdgeAdvanceDecision(tag, nextVersion)
40+
if err != nil {
41+
fmt.Fprintf(os.Stderr, "edge-advance-decision: %v\n", err)
42+
return 1
43+
}
44+
fmt.Fprintf(os.Stderr, "tag %s target edge version %s vs next %s\n", tag, target, nextVersion)
45+
if advance {
46+
fmt.Println("advance")
47+
} else {
48+
fmt.Println("skip")
49+
}
50+
return 0
51+
}
52+
53+
// edgePre0Version prints the auto-cut edge prerelease version (X.(Y+1).0-pre0)
54+
// for a bare stable version, so release.yml's always-cut-pre0 step forms the
55+
// `vX.(Y+1).0-pre0` annotated tag. It errors on a hyphenated/malformed input,
56+
// since it runs only on the stable path that already guarantees a bare X.Y.Z.
57+
func edgePre0Version(args []string) int {
58+
if len(args) != 1 {
59+
fmt.Fprintln(os.Stderr, "spacedock-release edge-pre0-version: need exactly one <stable-version> (e.g. 0.25.0)")
60+
return 2
61+
}
62+
version, err := release.Pre0EdgeVersion(args[0])
63+
if err != nil {
64+
fmt.Fprintf(os.Stderr, "edge-pre0-version: %v\n", err)
65+
return 1
66+
}
67+
fmt.Println(version)
68+
return 0
69+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// writeNextPlugin writes a plugin.json carrying version to a temp file and
11+
// returns its path — the `<next-plugin.json>` the decision subcommand reads (the
12+
// workflow pipes `git show origin/next:.claude-plugin/plugin.json` into it).
13+
func writeNextPlugin(t *testing.T, version string) string {
14+
t.Helper()
15+
path := filepath.Join(t.TempDir(), "plugin.json")
16+
body := "{\n \"name\": \"spacedock\",\n \"version\": \"" + version + "\"\n}\n"
17+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
18+
t.Fatalf("write next plugin.json: %v", err)
19+
}
20+
return path
21+
}
22+
23+
// TestEdgeAdvanceDecisionCommandPrintsVerdict — the subcommand's contract IS its
24+
// stdout: release.yml's decision step captures `advance`/`skip` and writes
25+
// advance=true|false to $GITHUB_OUTPUT. A forward stable cut advances; an
26+
// old-line patch whose target edge version is not strictly greater than next's
27+
// manifest skips. Exit is 0 for BOTH verdicts (a skip is a normal outcome).
28+
func TestEdgeAdvanceDecisionCommandPrintsVerdict(t *testing.T) {
29+
cases := []struct {
30+
name string
31+
tag string
32+
next string
33+
want string
34+
}{
35+
{"forward stable advances", "v0.26.0", "0.26.0-pre1", "advance"},
36+
{"old-line patch skips", "v0.25.1", "0.27.0-pre1", "skip"},
37+
{"patch-equality skips", "v0.25.1", "0.26.0-pre1", "skip"},
38+
{"pre0 vs next pre1 skips", "v0.26.0-pre0", "0.26.0-pre1", "skip"},
39+
}
40+
for _, c := range cases {
41+
t.Run(c.name, func(t *testing.T) {
42+
plugin := writeNextPlugin(t, c.next)
43+
out, code := captureStdout(t, func() int { return edgeAdvanceDecision([]string{c.tag, plugin}) })
44+
if code != 0 {
45+
t.Fatalf("edge-advance-decision exit = %d, want 0", code)
46+
}
47+
if got := strings.TrimSpace(out); got != c.want {
48+
t.Fatalf("edge-advance-decision(%q, next=%q) stdout = %q, want %q", c.tag, c.next, got, c.want)
49+
}
50+
})
51+
}
52+
}
53+
54+
// TestEdgeAdvanceDecisionCommandRejectsBadArgs — a missing manifest path or a
55+
// wrong arg count is a usage/IO error (non-zero), so a release.yml miswiring
56+
// fails loud rather than silently defaulting to skip (which would leave the edge
57+
// channel broken with no signal).
58+
func TestEdgeAdvanceDecisionCommandRejectsBadArgs(t *testing.T) {
59+
if code := edgeAdvanceDecision([]string{"v0.26.0"}); code == 0 {
60+
t.Fatalf("edge-advance-decision exit = 0 with one argument; want non-zero")
61+
}
62+
if code := edgeAdvanceDecision([]string{"v0.26.0", filepath.Join(t.TempDir(), "missing.json")}); code == 0 {
63+
t.Fatalf("edge-advance-decision exit = 0 on a missing manifest; want non-zero")
64+
}
65+
}
66+
67+
// TestEdgePre0VersionCommandPrintsComputedVersion — the subcommand prints exactly
68+
// X.(Y+1).0-pre0, so release.yml's always-cut-pre0 step forms `vX.(Y+1).0-pre0`
69+
// as the annotated auto-tag, whose minor matches the required minor next's skills
70+
// were stamped to.
71+
func TestEdgePre0VersionCommandPrintsComputedVersion(t *testing.T) {
72+
out, code := captureStdout(t, func() int { return edgePre0Version([]string{"0.25.0"}) })
73+
if code != 0 {
74+
t.Fatalf("edge-pre0-version exit = %d, want 0 on a bare stable semver", code)
75+
}
76+
if got := strings.TrimSpace(out); got != "0.26.0-pre0" {
77+
t.Fatalf("edge-pre0-version stdout = %q, want 0.26.0-pre0", got)
78+
}
79+
if code := edgePre0Version([]string{"0.25.0-pre1"}); code == 0 {
80+
t.Fatalf("edge-pre0-version exit = 0 on a hyphenated input; want non-zero")
81+
}
82+
}

cmd/spacedock-release/main.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ import (
3232
// erroring unless the literal appears exactly once. bump-calendar advances the
3333
// marketplace plugin entry's calendar key to today's `0.0.YYYYMMDDNN` (AC-2d).
3434
// All rewrite in place. dev-preversion prints the post-release dev pre-version
35-
// (X.(Y+1).0-pre1) the stable-tag edge advance stamps onto `next`. journey-delta
35+
// (X.(Y+1).0-pre1) the stable-tag edge advance stamps onto `next`.
36+
// edge-advance-decision prints `advance` or `skip` (exit 0 either way) deciding
37+
// whether a tag advances the edge line: it computes the tag's target edge
38+
// version and skips unless that is strictly greater than `next`'s current
39+
// manifest, so the whole edge-advance job no-ops on an old-line/patch tag.
40+
// edge-pre0-version prints the auto-cut edge prerelease version (X.(Y+1).0-pre0)
41+
// the stable path tags on the greened release commit. journey-delta
3642
// renders and posts the per-PR journey-cost delta against the previously
3743
// published release ledger's latest-by-captured_at baseline per scenario/model,
3844
// updating a single sticky PR comment found by its HTML marker. The AC-4
@@ -63,6 +69,10 @@ func main() {
6369
os.Exit(bumpCalendar(os.Args[2:]))
6470
case "dev-preversion":
6571
os.Exit(devPreversion(os.Args[2:]))
72+
case "edge-advance-decision":
73+
os.Exit(edgeAdvanceDecision(os.Args[2:]))
74+
case "edge-pre0-version":
75+
os.Exit(edgePre0Version(os.Args[2:]))
6676
case "journey-costs":
6777
os.Exit(journeyCosts(os.Args[2:]))
6878
case "journey-delta":
@@ -374,6 +384,8 @@ Usage:
374384
spacedock-release stamp-version <release-version> <manifest-or-prose> [<manifest-or-prose> ...]
375385
spacedock-release bump-calendar <marketplace.json>
376386
spacedock-release dev-preversion <stable-version>
387+
spacedock-release edge-advance-decision <tag> <next-plugin.json>
388+
spacedock-release edge-pre0-version <stable-version>
377389
spacedock-release journey-costs <release-version> --metrics-dir <dir> --out <path>
378390
spacedock-release journey-delta <previous-ledger.json> --metrics-dir <dir> --pr <number>
379391
spacedock-release e2e-gate <release-commit-sha>

docs/releasing.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,38 @@ rare conflict here cannot unwind or block the release that already published):
168168
re-pull. This is the automated form of the manual reconcile the 0.24.0-pre1
169169
cut required (`next` had drifted 40 commits behind `main`, hard-blocking
170170
`spacedock codex` on a binary/plugin version-compat check).
171-
- **Stable (`vX.Y.Z`) tag:** `next` is reconciled the same way, then stamped
172-
PAST the release to the post-release dev pre-version
173-
(`spacedock-release dev-preversion X.Y.Z``X.(Y+1).0-pre1`), so the edge
174-
line never masquerades as the stable version it just shipped, then the
175-
calendar key is bumped.
171+
- **Stable (`vX.Y.Z`) tag, latest line:** `next` is reconciled the same way,
172+
stamped PAST the release to `X.(Y+1).0-pre1`
173+
(`spacedock-release dev-preversion X.Y.Z`) so the edge line never masquerades
174+
as the stable version it just shipped, and the calendar key is bumped — then an
175+
ANNOTATED `vX.(Y+1).0-pre0` tag is auto-created **on the greened release commit**
176+
and pushed (via the re-triggering tap PAT). That prerelease tag's own release
177+
run reuses the greened commit's e2e-gate pass, builds+publishes the `X.(Y+1)`-minor
178+
edge binary, and bumps the `spacedock@next` cask — so the edge binary's minor
179+
catches up to the skills' gate line within minutes instead of waiting for the
180+
next hand-cut prerelease. The auto-tag MUST be annotated with a non-empty body
181+
(the release-notes extraction step rejects a lightweight tag), and MUST be
182+
pushed with the PAT (a `GITHUB_TOKEN` push does not fire the pre0 build). Expect
183+
two GitHub releases per stable cut.
184+
- **Old-line / patch (`vX.Y.1`, or any tag whose target edge version is not
185+
strictly greater than `next`'s current manifest version):** the whole
186+
`edge-advance` job SKIPS (`spacedock-release edge-advance-decision` prints
187+
`skip`, logged as a `::notice::`; every downstream step is gated on the
188+
decision). `next`'s tip — content, manifests, gate line, and the marketplace
189+
calendar key — is left untouched, so no edge installer re-pulls. The patch
190+
updates only the stable cask; its fix reaches edge through the normal
191+
`main``next` flow, never through a `-X theirs` reconcile that would clobber
192+
`next`'s newer `(Y+1)`-line content or rewind its manifest/gate line. The
193+
auto-pre0 step is stable-latest-line-only and decision-gated, so a patch never
194+
attempts a colliding pre0 tag.
195+
196+
The `vX.(Y+1).0-pre0` release run does not recurse: its `-pre0` tag routes to the
197+
prerelease path, whose `edge-advance-decision` skips (`pre0 < next`'s `pre1`), and
198+
the auto-pre0 step runs only on the stable path — so it neither re-tags nor
199+
rewinds `next`. goreleaser stamps the edge binary from the highest tag pointing at
200+
the commit (`git tag --points-at HEAD --sort -version:refname`), which is the
201+
`-pre0` tag, so the `X.(Y+1)` binary version is correct without an override; a
202+
`GORELEASER_CURRENT_TAG` pin is set on the goreleaser step as belt-and-suspenders.
176203

177204
The reconcile is a merge, never a reset or force-push: the previous `next` tip
178205
is always a first-parent ancestor of the new commit, so `git push origin

0 commit comments

Comments
 (0)