From 993866c1d7e417aca41adbf3faf1a53129205f6d Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 22:32:58 +0000 Subject: [PATCH] Keep publication recovery usable after cold Go tool runs --- cmd/releasekit/main.go | 34 ++++- cmd/releasekit/publication_handoff_test.go | 148 +++++++++++++++++++++ cmd/releasekit/recovery_test.go | 2 +- docs/cli.md | 12 +- docs/consumers/publish.md | 12 ++ docs/internal/gotool.md | 9 ++ docs/roadmap/remediation.md | 7 + internal/gotool/recovery.go | 108 +++++++++++++++ 8 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 internal/gotool/recovery.go diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index a656611..77052f5 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -16,6 +16,7 @@ import ( "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/internal/artifact" "go.maximumdirect.net/releasekit/internal/git" + "go.maximumdirect.net/releasekit/internal/gotool" "go.maximumdirect.net/releasekit/internal/releaseidentity" ) @@ -75,7 +76,7 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s request.PublicationRecordDir = invocation.RecordDir fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version) result, err := releasekit.Publish(ctx, request) - reportPublicationRecord(result, err, stderr) + reportPublicationRecord(ctx, result, err, stderr) if err != nil { fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, git.DisplayURL(result.Endpoint), candidateFailureText(err)) if errors.Is(err, releasekit.ErrInvalidVersion) { @@ -101,7 +102,7 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s return 0 } -func reportPublicationRecord(result releasekit.PublicationResult, publicationErr error, stderr io.Writer) { +func reportPublicationRecord(ctx context.Context, result releasekit.PublicationResult, publicationErr error, stderr io.Writer) { if result.RecordError != nil { fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError)) } @@ -112,14 +113,39 @@ func reportPublicationRecord(result releasekit.PublicationResult, publicationErr if publicationErr == nil || !publicationOffersRecovery(result.Phase) { return } - command, err := publicationRecoveryCommand(result.RecordPath) + command, err := selectedPublicationRecoveryCommand(ctx, result) if err != nil { fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err)) fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.") return } fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command)) - fmt.Fprintln(stderr, "If this executable is unavailable, use your selected supported ReleaseKit CLI with the same record.") + fmt.Fprintln(stderr, "If this entry point is unavailable, use your selected supported ReleaseKit CLI with the same record.") +} + +func selectedPublicationRecoveryCommand(ctx context.Context, result releasekit.PublicationResult) (string, error) { + if result.Evidence.ModuleDir != "" { + common, err := git.CommonDirectory(ctx, result.RepositoryDir) + if err != nil { + return "", err + } + args, err := gotool.RecoveryTool(ctx, result.Evidence.ModuleDir, filepath.Join(common, "releasekit", "publication-recovery")) + if err != nil { + return "", err + } + if len(args) > 0 { + command := "env GOWORK=off GOTOOLCHAIN=local go" + for _, arg := range args { + command += " " + shellQuote(git.DisplayDiagnostic(arg)) + } + path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath)) + if err != nil { + return "", err + } + return command + " reconcile --record " + shellQuote(path), nil + } + } + return publicationRecoveryCommand(result.RecordPath) } func publicationOffersRecovery(phase releasekit.PublicationPhase) bool { diff --git a/cmd/releasekit/publication_handoff_test.go b/cmd/releasekit/publication_handoff_test.go index 49bbc98..a685b27 100644 --- a/cmd/releasekit/publication_handoff_test.go +++ b/cmd/releasekit/publication_handoff_test.go @@ -4,10 +4,12 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "os" "os/exec" "path/filepath" "runtime" + "strconv" "strings" "testing" @@ -15,6 +17,152 @@ import ( "go.maximumdirect.net/releasekit/internal/publish" ) +func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("fixture observes the cold executable through /proc") + } + for _, registered := range []bool{true, false} { + t.Run(fmt.Sprintf("registered=%t", registered), func(t *testing.T) { + repo, remote := newSimplePublishRepository(t, false) + source := repositoryRoot(t) + // A distinct replacement identity gives this tool a fresh link action + // while retaining the shared compilation cache for its dependencies. + selectedSource := filepath.Join(t.TempDir(), "ReleaseKit source") + if err := os.Symlink(source, selectedSource); err != nil { + t.Fatal(err) + } + if !registered { + relative, err := filepath.Rel(repo, selectedSource) + if err != nil { + t.Fatal(err) + } + selectedSource = relative + } + module := "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(selectedSource) + "\n" + if registered { + module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n" + } + writeRepositoryFile(t, repo, "go.mod", module) + sums, err := os.ReadFile(filepath.Join(source, "go.sum")) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repo, "go.sum", string(sums)) + if !registered { + for _, name := range []string{"release.sh", "select-module.sh"} { + data, err := os.ReadFile(filepath.Join(source, "examples", "multi-module", name)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, name), data, 0700); err != nil { + t.Fatal(err) + } + } + writeRepositoryFile(t, repo, "release-modules.tsv", "root\tv*\t.\treleasekit.json\n") + } + goBinary := filepath.Join(runtime.GOROOT(), "bin", "go") + if registered { + prepare := exec.Command(goBinary, "list", "-mod=mod", "-deps", "go.maximumdirect.net/releasekit/cmd/releasekit") + prepare.Dir = repo + if out, err := prepare.CombinedOutput(); err != nil { + t.Fatalf("prepare tool: %v: %s", err, out) + } + } + cliGitRun(t, repo, "add", ".") + cliGitRun(t, repo, "commit", "-m", "Select release tool") + cliGitRun(t, repo, "push", "origin", "main") + if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + executed := filepath.Join(t.TempDir(), "executed-tool") + wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\nexec " + shellQuote(realGit) + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + var launch *exec.Cmd + if registered { + launch = exec.Command(goBinary, "tool", "releasekit", "publish", "--repo", repo, "v1.2.3") + } else { + launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3") + } + launch.Dir = repo + out, err := launch.CombinedOutput() + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 1 { + t.Fatalf("publication: %v: %s", err, out) + } + text := string(out) + launchedPath, err := os.ReadFile(executed) + if err != nil { + t.Fatalf("did not exercise a cold temporary executable: %v: %s", err, text) + } + if _, err := os.Stat(string(launchedPath)); !os.IsNotExist(err) { + t.Fatalf("cold executable still exists: %s: %v", launchedPath, err) + } + entries, err := os.ReadDir(filepath.Dir(repo)) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") { + t.Fatalf("launcher bootstrap survived: %s", entry.Name()) + } + } + command := outputLineValue(text, "recovery command: ") + if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") { + t.Fatalf("unstable handoff: %s", command) + } + if !registered && !strings.Contains(command, "publication-recovery") { + t.Fatalf("API-only handoff lacks durable metadata: %s", command) + } + // Execute from another directory after the original driver and the + // API-only launcher's temporary files are gone. + evidence, err := publish.LoadEvidence(outputLineValue(text, "publication record saved: ")) + if err != nil { + t.Fatal(err) + } + evidence.Endpoint = filepath.Join(t.TempDir(), "unavailable.git") + unknownRecord, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence) + if err != nil { + t.Fatal(err) + } + for _, scenario := range []struct { + record string + status int + }{{"", 1}, {filepath.Join(t.TempDir(), "missing.json"), 2}, {unknownRecord, 3}, {"", 0}} { + if scenario.status == 0 { + if err := os.Remove(filepath.Join(remote, "hooks", "pre-receive")); err != nil { + t.Fatal(err) + } + cliGitRun(t, repo, "push", "origin", "refs/tags/v1.2.3") + } + selected := command + if scenario.record != "" { + selected += " --record " + shellQuote(scenario.record) + } + recovery := exec.Command("sh", "-c", selected) + recovery.Dir = t.TempDir() + recovered, err := recovery.CombinedOutput() + if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) { + t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered) + } + if scenario.status == 1 && !strings.Contains(string(recovered), "state=not_published") { + t.Fatalf("wrong observation: %s", recovered) + } + } + if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" { + t.Fatalf("handoff modified candidate: %s", status) + } + }) + } +} + func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) { repository, _ := newSimplePublishRepository(t, true) var stdout, stderr bytes.Buffer diff --git a/cmd/releasekit/recovery_test.go b/cmd/releasekit/recovery_test.go index ef60df7..916c110 100644 --- a/cmd/releasekit/recovery_test.go +++ b/cmd/releasekit/recovery_test.go @@ -91,7 +91,7 @@ func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) { t.Fatalf("private metadata changed: %#v, %v", loaded, err) } var out bytes.Buffer - reportPublicationRecord(releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out) + reportPublicationRecord(t.Context(), releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out) if strings.Contains(out.String(), secret) { t.Fatalf("handoff leaked credentials: %s", &out) } diff --git a/docs/cli.md b/docs/cli.md index b915ebe..906e791 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -144,10 +144,14 @@ relative override is resolved from the source repository root. Standard error reports the absolute saved record path or a recording warning separately from the publication result. Recording failure does not change publication status. When publication fails and a record was saved, the CLI also prints a copyable -`reconcile --record` command for that exact record. The command uses the running -executable's absolute path, so it remains usable when the executable is not on -`PATH`; if that executable is no longer available, use another supported -ReleaseKit CLI. +`reconcile --record` command for that exact record. The command selects the +consumer module's registered Go tool when available. API-only consumers receive +a Go-tool invocation with durable private module files; see +[recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence). +Consumers without a module-selected ReleaseKit dependency use the running +executable's absolute path. Each entry point preserves the recovery exit status. +If the selected entry point becomes unavailable, use another supported ReleaseKit +CLI with the saved record. The command returns status 0 for confirmed publication, 1 for a known publication failure or conflict, 2 for usage, profile, or invalid-version diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index 5608250..ece8d23 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -135,6 +135,18 @@ directories privately and creates record files with owner-only permissions; existing directory permissions are left as they are. The caller owns storage 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 +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 +their recovery value expires. Another supported CLI can read the saved record +independently of these launch files. + The saved endpoint is retained exactly for later observation and may include credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as sensitive. Store overrides in a location with suitable access controls, avoid diff --git a/docs/internal/gotool.md b/docs/internal/gotool.md index cde61af..fdc1639 100644 --- a/docs/internal/gotool.md +++ b/docs/internal/gotool.md @@ -8,6 +8,15 @@ Standalone and delivery builds create their own owners. The package uses `internal/process` for cancellation, the working directory, output, and wrapped 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. +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) +owns recovery-file placement and retention. + ## Command output ownership `Tool.Run` executes a status-only command. It forwards combined stdout and diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index db0e964..2484a62 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -249,6 +249,13 @@ remain open and are preserved rather than duplicated here. ## R07: Make the recovery handoff survive a cold Go-tool invocation — P2 +**Completed:** CLI recovery prefers the selected module's Go tool. API-only +consumers receive privately retained alternate module/checksum files under Git +metadata; native consumers retain the executable-path fallback. Assembled tests +passed after cold executables disappeared and launcher files were removed, +covering both adoption styles, relative replacements, clean candidates, and +recovery exit statuses 0–3. CLI, consumer, and internal guidance is updated. + **Relevant code:** `cmd/releasekit/main.go:134` (`publicationRecoveryCommand`); `examples/multi-module/release.sh`; `docs/consumers/adoption.md`'s module-selected Go-tool workflow; `cmd/releasekit/publication_handoff_test.go`. diff --git a/internal/gotool/recovery.go b/internal/gotool/recovery.go new file mode 100644 index 0000000..82bc602 --- /dev/null +++ b/internal/gotool/recovery.go @@ -0,0 +1,108 @@ +package gotool + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "golang.org/x/mod/modfile" + + "go.maximumdirect.net/releasekit/internal/process" +) + +const releaseKitModule = "go.maximumdirect.net/releasekit" +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. +func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) { + path := filepath.Join(moduleDir, "go.mod") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + module, err := modfile.Parse(path, data, nil) + if err != nil { + return nil, err + } + for _, tool := range module.Tool { + if tool.Path == releaseKitCommand { + return []string{"-C", moduleDir, "tool", "-modfile=", releaseKitCommand}, nil + } + } + // Ask Go about the selected module graph, including transitive dependencies. + // This reads real module metadata, independently of a launcher's -modfile. + var selection bytes.Buffer + err = process.Run(ctx, process.Invocation{ + Args: []string{"go", "list", "-e", "-json", "-mod=readonly", "-modfile=", "-m", releaseKitModule}, + Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"}, + Stdout: &selection, Stderr: io.Discard, + }) + if err != nil { + return nil, fmt.Errorf("identify recovery tool selection: %w", err) + } + var selected struct { + Main bool + Version string + Error *struct{ Err string } + } + if err := json.Unmarshal(selection.Bytes(), &selected); err != nil { + return nil, err + } + if !selected.Main && selected.Version == "" { + // A source-built CLI also supports consumers without a module dependency. + return nil, nil + } + if selected.Error != nil { + return nil, fmt.Errorf("identify recovery tool selection: %s", selected.Error.Err) + } + if err := module.AddTool(releaseKitCommand); err != nil { + return nil, err + } + data, err = module.Format() + if err != nil { + return nil, err + } + sums, err := os.ReadFile(filepath.Join(moduleDir, "go.sum")) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if err := os.MkdirAll(recordDir, 0700); err != nil { + return nil, err + } + directory, err := os.MkdirTemp(recordDir, "recovery-") + if err != nil { + return nil, fmt.Errorf("create durable recovery module directory: %w", err) + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(directory) + } + }() + alternate := filepath.Join(directory, "releasekit.mod") + if err := os.WriteFile(alternate, data, 0600); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(directory, "releasekit.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. + 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"}, + 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 +}