package gotool import ( "bytes" "context" "encoding/json" "fmt" "io" "os" "path/filepath" "strconv" "strings" "golang.org/x/mod/modfile" "go.maximumdirect.net/releasekit/internal/process" ) const releaseKitModule = "go.maximumdirect.net/releasekit" const releaseKitCommand = releaseKitModule + "/cmd/releasekit" // TemporaryExecutable recognizes executables in Go's temporary build layout, // /go-build/b/exe/. Those paths disappear when // the Go driver finishes and cannot provide a later native-command fallback. func TemporaryExecutable(path string) bool { exeDir := filepath.Dir(path) if filepath.Base(exeDir) != "exe" { return false } actionDir := filepath.Dir(exeDir) action, ok := strings.CutPrefix(filepath.Base(actionDir), "b") if !ok || action == "" { return false } if _, err := strconv.ParseUint(action, 10, 64); err != nil { return false } nonce, ok := strings.CutPrefix(filepath.Base(filepath.Dir(actionDir)), "go-build") if !ok || nonce == "" { return false } _, err := strconv.ParseUint(nonce, 10, 64) return err == nil } // 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) (*RecoveryInvocation, 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 &RecoveryInvocation{Directory: moduleDir, Args: []string{"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 } // 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 } 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) } }() if err := os.WriteFile(filepath.Join(directory, "go.mod"), data, 0600); err != nil { return nil, err } if err := os.WriteFile(filepath.Join(directory, "go.sum"), sums, 0600); err != nil { 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: 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 &RecoveryInvocation{Directory: directory, Args: []string{"tool", "-modfile=", releaseKitCommand}}, nil }