diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bcdac8fb6..d6772be46 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "spacedock", - "version": "0.28.0-pre1", + "version": "0.28.0-pre2", "description": "Turn directories of markdown files into structured workflows operated by AI agents", "author": { "name": "CL Kao" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index bcdac8fb6..d6772be46 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "spacedock", - "version": "0.28.0-pre1", + "version": "0.28.0-pre2", "description": "Turn directories of markdown files into structured workflows operated by AI agents", "author": { "name": "CL Kao" diff --git a/.github/workflows/runtime-live-e2e.yml b/.github/workflows/runtime-live-e2e.yml index 1a5e4768f..effe8617a 100644 --- a/.github/workflows/runtime-live-e2e.yml +++ b/.github/workflows/runtime-live-e2e.yml @@ -80,7 +80,7 @@ jobs: run: go test ./... - name: Run deterministic live-harness controls offline - run: go test ./internal/ensigncycle -count=1 -run 'TestCleanupKeepMovingRootRetainsOnlyFailures|TestCodexLiveRunnerExecArgvEnablesMultiAgentV2|TestCodexLiveRunnerUsesSpacedockFrontDoorBeforeHostArgs|TestAssertRecordedGateHoldLogAcceptsPrepareFirstLifecycle|TestShallowBootFixtureContainsOnlyHeldGate|TestPiLiveSmokePromptRequiresExactStageReportHeading|TestPiLiveEnvDropsForeignRuntimeMarkers|TestPiLiveEnvScrubsAmbientPiSubagentMarkers|TestPiIntercomPackageRootDefaultsBesideSubagents' + run: go test ./internal/ensigncycle -count=1 -run 'TestCleanupKeepMovingRootRetainsOnlyFailures|TestCodexLiveRunnerExecArgvEnablesMultiAgentV2|TestCodexLiveRunnerUsesSpacedockFrontDoorBeforeHostArgs|TestAssertRecordedGateHoldLogAcceptsPrepareFirstLifecycle|TestShallowBootFixtureContainsOnlyHeldGate|TestPiLiveEnvDropsForeignRuntimeMarkers|TestPiLiveEnvScrubsAmbientPiSubagentMarkers|TestPiIntercomPackageRootDefaultsBesideSubagents' # Pull requests normalize to one Sonnet 5 leg. Manual dispatches choose the # routine Sonnet cadence or the separately approved Opus pre-release cadence. @@ -811,7 +811,7 @@ jobs: if: ${{ !cancelled() }} run: | set -o pipefail - gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 15m -run 'TestLivePiFrontDoorSmoke|TestLivePiNonSelfDescribingDispatch' ./internal/ensigncycle + gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 15m -run TestLivePiFrontDoorSmoke ./internal/ensigncycle - name: Upload live artifacts if: always() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4c4f13ab0..08749270f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -112,11 +112,10 @@ homebrew_casks: # release, pinning the per-arch url + sha256 from the release assets + # checksums.txt — the seam the homebrew-tap entity consumes. `brews:` is # hard-deprecated at goreleaser v2.16; casks auto-detect the bare `spacedock` - # binary from the archive root, so no install/test stanza is given. Two casks - # ship per release: the STABLE `spacedock` cask (from the stable archive, - # installs the `main` plugin) and the EDGE `spacedock@next` cask (from the edge - # archive, installs the `next` plugin). Each pins ONLY its own channel's archive - # via `ids: []` so the two casks never cross-pin. + # binary from the archive root, so no install/test stanza is given. The config + # defines two casks, but each tag publishes only its matching channel. A final + # tag publishes `spacedock`. A prerelease tag publishes `spacedock@next`. Each + # cask pins only its channel archive via `ids: []`. - name: spacedock # `auto` skips the cask bump when goreleaser marks the release a pre-release # (release.prerelease: auto flags a hyphenated tag like v0.23.0-pre), so a @@ -174,11 +173,9 @@ homebrew_casks: # devBranch=next binary that installs the `next` plugin. Its on-demand re-pull # cadence rides the unchanged next-publish.yml; goreleaser only emits the cask. - name: spacedock@next - # `false` publishes the edge cask on every tag, including pre-releases, so the - # edge tap tracks `-pre` cuts. This diverges from the stable cask's `auto` - # (which skips on a pre-release) — the edge channel is meant to surface - # pre-release builds, while stable never gets a pre-release. - skip_upload: false + # Skip final tags. Publish only prerelease tags, where `.Prerelease` is not + # empty. This is the inverse of the stable cask's `auto` routing. + skip_upload: '{{ eq .Prerelease "" }}' ids: - spacedock-edge # The archive ships the binary as `spacedock`, so the edge command is also diff --git a/docs/dev/README.md b/docs/dev/README.md index 195a60527..9c99473b7 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -80,6 +80,7 @@ The FO contract's Operating Principles and Working Principles govern generic pro - **No prose-grep over instruction files.** A string, substring, or regex match over an instruction file the model reads (the FO/ensign contract, this README, a skill) never proves a behavioral claim — a valid paraphrase fails it and an inverted clause passes it. To settle a case, ask whether the expected value comes from outside the file under test; if it does not, the check is a tautology and is banned. A check that binds two independent values that can diverge, such as the plugin manifest's version sharing a major.minor with the binary's version, is legitimate and is not prose-grep. Captain ruling (2026-07-20, verbatim): prose-greps are one-off validation evidence, never committed tests. A grep whose output is pasted into the validation report is legitimate external evidence for that run; the same grep committed as a test is banned. A grep soundly establishes presence or absence when that existence fact is itself the claim; it never establishes what a program or an agent actually does — express that claim in a form that can be exercised. - **Evidence must be able to fail.** Each AC's cited evidence names the concrete change that would flip it — the falsifying edit. A criterion whose author cannot name that change does not count. The gate reads the falsifying change, not a pass count. +- **One proof owner per failure mode.** Reuse or modify an existing behavioral test before adding one. Add another committed check only when a distinct falsifying edit would escape the primary owner; otherwise combine or delete it. Use one-off manual validation for release provenance or external wiring that a committed test cannot reproduce truthfully. - **Detached adversarial audit (high-stakes surfaces).** Before merging a change to one of four high-stakes surfaces (the front-door launcher, the `status` mutation and guard paths, the shipped contract and scaffolding, and the CI and release machinery), run a read-only audit on a throwaway checkout. It tries to refute the validation by constructing an adversarial edit the deliverable's own tests should catch, then confirms they do. A test that stays green under a claim-breaking edit is a hole. Material findings route back through validation to implementation, and "refuted nothing material" is a valid recorded outcome. The audit also fires on AC provenance: when an AC's expected value is derived from the same package's production functions or constants, run the adversarial-edit check on it. Scope it to that provenance form; the broader equality/byte-identity form over-fires on ordinary unit tests. The two triggers are independent: the four-surface trigger runs the full audit on a throwaway checkout, while the provenance trigger fires wherever such an AC appears — including on a change routine enough to skip the full audit — and covers only that AC's adversarial-edit check, not the whole change. - **Required CI lanes are a function of the diff, not the FO's read of "relatedness."** Merging on the deterministic lanes (build/install/offline) alone is allowed ONLY when the diff provably touches nothing a live lane loads or drives. When the diff touches a file a live lane exercises — the shipped FO/ensign contract or a host adapter (`skills/**/references/**`), the dispatch/launch path, or the lane's own live tests — that lane is REQUIRED green before merge, and a flake there is grounds to re-run to green (serial, isolated), NEVER to skip, leave its deployment unapproved, or wave off as "the known flake." The path→lane mapping is the gate: a change to the Claude adapter requires `claude-live`; to a host adapter, that host's lane; to the host-neutral dispatch core, every host lane. - **Instruction-file read quarantine.** Tests do not read prompt or instruction files except in two cases. First, in `internal/contractlint`, and there only for structural checks: reference closure, frontmatter validity, structural absence, and dedup. Second, to extract a shipped runnable block and execute it against independent fixture conditions, where the oracle is the fixture's on-disk state or the block's observed output and never the file's wording — `skills/integration/survey_probe_test.go` is the reference shape. Prose-grep and prose-to-code consistency checks never substitute for running the behavior. @@ -148,7 +149,7 @@ A task moves to ideation when a pilot starts fleshing out the idea: clarify the - At least one AC must MEASURE the end-value the entity exists for, against an independent baseline that can move the wrong way (a number/delta/count/timing, a behavior, or resulting on-disk state). An AC that only asserts its mechanism shipped — "the prose updates to X", "the verb owns Y", "the section is rewritten to Z" — is end-state phrasing of a *means*; it counts only paired with the value-measuring AC it serves (cf. `trim-dispatch-adapter-prose` AC-1: cumulative line delta vs origin/main is NEGATIVE). - Every task must produce a real, checkable change (code, a fixture, on-disk state, or instruction text whose effect a separate check can confirm). If the task's only output is a decision with nothing shipped, it does not belong in this queue; record the decision in the roadmap instead. Cleanup and overhaul qualify: the change is the new code plus passing tests. - When the design rests on an unverified mechanism (a parser round-trip, a runtime handoff, an on-disk format, a tool actually supporting a flag), spike the riskiest path first (see Proof policy above) and record the result in the task body. The throwaway exercise seeds the implementation's first test. If nothing is unverified, record "no spike needed: {the proven mechanisms it relies on}". - - Test plans should state what verifies the implementation, estimated cost/complexity, and whether fixture, CLI, or live workflow tests are needed. + - Test plans should name the existing primary proof owner (or explain why none exists), the distinct falsifying edit for each additional check, estimated cost/complexity, and whether deterministic, live, or one-off manual validation is needed. - Plans should describe intended behavior at the level a future worker or validator needs to reason about it. Prefer observable behavior over implementation internals unless the task is specifically about that internal representation. - For every new mechanism in the proposed approach or test plan, name the value AC it serves, the simplest alternative considered, and why that alternative is insufficient. An enabling mechanism is not justified by proving its own internals. - Prove behavior by exercising it and observing the outcome (output bytes, exit code, resulting on-disk state, or a test feeding many inputs and asserting uniform handling): Go unit tests for parser and command behavior, golden fixtures for status output, behavior fixtures that drive the binary for command-level claims, and live workflow smoke tests only when runtime behavior is the claim. See the Proof policy above for what counts as proof and what does not. diff --git a/docs/releasing.md b/docs/releasing.md index 3b4135aad..0db194a27 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,8 +20,9 @@ marketplace source. prerelease's `_stable` tarball — it exists only because the cask pipe cannot tolerate a skipped build; - publishes the GitHub Release with those assets; -- bumps BOTH `spacedock-dev/homebrew-tap` casks (`spacedock` stable + - `spacedock@next` edge) via `HOMEBREW_TAP_TOKEN`; +- updates one matching cask in `spacedock-dev/homebrew-tap` via + `HOMEBREW_TAP_TOKEN`: final tags update `spacedock`, and prerelease tags update + `spacedock@next`; - stamps the plugin manifests' `version` on `main` on a `.0` release only (a patch tag does not touch `main`), then advances the stable channel ref (see below). diff --git a/docs/runtime-live-ci-registry.md b/docs/runtime-live-ci-registry.md index 550e5c879..4ba9c502d 100644 --- a/docs/runtime-live-ci-registry.md +++ b/docs/runtime-live-ci-registry.md @@ -313,22 +313,6 @@ limited to the named runtime boundary. - **Fixture:** `pi/split-root-smoke` — a current-checkout Pi environment and split-root workflow with one child-dispatchable member. -### `pi-non-self-describing-dispatch` - -- **Entry point:** `TestLivePiNonSelfDescribingDispatch` -- **Lane:** `pi-live` -- **Required outcome:** A Pi worker dispatched with a checklist equal to a real - entity's acceptance criteria (no skill-path, stage-report heading, or - DONE/Summary hints) still writes a complete `## Stage Report: implementation` - with a clean state-checkout commit. The worker's only stage-report format - source is the `### Stage Report format` block the dispatch build artifact - embeds for host=pi. This is the tautology-closing lane: reverting the body - embed makes it RED while the self-describing `pi-front-door-subagent-dispatch` - lane stays green. -- **Fixture:** `pi/non-self-describing-smoke` — a split-root Pi workflow whose - implementation stage-def names only the real work (no stage-report mention), - so the embedded dispatch body block is the worker's only format source. - ## Non-gating live experiments These tests are intentionally not release evidence and are not selected by a diff --git a/internal/cli/gate_test.go b/internal/cli/gate_test.go index 9ff713609..5ab5cabb7 100644 --- a/internal/cli/gate_test.go +++ b/internal/cli/gate_test.go @@ -128,54 +128,25 @@ func TestGatePrepareCLIPrintsExactRoomBindingAndCurrentV1HelpSurface(t *testing. } func TestGatePrepareCLIPassesStateRelativeArtifactWithoutCwdJoin(t *testing.T) { - workflow, state, _ := gatePrepareCLIFixture(t) - selected := filepath.Join(state, "selected", "gate-review.md") - if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(selected, []byte("# Selected review\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, state, "add", "selected") - git(t, state, "commit", "-q", "-m", "committed selected artifact") - - var out, errOut bytes.Buffer - code := run(context.Background(), []string{ - "gate", "prepare", "task", - "--question", "Advance?", - "--artifact", filepath.ToSlash(filepath.Join("selected", "gate-review.md")), - "--summary", "state-relative artifact", - "--workflow-dir", workflow, - }, nil, workflow, nil, &out, &errOut, &status.NativeRunner{}, nil) - if code != 0 { - t.Fatalf("prepare exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String()) - } - if !strings.Contains(out.String(), "state=open") { - t.Fatalf("prepare stdout=%q want state=open", out.String()) - } -} - -func TestGatePrepareCLIResolvesLaunchRelativeSelectedSources(t *testing.T) { - for _, flag := range []string{"--artifact", "--reference"} { - t.Run(flag, func(t *testing.T) { + for _, form := range []string{"state-relative", "launch-relative"} { + t.Run(form, func(t *testing.T) { workflow, state, artifact := gatePrepareCLIFixture(t) - selected := filepath.Join(state, "selected", "review.md") + selected := filepath.Join(state, "selected", "gate-review.md") if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil { t.Fatal(err) } writeFile(t, selected, "# Selected review\n") git(t, state, "add", "selected") - git(t, state, "commit", "-q", "-m", "selected source") + git(t, state, "commit", "-q", "-m", "committed selected artifact") - launchDir := filepath.Dir(filepath.Dir(workflow)) - relative := filepath.ToSlash(filepath.Join("docs", "dev", ".state", "selected", "review.md")) - args := []string{"gate", "prepare", "task", "--question", "Advance?", "--artifact", artifact, - "--summary", "launch-relative source", "--workflow-dir", workflow} - if flag == "--artifact" { - args[6] = relative - } else { - args = append(args, "--reference", relative) + launchDir := workflow + artifact = filepath.ToSlash(filepath.Join("selected", "gate-review.md")) + if form == "launch-relative" { + launchDir = filepath.Dir(filepath.Dir(workflow)) + artifact = filepath.ToSlash(filepath.Join("docs", "dev", ".state", "selected", "gate-review.md")) } + args := []string{"gate", "prepare", "task", "--question", "Advance?", "--artifact", artifact, + "--summary", form + " artifact", "--workflow-dir", workflow} var out, errOut bytes.Buffer code := run(context.Background(), args, nil, launchDir, nil, &out, &errOut, &status.NativeRunner{}, nil) if code != 0 || errOut.Len() != 0 || !strings.Contains(out.String(), "state=open") { diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 2a453161a..e126ec2d9 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -206,6 +206,28 @@ func runPi(ctx context.Context, args []string, dir string, env []string, ops piR } cfg := piRuntimeConfigFromEnv(env, dir, lastString(pluginDirs)) check := checkPiRuntime(ops, cfg) + // The front door owns one Spacedock-package repair attempt + // (pin-pi-package-to-binary-release): a release-shaped binary whose entry + // is missing, unpinned, or wrong-line installs its own pinned ref — + // proactively, even when the package would "launch", because the + // first-officer binary gate aborts inside pi on a wrong-line package. + // Suppressed for dev-sentinel binaries, non-git entries, and declared dev + // overrides (--plugin-dir / SPACEDOCK_REPO_ROOT own the package surface + // for their run). The recheck below, never a second attempt, decides + // launch readiness. + if attempted, installErr := piRepairAttempt(ops, cfg, check, stderr); attempted { + if installErr != nil { + fmt.Fprint(stderr, "spacedock pi: package repair failed; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") + return 1 + } + check = checkPiRuntime(ops, cfg) + ref, isGit := piGitSourceRef(check.packageStatus.source) + if !check.packageStatus.registered || !isGit || ref != piReleaseRef() { + fmt.Fprint(stderr, "spacedock pi: package repair did not install the required release; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") + printPiDoctorReport(stdout, check) + return 1 + } + } if !piRuntimeLaunchReady(check) { fmt.Fprint(stderr, "spacedock pi: Pi runtime is not ready; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") printPiDoctorReport(stdout, check) @@ -365,7 +387,12 @@ func runInitWithPi(ctx context.Context, args []string, hostOps hostOps, piOps pi return runInit(ctx, args, hostOps, stdout, stderr) } if !checkOnly { - source := piSpacedockPackageSource + // Pin the default install source to the running launcher's release + // identity (pin-pi-package-to-binary-release): a release-shaped binary + // installs its own tag so Pi never floats the package across release + // lines; a dev-sentinel build keeps the bare floating source. The + // --plugin-dir dev override still wins when declared. + source := piPinnedSource(piReleaseRef()) if pluginDir != "" { source = pluginDir } @@ -612,6 +639,42 @@ func piRuntimeLaunchReady(c piCheckResult) bool { return c.piBinOK && c.extensionOK && c.subagentsSkillOK && c.subagentsIntercomBridgeOK && c.intercomPackageOK && c.intercomSkillOK && c.spacedockPackageOK } +// piRepairAttempt runs the `spacedock pi` front door's one Spacedock-package +// repair (pin-pi-package-to-binary-release). It reports whether an install +// was attempted and returns its install error. Exactly one install runs per +// launch; after a successful install, the caller rechecks the package source +// and runtime before the launch gate. +func piRepairAttempt(ops piRuntimeOps, cfg piRuntimeConfig, check piCheckResult, stderr io.Writer) (bool, error) { + // A declared development override owns the package surface for its run: + // the repair never fights --plugin-dir / SPACEDOCK_REPO_ROOT. + if cfg.repoRoot != "" { + return false, nil + } + // The repair cannot help when pi itself is unavailable — and `pi install` + // needs the pi binary anyway. (The failure being outside the package gate + // does not matter here: piPackageNeedsRepair already scopes the trigger to + // the package entry, including the proactive wrong-line case where the + // package currently "launches" but the FO binary gate would abort.) + if !check.piBinOK { + return false, nil + } + ref := piReleaseRef() + needed, reason := piPackageNeedsRepair(check.packageStatus, ref) + if !needed { + return false, nil + } + source := piPinnedSource(ref) + fmt.Fprintf(stderr, "spacedock pi: Spacedock package %s; installing pinned source %s\n", reason, source) + out, err := ops.PiInstall(source) + if strings.TrimSpace(out) != "" { + fmt.Fprint(stderr, out) + } + if err != nil { + fmt.Fprintf(stderr, "spacedock pi: package repair install failed: %v\n", err) + } + return true, err +} + func piDoctorHealthy(c piCheckResult) bool { return piRuntimeLaunchReady(c) && c.authOK } diff --git a/internal/cli/pi_package_source.go b/internal/cli/pi_package_source.go new file mode 100644 index 000000000..9b4ddbb8b --- /dev/null +++ b/internal/cli/pi_package_source.go @@ -0,0 +1,150 @@ +// ABOUTME: Derives the Spacedock Pi package install source from the running +// launcher's release identity — release-shaped binaries pin, dev builds float. +package cli + +import ( + "regexp" + "runtime/debug" + "strings" +) + +// piReleaseRef returns the git ref a release-shaped binary pins the Spacedock +// Pi package source to, resolved in order (ideation, pin-pi-package-to-binary- +// release): +// +// 1. the linker-stamped internal/cli.Version — release artifacts set it +// (`-X ...Version={{ .Version }}`, stable and edge channels alike); +// 2. when Version is the "dev" sentinel, the Go build-info main module +// version when it is a semver tag — this covers `go install …@vX.Y.Z` +// proxy builds, which carry no ldflags but ARE release-shaped (the module +// proxy embeds the tagged manifest, which is why displayVersion reports +// them as X.Y.Z+dev); +// 3. otherwise "" — the dev sentinel: a plain `go build` / `go install +// ./cmd/spacedock` checkout build has no release identity, keeps the +// unpinned floating source, and performs no repair (no pin target). +// +// A release-shaped binary (stamped or proxy-tagged) never floats; the returned +// ref is the git tag the package source pins to. +func piReleaseRef() string { + return piReleaseRefFrom(Version, piBuildInfoMainVersion()) +} + +// piBuildInfoMainVersion is the seam for the Go build-info main module version; +// a var so tests can stub the proxy-build identity. +var piBuildInfoMainVersion = func() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "" + } + return strings.TrimSpace(bi.Main.Version) +} + +// piReleaseRefFrom is the pure derivation over (linker stamp, build-info main +// version). A value counts as a release ref when it parses as a semver version +// (optionally "v"-prefixed, optional -preN prerelease) and is not a Go module +// pseudo-version (which go install emits for branch/HEAD resolutions like +// @next — those are dev-shaped and must float). +func piReleaseRefFrom(linkerStamp, buildInfoVersion string) string { + if ref := piSemverTag(linkerStamp); ref != "" { + return ref + } + // A proxy build self-reports Version == "dev" but embeds the tagged + // manifest; the build info carries the resolved module version. A checkout + // build reports "" or "(devel)" here, which the semver gate rejects. + if bi := buildInfoVersion; bi != "" && bi != "(devel)" && bi != "devel" { + return piSemverTag(bi) + } + return "" +} + +func piSemverTag(v string) string { + v = strings.TrimSpace(v) + if piPseudoVersion.MatchString(v) { + return "" // Go module pseudo-version (branch/HEAD resolution): dev-shaped + } + v = strings.TrimPrefix(v, "v") + if v == "" { + return "" + } + base := v + if i := strings.IndexAny(v, "-+"); i >= 0 { + base = v[:i] + } + parts := strings.Split(base, ".") + if len(parts) != 3 { + return "" + } + for _, p := range parts { + if p == "" || len(p) > 9 { + return "" + } + for _, r := range p { + if r < '0' || r > '9' { + return "" + } + } + } + // Go module pseudo-versions resolve no tag; the dev sentinel applies. + // (Matched above before normalization.) + return "v" + v +} + +// piPseudoVersion matches Go module pseudo-versions — the resolution go +// install emits for branch/HEAD specs like @next (vX.Y.Z-0.-, and +// the base-tag form vX.Y.Z-pre.0.-). Both end in a 14-digit +// timestamp followed by the commit hash; they resolve no tag, so they carry +// no release identity: the dev sentinel applies. +var piPseudoVersion = regexp.MustCompile(`\d{14}-[0-9a-f]{7,12}(?:\+[^ ]+)?$`) + +// piPinnedSource returns the install source for the Spacedock Pi package given +// the binary's release ref: a ref-pinned source for a release-shaped binary +// (never floats across tags), or the bare floating source for the dev sentinel. +func piPinnedSource(releaseRef string) string { + if releaseRef == "" { + return piSpacedockPackageSource + } + return piSpacedockPackageSource + "@" + releaseRef +} + +// piGitSourceRef parses a Pi settings `packages` entry as a git source. ok is +// true only for `git:` sources; ref is the entry's @ref ("" when the git +// source is unpinned). Non-git entries (file:, npm:, local paths) return +// ok=false — they are user-managed and the repair never rewrites them. +func piGitSourceRef(source string) (ref string, ok bool) { + const prefix = "git:" + if !strings.HasPrefix(source, "git:") { + return "", false + } + spec := strings.TrimPrefix(source, "git:") + if i := strings.LastIndex(spec, "@"); i > 0 { + return spec[i+1:], true + } + return "", true +} + +// piPackageNeedsRepair reports whether the front door's one repair attempt +// must run for the discovered package status under a binary whose release ref +// is releaseRef. The repair triggers only for a release-shaped binary whose +// entry is missing, unpinned, or pinned to another ref — the ref delta IS the +// version mismatch. A non-git entry is user-managed (never rewritten), and a +// dev-sentinel binary never repairs (no pin target; a floating reinstall would +// clobber a pinned entry with an unpinned one). +func piPackageNeedsRepair(st piPackageStatus, releaseRef string) (bool, string) { + if releaseRef == "" { + return false, "" // dev sentinel: no pin target, never repairs + } + if !st.registered { + return true, "missing" + } + entryRef, isGit := piGitSourceRef(st.source) + if !isGit { + return false, "" // user-managed entry: no clobber in either direction + } + if entryRef == "" { + return true, "unpinned" + } + if entryRef != releaseRef { + return true, "wrong release line" + } + return false, "" +} diff --git a/internal/cli/pi_package_source_test.go b/internal/cli/pi_package_source_test.go new file mode 100644 index 000000000..3cc66f2b7 --- /dev/null +++ b/internal/cli/pi_package_source_test.go @@ -0,0 +1,96 @@ +package cli + +import "testing" + +// TestPiReleaseRefDerivation pins the ordered ref derivation (linker stamp, +// then build-info main version, then the dev sentinel) for every identity +// class the derivation must distinguish. Falsifier: changing any derivation +// arm flips the row for that identity — notably the proxy-build row (the +// go-install @vX.Y.Z build that must pin) and the pseudo-version rows (go +// install @next resolutions, which must float). +func TestPiReleaseRefDerivation(t *testing.T) { + const pseudo1 = "v0.28.0-pre0.0.20260828120000-abcdef123456" // @next over a pre tag + const pseudo2 = "v0.27.1-0.20260828120000-abcdef123456" // no base tag + const dirtyPseudo = "v0.28.0-pre0.0.20260828165724-81e3386e8234+dirty" + for _, tc := range []struct { + name, stamp, buildInfo, want string + }{ + {"linker stamp wins", "0.27.2", "v0.26.0", "v0.27.2"}, + {"v-prefixed stamp normalizes", "v0.27.2", "", "v0.27.2"}, + {"prerelease stamp", "0.28.0-pre1", "(devel)", "v0.28.0-pre1"}, + {"proxy build pins its tag", "dev", "v0.27.2", "v0.27.2"}, + {"checkout build is the dev sentinel", "dev", "(devel)", ""}, + {"empty build info is the dev sentinel", "dev", "", ""}, + {"next pseudo-version floats", "dev", pseudo1, ""}, + {"plain pseudo-version floats", "dev", pseudo2, ""}, + {"dirty checkout pseudo-version floats", "dev", dirtyPseudo, ""}, + {"non-semver build info floats", "dev", "not-a-version", ""}, + {"empty stamp uses build info", "", "v0.27.2", "v0.27.2"}, + } { + if got := piReleaseRefFrom(tc.stamp, tc.buildInfo); got != tc.want { + t.Fatalf("%s: piReleaseRefFrom(%q,%q)=%q want %q", tc.name, tc.stamp, tc.buildInfo, got, tc.want) + } + } +} + +// TestPiPinnedSourceAndRefParsing pins the source-string construction and the +// git/non-git entry classifier the repair trigger and AC-5's no-clobber +// guarantee depend on. Falsifier: dropping the @ref suffix, floating a pinned +// ref, or classifying a git entry as non-git (or vice versa) flips a row. +func TestPiPinnedSourceAndRefParsing(t *testing.T) { + const repo = "git:github.com/spacedock-dev/spacedock" + for _, tc := range []struct{ ref, want string }{ + {"v0.27.2", repo + "@v0.27.2"}, + {"v0.28.0-pre0", repo + "@v0.28.0-pre0"}, + {"", repo}, // dev sentinel keeps the bare floating source + } { + if got := piPinnedSource(tc.ref); got != tc.want { + t.Fatalf("piPinnedSource(%q)=%q want %q", tc.ref, got, tc.want) + } + } + for _, tc := range []struct { + source, ref string + isGit bool + }{ + {"git:github.com/spacedock-dev/spacedock", "", true}, + {"git:github.com/spacedock-dev/spacedock@v0.27.2", "v0.27.2", true}, + {"file:/tmp/repo", "", false}, + {"npm:spacedock", "", false}, + {"/usr/local/spacedock", "", false}, + {"", "", false}, + } { + if ref, ok := piGitSourceRef(tc.source); ref != tc.ref || ok != tc.isGit { + t.Fatalf("piGitSourceRef(%q)=(%q,%v) want (%q,%v)", tc.source, ref, ok, tc.ref, tc.isGit) + } + } +} + +// TestPiPackageNeedsRepairTable pins the trigger decision: a release-shaped +// binary repairs a missing, unpinned, or wrong-ref git entry; its own ref, +// any non-git entry, and the dev sentinel never trigger. +func TestPiPackageNeedsRepairTable(t *testing.T) { + missing := piPackageStatus{} + unpinned := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock"} + wrongLine := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.28.0-pre1"} + ownRef := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.27.2"} + userEntry := piPackageStatus{registered: true, source: "file:/x"} + for _, tc := range []struct { + name string + st piPackageStatus + relRef string + want bool + wantReason string + }{ + {"missing entry, release-shaped", missing, "v0.27.2", true, "missing"}, + {"unpinned git entry", unpinned, "v0.27.2", true, "unpinned"}, + {"wrong release line", wrongLine, "v0.27.2", true, "wrong release line"}, + {"pinned to own ref", ownRef, "v0.27.2", false, ""}, + {"non-git entry is user-managed", userEntry, "v0.27.2", false, ""}, + {"dev sentinel never repairs", missing, "", false, ""}, + } { + got, reason := piPackageNeedsRepair(tc.st, tc.relRef) + if got != tc.want || (got && reason != tc.wantReason) { + t.Fatalf("%s: repair=(%v,%q) want (%v,%q)", tc.name, got, reason, tc.want, tc.wantReason) + } + } +} diff --git a/internal/cli/terminal_consume_test.go b/internal/cli/terminal_consume_test.go index ef1a9f7c7..30c075951 100644 --- a/internal/cli/terminal_consume_test.go +++ b/internal/cli/terminal_consume_test.go @@ -215,24 +215,6 @@ func TestConsumedNonterminalApprovalAllowsOrdinaryTerminalFields(t *testing.T) { } } -// TestConsumedNonterminalApprovalAllowsMergeGuard proves the alternate ordinary -// terminal journey uses consumed gate history without inventing new authority. -func TestConsumedNonterminalApprovalAllowsMergeGuard(t *testing.T) { - root, entity := consumedNonterminalWorkflow(t) - code, out, errOut := terminalInvoke(t, root, "merge", "guard", "task", "--verdict", "passed", "--workflow-dir", root) - if code != 0 || strings.Contains(out+errOut, "ineligible") { - t.Fatalf("merge guard exit=%d stdout=%q stderr=%q", code, out, errOut) - } - archived := filepath.Join(root, "_archive", filepath.Base(entity)) - fields := entityFields(t, archived) - if fields["status"] != "done" || fields["verdict"] != "PASSED" || strings.TrimSpace(fields["completed"]) == "" { - t.Fatalf("archived terminal fields = status:%q verdict:%q completed:%q", fields["status"], fields["verdict"], fields["completed"]) - } - if got := gateApplicationStates(t, archived); !slices.Equal(got, []string{"consumed"}) { - t.Fatalf("merge guard rewrote consumed authority: %v", got) - } -} - // TestTerminalDeliveryFailureReworkRoundTrip is AC-1's value spine: approval // recorded -> consume routes without spending (pending, approved-awaiting-merge) // -> merge guard arms; delivery fails beyond retry -> --rework supersedes diff --git a/internal/dispatch/build_json_ergonomics_test.go b/internal/dispatch/build_json_ergonomics_test.go index ee4d04997..ac86e28f9 100644 --- a/internal/dispatch/build_json_ergonomics_test.go +++ b/internal/dispatch/build_json_ergonomics_test.go @@ -240,7 +240,7 @@ func assertPiBuildOutput(t *testing.T, stdout string) { t.Fatalf("derived Pi prompt should be the read-dispatch-file form: %q", out.Prompt) } body := readDispatchBody(t, out.DispatchFilePath) - for _, want := range []string{"read this dispatch file", "/skill:ensign", "Pi subagent completion result", "Do not emit Claude team-tool calls"} { + for _, want := range []string{"read this dispatch file", "Pi subagent completion result", "Do not emit Claude team-tool calls"} { if !strings.Contains(body, want) { t.Fatalf("derived Pi dispatch body missing %q:\n%s", want, body) } diff --git a/internal/dispatch/build_stage_report_protocol_test.go b/internal/dispatch/build_stage_report_protocol_test.go deleted file mode 100644 index 40a607ac2..000000000 --- a/internal/dispatch/build_stage_report_protocol_test.go +++ /dev/null @@ -1,68 +0,0 @@ -// ABOUTME: AC-2 — the dispatch build artifact body carries the stage-report -// ABOUTME: protocol template for host=pi, and omits it for claude and codex. -package dispatch - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestPiFirstActionInvokesEnsignSkill(t *testing.T) { - root := t.TempDir() - writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(false)) - worktreeRel := ".worktrees/spacedock-ensign-first-action" - if err := os.MkdirAll(filepath.Join(root, worktreeRel), 0o755); err != nil { - t.Fatal(err) - } - entityPath := filepath.Join(root, "thing.md") - writeFile(t, entityPath, entityFM("Thing", "implementation", worktreeRel)) - gitInit(t, root) - - stdin := mergeStdin(map[string]any{ - "schema_version": 2, - "entity_path": entityPath, - "workflow_dir": root, - "stage": "implementation", - "checklist": []string{"- a"}, - "bare_mode": false, - "host": "pi", - }, nil) - - native := runNative(stdin, "build", "--workflow-dir", root) - if native.exit != 0 { - t.Fatalf("build exit=%d stderr=%q", native.exit, native.stderr) - } - body := readDispatchBody(t, dispatchFilePathFromStdout(t, native.stdout)) - - // The false claim that the dispatch file itself carries the ensign - // discipline entry points must be gone. - for _, banned := range []string{ - "This file contains the shared ensign discipline entry points", - } { - if strings.Contains(body, banned) { - t.Fatalf("pi First-action still carries false claim %q:\n%s", banned, body) - } - } - // The worker must be told to load the ensign skill before reading the - // dispatch file. - hasSkillLoad := strings.Contains(body, "/skill:ensign") || - strings.Contains(body, "skills/ensign/SKILL.md") - if !hasSkillLoad { - t.Fatalf("pi First-action missing ensign skill-load instruction (/skill:ensign or skills/ensign/SKILL.md):\n%s", body) - } - // The skill-load must come before the instruction to read the dispatch - // file, mirroring Claude and Codex. - skillIdx := strings.Index(body, "/skill:ensign") - if skillIdx < 0 { - skillIdx = strings.Index(body, "skills/ensign/SKILL.md") - } - readIdx := strings.Index(body, "read this dispatch file") - if readIdx < 0 { - t.Fatalf("pi First-action missing 'read this dispatch file' instruction:\n%s", body) - } - if skillIdx >= readIdx { - t.Fatalf("pi First-action: ensign skill-load must precede 'read this dispatch file' (skillIdx=%d readIdx=%d):\n%s", skillIdx, readIdx, body) - } -} diff --git a/internal/ensigncycle/claude_live_runner_test.go b/internal/ensigncycle/claude_live_runner_test.go index 945580d45..19872d1a4 100644 --- a/internal/ensigncycle/claude_live_runner_test.go +++ b/internal/ensigncycle/claude_live_runner_test.go @@ -33,8 +33,8 @@ const antiShutdownOverride = "Do not shut down your team or prepare your final " // real `spacedock claude` launch and returns the (before, after, observed) state // the shared assertions consume — the same assertions the Codex runner feeds. The // ONLY Claude-specific surface is auth/HOME isolation (isolatedClaudeEnv: clean -// HOME + OAuth benchmark-token / ANTHROPIC_API_KEY), the stable release install, -// the `spacedock claude -- -p --output-format +// HOME + OAuth benchmark-token / ANTHROPIC_API_KEY), the --plugin-dir local +// checkout candidate, the `spacedock claude -- -p --output-format // stream-json` launch, and the observed-extract: the final message comes from the // stream's result/success event (the front-door analog of Codex // --output-last-message) via extractClaudeFinalMessage. The common declarations, @@ -43,6 +43,7 @@ const antiShutdownOverride = "Do not shut down your team or prepare your final " type claudeLiveRunner struct { t *testing.T binary string + pluginDir string env []string modelName string artifactRoot string @@ -144,9 +145,10 @@ func runClaudeRecordedGateLifecycleScenario(t *testing.T, runner liveDriver, sce durableSemantic("recorded-gate-lifecycle-violation", assert(observation))) } -func newClaudeLiveRunner(t *testing.T, setupIDs ...string) claudeLiveRunner { +func newClaudeLiveRunner(t *testing.T) claudeLiveRunner { t.Helper() - binary, marketplace := stableLiveRelease(t) + binary := buildRecordedGateBinary(t) + pluginDir := livePluginDir(t) model := envOr("SPACEDOCK_LIVE_MODEL", "sonnet") // isolatedClaudeEnv resolves the credential (OAuth benchmark-token locally, @@ -156,24 +158,12 @@ func newClaudeLiveRunner(t *testing.T, setupIDs ...string) claudeLiveRunner { // binary. Both are reused verbatim from the full-cycle live test. env := isolatedClaudeEnv(t, os.Getenv("HOME")) env = withBinaryOnPath(env, binary) - env = withRecordedGateEnv(env, "SPACEDOCK_MARKETPLACE_SOURCE", marketplace) - setupID := t.Name() - if len(setupIDs) > 0 { - setupID = setupIDs[0] - } - if base, ok := envValue(env, "CLAUDE_CONFIG_DIR"); ok { - env = withClaudeConfigDir(env, filepath.Join(base, setupID)) - } homeDir, _ := envValue(env, "HOME") - setupDir := codexLiveSetupArtifactDir(claudeLiveArtifactDir(t, "claude-shared-scenarios"), setupID) - if err := os.MkdirAll(setupDir, 0o755); err != nil { - t.Fatal(err) - } - runCodexLiveCommand(t, setupDir, "stable-plugin-install.txt", "", env, binary, "install", "--host", "claude") return claudeLiveRunner{ t: t, binary: binary, + pluginDir: pluginDir, env: env, modelName: model, artifactRoot: claudeLiveArtifactDir(t, "claude-shared-scenarios"), @@ -210,7 +200,16 @@ func (r claudeLiveRunner) gradeShallowBootObservation(t *testing.T, result liveR emitShallowBootWindowMetrics(t, result.stream, r.modelName) } func (r claudeLiveRunner) prepareRecordedGate(t *testing.T) (liveDriver, func(liveResult)) { - return r, noLiveGrade + source := r.pluginDir + r.pluginDir = t.TempDir() + if err := copyTree(source, r.pluginDir); err != nil { + t.Fatal(err) + } + return r, func(result liveResult) { + if !strings.Contains(result.stream, r.pluginDir) || !strings.Contains(result.stream, "# First Officer Gate Lifecycle") { + t.Fatalf("recorded gate lifecycle did not load the copied skill body\nArtifacts: %s", result.artifactDir) + } + } } // withStubPATH returns a runner copy whose launched FO subprocess resolves a stub @@ -602,8 +601,8 @@ func runClaudeShallowBootScenario(t *testing.T, runner liveDriver, scenario shar // run launches the real `spacedock claude` front door for one shared scenario and // returns the (finalMessage, full stream) the shared assertions consume. The -// launch shape uses the installed stable package through the ordinary front door; -// every host flag (-p with the scenario prompt, +// launch shape uses the checkout candidate through --plugin-dir; every host flag +// (-p with the scenario prompt, // --permission-mode, --output-format stream-json, --verbose, --model) rides AFTER // `--` and forwards verbatim to claude. The observed source is the stream's // result/success event via extractClaudeFinalMessage — a 401/is_error result is a @@ -626,7 +625,10 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work streamPath := filepath.Join(artifactDir, "claude-stream.jsonl") finalPath := filepath.Join(artifactDir, "claude-final-message.txt") - frontDoorArgs := []string{"claude", "--", + frontDoorArgs := []string{"claude", + "--plugin-dir", r.pluginDir, + "--skip-compat-check", + "--", "-p", prompt + " " + antiShutdownOverride, "--permission-mode", "bypassPermissions", "--output-format", "stream-json", @@ -635,8 +637,13 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work } cmd := exec.Command(r.binary, frontDoorArgs...) cmd.Dir = workflowRoot + // Per-scenario CLAUDE_CONFIG_DIR keeps parallel scenarios isolated. cmd.Env = r.env configDir, _ := envValue(r.env, "CLAUDE_CONFIG_DIR") + if base, ok := envValue(r.env, "CLAUDE_CONFIG_DIR"); ok { + configDir = filepath.Join(base, scenario.name) + cmd.Env = withClaudeConfigDir(r.env, configDir) + } if err := seedStoredLoginCredential(configDir); err == nil { cmd.Env = withoutEnvKey(cmd.Env, "CLAUDE_CODE_OAUTH_TOKEN") } diff --git a/internal/ensigncycle/codex_live_runner_test.go b/internal/ensigncycle/codex_live_runner_test.go index ed093821c..34b1accce 100644 --- a/internal/ensigncycle/codex_live_runner_test.go +++ b/internal/ensigncycle/codex_live_runner_test.go @@ -15,7 +15,7 @@ import ( // real `spacedock codex` launch and returns the (before, after, observed) state // the shared assertions consume. Auth/HOME isolation (isolated CODEX_HOME + // minimal config plus copied auth.json / OPENAI_API_KEY), Spacedock-owned local -// stable release plugin setup, and the `--output-last-message` observed-extract are the ONLY +// checkout-candidate plugin setup, and the `--output-last-message` observed-extract are the ONLY // Codex-specific surface; the common declarations, fixtures, prompts, and assertions // are shared with the Claude runner. type codexLiveRunner struct { @@ -107,7 +107,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { t.Fatal("codex not on PATH; install Codex CLI before running the live Codex suite") } - binary, marketplace := stableLiveRelease(t) + binary := spacedockBinary(t) repo := repoRoot(t) artifactRoot := codexLiveArtifactDir(t, "codex-shared-scenarios") codexHome := newCodexLiveIsolatedHome(t, repo, artifactRoot) @@ -126,7 +126,6 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { } } env := codexLiveEnv(codexHome, cleanHome, filepath.Dir(binary), openAIAPIKey, decision.mode) - env = withRecordedGateEnv(env, "SPACEDOCK_MARKETPLACE_SOURCE", marketplace) setupID := "" if len(setupIDs) > 0 { @@ -142,7 +141,6 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { case codexAuthOAuth, codexAuthLocal: runCodexLiveCommand(t, setupDir, "codex-login-status.txt", "", env, codexBin, "login", "status") } - runCodexLiveCommand(t, setupDir, "stable-plugin-install.txt", "", env, binary, "install", "--host", "codex") adapterPath := filepath.Join(repo, "skills", "first-officer", "references", "codex-first-officer-runtime.md") if _, err := os.Stat(adapterPath); err != nil { @@ -156,7 +154,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { t.Fatal("current-checkout source HEAD is empty") } - return codexLiveRunner{binary: binary, codexBin: codexBin, codexHome: codexHome, env: env, artifactRoot: artifactRoot} + return codexLiveRunner{binary: binary, pluginDir: livePluginDir(t), codexBin: codexBin, codexHome: codexHome, env: env, artifactRoot: artifactRoot} } func codexLiveSetupArtifactDir(artifactRoot, setupID string) string { diff --git a/internal/ensigncycle/live_test.go b/internal/ensigncycle/live_test.go index cdb101966..1e6f534d2 100644 --- a/internal/ensigncycle/live_test.go +++ b/internal/ensigncycle/live_test.go @@ -5,8 +5,6 @@ package ensigncycle import ( - "encoding/json" - "fmt" "os" "os/exec" "path/filepath" @@ -89,12 +87,11 @@ func cachedLivePluginDir(t *testing.T, repo string) string { livePluginOnce.Do(func() { // MkdirTemp (not t.TempDir) so the staged plugin outlives the first test's // cleanup and the cached path stays valid for every scenario in the run. - marketplace, err := os.MkdirTemp("", "spacedock-live-plugin-") + staged, err := os.MkdirTemp("", "spacedock-live-plugin-") if err != nil { livePluginErr = err return } - staged := filepath.Join(marketplace, "spacedock") for _, sub := range []string{".claude-plugin", ".codex-plugin", "skills", "agents"} { src := filepath.Join(repo, sub) if _, statErr := os.Stat(src); statErr != nil { @@ -105,16 +102,6 @@ func cachedLivePluginDir(t *testing.T, repo string) string { return } } - manifestDir := filepath.Join(marketplace, ".claude-plugin") - if err := os.MkdirAll(manifestDir, 0o755); err != nil { - livePluginErr = err - return - } - manifest := []byte("{\n \"name\": \"spacedock\",\n \"owner\": { \"name\": \"Spacedock live suite\" },\n \"plugins\": [\n { \"name\": \"spacedock\", \"source\": \"./spacedock\", \"description\": \"release candidate\", \"category\": \"workflow\" }\n ]\n}\n") - if err := os.WriteFile(filepath.Join(manifestDir, "marketplace.json"), manifest, 0o644); err != nil { - livePluginErr = err - return - } // git init so the FO's `git rev-parse --show-toplevel` resolves to this // workflow-free root, not an enclosing checkout that has a docs/dev. testgit.InitRepo(t, staged, "-q") @@ -126,50 +113,6 @@ func cachedLivePluginDir(t *testing.T, repo string) string { return livePluginPath } -var ( - stableLiveBinaryOnce sync.Once - stableLiveBinaryPath string - stableLiveBinaryErr error -) - -// stableLiveRelease returns the current plugin packaged as the stable channel -// plus a binary stamped with that package's release version and channel. Every -// common live journey installs this package before using the ordinary front door. -func stableLiveRelease(t *testing.T) (binary, marketplace string) { - t.Helper() - plugin := livePluginDir(t) - stableLiveBinaryOnce.Do(func() { - manifestData, err := os.ReadFile(filepath.Join(plugin, ".claude-plugin", "plugin.json")) - if err != nil { - stableLiveBinaryErr = err - return - } - var manifest struct { - Version string `json:"version"` - } - if err := json.Unmarshal(manifestData, &manifest); err != nil { - stableLiveBinaryErr = err - return - } - buildDir, err := os.MkdirTemp("", "spacedock-live-stable-") - if err != nil { - stableLiveBinaryErr = err - return - } - stableLiveBinaryPath = filepath.Join(buildDir, "spacedock") - stamp := fmt.Sprintf("-X github.com/spacedock-dev/spacedock/internal/cli.Version=%s -X github.com/spacedock-dev/spacedock/internal/cli.devBranch=main", manifest.Version) - cmd := exec.Command("go", "build", "-ldflags", stamp, "-o", stableLiveBinaryPath, "./cmd/spacedock") - cmd.Dir = repoRoot(t) - if out, err := cmd.CombinedOutput(); err != nil { - stableLiveBinaryErr = fmt.Errorf("build stable live binary: %w: %s", err, out) - } - }) - if stableLiveBinaryErr != nil { - t.Fatal(stableLiveBinaryErr) - } - return stableLiveBinaryPath, filepath.Dir(plugin) -} - // copyTree recursively copies src to dst, preserving file modes. Symlinks are // resolved to real files so the staged plugin has no path back into the real repo. func copyTree(src, dst string) error { diff --git a/internal/ensigncycle/pi_evidence_grade_impl_test.go b/internal/ensigncycle/pi_evidence_grade_impl_test.go index 3c06f55ae..201feba38 100644 --- a/internal/ensigncycle/pi_evidence_grade_impl_test.go +++ b/internal/ensigncycle/pi_evidence_grade_impl_test.go @@ -232,24 +232,3 @@ func validPiBootContractEvidence() piBootContractEvidence { Transcript: "/run/child-transcript.jsonl", } } - -func TestLivePiFrontDoorSmokeRetainsAllGraders(t *testing.T) { - source, err := os.ReadFile("pi_live_runner_test.go") - if err != nil { - t.Fatal(err) - } - start := strings.Index(string(source), "func TestLivePiFrontDoorSmoke") - if start < 0 { - t.Fatal("cannot locate TestLivePiFrontDoorSmoke") - } - end := strings.Index(string(source)[start:], "func newPiLiveSmokeFixture") - if end < 0 { - t.Fatal("cannot locate TestLivePiFrontDoorSmoke") - } - body := string(source)[start : start+end] - for _, grader := range []string{"runPiLiveCommand", "assertPiLiveSmokeResult", "assertPiEnsignBootContract"} { - if !strings.Contains(body, grader) { - t.Fatalf("TestLivePiFrontDoorSmoke lost grader %q", grader) - } - } -} diff --git a/internal/ensigncycle/pi_live_controls_test.go b/internal/ensigncycle/pi_live_controls_test.go index 5e0ab396a..157755dd9 100644 --- a/internal/ensigncycle/pi_live_controls_test.go +++ b/internal/ensigncycle/pi_live_controls_test.go @@ -34,21 +34,6 @@ Reference paths: ensign contract at %[1]s/skills/ensign/SKILL.md; Pi ensign adap repo, workflowRoot, stateRoot, entityPath, envelope.Agent, envelope.Skill, envelope.Prompt, piLiveSmokeMarker) } -func TestPiLiveSmokePromptRequiresExactStageReportHeading(t *testing.T) { - envelope := piSmokeEnvelope{Agent: "worker", Skill: "ensign", Prompt: "Read /tmp/spacedock-dispatch/x.md and treat its content as your assignment."} - prompt := piLiveSmokePrompt("/repo", "/workflow", "/workflow/.spacedock-state", "/workflow/.spacedock-state/pi-live-smoke/index.md", envelope) - want := "exact heading '## Stage Report: implementation'" - if !strings.Contains(prompt, want) { - t.Fatalf("pi live smoke prompt missing %q:\n%s", want, prompt) - } - source := readFile(t, "pi_live_runner_test.go") - for _, contract := range []string{`filepath.Join(repoRoot(t), "skills", "ensign", "SKILL.md")`, `filepath.Join(repoRoot(t), "skills", "ensign", "references", "pi-ensign-runtime.md")`} { - if !strings.Contains(source, contract) { - t.Fatalf("Pi live smoke checklist missing boot-contract action %s", contract) - } - } -} - func piLiveEnv(piHome, sessionDir, cleanHome, binaryDir, piSubagentsRoot string) []string { env := cleanEnviron( "CODEX_THREAD_ID", "CLAUDECODE", "HOME", "PI_CODING_AGENT_DIR", diff --git a/internal/ensigncycle/pi_live_runner_test.go b/internal/ensigncycle/pi_live_runner_test.go index 62c8ead64..4c993f7c4 100644 --- a/internal/ensigncycle/pi_live_runner_test.go +++ b/internal/ensigncycle/pi_live_runner_test.go @@ -135,7 +135,7 @@ func piSpacedockBinary(t *testing.T, repo string) string { func runPiSmokeDispatchBuild(t *testing.T, binary, workflowRoot, entityPath string) piSmokeEnvelope { t.Helper() checklist := []string{ - "- First read " + filepath.Join(repoRoot(t), "skills", "ensign", "SKILL.md") + " and " + filepath.Join(repoRoot(t), "skills", "ensign", "references", "pi-ensign-runtime.md") + "; then append a stage report with the exact heading '## Stage Report: implementation' containing the exact marker " + piLiveSmokeMarker + ", at least one '- DONE:' item, and a '### Summary' subsection", + "- append the smoke marker line `" + piLiveSmokeMarker + "` to the entity file", "- Commit only the entity path in the state checkout with message 'ensign: pi live smoke' (path-scoped git add/commit for pi-live-smoke/index.md)", } stdin, err := json.Marshal(map[string]any{ diff --git a/internal/ensigncycle/pi_nonself_describing_build_test.go b/internal/ensigncycle/pi_nonself_describing_build_test.go deleted file mode 100644 index ca6c06c78..000000000 --- a/internal/ensigncycle/pi_nonself_describing_build_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package ensigncycle - -import ( - "path/filepath" - "testing" -) - -// writePiNonSelfDescribingSmokeWorkflow creates a split-root smoke workflow -// whose implementation stage-def names only the real work (append a marker -// line) — no "stage report" mention — so the worker's stage-report format -// source is the embedded dispatch body block, not the stage-def. -// -//spacedock:live-fixture id=pi/non-self-describing-smoke -func writePiNonSelfDescribingSmokeWorkflow(t *testing.T) (workflowRoot, stateRoot, entityPath string) { - t.Helper() - workflowRoot = t.TempDir() - stateRoot = filepath.Join(workflowRoot, ".spacedock-state") - writeFile(t, filepath.Join(workflowRoot, "README.md"), piNonSelfDescribingSmokeReadme()) - entityPath = filepath.Join(stateRoot, "pi-nonsd-smoke", "index.md") - writeFile(t, entityPath, piNonSelfDescribingSmokeEntity()) - gitInit(t, workflowRoot) - gitInit(t, stateRoot) - return workflowRoot, stateRoot, entityPath -} - -func piNonSelfDescribingSmokeReadme() string { - return "---\n" + - "entity-type: task\n" + - "id-style: slug\n" + - "state: .spacedock-state\n" + - "stages:\n" + - " defaults:\n" + - " worktree: false\n" + - " concurrency: 1\n" + - " states:\n" + - " - name: implementation\n" + - " initial: true\n" + - " - name: done\n" + - " terminal: true\n" + - "---\n" + - "# Pi Non-Self-Describing Smoke\n\n" + - "### implementation\n\n" + - "Append the live Pi smoke marker line `PI-NONSD-SMOKE-MARKER` as a standalone line to the entity file, then commit only the entity path in the state checkout.\n\n" + - "- **Outputs:** The marker line present in the entity file and a path-scoped state commit.\n\n" + - "### done\n\nTerminal state.\n" -} - -func piNonSelfDescribingSmokeEntity() string { - return "---\n" + - "id: pi-nonsd-smoke\n" + - "title: Pi Non-Self-Describing Smoke\n" + - "status: implementation\n" + - "completed:\n" + - "verdict:\n" + - "worktree:\n" + - "---\n" + - "# Pi Non-Self-Describing Smoke\n\n" + - "This entity is mutated only by the Pi subagent non-self-describing live smoke.\n" -} diff --git a/internal/ensigncycle/pi_nonself_describing_live_test.go b/internal/ensigncycle/pi_nonself_describing_live_test.go deleted file mode 100644 index fec0efb17..000000000 --- a/internal/ensigncycle/pi_nonself_describing_live_test.go +++ /dev/null @@ -1,174 +0,0 @@ -//go:build live - -package ensigncycle - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// TestLivePiNonSelfDescribingDispatch (AC-1, AC-3) is the tautology-closing -// live lane: it dispatches a Pi worker through `dispatch build --host pi` with -// a checklist equal to a real entity's acceptance criteria — no "First read -// ensign/SKILL.md", no stage-report heading, no DONE/Summary structure — and -// asserts the worker still writes a complete `## Stage Report: implementation` -// (heading + `- DONE:` + `### Summary`) with a clean state-checkout commit. -// The worker's only format source is the ensign skill the firstActionBlock -// block the build artifact now carries for host=pi. Reverting the AC-2 body -// embed makes this lane RED (the worker has no format source), while the -// self-describing TestLivePiFrontDoorSmoke stays green — proving this lane -// tests the real mode, not a fixture hint. -// -//spacedock:live-proof id=pi-non-self-describing-dispatch lane=pi-live -func TestLivePiNonSelfDescribingDispatch(t *testing.T) { - repo := repoRoot(t) - piSubagentsRoot := piSubagentsPackageRoot(t) - binary := piSpacedockBinary(t, repo) - workflowRoot, stateRoot, entityPath, artifactDir, env, model := newPiNonSelfDescribingSmokeFixture(t, "pi-nonsd-smoke", repo, piSubagentsRoot, binary) - - envelope := runPiNonSelfDescribingDispatchBuild(t, binary, workflowRoot, entityPath) - prompt := piNonSelfDescribingSmokePrompt(repo, workflowRoot, stateRoot, entityPath, envelope) - runPiLiveCommand(t, artifactDir, workflowRoot, env, binary, - "pi", - prompt, - "--plugin-dir", repo, - "--", - "--print", - "--model", model, - "--session-dir", filepath.Join(artifactDir, "sessions"), - ) - assertPiNonSelfDescribingSmokeResult(t, stateRoot, entityPath, artifactDir) -} - -func newPiNonSelfDescribingSmokeFixture(t *testing.T, name, repo, piSubagentsRoot, binary string) (workflowRoot, stateRoot, entityPath, artifactDir string, env []string, model string) { - t.Helper() - piHome := t.TempDir() - sessionDir := t.TempDir() - cleanHome := t.TempDir() - decision := seedPiLiveAuth(t, piHome, os.Getenv("HOME"), os.Getenv("CODEX_AUTH_JSON"), os.Getenv("OPENAI_API_KEY"), os.Getenv("SPACEDOCK_PI_LIVE_REQUIRED")) - writeFile(t, filepath.Join(piHome, "settings.json"), fmt.Sprintf("{\"packages\":[%q]}\n", "file:"+repo)) - writePiSubagentsProjectArtifactDir(t, piHome) - workflowRoot, stateRoot, entityPath = writePiNonSelfDescribingSmokeWorkflow(t) - artifactDir = filepath.Join(piLiveArtifactDir(t, name), "run") - if err := os.MkdirAll(filepath.Join(artifactDir, "sessions"), 0o755); err != nil { - t.Fatal(err) - } - env = piLiveEnvForAuth(piHome, sessionDir, cleanHome, filepath.Dir(binary), piSubagentsRoot, os.Getenv("OPENAI_API_KEY"), decision.mode) - model = piLiveChildModel(decision) - return workflowRoot, stateRoot, entityPath, artifactDir, env, model -} - -// runPiNonSelfDescribingDispatchBuild assembles the initial-dispatch artifact -// for the non-self-describing smoke entity with a checklist equal to a real -// entity's acceptance criteria: no ensign skill path, no stage-report heading, -// no DONE/Summary structure. The worker's only stage-report format source is -// the ensign skill the firstActionBlock directs the worker to load for -// host=pi (AC-2). -func runPiNonSelfDescribingDispatchBuild(t *testing.T, binary, workflowRoot, entityPath string) piSmokeEnvelope { - t.Helper() - // A real-entity acceptance-criteria checklist: the work to do and the - // commit discipline, with zero format or skill-path hints. - checklist := []string{ - "- append the smoke marker line `PI-NONSD-SMOKE-MARKER` to the entity file", - "- commit only the entity path in the state checkout with message 'ensign: pi live smoke' (path-scoped git add/commit for pi-nonsd-smoke/index.md)", - } - stdin, err := json.Marshal(map[string]any{ - "schema_version": 2, - "entity_path": entityPath, - "workflow_dir": workflowRoot, - "stage": "implementation", - "checklist": checklist, - "bare_mode": true, - "host": "pi", - }) - if err != nil { - t.Fatal(err) - } - cmd := exec.Command(binary, "dispatch", "build", "--workflow-dir", workflowRoot) - cmd.Dir = workflowRoot - cmd.Stdin = strings.NewReader(string(stdin)) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("dispatch build --host pi failed: %v\nstderr:\n%s", err, stderr.String()) - } - out := stdout.Bytes() - var envelope piSmokeEnvelope - if err := json.Unmarshal(out, &envelope); err != nil { - t.Fatalf("dispatch build stdout is not the build envelope: %v\n%s\nstderr:\n%s", err, out, stderr.String()) - } - if envelope.Agent != "worker" || envelope.Skill != "ensign" { - t.Fatalf("pi build envelope = agent %q skill %q, want worker/ensign:\n%s", envelope.Agent, envelope.Skill, out) - } - if envelope.Prompt == "" || envelope.DispatchFile == "" { - t.Fatalf("pi build envelope missing prompt/dispatch_file_path:\n%s", out) - } - // Adversarial guard: confirm the dispatch body's First action block - // directs the worker to load the ensign skill (the format source), not - // that the body carries the format inline (the embed was removed). - body, err := os.ReadFile(envelope.DispatchFile) - if err != nil { - t.Fatalf("read dispatch artifact: %v", err) - } - bodyStr := string(body) - for _, want := range []string{"/skill:ensign", "skills/ensign/SKILL.md"} { - if !strings.Contains(bodyStr, want) { - t.Fatalf("non-self-describing dispatch body missing skill-load instruction %q:\n%s", want, bodyStr) - } - } - if strings.Contains(bodyStr, "### Stage Report format") { - t.Fatalf("non-self-describing dispatch body still carries the removed embed block") - } - return envelope -} - -// piNonSelfDescribingSmokePrompt is the FO prompt for the non-self-describing -// lane. It forwards the dispatch artifact's spawn fields verbatim and, after -// the worker returns, verifies the entity carries a complete -// `## Stage Report: implementation` and the state git log has the worker -// commit. Unlike piLiveSmokePrompt it does NOT tell the FO to verify the -// smoke marker — the marker is the worker's real work, not the proof; the -// stage report (sourced from the loaded ensign skill) is the proof. -func piNonSelfDescribingSmokePrompt(repo, workflowRoot, stateRoot, entityPath string, envelope piSmokeEnvelope) string { - return fmt.Sprintf(`You are the Spacedock first officer for a live Pi smoke test. - -An initial-dispatch artifact was assembled for the entity with `+"`spacedock dispatch build --host pi`"+`; forward it through pi-subagents exactly as emitted — this smoke exists to prove the build artifact's embedded stage-report format drives the worker's report even when the checklist does not name the format. - - agent: %[5]s - skill: %[6]s - task: %[7]s - -Use the pi-subagents subagent(...) tool exactly once with those fields verbatim (context must be "fresh", working directory %[2]s). Do not use or mention Claude Agent, SendMessage, TeamCreate, or TeamDelete tools. Do not paraphrase, re-order, or extend the task string. - -After subagent(...) returns, you as first officer must verify the entity file %[4]s contains a '## Stage Report: implementation' section with at least one '- DONE:' item and a '### Summary' subsection, and verify the state checkout %[3]s git log contains 'ensign: pi live smoke' over pi-nonsd-smoke/index.md. Exit successfully only after those durable checks pass; your final message names the agent and skill values you passed to subagent(...) and the child's run id. - -Reference paths: ensign contract at %[1]s/skills/ensign/SKILL.md; Pi ensign adapter at %[1]s/skills/ensign/references/pi-ensign-runtime.md (the worker's dispatch artifact already points at them).`, - repo, workflowRoot, stateRoot, entityPath, envelope.Agent, envelope.Skill, envelope.Prompt) -} - -func assertPiNonSelfDescribingSmokeResult(t *testing.T, stateRoot, entityPath, artifactDir string) { - t.Helper() - entity := readFile(t, entityPath) - // The complete stage report structure (heading + DONE + Summary) plus the - // durable git commit prove the spawned worker followed the embedded - // stage-report format block without the checklist naming it. - for _, want := range []string{"## Stage Report: implementation", "- DONE:", "### Summary"} { - if !strings.Contains(entity, want) { - t.Fatalf("entity missing %q after non-self-describing pi subagent smoke; artifacts in %s\n%s", want, artifactDir, entity) - } - } - log := git(t, stateRoot, "log", "--oneline", "--", "pi-nonsd-smoke", "index.md") - if !strings.Contains(log, "ensign: pi live smoke") { - t.Fatalf("state checkout git log missing worker commit; artifacts in %s\n%s", artifactDir, log) - } - if strings.TrimSpace(git(t, stateRoot, "status", "--short", "--", "pi-nonsd-smoke", "index.md")) != "" { - t.Fatalf("state checkout entity has uncommitted changes after worker commit; artifacts in %s\n%s", artifactDir, git(t, stateRoot, "status", "--short")) - } -} diff --git a/internal/ensigncycle/team_capability_test.go b/internal/ensigncycle/team_capability_test.go index 0c28ce06b..17e269a31 100644 --- a/internal/ensigncycle/team_capability_test.go +++ b/internal/ensigncycle/team_capability_test.go @@ -64,6 +64,8 @@ func TestCleanupKeepMovingRootRetainsOnlyFailures(t *testing.T) { func codexLiveFrontDoorArgv(pluginDir, workflowRoot, finalPath, prompt string) []string { return []string{ "codex", + "--plugin-dir", pluginDir, + "--skip-compat-check", prompt, "--", "exec", @@ -106,12 +108,12 @@ func TestCodexLiveRunnerUsesSpacedockFrontDoorBeforeHostArgs(t *testing.T) { if fence < 0 { t.Fatalf("Codex live argv has no host-argument fence: %v", args) } - if args[0] != "codex" { - t.Fatalf("Codex front door is not first: %v", args) + if args[0] != "codex" || !argvHasAdjacent(args[:fence], "--plugin-dir", "/tmp/plugin") { + t.Fatalf("Spacedock-owned Codex setup is not before host args: %v", args) } - for _, arg := range args { + for _, arg := range args[fence+1:] { if arg == "--plugin-dir" || arg == "/tmp/plugin" || arg == "--skip-compat-check" { - t.Fatalf("common live runner bypassed the installed stable package: %v", args) + t.Fatalf("Spacedock-owned argument leaked after host fence: %v", args) } } if args[fence+1] != "exec" { diff --git a/internal/gates/round_test.go b/internal/gates/round_test.go index 6b32e622b..0781058e8 100644 --- a/internal/gates/round_test.go +++ b/internal/gates/round_test.go @@ -151,80 +151,23 @@ func TestRoundAcceptsWorkflowForeignLabelsAndActors(t *testing.T) { } func TestRoundPublishesFlatAndFolderThroughSharedReviewHome(t *testing.T) { - for _, form := range []string{"folder", "flat"} { - t.Run(form, func(t *testing.T) { - var root, entity, briefing, log string - if form == "folder" { - root, entity, briefing, log, _ = advisoryRoundFixture(t) - } else { - root, entity, briefing, log, _ = flatAdvisoryRoundFixture(t) - } - before := lifecycleBytes(t, entity) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { - t.Fatal(err) - } - pointer, err := readRoundPointerData(mustReadBytes(t, entity)) - if err != nil || pointer.Briefing.RoomRef != "@review/implementation/round-1" { - t.Fatalf("pointer=%#v err=%v", pointer, err) - } - if !bytes.Equal(before, lifecycleBytes(t, entity)) { - t.Fatal("round changed status or gates") - } - if _, err := os.Stat(filepath.Join(root, "review", "implementation", "round-1", "briefing.json")); err != nil { - t.Fatal(err) - } - if _, err := ValidateRoundFile(entity, "implementation/1"); err != nil { - t.Fatal(err) - } - }) + root, entity, briefing, log, _ := flatAdvisoryRoundFixture(t) + before := lifecycleBytes(t, entity) + if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { + t.Fatal(err) } - - t.Run("declared folder policy refuses a new flat home and grandfathers an existing one", func(t *testing.T) { - root, entity, briefing, log, _ := flatAdvisoryRoundFixture(t) - state := filepath.Dir(root) - readme := "---\nentity-form: folder\nstages:\n states:\n - name: implementation\n---\n# Workflow\n" - if err := os.WriteFile(filepath.Join(state, "README.md"), []byte(readme), 0o644); err != nil { - t.Fatal(err) - } - before := treeDigest(t, state) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err == nil || treeDigest(t, state) != before { - t.Fatalf("declared-folder refusal err=%v or changed bytes", err) - } - if err := os.MkdirAll(filepath.Join(root, "review", "retained"), 0o755); err != nil { - t.Fatal(err) - } - if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { - t.Fatalf("grandfathered flat round: %v", err) - } - }) -} - -func TestRoundArtifactBoundaryIsByteCleanForFlatAndFolder(t *testing.T) { - for _, form := range []string{"folder", "flat"} { - for _, row := range []string{"outside home", "mutable entity"} { - t.Run(form+"/"+row, func(t *testing.T) { - root, entity, briefing, log := roundFixtureForForm(t, form) - target, uri := filepath.Join(filepath.Dir(root), "sibling.txt"), "../../../../sibling.txt" - if row == "mutable entity" { - target, uri = entity, "../../../index.md" - if form == "flat" { - uri = "../../../../task.md" - } - } else if err := os.WriteFile(target, []byte("sibling\n"), 0o644); err != nil { - t.Fatal(err) - } - body := bytes.Replace(mustReadBytes(t, briefing), []byte("../../../candidate.patch"), []byte(uri), 1) - body = bytes.Replace(body, []byte("sha256:8e85d4c9523a617e05b17c92390b10b2f9892152ca348433311230ac3ad98dd3"), []byte(RawDigest(mustReadBytes(t, target))), 1) - if err := os.WriteFile(briefing, body, 0o644); err != nil { - t.Fatal(err) - } - state := filepath.Dir(root) - before := treeDigest(t, state) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err == nil || treeDigest(t, state) != before { - t.Fatalf("boundary refusal err=%v or changed bytes", err) - } - }) - } + pointer, err := readRoundPointerData(mustReadBytes(t, entity)) + if err != nil || pointer.Briefing.RoomRef != "@review/implementation/round-1" { + t.Fatalf("pointer=%#v err=%v", pointer, err) + } + if !bytes.Equal(before, lifecycleBytes(t, entity)) { + t.Fatal("round changed status or gates") + } + if _, err := os.Stat(filepath.Join(root, "review", "implementation", "round-1", "briefing.json")); err != nil { + t.Fatal(err) + } + if _, err := ValidateRoundFile(entity, "implementation/1"); err != nil { + t.Fatal(err) } } @@ -250,32 +193,22 @@ func TestRoundFrozenLegacyFolderPointerReplaysUnchanged(t *testing.T) { } func TestRoundReplayRefusesSiblingAndMutableEntityArtifacts(t *testing.T) { - for _, form := range []string{"folder", "flat"} { - t.Run(form, func(t *testing.T) { - root, entity, briefing, log := roundFixtureForForm(t, form) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { - t.Fatal(err) - } - candidate := filepath.Join(root, "candidate.patch") - sibling := filepath.Join(filepath.Dir(root), "sibling.patch") - copyRoundFixture(t, sibling, "candidate.patch") - for _, target := range []string{sibling, entity} { - if err := os.Remove(candidate); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, candidate); err != nil { - t.Fatal(err) - } - before := treeDigest(t, filepath.Dir(root)) - if _, err := ValidateRoundFile(entity, "implementation/1"); err == nil || treeDigest(t, filepath.Dir(root)) != before { - t.Fatalf("replay target %s was accepted or changed bytes", target) - } - if err := os.Remove(candidate); err != nil { - t.Fatal(err) - } - copyRoundFixture(t, candidate, "candidate.patch") - } - }) + root, entity, briefing, log, _ := flatAdvisoryRoundFixture(t) + if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { + t.Fatal(err) + } + candidate := filepath.Join(root, "candidate.patch") + sibling := filepath.Join(filepath.Dir(root), "sibling.patch") + copyRoundFixture(t, sibling, "candidate.patch") + if err := os.Remove(candidate); err != nil { + t.Fatal(err) + } + if err := os.Symlink(sibling, candidate); err != nil { + t.Fatal(err) + } + before := treeDigest(t, filepath.Dir(root)) + if _, err := ValidateRoundFile(entity, "implementation/1"); err == nil || treeDigest(t, filepath.Dir(root)) != before { + t.Fatalf("replay target %s was accepted or changed bytes", sibling) } } diff --git a/internal/release/cask_dependencies_test.go b/internal/release/cask_dependencies_test.go index ed9a932d0..64c29a08a 100644 --- a/internal/release/cask_dependencies_test.go +++ b/internal/release/cask_dependencies_test.go @@ -177,6 +177,7 @@ func renderCasks(t *testing.T) (map[string]string, bool) { type goreleaserCaskConfig struct { HomebrewCasks []struct { Name string `yaml:"name"` + SkipUpload string `yaml:"skip_upload"` Caveats string `yaml:"caveats"` Dependencies []struct { Cask string `yaml:"cask"` @@ -185,6 +186,30 @@ type goreleaserCaskConfig struct { } `yaml:"homebrew_casks"` } +// TestCaskReleaseChannelRouting makes sure that a final tag cannot replace the +// edge cask and that a prerelease tag cannot replace the stable cask. +func TestCaskReleaseChannelRouting(t *testing.T) { + var cfg goreleaserCaskConfig + if err := yaml.Unmarshal([]byte(readGoreleaserConfig(t)), &cfg); err != nil { + t.Fatalf("parse .goreleaser.yaml: %v", err) + } + want := map[string]string{ + "spacedock": "auto", + "spacedock@next": `{{ eq .Prerelease "" }}`, + } + for _, cask := range cfg.HomebrewCasks { + if expected, ok := want[cask.Name]; ok { + if cask.SkipUpload != expected { + t.Errorf("cask %q skip_upload = %q, want %q", cask.Name, cask.SkipUpload, expected) + } + delete(want, cask.Name) + } + } + for name := range want { + t.Errorf("missing cask %q", name) + } +} + // assertCaskConfig is the fallback proof: it parses .goreleaser.yaml's // homebrew_casks declarations and asserts the same AC-1/AC-2 properties the // render check proves on the emitted Ruby — each cask declares a `cask: