Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 72 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,14 @@ jobs:
# 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
Expand Down Expand Up @@ -301,11 +309,33 @@ jobs:
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, '-')"
if: "contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
run: |
set -euo pipefail
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
Expand All @@ -320,7 +350,7 @@ jobs:
# 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, '-')"
if: "!contains(github.ref, '-') && steps.decision.outputs.advance == 'true'"
run: |
set -euo pipefail
RELEASE_COMMIT="$(git rev-list -1 "$GITHUB_REF_NAME")"
Expand All @@ -338,11 +368,15 @@ jobs:
git commit -m "next: bump dev pre-version to $DEV_VERSION" \
-- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"

# Both 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).
# 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]"
Expand All @@ -351,3 +385,35 @@ jobs:
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"
69 changes: 69 additions & 0 deletions cmd/spacedock-release/edge_advance_decision.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// ABOUTME: `edge-advance-decision` prints advance/skip gating the whole edge-advance
// ABOUTME: job; `edge-pre0-version` prints the auto-cut edge prerelease version.
package main

import (
"fmt"
"os"

"github.com/spacedock-dev/spacedock/internal/release"
)

// edgeAdvanceDecision decides whether a tag advances the edge line or skips the
// whole edge-advance job, given `next`'s current manifest. It prints `advance`
// or `skip` to stdout (exit 0 for BOTH — a skip is a normal outcome, not an
// error) so release.yml's decision step can write advance=true|false to
// $GITHUB_OUTPUT and gate every downstream step on it. A missing/unparseable
// manifest or a malformed tag/version is a real error (non-zero), so a miswiring
// fails loud rather than silently skipping and leaving the edge channel broken.
func edgeAdvanceDecision(args []string) int {
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "spacedock-release edge-advance-decision: need <tag> <next-plugin.json>")
return 2
}
tag, manifestPath := args[0], args[1]
data, err := os.ReadFile(manifestPath)
if err != nil {
fmt.Fprintf(os.Stderr, "read %s: %v\n", manifestPath, err)
return 1
}
nextVersion, err := release.ManifestVersion(data)
if err != nil {
fmt.Fprintf(os.Stderr, "parse %s: %v\n", manifestPath, err)
return 1
}
if nextVersion == "" {
fmt.Fprintf(os.Stderr, "edge-advance-decision: %s carries no top-level version\n", manifestPath)
return 1
}
advance, target, err := release.EdgeAdvanceDecision(tag, nextVersion)
if err != nil {
fmt.Fprintf(os.Stderr, "edge-advance-decision: %v\n", err)
return 1
}
fmt.Fprintf(os.Stderr, "tag %s target edge version %s vs next %s\n", tag, target, nextVersion)
if advance {
fmt.Println("advance")
} else {
fmt.Println("skip")
}
return 0
}

// edgePre0Version prints the auto-cut edge prerelease version (X.(Y+1).0-pre0)
// for a bare stable version, so release.yml's always-cut-pre0 step forms the
// `vX.(Y+1).0-pre0` annotated tag. It errors on a hyphenated/malformed input,
// since it runs only on the stable path that already guarantees a bare X.Y.Z.
func edgePre0Version(args []string) int {
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "spacedock-release edge-pre0-version: need exactly one <stable-version> (e.g. 0.25.0)")
return 2
}
version, err := release.Pre0EdgeVersion(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "edge-pre0-version: %v\n", err)
return 1
}
fmt.Println(version)
return 0
}
82 changes: 82 additions & 0 deletions cmd/spacedock-release/edge_advance_decision_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"
)

// writeNextPlugin writes a plugin.json carrying version to a temp file and
// returns its path — the `<next-plugin.json>` the decision subcommand reads (the
// workflow pipes `git show origin/next:.claude-plugin/plugin.json` into it).
func writeNextPlugin(t *testing.T, version string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "plugin.json")
body := "{\n \"name\": \"spacedock\",\n \"version\": \"" + version + "\"\n}\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write next plugin.json: %v", err)
}
return path
}

