Skip to content

Commit dbc65d9

Browse files
clkaoSpike Testclaude
authored
Release process must advance the next edge line (reconcile + calendar bump) on every release, not just stable (#455)
* release: advance the `next` edge line on every tag, not just stable release.yml's hyphenated-tag carve-out advanced neither `stable` (correctly) nor `next`, so a prerelease cut left the edge marketplace serving a stale plugin — the 0.24.0-pre1 cut drifted `next` 40 commits behind `main` and hard-blocked `spacedock codex` on a binary/plugin version-compat check. Add an `edge-advance` job (`needs: goreleaser`, a sibling so a reconcile conflict can't unwind the published release) that reconciles `next` to the tagged commit via `git merge -X theirs` on both tag paths, then bumps the marketplace calendar key. On a stable tag it also stamps `next` 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. The reconcile is a fast-forward merge, never a reset or force-push. Add release.DevPreVersion + the `spacedock-release dev-preversion` subcommand (reusing stamp-version/bump-calendar unchanged), a temp-repo fixture proving the reconcile closes the divergence force-free on both paths, a never-forces workflow guard with adversarial twins, and the docs/releasing.md edge-line section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * release: couple edge-advance fixture + wiring to the on-disk release.yml Close the two coupling holes the detached adversarial audit found: flipping release.yml's reconcile steps to `-X ours` (the 0.24.0-pre1 incident) or widening the stable step's `if:` to `always()` both stayed green. - The divergence fixture now reads its merge `-X <strategy>` from the parsed edge-advance reconcile step for the tag's path instead of hardcoding `-X theirs`, so a workflow drift to `-X ours` reds the tree-match assertion directly. Corrected the overclaiming doc-comment (it reconstructs the sequence; it does not run release.yml's steps). - Add TestReleaseWorkflowEdgeAdvanceWiring + assertEdgeAdvanceWiring in the same readWorkflow harness, asserting both reconcile steps merge `-X theirs`, the `if:` guards are complementary (exactly one fires per tag), and bump-calendar carries no `if:`. Adversarial twins (-X ours, always(), copy-paste guard, gated bump) each red. Consolidated on a shared edgeAdvanceJob lookup. The never-force and DevPreVersion lenses are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Spike Test <spike@example.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6aa200e commit dbc65d9

7 files changed

Lines changed: 923 additions & 1 deletion

File tree

