Skip to content

Commit 6b8109d

Browse files
author
Spike Test
committed
Merge remote-tracking branch 'origin/main' into spacedock-ensign/boot-metrics-record-and-pr-delta
# Conflicts: # cmd/spacedock-release/main.go
2 parents cb5b0cf + d48d0ee commit 6b8109d

76 files changed

Lines changed: 2376 additions & 820 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"automation"
1717
],
1818
"skills": "./skills/",
19-
"requires-contract": ">=2,<3",
19+
"requires-contract": ">=3,<4",
2020
"interface": {
2121
"displayName": "Spacedock",
2222
"shortDescription": "Plain text workflows for AI agents",

.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"automation"
1717
],
1818
"skills": "./skills/",
19-
"requires-contract": ">=2,<3",
19+
"requires-contract": ">=3,<4",
2020
"interface": {
2121
"displayName": "Spacedock",
2222
"shortDescription": "Plain text workflows for AI agents",

.github/workflows/release.yml

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,20 @@ jobs:
150150
echo "release commit for $GITHUB_REF_NAME: $RELEASE_COMMIT"
151151
go run ./cmd/spacedock-release e2e-gate "$RELEASE_COMMIT"
152152
153-
# Assert the tagged commit's plugin manifests already carry the tag's semver
154-
# — the stamp-then-tag ordering docs/releasing.md documents. The checkout
155-
# above is at the tagged commit, so these are the tagged commit's manifests.
156-
# A mismatch (the v0.20.0 pre-stamp inversion: tag 0.20.0, manifest 0.19.9)
157-
# exits non-zero and blocks the cut, since goreleaser needs: this job. The
158-
# `if` skips pre-releases (a vX.Y.Z-pre.N tag legitimately differs from its
159-
# X.Y.Z manifest), matching the stamp step's pre-release carve-out below.
160-
- name: Gate the cut on the tagged manifest matching the tag semver
153+
# Assert the tagged commit's plugin manifests AND the FO shared-core prose
154+
# already carry the tag's semver/minor — the stamp-then-tag ordering
155+
# docs/releasing.md documents. The checkout above is at the tagged commit,
156+
# so these are the tagged commit's files. A mismatch (the v0.20.0 pre-stamp
157+
# inversion: tag 0.20.0, manifest 0.19.9; or a forgotten prose stamp) exits
158+
# non-zero and blocks the cut, since goreleaser needs: this job. The `if`
159+
# skips pre-releases (a vX.Y.Z-pre.N tag legitimately differs from its
160+
# X.Y.Z manifest/prose), matching the stamp step's pre-release carve-out
161+
# below.
162+
- name: Gate the cut on the tagged manifest/prose matching the tag semver
161163
if: "!contains(github.ref, '-')"
162164
run: |
163165
set -euo pipefail
164-
go run ./cmd/spacedock-release manifest-tag-gate "$GITHUB_REF_NAME" .claude-plugin/plugin.json .codex-plugin/plugin.json
166+
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
165167
166168
goreleaser:
167169
needs: e2e-gate
@@ -244,6 +246,7 @@ jobs:
244246
run: |
245247
set -euo pipefail
246248
RELEASE_VERSION="${GITHUB_REF_NAME#v}"
249+
FO_PROSE=skills/first-officer/references/first-officer-shared-core.md
247250
# The tagged commit is the SHA the e2e-gate greened and the manifest-tag
248251
# gate matched. Resolve it BEFORE switching to main, so the stable ref
249252
# below advances to exactly THIS commit, not whatever main HEAD happens to
@@ -252,14 +255,14 @@ jobs:
252255
git fetch origin main
253256
git switch main
254257
go run ./cmd/spacedock-release stamp-version "$RELEASE_VERSION" \
255-
.claude-plugin/plugin.json .codex-plugin/plugin.json
256-
if git diff --quiet -- .claude-plugin/plugin.json .codex-plugin/plugin.json; then
257-
echo "plugin manifests already at $RELEASE_VERSION; nothing to commit"
258+
.claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
259+
if git diff --quiet -- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"; then
260+
echo "plugin manifests and FO prose already at $RELEASE_VERSION; nothing to commit"
258261
else
259262
git config user.name "github-actions[bot]"
260263
git config user.email "github-actions[bot]@users.noreply.github.com"
261-
git commit -m "release: stamp plugin manifests to $RELEASE_VERSION" \
262-
-- .claude-plugin/plugin.json .codex-plugin/plugin.json
264+
git commit -m "release: stamp plugin manifests and FO prose to $RELEASE_VERSION" \
265+
-- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
263266
git push origin main
264267
fi
265268
# Advance the stable channel ref to the TAGGED commit. The
@@ -329,10 +332,11 @@ jobs:
329332
git config user.email "github-actions[bot]@users.noreply.github.com"
330333
git merge -X theirs --no-edit "$RELEASE_COMMIT" \
331334
-m "next: reconcile edge line to $GITHUB_REF_NAME"
335+
FO_PROSE=skills/first-officer/references/first-officer-shared-core.md
332336
go run ./cmd/spacedock-release stamp-version "$DEV_VERSION" \
333-
.claude-plugin/plugin.json .codex-plugin/plugin.json
337+
.claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
334338
git commit -m "next: bump dev pre-version to $DEV_VERSION" \
335-
-- .claude-plugin/plugin.json .codex-plugin/plugin.json
339+
-- .claude-plugin/plugin.json .codex-plugin/plugin.json "$FO_PROSE"
336340
337341
# Both paths end here: bump the marketplace calendar key (the moving-branch
338342
# re-pull key `claude plugin update` / `codex` read) and fast-forward `next`.

cmd/spacedock-release/main.go

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,35 @@ import (
1919
//
2020
// Usage:
2121
//
22-
// spacedock-release stamp-version <release-version> <plugin.json> [<plugin.json> ...]
22+
// spacedock-release stamp-version <release-version> <manifest-or-prose> [<manifest-or-prose> ...]
2323
// spacedock-release bump-calendar <marketplace.json>
2424
// spacedock-release dev-preversion <stable-version>
2525
// spacedock-release journey-delta <previous-ledger.json> --metrics-dir <dir> --pr <number>
2626
// spacedock-release e2e-gate <release-commit-sha>
2727
//
28-
// stamp-version rewrites each manifest's top-level `version` to the release
29-
// version (AC-4). bump-calendar advances the marketplace plugin entry's calendar
30-
// key to today's `0.0.YYYYMMDDNN` (AC-2d). Both rewrite in place. dev-preversion
31-
// prints the post-release dev pre-version (X.(Y+1).0-pre1) the stable-tag edge
32-
// advance stamps onto `next`. journey-delta renders and posts the per-PR
33-
// journey-cost delta against the previously published release ledger's
34-
// latest-by-captured_at baseline per scenario/model, updating a single sticky
35-
// PR comment found by its HTML marker. The AC-4 release-ledger backfill (a
36-
// captain-flagged one-time manual procedure, never a CI step) is a documented
37-
// runbook, not a shipped subcommand: extraction reuses the exported
38-
// ensigncycle.BuildShallowBootWindowRecord via a throwaway `go run` script
39-
// against each archived stream, and the pre-upload safeguard is
28+
// stamp-version rewrites each argument to the release version: a `.json`
29+
// plugin.json gets its top-level `version` field rewritten (AC-4); a `.md`
30+
// argument is the FO shared-core prose, whose single release-stamped "required
31+
// binary minor" literal is rewritten to the release's major.minor (D5) —
32+
// erroring unless the literal appears exactly once. bump-calendar advances the
33+
// marketplace plugin entry's calendar key to today's `0.0.YYYYMMDDNN` (AC-2d).
34+
// 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
36+
// renders and posts the per-PR journey-cost delta against the previously
37+
// published release ledger's latest-by-captured_at baseline per scenario/model,
38+
// updating a single sticky PR comment found by its HTML marker. The AC-4
39+
// release-ledger backfill (a captain-flagged one-time manual procedure, never a
40+
// CI step) is a documented runbook, not a shipped subcommand: extraction reuses
41+
// the exported ensigncycle.BuildShallowBootWindowRecord via a throwaway `go run`
42+
// script against each archived stream, and the pre-upload safeguard is
4043
// `jq -S .scenarios <original> | diff - <(jq -S .scenarios <rebuilt>)` —
4144
// any diff output means STOP, do not upload. e2e-gate is the
4245
// release-time precondition: it passes (exit 0) only when a conclusion:success
4346
// Runtime Live E2E run exists for the commit, or when SPACEDOCK_E2E_GATE_WAIVER
4447
// is set, and blocks the cut (exit 1) otherwise. manifest-tag-gate blocks the cut
45-
// unless every tagged plugin.json's version equals the tag semver (the stamp-then-tag
46-
// ordering). notes summarizes the commit log
48+
// unless every tagged `.json` manifest's version equals the tag semver AND every
49+
// tagged `.md` prose's stamped minor equals the tag's major.minor (the
50+
// stamp-then-tag ordering). notes summarizes the commit log
4751
// since the last tag into clean release notes and, on confirmation, cuts the
4852
// annotated tag whose body carries them (CI extracts that body and feeds
4953
// goreleaser via --release-notes).
@@ -152,7 +156,16 @@ func stampVersion(args []string) int {
152156
fmt.Fprintf(os.Stderr, "read %s: %v\n", path, err)
153157
return 1
154158
}
155-
out, err := release.StampVersion(data, version)
159+
// D5: a `.md` argument is the FO shared-core prose — one release-stamped
160+
// "required binary minor" literal, rewritten by StampProseVersion. Every
161+
// other extension (the plugin.json manifests) keeps the existing JSON
162+
// full-version stamp. One invocation, one atomic multi-file rewrite.
163+
var out []byte
164+
if strings.HasSuffix(path, ".md") {
165+
out, err = release.StampProseVersion(data, version)
166+
} else {
167+
out, err = release.StampVersion(data, version)
168+
}
156169
if err != nil {
157170
fmt.Fprintf(os.Stderr, "stamp %s: %v\n", path, err)
158171
return 1
@@ -358,13 +371,13 @@ func usage() {
358371
fmt.Fprint(os.Stderr, `spacedock-release is the release-pipeline version tool.
359372
360373
Usage:
361-
spacedock-release stamp-version <release-version> <plugin.json> [<plugin.json> ...]
374+
spacedock-release stamp-version <release-version> <manifest-or-prose> [<manifest-or-prose> ...]
362375
spacedock-release bump-calendar <marketplace.json>
363376
spacedock-release dev-preversion <stable-version>
364377
spacedock-release journey-costs <release-version> --metrics-dir <dir> --out <path>
365378
spacedock-release journey-delta <previous-ledger.json> --metrics-dir <dir> --pr <number>
366379
spacedock-release e2e-gate <release-commit-sha>
367-
spacedock-release manifest-tag-gate <tag> <plugin.json> [<plugin.json> ...]
380+
spacedock-release manifest-tag-gate <tag> <manifest-or-prose> [<manifest-or-prose> ...]
368381
spacedock-release notes <release-version>
369382
`)
370383
}

cmd/spacedock-release/manifest_tag_gate.go

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,59 @@
1-
// ABOUTME: `spacedock-release manifest-tag-gate <tag> <plugin.json>...` — blocks
2-
// ABOUTME: the cut unless every tagged manifest's version equals the tag semver.
1+
// ABOUTME: `spacedock-release manifest-tag-gate <tag> <manifest-or-prose>...` —
2+
// ABOUTME: blocks the cut unless every tagged manifest/prose agrees with the tag.
33
package main
44

55
import (
66
"fmt"
77
"os"
8+
"strings"
89

910
"github.com/spacedock-dev/spacedock/internal/release"
1011
)
1112

12-
// runManifestTagGate asserts the tag semver equals the version stamped into each
13-
// plugin manifest the cutter is about to tag. It reads the manifest version from
14-
// each file (an independent value from the tag), runs the pure decision predicate,
15-
// records the outcome to $GITHUB_STEP_SUMMARY, and returns the process exit code:
16-
// 0 when every manifest matches the tag, 1 when any diverges or cannot be read, 2
17-
// on a usage error. This is the divergeable guard behind the reconciled
18-
// releasing.md's stamp-then-tag ordering: a tag-vs-manifest mismatch (the pre-stamp
19-
// inversion) is caught before goreleaser fires.
13+
// runManifestTagGate asserts the tag semver agrees with each tagged file the
14+
// cutter is about to tag: a `.json` plugin manifest's version must equal the
15+
// tag semver exactly; a `.md` prose file's stamped minor (D5) must equal the
16+
// tag's major.minor. It reads each file's value (independent of the tag), runs
17+
// the pure decision predicate, records the outcome to $GITHUB_STEP_SUMMARY, and
18+
// returns the process exit code: 0 when every file matches the tag, 1 when any
19+
// diverges or cannot be read, 2 on a usage error. This is the divergeable guard
20+
// behind the reconciled releasing.md's stamp-then-tag ordering: a
21+
// tag-vs-manifest/prose mismatch (the pre-stamp inversion) is caught before
22+
// goreleaser fires.
2023
func runManifestTagGate(args []string) int {
2124
if len(args) < 2 {
22-
fmt.Fprintln(os.Stderr, "spacedock-release manifest-tag-gate: need <tag> <plugin.json> [<plugin.json> ...]")
25+
fmt.Fprintln(os.Stderr, "spacedock-release manifest-tag-gate: need <tag> <manifest-or-prose> [<manifest-or-prose> ...]")
2326
return 2
2427
}
25-
tag, manifests := args[0], args[1:]
26-
for _, path := range manifests {
28+
tag, files := args[0], args[1:]
29+
for _, path := range files {
2730
data, err := os.ReadFile(path)
2831
if err != nil {
2932
reason := fmt.Sprintf("manifest-tag gate BLOCKED for %s: cannot read %s (%v)", tag, path, err)
3033
fmt.Fprintln(os.Stderr, reason)
3134
recordManifestTagSummary(reason)
3235
return 1
3336
}
34-
version, err := release.ManifestVersion(data)
35-
if err != nil {
36-
reason := fmt.Sprintf("manifest-tag gate BLOCKED for %s: cannot parse %s (%v)", tag, path, err)
37-
fmt.Fprintln(os.Stderr, reason)
38-
recordManifestTagSummary(reason)
39-
return 1
37+
var dec release.ManifestTagDecision
38+
if strings.HasSuffix(path, ".md") {
39+
minor, err := release.ProseMinor(data)
40+
if err != nil {
41+
reason := fmt.Sprintf("manifest-tag gate BLOCKED for %s: cannot read prose minor from %s (%v)", tag, path, err)
42+
fmt.Fprintln(os.Stderr, reason)
43+
recordManifestTagSummary(reason)
44+
return 1
45+
}
46+
dec = release.EvaluateProseMinorTagGate(tag, minor)
47+
} else {
48+
version, err := release.ManifestVersion(data)
49+
if err != nil {
50+
reason := fmt.Sprintf("manifest-tag gate BLOCKED for %s: cannot parse %s (%v)", tag, path, err)
51+
fmt.Fprintln(os.Stderr, reason)
52+
recordManifestTagSummary(reason)
53+
return 1
54+
}
55+
dec = release.EvaluateManifestTagGate(tag, version)
4056
}
41-
dec := release.EvaluateManifestTagGate(tag, version)
4257
if !dec.Pass {
4358
reason := fmt.Sprintf("%s (%s)", dec.Reason, path)
4459
fmt.Fprintf(os.Stderr, "spacedock-release manifest-tag-gate: %s\n", reason)
@@ -47,7 +62,7 @@ func runManifestTagGate(args []string) int {
4762
}
4863
fmt.Printf("%s (%s)\n", dec.Reason, path)
4964
}
50-
recordManifestTagSummary(fmt.Sprintf("manifest-tag gate PASSED for %s: all %d manifest(s) match the tag semver", tag, len(manifests)))
65+
recordManifestTagSummary(fmt.Sprintf("manifest-tag gate PASSED for %s: all %d file(s) match the tag", tag, len(files)))
5166
return 0
5267
}
5368

cmd/spacedock-release/manifest_tag_gate_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,37 @@ func TestManifestTagGateCommandBlocksUnreadableManifest(t *testing.T) {
6262
t.Fatalf("manifest-tag-gate exit = 0 on an unreadable manifest; want non-zero")
6363
}
6464
}
65+
66+
// writeProse writes an FO shared-core prose fixture stamped at the given minor
67+
// to a temp .md file and returns its path.
68+
func writeProse(t *testing.T, minor string) string {
69+
t.Helper()
70+
path := filepath.Join(t.TempDir(), "first-officer-shared-core.md")
71+
body := "These skills require binary minor " + minor + " (same major.minor; patch and prerelease skew are fine).\n"
72+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
73+
t.Fatal(err)
74+
}
75+
return path
76+
}
77+
78+
// TestManifestTagGateCommandChecksProseMinor — D5: the subcommand also takes the
79+
// FO shared-core `.md` prose file, gating on its stamped minor (not the full
80+
// manifest version) against the tag's major.minor.
81+
func TestManifestTagGateCommandChecksProseMinor(t *testing.T) {
82+
manifest := writeManifest(t, "0.24.0")
83+
prose := writeProse(t, "0.24")
84+
if code := runManifestTagGate([]string{"v0.24.0", manifest, prose}); code != 0 {
85+
t.Fatalf("manifest-tag-gate exit = %d, want 0 when both manifest and prose agree with the tag", code)
86+
}
87+
}
88+
89+
// TestManifestTagGateCommandBlocksOnProseMinorMismatch — a stable tag whose
90+
// major.minor disagrees with the prose-stamped minor (a forgotten prose stamp)
91+
// blocks the cut even when the JSON manifest agrees.
92+
func TestManifestTagGateCommandBlocksOnProseMinorMismatch(t *testing.T) {
93+
manifest := writeManifest(t, "0.24.0")
94+
staleProse := writeProse(t, "0.23") // forgotten stamp
95+
if code := runManifestTagGate([]string{"v0.24.0", manifest, staleProse}); code == 0 {
96+
t.Fatalf("manifest-tag-gate exit = 0 with a prose minor lagging the tag; want non-zero")
97+
}
98+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// ABOUTME: `spacedock-release stamp-version` CLI test — one invocation stamps a
2+
// ABOUTME: JSON manifest AND the FO prose file, dispatching by file extension.
3+
package main
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// TestStampVersionCommandStampsManifestAndProseInOneInvocation is D5's atomic
13+
// multi-file round-trip: one `stamp-version` call over a `.json` manifest AND a
14+
// `.md` prose fixture rewrites the manifest's `version` field AND the prose's
15+
// pinned minor literal — the shape release.yml's stamp steps actually invoke
16+
// (the plugin manifests plus the FO shared-core file in one command).
17+
func TestStampVersionCommandStampsManifestAndProseInOneInvocation(t *testing.T) {
18+
dir := t.TempDir()
19+
manifestPath := filepath.Join(dir, "plugin.json")
20+
prosePath := filepath.Join(dir, "first-officer-shared-core.md")
21+
22+
if err := os.WriteFile(manifestPath, []byte(`{"name": "spacedock", "version": "0.23.0", "skills": "./skills/"}`+"\n"), 0o644); err != nil {
23+
t.Fatal(err)
24+
}
25+
if err := os.WriteFile(prosePath, []byte("These skills require binary minor 0.23 (blah).\n"), 0o644); err != nil {
26+
t.Fatal(err)
27+
}
28+
29+
if code := stampVersion([]string{"0.24.0", manifestPath, prosePath}); code != 0 {
30+
t.Fatalf("stampVersion exit = %d, want 0", code)
31+
}
32+
33+
manifestOut, err := os.ReadFile(manifestPath)
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
if !strings.Contains(string(manifestOut), `"version": "0.24.0"`) {
38+
t.Fatalf("manifest not stamped: %s", manifestOut)
39+
}
40+
41+
proseOut, err := os.ReadFile(prosePath)
42+
if err != nil {
43+
t.Fatal(err)
44+
}
45+
if !strings.Contains(string(proseOut), "These skills require binary minor 0.24 ") {
46+
t.Fatalf("prose not stamped: %s", proseOut)
47+
}
48+
if strings.Contains(string(proseOut), "minor 0.23") {
49+
t.Fatalf("prose still carries the old minor: %s", proseOut)
50+
}
51+
}
52+
53+
// TestStampVersionCommandErrorsOnUnstampableProse locks that a `.md` argument
54+
// with no pinned literal (or a duplicated one) errors the whole invocation
55+
// rather than silently leaving the prose untouched.
56+
func TestStampVersionCommandErrorsOnUnstampableProse(t *testing.T) {
57+
dir := t.TempDir()
58+
prosePath := filepath.Join(dir, "no-literal.md")
59+
if err := os.WriteFile(prosePath, []byte("no pinned literal here\n"), 0o644); err != nil {
60+
t.Fatal(err)
61+
}
62+
if code := stampVersion([]string{"0.24.0", prosePath}); code == 0 {
63+
t.Fatalf("stampVersion exit = 0 on an unstampable prose file; want non-zero")
64+
}
65+
}

0 commit comments

Comments
 (0)