// TestEdgeAdvanceDecisionCommandPrintsVerdict — the subcommand's contract IS its
// stdout: release.yml's decision step captures `advance`/`skip` and writes
// advance=true|false to $GITHUB_OUTPUT. A forward stable cut advances; an
// old-line patch whose target edge version is not strictly greater than next's
// manifest skips. Exit is 0 for BOTH verdicts (a skip is a normal outcome).
func TestEdgeAdvanceDecisionCommandPrintsVerdict(t *testing.T) {
cases := []struct {
name string
tag string
next string
want string
}{
{"forward stable advances", "v0.26.0", "0.26.0-pre1", "advance"},
{"old-line patch skips", "v0.25.1", "0.27.0-pre1", "skip"},
{"patch-equality skips", "v0.25.1", "0.26.0-pre1", "skip"},
{"pre0 vs next pre1 skips", "v0.26.0-pre0", "0.26.0-pre1", "skip"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
plugin := writeNextPlugin(t, c.next)
out, code := captureStdout(t, func() int { return edgeAdvanceDecision([]string{c.tag, plugin}) })
if code != 0 {
t.Fatalf("edge-advance-decision exit = %d, want 0", code)
}
if got := strings.TrimSpace(out); got != c.want {
t.Fatalf("edge-advance-decision(%q, next=%q) stdout = %q, want %q", c.tag, c.next, got, c.want)
}
})
}
}

// TestEdgeAdvanceDecisionCommandRejectsBadArgs — a missing manifest path or a
// wrong arg count is a usage/IO error (non-zero), so a release.yml miswiring
// fails loud rather than silently defaulting to skip (which would leave the edge
// channel broken with no signal).
func TestEdgeAdvanceDecisionCommandRejectsBadArgs(t *testing.T) {
if code := edgeAdvanceDecision([]string{"v0.26.0"}); code == 0 {
t.Fatalf("edge-advance-decision exit = 0 with one argument; want non-zero")
}
if code := edgeAdvanceDecision([]string{"v0.26.0", filepath.Join(t.TempDir(), "missing.json")}); code == 0 {
t.Fatalf("edge-advance-decision exit = 0 on a missing manifest; want non-zero")
}
}