.github/workflows/release.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,77 @@ jobs:
247247
# push, so the default GITHUB_TOKEN suffices (no cross-repo PAT, unlike the
248248
# homebrew-tap push).
249249
git push origin "$RELEASE_COMMIT:refs/heads/stable"
250+
251+
# Advances `next` — the branch `spacedock-edge` resolves — on EVERY tag, not
252+
# just stable. A SIBLING of goreleaser (`needs: goreleaser`, mirroring the
253+
# journey-ledger job's isolation): a rare conflict here must not block or unwind
254+
# a release that already published. See docs/releasing.md "Advancing the Edge
255+
# Line". The reconcile is a merge favoring the release, never a reset or force —
256+
# the pre-merge `next` tip stays a first-parent ancestor of the pushed commit,
257+
# so `git push edge-advance:next` is a plain fast-forward.
258+
edge-advance:
259+
needs: goreleaser
260+
runs-on: ubuntu-latest
261+
steps:
262+
- name: Checkout
263+
uses: actions/checkout@v5
264+
with:
265+
# Full history + tags so `git rev-list -1 "$GITHUB_REF_NAME"` resolves
266+
# the tagged commit the reconcile merges into `next`.
267+
fetch-depth: 0
268+
fetch-tags: true
269+
270+
- name: Set up Go
271+
uses: actions/setup-go@v6
272+
with:
273+
go-version: "1.22"
274+
275+
# Prerelease (`-pre`) tag: reconcile `next` to the tagged commit's content,
276+
# favoring the release over whatever `next` drifted to. The manifests inherit
277+
# the tag's version; the calendar bump below makes installers re-pull.
278+
- name: Reconcile the edge line to the prerelease commit
279+
if: "contains(github.ref, '-')"
280+
run: |
281+
set -euo pipefail
282+
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
283+
git fetch origin next
284+
git switch -c edge-advance origin/next
285+
git config user.name "github-actions[bot]"
286+
git config user.email "github-actions[bot]@users.noreply.github.com"
287+
git merge -X theirs --no-edit "$RELEASE_COMMIT" \
288+
-m "next: reconcile edge line to $GITHUB_REF_NAME"
289+
290+
# Stable (`vX.Y.Z`) tag: reconcile the same way, then stamp the manifests
291+
# PAST the release to the post-release dev pre-version (X.(Y+1).0-pre1), so
292+
# the edge line never masquerades as the stable version it just shipped.
293+
- name: Reconcile the edge line past the stable release
294+
if: "!contains(github.ref, '-')"
295+
run: |
296+
set -euo pipefail
297+
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
298+
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
299+
DEV_VERSION="$(go run ./cmd/spacedock-release dev-preversion "$RELEASE_VERSION")"
300+
git fetch origin next
301+
git switch -c edge-advance origin/next
302+
git config user.name "github-actions[bot]"
303+
git config user.email "github-actions[bot]@users.noreply.github.com"
304+
git merge -X theirs --no-edit "$RELEASE_COMMIT" \
305+
-m "next: reconcile edge line to $GITHUB_REF_NAME"
306+
go run ./cmd/spacedock-release stamp-version "$DEV_VERSION" \
307+
.claude-plugin/plugin.json .codex-plugin/plugin.json
308+
git commit -m "next: bump dev pre-version to $DEV_VERSION" \
309+
-- .claude-plugin/plugin.json .codex-plugin/plugin.json
310+
311+
# Both paths end here: bump the marketplace calendar key (the moving-branch
312+
# re-pull key `claude plugin update` / `codex` read) and fast-forward `next`.
313+
# Exactly one reconcile step above ran, so `edge-advance` already carries the
314+
# merge (plus, on a stable tag, the dev-preversion stamp).
315+
- name: Bump the marketplace calendar key and push the edge line
316+
run: |
317+
set -euo pipefail
318+
git config user.name "github-actions[bot]"
319+
git config user.email "github-actions[bot]@users.noreply.github.com"
320+
go run ./cmd/spacedock-release bump-calendar .claude-plugin/marketplace.json
321+
git commit -m "next: bump marketplace calendar version" \
322+
-- .claude-plugin/marketplace.json
323+
git push origin edge-advance:next
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package main
2+
3+
import (
4+
"io"
5+
"os"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestDevPreversionCommandPrintsComputedVersion — the subcommand prints exactly
11+
// the post-release dev pre-version (X.(Y+1).0-pre1) to stdout, so release.yml's
12+
// `DEV_VERSION="$(... dev-preversion "$RELEASE_VERSION")"` capture stamps `next`
13+
// PAST the just-released stable version rather than re-stamping it.
14+
func TestDevPreversionCommandPrintsComputedVersion(t *testing.T) {
15+
out, code := captureStdout(t, func() int { return devPreversion([]string{"0.24.0"}) })
16+
if code != 0 {
17+
t.Fatalf("dev-preversion exit = %d, want 0 on a bare stable semver", code)
18+
}
19+
if got := strings.TrimSpace(out); got != "0.25.0-pre1" {
20+
t.Fatalf("dev-preversion stdout = %q, want 0.25.0-pre1", got)
21+
}
22+
}
23+
24+
// TestDevPreversionCommandRejectsMissingArg — with no version argument the
25+
// subcommand exits with a usage error and prints nothing consumable.
26+
func TestDevPreversionCommandRejectsMissingArg(t *testing.T) {
27+
if code := devPreversion(nil); code == 0 {
28+
t.Fatalf("dev-preversion exit = 0 with no argument; want non-zero")
29+
}
30+
}
31+
32+
// TestDevPreversionCommandRejectsExtraArg — the subcommand takes exactly one
33+
// version; a second argument is a usage error, so a release.yml miswiring fails
34+
// loud rather than silently ignoring the extra token.
35+
func TestDevPreversionCommandRejectsExtraArg(t *testing.T) {
36+
if code := devPreversion([]string{"0.24.0", "0.25.0"}); code == 0 {
37+
t.Fatalf("dev-preversion exit = 0 with two arguments; want non-zero")
38+
}
39+
}
40+
41+
// TestDevPreversionCommandRejectsHyphenatedInput — a pre-release (hyphenated)
42+
// input is malformed for this subcommand (it runs only on the stable branch),
43+
// so it exits non-zero instead of emitting a doubly-suffixed version.
44+
func TestDevPreversionCommandRejectsHyphenatedInput(t *testing.T) {
45+
if code := devPreversion([]string{"0.24.0-pre1"}); code == 0 {
46+
t.Fatalf("dev-preversion exit = 0 on a hyphenated input; want non-zero")
47+
}
48+
}
49+
50+
// captureStdout runs fn with os.Stdout redirected to a pipe and returns what fn
51+
// printed plus fn's return code, so a subcommand whose contract IS its stdout
52+
// (release.yml captures it via $()) is verified on the real bytes it emits.
53+
func captureStdout(t *testing.T, fn func() int) (string, int) {
54+
t.Helper()
55+
r, w, err := os.Pipe()
56+
if err != nil {
57+
t.Fatalf("os.Pipe: %v", err)
58+
}
59+
orig := os.Stdout
60+
os.Stdout = w
61+
defer func() { os.Stdout = orig }()
62+
63+
code := fn()
64+
w.Close()
65+
data, err := io.ReadAll(r)
66+
if err != nil {
67+
t.Fatalf("read captured stdout: %v", err)
68+
}
69+
return string(data), code
70+
}

cmd/spacedock-release/main.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ import (
2121
//
2222
// spacedock-release stamp-version <release-version> <plugin.json> [<plugin.json> ...]
2323
// spacedock-release bump-calendar <marketplace.json>
24+
// spacedock-release dev-preversion <stable-version>
2425
// spacedock-release e2e-gate <release-commit-sha>
2526
//
2627
// stamp-version rewrites each manifest's top-level `version` to the release
2728
// version (AC-4). bump-calendar advances the marketplace plugin entry's calendar
28-
// key to today's `0.0.YYYYMMDDNN` (AC-2d). Both rewrite in place. e2e-gate is the
29+
// key to today's `0.0.YYYYMMDDNN` (AC-2d). Both rewrite in place. dev-preversion
30+
// prints the post-release dev pre-version (X.(Y+1).0-pre1) the stable-tag edge
31+
// advance stamps onto `next`. e2e-gate is the
2932
// release-time precondition: it passes (exit 0) only when a conclusion:success
3033
// Runtime Live E2E run exists for the commit, or when SPACEDOCK_E2E_GATE_WAIVER
3134
// is set, and blocks the cut (exit 1) otherwise. manifest-tag-gate blocks the cut
@@ -44,6 +47,8 @@ func main() {
4447
os.Exit(stampVersion(os.Args[2:]))
4548
case "bump-calendar":
4649
os.Exit(bumpCalendar(os.Args[2:]))
50+
case "dev-preversion":
51+
os.Exit(devPreversion(os.Args[2:]))
4752
case "journey-costs":
4853
os.Exit(journeyCosts(os.Args[2:]))
4954
case "e2e-gate":
@@ -173,6 +178,26 @@ func bumpCalendar(args []string) int {
173178
return 0
174179
}
175180

181+
// devPreversion prints the post-release dev pre-version for the `next` edge line
182+
// (X.(Y+1).0-pre1) computed from a just-released stable version. release.yml's
183+
// stable-tag path captures this stdout to stamp `next` PAST the released stable
184+
// version. It takes exactly one <stable-version> arg and errors on a hyphenated
185+
// or malformed input, since it runs only on the `!contains(github.ref, '-')`
186+
// branch that already guarantees a bare X.Y.Z.
187+
func devPreversion(args []string) int {
188+
if len(args) != 1 {
189+
fmt.Fprintln(os.Stderr, "spacedock-release dev-preversion: need exactly one <stable-version> (e.g. 0.24.0)")
190+
return 2
191+
}
192+
version, err := release.DevPreVersion(args[0])
193+
if err != nil {
194+
fmt.Fprintf(os.Stderr, "dev-preversion: %v\n", err)
195+
return 1
196+
}
197+
fmt.Println(version)
198+
return 0
199+
}
200+
176201
// notes summarizes the commits since the last tag into clean release notes and,
177202
// on the captain's confirmation, cuts an annotated tag whose body IS those
178203
// notes. The tag is created locally only — pushing it (which fires the release
@@ -323,6 +348,7 @@ func usage() {
323348
Usage:
324349
spacedock-release stamp-version <release-version> <plugin.json> [<plugin.json> ...]
325350
spacedock-release bump-calendar <marketplace.json>
351+
spacedock-release dev-preversion <stable-version>
326352
spacedock-release journey-costs <release-version> --metrics-dir <dir> --out <path>
327353
spacedock-release e2e-gate <release-commit-sha>
328354
spacedock-release manifest-tag-gate <tag> <plugin.json> [<plugin.json> ...]

docs/releasing.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ marketplace source.
1616
`spacedock@next` edge) via `HOMEBREW_TAP_TOKEN`;
1717
- stamps the plugin manifests' `version` on `main`, then advances the stable
1818
channel ref (see below).
19+
- advances the `next` edge line to match the release — reconciled on a
20+
prerelease tag, reconciled plus bumped to the post-release dev pre-version on
21+
a stable tag (see "Advancing the Edge Line" below).
1922

2023
The marketplace manifest no longer lives in the plugin branch. It is the
2124
standalone `spacedock-dev/marketplace` repo, where each channel is a branch with
@@ -148,13 +151,43 @@ green Runtime Live E2E run for its exact SHA. Stamp and push the release commit
148151
git branch -d release/X.Y.Z
149152
```
150153

154+
## Advancing the Edge Line (`next`)
155+
156+
Every tag push also advances `next` — the branch the `spacedock-edge`
157+
marketplace resolves — in a job that `needs: goreleaser` (a sibling job, so a
158+
rare conflict here cannot unwind or block the release that already published):
159+
160+
- **Prerelease (`-pre`) tag:** `next` is reconciled to the tagged commit's
161+
content — `git merge -X theirs "$RELEASE_COMMIT"`, favoring the release over
162+
whatever `next` had drifted to — then the marketplace calendar key is bumped
163+
(`spacedock-release bump-calendar`) so `claude plugin update` / `codex`
164+
re-pull. This is the automated form of the manual reconcile the 0.24.0-pre1
165+
cut required (`next` had drifted 40 commits behind `main`, hard-blocking
166+
`spacedock codex` on a binary/plugin version-compat check).
167+
- **Stable (`vX.Y.Z`) tag:** `next` is reconciled the same way, then stamped
168+
PAST the release to the post-release dev pre-version
169+
(`spacedock-release dev-preversion X.Y.Z``X.(Y+1).0-pre1`), so the edge
170+
line never masquerades as the stable version it just shipped, then the
171+
calendar key is bumped.
172+
173+
The reconcile is a merge, never a reset or force-push: the previous `next` tip
174+
is always a first-parent ancestor of the new commit, so `git push origin
175+
<sha>:next` is a plain fast-forward. A real conflict (two sides changing the
176+
same file in incompatible, non-superseded ways) fails the step loudly instead
177+
of guessing — the same manual reconciliation this replaces remains the escape
178+
hatch.
179+
151180
## Dev-Only `next` Publishing
152181

153182
Keep `next` for development. Source builds may use
154183
`go install github.com/spacedock-dev/spacedock/cmd/spacedock@next`, local
155184
checkouts may use `--plugin-dir`, and the deliberate `next-publish` workflow may
156185
bump the marketplace calendar key for dev testers.
157186

187+
Every release tag now advances `next` and bumps its calendar key automatically
188+
(see "Advancing the Edge Line" above); `next-publish` stays for an out-of-band
189+
re-pull between releases (e.g. a `next`-only fix that isn't worth a full cut).
190+
158191
Do not send stable users to `next`. If a command or manifest uses `@next`, it is
159192
a dev-only path.
160193

0 commit comments

Comments
 (0)