From cd08a2892ad622fcee5cf06af13ac7785b12481d Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 3 Nov 2026 00:05:32 +0000 Subject: [PATCH] Run API-only recovery independently of consumer vendoring --- cmd/releasekit/publication_handoff_test.go | 97 ++++++++++++++++++++++ docs/consumers/publish.md | 13 +-- docs/internal/gotool.md | 9 +- docs/roadmap/implementation.md | 27 ++++-- docs/roadmap/remediation.md | 5 ++ internal/gotool/recovery.go | 38 +++++++-- 6 files changed, 165 insertions(+), 24 deletions(-) diff --git a/cmd/releasekit/publication_handoff_test.go b/cmd/releasekit/publication_handoff_test.go index a685b27..d815ca0 100644 --- a/cmd/releasekit/publication_handoff_test.go +++ b/cmd/releasekit/publication_handoff_test.go @@ -14,9 +14,106 @@ import ( "testing" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" + "go.maximumdirect.net/releasekit/internal/gotool" "go.maximumdirect.net/releasekit/internal/publish" ) +func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fixture executes POSIX recovery commands") + } + repo, remote := newSimplePublishRepository(t, false) + source := repositoryRoot(t) + relative, err := filepath.Rel(repo, source) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repo, "go.mod", "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.0.0\n\nreplace go.maximumdirect.net/releasekit => "+strconv.Quote(relative)+"\n") + writeRepositoryFile(t, repo, "uses_releasekit.go", "package candidate\nimport _ \"go.maximumdirect.net/releasekit\"\n") + sums, err := os.ReadFile(filepath.Join(source, "go.sum")) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repo, "go.sum", string(sums)) + goBinary := filepath.Join(runtime.GOROOT(), "bin", "go") + for _, args := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} { + command := exec.Command(goBinary, args...) + command.Dir = repo + if out, err := command.CombinedOutput(); err != nil { + t.Fatalf("prepare vendored API: %v: %s", err, out) + } + } + cliGitRun(t, repo, "add", ".") + cliGitRun(t, repo, "commit", "-m", "Vendor API dependency") + cliGitRun(t, repo, "push", "origin", "main") + before := cliGitOutput(t, repo, "rev-parse", "HEAD^{tree}") + if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + var out, diagnostics bytes.Buffer + if status := run(t.Context(), []string{"publish", "--repo", repo, "v1.2.3"}, &out, &diagnostics); status != 1 { + t.Fatalf("publication status=%d: %s", status, &diagnostics) + } + command := outputLineValue(diagnostics.String(), "recovery command: ") + if command == "" { + t.Fatalf("no handoff: %s", &diagnostics) + } + recovery := exec.Command("sh", "-c", command) + recovery.Dir = t.TempDir() + recovered, err := recovery.CombinedOutput() + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 1 || !strings.Contains(string(recovered), "module=example.com/cli-candidate tag=v1.2.3 state=not_published") { + t.Fatalf("vendored recovery: %v: %s", err, recovered) + } + if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" { + t.Fatalf("handoff changed consumer files: %s", status) + } + if after := cliGitOutput(t, repo, "write-tree"); after != before { + t.Fatalf("handoff changed consumer index: %s != %s", after, before) + } +} + +func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) { + proxy := writeReleaseKitModuleProxy(t) + environment := make(map[string]string) + useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy)) + for key, value := range environment { + t.Setenv(key, value) + } + module := t.TempDir() + data := "module example.com/recovery-consumer\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n\nreplace go.maximumdirect.net/releasekit v0.2.197 => go.maximumdirect.net/releasekit v0.2.198\n" + if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil { + t.Fatal(err) + } + args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir()) + if err != nil { + t.Fatal(err) + } + command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...) + command.Dir = t.TempDir() + output, err := command.CombinedOutput() + if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") { + t.Fatalf("versioned recovery selection: %v: %s", err, output) + } + after, err := os.ReadFile(filepath.Join(module, "go.mod")) + if err != nil || string(after) != data { + t.Fatalf("consumer metadata changed: %q, %v", after, err) + } +} + +func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) { + args, err := gotool.RecoveryTool(t.Context(), repositoryRoot(t), t.TempDir()) + if err != nil { + t.Fatal(err) + } + command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...) + command.Dir = t.TempDir() + output, err := command.CombinedOutput() + if err != nil || !strings.HasPrefix(string(output), "releasekit ") { + t.Fatalf("source module recovery entry point: %v: %s", err, output) + } +} + func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("fixture observes the cold executable through /proc") diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index ece8d23..5e3a7d7 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -137,13 +137,16 @@ retention and removal. The CLI prepares a recovery entry point when a failed publication has a saved record. Registered-tool consumers use their selected module's Go tool directly. -For API-only consumers, ReleaseKit retains alternate module and checksum files +For API-only consumers, ReleaseKit retains private bootstrap module and checksum files under `/releasekit/publication-recovery/`, with private directory and file permissions. These files add the CLI tool declaration and -prepare its module graph without changing consumer source. They survive a -launcher's temporary module-file cleanup. The printed command runs Go from the -selected module directory; retain that checkout and the private recovery files -while the entry point is needed. Remove the private recovery directories when +prepare its module graph without changing consumer source. The bootstrap has +its own module root, so tool resolution is independent of consumer vendoring; +local replacements retain their original source locations. These files survive +a launcher's temporary module-file cleanup. Registered-tool commands run from +the selected module; API-only commands run from the private bootstrap root. +Retain the source checkout and private recovery files while the entry point is +needed. Remove the private recovery directories when their recovery value expires. Another supported CLI can read the saved record independently of these launch files. diff --git a/docs/internal/gotool.md b/docs/internal/gotool.md index fdc1639..2190d55 100644 --- a/docs/internal/gotool.md +++ b/docs/internal/gotool.md @@ -9,9 +9,12 @@ Standalone and delivery builds create their own owners. The package uses errors. For CLI publication recovery, the package selects a registered module tool or -prepares durable alternate module files for an API-only consumer. Go's module -graph query includes transitive dependencies; preparation retains local module -selection and uses the original module directory for relative replacements. +prepares a durable private module root for an API-only consumer. Go's module +graph query includes transitive dependencies; preparation retains module +requirements, versioned replacements, and checksums. Local replacements resolve +against their original module directory before relocation. The original main +module remains a local replacement, including when releasing ReleaseKit itself. +The private root provides tool resolution independently of consumer vendoring. The CLI formats the later invocation and falls back to its native executable for consumers without a selected ReleaseKit module. The [publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index dbf8aad..e1983b4 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -454,13 +454,20 @@ failures without additional consumer release infrastructure. command selecting that exact path. Partial records can explain missing evidence. Earlier failures still report their saved path when available. Successful publication reports the path without prescribing a recovery step. -4. Build the printed command from the running executable's absolute path and the - absolute record path, quoting each argument for the POSIX-shell runbooks. - This works for a directly built CLI and a Go-tool-launched cached executable - without assuming `releasekit` is on PATH or that the consumer registered a - tool directive. Quote apostrophes safely; print the command without executing - a shell. Explain in recovery guidance that operators can use their selected - supported CLI if the original executable is no longer available. +4. Prefer the consumer module's registered Go tool for the printed command. + API-only consumers use durable private module/checksum files under Git + metadata, with a separate bootstrap module root so consumer vendoring does + not govern tool resolution. Retain the selected requirements, checksums, + versioned replacements, and original locations of local replacements. + Consumers without a selected ReleaseKit dependency use the native executable + path. Select the absolute record path explicitly and quote every argument + for the POSIX-shell runbooks, including apostrophes. The command survives a + cold Go-tool executable's removal and launcher-owned metadata cleanup. + Explain how to use another selected supported CLI if an entry point becomes + unavailable. + +**R14 follow-up completed:** API-only recovery uses an independent private module +root. R15 tracks cancellation and preparation-failure handling separately. **Verification:** Extend existing publication CLI tests for the default, override, recording warning with successful status, and original failed/unknown status. @@ -468,6 +475,10 @@ Use a built CLI in an assembled test to execute the printed recovery command and verify it selects the original record among retries/modules. Test shell argument quoting with synthetic paths. Keep the existing module-selected Go-tool launchers and source bootstrap working without adding consumer-owned recording. +Include an API-only consumer that vendors the library without declaring the CLI +tool; execute its handoff and verify source/vendor contents remain unchanged. +Exercise relative local replacements, versioned replacements, and ReleaseKit's +own source-module recovery entry point. ## Stage 8: Finish assembled coverage and canonical documentation @@ -482,6 +493,8 @@ contracts and a short consumer recovery workflow. coordinate cancellation/interruption without arbitrary sleeps. Remove or move the local tag and change branch, HEAD, remote URLs, or transport settings before later recovery. Cover root/nested tags and original-record selection. + Retain the assembled vendored API-only handoff coverage added for R14, + alongside cold registered-tool and temporary-launcher recovery. 2. Establish read-only behavior through resulting refs, worktree/branch state, saved records, and controlled observations of candidate/hosted boundaries. Reuse lower-level classification and codec tests. Tests use local remotes, diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 02efdc6..5b04735 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -604,6 +604,11 @@ correctness for these additional combinations. ## R14: Support recovery for vendored API-only consumers — P2 +**Completed:** API-only recovery uses a private module root with preserved module +settings and relocated local replacements. Vendored API-only, relative/versioned +replacement, cold Go-tool, native handoff, and source-module entry-point tests +passed. Stage 7/8 instructions and consumer/internal guidance are updated. + **Relevant code:** `internal/gotool/recovery.go:95–107`; the command assembly in `cmd/releasekit/main.go:126–145`. Affects Stage 7 and its Stage 8 coverage. diff --git a/internal/gotool/recovery.go b/internal/gotool/recovery.go index 82bc602..f70fd99 100644 --- a/internal/gotool/recovery.go +++ b/internal/gotool/recovery.go @@ -19,7 +19,7 @@ const releaseKitCommand = releaseKitModule + "/cmd/releasekit" // RecoveryTool returns module-selected Go arguments for a later invocation. // An empty result means this module does not select ReleaseKit. API-only modules -// receive private alternate module files that outlive launcher-owned bootstraps. +// receive a private module root that outlives launcher-owned bootstraps. func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) { path := filepath.Join(moduleDir, "go.mod") data, err := os.ReadFile(path) @@ -64,6 +64,28 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e if err := module.AddTool(releaseKitCommand); err != nil { return nil, err } + // Relocating the root avoids the consumer's vendor tree. Preserve local + // replacements relative to the directory that originally owned go.mod. + for _, replacement := range module.Replace { + if replacement.New.Version == "" && !filepath.IsAbs(replacement.New.Path) { + if err := module.AddReplace(replacement.Old.Path, replacement.Old.Version, filepath.Join(moduleDir, replacement.New.Path), ""); err != nil { + return nil, err + } + } + } + // The original main module's source remains local if a dependency refers + // to it. This also permits recovery when releasing ReleaseKit itself. + if err := module.AddReplace(module.Module.Mod.Path, "", moduleDir, ""); err != nil { + return nil, err + } + if selected.Main { + if err := module.AddRequire(releaseKitModule, "v0.0.0"); err != nil { + return nil, err + } + } + if err := module.AddModuleStmt("releasekit.local/recovery"); err != nil { + return nil, err + } data, err = module.Format() if err != nil { return nil, err @@ -85,24 +107,22 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e _ = os.RemoveAll(directory) } }() - alternate := filepath.Join(directory, "releasekit.mod") - if err := os.WriteFile(alternate, data, 0600); err != nil { + if err := os.WriteFile(filepath.Join(directory, "go.mod"), data, 0600); err != nil { return nil, err } - if err := os.WriteFile(filepath.Join(directory, "releasekit.sum"), sums, 0600); err != nil { + if err := os.WriteFile(filepath.Join(directory, "go.sum"), sums, 0600); err != nil { return nil, err } - // go tool accepts an alternate module file but has no -mod flag. Prepare - // the private graph and sums before the later read-only tool invocation. + // Prepare the private graph and sums before the later tool invocation. captured := &combinedWriter{mode: captureStatus} err = process.Run(ctx, process.Invocation{ - Args: []string{"go", "list", "-mod=mod", "-modfile=" + alternate, "-deps", releaseKitCommand}, - Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"}, + Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand}, + Dir: directory, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"}, Stdout: captured, Stderr: captured, }) if err != nil { return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted())) } complete = true - return []string{"-C", moduleDir, "tool", "-modfile=" + alternate, releaseKitCommand}, nil + return []string{"-C", directory, "tool", "-modfile=", releaseKitCommand}, nil }