From d8ec9389e0c5243f7835cd093f7d46d20b2f115b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 4 Nov 2026 00:50:59 +0000 Subject: [PATCH] Prepare recovery tools for the host target --- cmd/releasekit/publication_handoff_test.go | 29 ++++++++++++++---- docs/roadmap/implementation.md | 2 ++ internal/gotool/recovery.go | 34 ++++++++++++++++++---- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/cmd/releasekit/publication_handoff_test.go b/cmd/releasekit/publication_handoff_test.go index 67e5161..c831648 100644 --- a/cmd/releasekit/publication_handoff_test.go +++ b/cmd/releasekit/publication_handoff_test.go @@ -57,8 +57,8 @@ func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) { if err != nil { t.Fatal(err) } - launcher := exec.Command(goBinary, append(launcherArgs, "publish", "--repo", repo, "v1.2.3")...) - launcher.Dir = t.TempDir() + launcher := exec.Command(goBinary, append(launcherArgs.Args, "publish", "--repo", repo, "v1.2.3")...) + launcher.Dir = launcherArgs.Directory publicationOutput, err := launcher.CombinedOutput() var exitError *exec.ExitError if !errors.As(err, &exitError) || exitError.ExitCode() != 1 { @@ -98,12 +98,29 @@ func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) { if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil { t.Fatal(err) } + shimDir := t.TempDir() + logPath := filepath.Join(t.TempDir(), "target") + realGo := filepath.Join(runtime.GOROOT(), "bin", "go") + shim := "#!/bin/sh\ncase \" $* \" in *\" -deps \"*) printf '%s/%s\\n' \"$GOOS\" \"$GOARCH\" > \"$RECOVERY_TARGET_LOG\" ;; esac\nexec \"$RECOVERY_REAL_GO\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("RECOVERY_TARGET_LOG", logPath) + t.Setenv("RECOVERY_REAL_GO", realGo) + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + foreignOS, foreignArch := foreignTarget() + t.Setenv("GOOS", foreignOS) + t.Setenv("GOARCH", foreignArch) 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() + if target, err := os.ReadFile(logPath); err != nil || strings.TrimSpace(string(target)) != runtime.GOOS+"/"+runtime.GOARCH { + t.Fatalf("recovery preparation target = %q, %v", target, err) + } + command := exec.Command(realGo, args.Args...) + command.Dir = args.Directory + command.Env = setEnvironment(setEnvironment(os.Environ(), "GOOS", runtime.GOOS), "GOARCH", runtime.GOARCH) 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) @@ -119,8 +136,8 @@ func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) { if err != nil { t.Fatal(err) } - command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...) - command.Dir = t.TempDir() + command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args.Args, "version")...) + command.Dir = args.Directory output, err := command.CombinedOutput() if err != nil || !strings.HasPrefix(string(output), "releasekit ") { t.Fatalf("source module recovery entry point: %v: %s", err, output) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 647c1b5..44c2417 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -96,6 +96,8 @@ checks in Stage 6. ## Stage 4: Normalize module-based recovery preparation +**Completed.** The CLI adapter and assembled handoff tests follow in the next stage. + 1. In [the Go recovery implementation](../../internal/gotool/recovery.go), keep the existing registered-tool, API-only, and no-dependency selections. Have `RecoveryTool` return `(*RecoveryInvocation, error)` within the diff --git a/internal/gotool/recovery.go b/internal/gotool/recovery.go index bf85083..ba60535 100644 --- a/internal/gotool/recovery.go +++ b/internal/gotool/recovery.go @@ -43,10 +43,16 @@ func TemporaryExecutable(path string) bool { return err == nil } -// RecoveryTool returns module-selected Go arguments for a later invocation. -// An empty result means this module does not select ReleaseKit. API-only modules +// RecoveryInvocation describes a module-selected Go tool invocation. +type RecoveryInvocation struct { + Directory string + Args []string +} + +// RecoveryTool returns a module-selected Go invocation for later use. +// A nil result means this module does not select ReleaseKit. API-only modules // receive a private module root that outlives launcher-owned bootstraps. -func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) { +func RecoveryTool(ctx context.Context, moduleDir, recordDir string) (*RecoveryInvocation, error) { path := filepath.Join(moduleDir, "go.mod") data, err := os.ReadFile(path) if err != nil { @@ -58,7 +64,7 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e } for _, tool := range module.Tool { if tool.Path == releaseKitCommand { - return []string{"-C", moduleDir, "tool", "-modfile=", releaseKitCommand}, nil + return &RecoveryInvocation{Directory: moduleDir, Args: []string{"tool", "-modfile=", releaseKitCommand}}, nil } } // Ask Go about the selected module graph, including transitive dependencies. @@ -140,15 +146,31 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e return nil, err } // Prepare the private graph and sums before the later tool invocation. + environment := map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"} + hostJSON, err := discover(ctx, directory, environment, "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH") + if err != nil { + return nil, fmt.Errorf("prepare recovery tool: %w", err) + } + var host struct { + GOHOSTOS string + GOHOSTARCH string + } + if err := json.Unmarshal(hostJSON, &host); err != nil { + return nil, fmt.Errorf("prepare recovery tool: decode host target: %w", err) + } + if host.GOHOSTOS == "" || host.GOHOSTARCH == "" { + return nil, fmt.Errorf("prepare recovery tool: missing host target") + } + environment["GOOS"], environment["GOARCH"] = host.GOHOSTOS, host.GOHOSTARCH captured := &combinedWriter{mode: captureStatus} err = process.Run(ctx, process.Invocation{ Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand}, - Dir: directory, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"}, + Dir: directory, Env: environment, 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", directory, "tool", "-modfile=", releaseKitCommand}, nil + return &RecoveryInvocation{Directory: directory, Args: []string{"tool", "-modfile=", releaseKitCommand}}, nil }