// TestEdgePre0VersionCommandPrintsComputedVersion — the subcommand prints exactly
// X.(Y+1).0-pre0, so release.yml's always-cut-pre0 step forms `vX.(Y+1).0-pre0`
// as the annotated auto-tag, whose minor matches the required minor next's skills
// were stamped to.
func TestEdgePre0VersionCommandPrintsComputedVersion(t *testing.T) {
out, code := captureStdout(t, func() int { return edgePre0Version([]string{"0.25.0"}) })
if code != 0 {
t.Fatalf("edge-pre0-version exit = %d, want 0 on a bare stable semver", code)
}
if got := strings.TrimSpace(out); got != "0.26.0-pre0" {
t.Fatalf("edge-pre0-version stdout = %q, want 0.26.0-pre0", got)
}
if code := edgePre0Version([]string{"0.25.0-pre1"}); code == 0 {
t.Fatalf("edge-pre0-version exit = 0 on a hyphenated input; want non-zero")
}
}
14 changes: 13 additions & 1 deletion cmd/spacedock-release/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ import (
// erroring unless the literal appears exactly once. bump-calendar advances the
// marketplace plugin entry's calendar key to today's `0.0.YYYYMMDDNN` (AC-2d).
// All rewrite in place. dev-preversion prints the post-release dev pre-version
// (X.(Y+1).0-pre1) the stable-tag edge advance stamps onto `next`. journey-delta
// (X.(Y+1).0-pre1) the stable-tag edge advance stamps onto `next`.
// edge-advance-decision prints `advance` or `skip` (exit 0 either way) deciding
// whether a tag advances the edge line: it computes the tag's target edge
// version and skips unless that is strictly greater than `next`'s current
// manifest, so the whole edge-advance job no-ops on an old-line/patch tag.
// edge-pre0-version prints the auto-cut edge prerelease version (X.(Y+1).0-pre0)
// the stable path tags on the greened release commit. journey-delta
// renders and posts the per-PR journey-cost delta against the previously
// published release ledger's latest-by-captured_at baseline per scenario/model,
// updating a single sticky PR comment found by its HTML marker. The AC-4
Expand Down Expand Up @@ -63,6 +69,10 @@ func main() {
os.Exit(bumpCalendar(os.Args[2:]))
case "dev-preversion":
os.Exit(devPreversion(os.Args[2:]))
case "edge-advance-decision":
os.Exit(edgeAdvanceDecision(os.Args[2:]))
case "edge-pre0-version":
os.Exit(edgePre0Version(os.Args[2:]))
case "journey-costs":
os.Exit(journeyCosts(os.Args[2:]))
case "journey-delta":
Expand Down Expand Up @@ -374,6 +384,8 @@ Usage:
spacedock-release stamp-version <release-version> <manifest-or-prose> [<manifest-or-prose> ...]
spacedock-release bump-calendar <marketplace.json>
spacedock-release dev-preversion <stable-version>
spacedock-release edge-advance-decision <tag> <next-plugin.json>
spacedock-release edge-pre0-version <stable-version>
spacedock-release journey-costs <release-version> --metrics-dir <dir> --out <path>
spacedock-release journey-delta <previous-ledger.json> --metrics-dir <dir> --pr <number>
spacedock-release e2e-gate <release-commit-sha>
Expand Down
37 changes: 32 additions & 5 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,38 @@ rare conflict here cannot unwind or block the release that already published):
re-pull. This is the automated form of the manual reconcile the 0.24.0-pre1
cut required (`next` had drifted 40 commits behind `main`, hard-blocking
`spacedock codex` on a binary/plugin version-compat check).
- **Stable (`vX.Y.Z`) tag:** `next` is reconciled the same way, then stamped
PAST the release to the post-release dev pre-version
(`spacedock-release dev-preversion X.Y.Z` → `X.(Y+1).0-pre1`), so the edge
line never masquerades as the stable version it just shipped, then the
calendar key is bumped.
- **Stable (`vX.Y.Z`) tag, latest line:** `next` is reconciled the same way,
stamped PAST the release to `X.(Y+1).0-pre1`
(`spacedock-release dev-preversion X.Y.Z`) so the edge line never masquerades
as the stable version it just shipped, and the calendar key is bumped — then an
ANNOTATED `vX.(Y+1).0-pre0` tag is auto-created **on the greened release commit**
and pushed (via the re-triggering tap PAT). That prerelease tag's own release
run reuses the greened commit's e2e-gate pass, builds+publishes the `X.(Y+1)`-minor
edge binary, and bumps the `spacedock@next` cask — so the edge binary's minor
catches up to the skills' gate line within minutes instead of waiting for the
next hand-cut prerelease. The auto-tag MUST be annotated with a non-empty body
(the release-notes extraction step rejects a lightweight tag), and MUST be
pushed with the PAT (a `GITHUB_TOKEN` push does not fire the pre0 build). Expect
two GitHub releases per stable cut.
- **Old-line / patch (`vX.Y.1`, or any tag whose target edge version is not
strictly greater than `next`'s current manifest version):** the whole
`edge-advance` job SKIPS (`spacedock-release edge-advance-decision` prints
`skip`, logged as a `::notice::`; every downstream step is gated on the
decision). `next`'s tip — content, manifests, gate line, and the marketplace
calendar key — is left untouched, so no edge installer re-pulls. The patch
updates only the stable cask; its fix reaches edge through the normal
`main`→`next` flow, never through a `-X theirs` reconcile that would clobber
`next`'s newer `(Y+1)`-line content or rewind its manifest/gate line. The
auto-pre0 step is stable-latest-line-only and decision-gated, so a patch never
attempts a colliding pre0 tag.

The `vX.(Y+1).0-pre0` release run does not recurse: its `-pre0` tag routes to the
prerelease path, whose `edge-advance-decision` skips (`pre0 < next`'s `pre1`), and
the auto-pre0 step runs only on the stable path — so it neither re-tags nor
rewinds `next`. goreleaser stamps the edge binary from the highest tag pointing at
the commit (`git tag --points-at HEAD --sort -version:refname`), which is the
`-pre0` tag, so the `X.(Y+1)` binary version is correct without an override; a
`GORELEASER_CURRENT_TAG` pin is set on the goreleaser step as belt-and-suspenders.

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