From b5c6638538705dc3737aaddb70adf788996c54e1 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 4 Nov 2026 01:25:34 +0000 Subject: [PATCH] Preserve replacement selectors during recovery --- cmd/releasekit/publication_handoff_test.go | 55 ++++++++++++++++++---- docs/internal/gotool.md | 6 ++- docs/roadmap/remediation.md | 2 + internal/gotool/recovery.go | 8 ++-- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/cmd/releasekit/publication_handoff_test.go b/cmd/releasekit/publication_handoff_test.go index 6238ff0..2fcfa93 100644 --- a/cmd/releasekit/publication_handoff_test.go +++ b/cmd/releasekit/publication_handoff_test.go @@ -15,6 +15,8 @@ import ( "testing" "time" + "golang.org/x/mod/modfile" + "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/internal/gotool" "go.maximumdirect.net/releasekit/internal/publish" @@ -159,16 +161,25 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) { if err := os.Symlink(source, selectedSource); err != nil { t.Fatal(err) } - if !registered { - relative, err := filepath.Rel(repo, selectedSource) + const toolModule = "go.maximumdirect.net/releasekit" + const toolVersion = "v0.0.0" + module := "module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire " + toolModule + " " + toolVersion + "\n" + var wildcardSource string + if registered { + module += "\nreplace " + toolModule + " => " + strconv.Quote(selectedSource) + "\n" + module += "\ntool " + toolModule + "/cmd/releasekit\n" + } else { + wildcardSource = t.TempDir() + writeRepositoryFile(t, wildcardSource, "go.mod", "module "+toolModule+"\n\ngo 1.26.0\n") + wildcard, err := filepath.Rel(repo, wildcardSource) 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" + specific, err := filepath.Rel(repo, selectedSource) + if err != nil { + t.Fatal(err) + } + module += "\nreplace (\n" + toolModule + " => " + strconv.Quote(wildcard) + "\n" + toolModule + " " + toolVersion + " => " + strconv.Quote(specific) + "\n)\n" } writeRepositoryFile(t, repo, "go.mod", module) sums, err := os.ReadFile(filepath.Join(source, "go.sum")) @@ -251,8 +262,34 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) { 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) + if !registered { + store := filepath.Join(repo, ".git", "releasekit", "publication-recovery") + entries, err := os.ReadDir(store) + if err != nil || len(entries) != 1 { + t.Fatalf("API-only recovery modules = %v, %v", entries, err) + } + dir := filepath.Join(store, entries[0].Name()) + if !strings.Contains(command, shellQuote(dir)) { + t.Fatalf("API-only handoff lacks durable metadata: %s", command) + } + path := filepath.Join(dir, "go.mod") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + prepared, err := modfile.Parse(path, data, nil) + if err != nil { + t.Fatal(err) + } + paths := map[string]string{} + for _, replacement := range prepared.Replace { + if replacement.Old.Path == toolModule { + paths[replacement.Old.Version] = replacement.New.Path + } + } + if len(paths) != 2 || paths[""] != wildcardSource || paths[toolVersion] != selectedSource { + t.Fatalf("durable recovery lost replacement selection: %v", paths) + } } // Execute from another directory after the original driver and the // API-only launcher's temporary files are gone. diff --git a/docs/internal/gotool.md b/docs/internal/gotool.md index 2aeb03a..1a5cda6 100644 --- a/docs/internal/gotool.md +++ b/docs/internal/gotool.md @@ -12,8 +12,10 @@ For CLI publication recovery, the package selects a registered module tool or 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. +against their original module directory before relocation. Each relocation +updates the directive's destination and retains its path and version selector. +The original main module remains a local replacement, including when releasing +ReleaseKit itself. The private root provides tool resolution independently of consumer vendoring. API-only preparation discovers the host target for dependency inspection. The returned invocation carries its directory and tool arguments; the CLI's later diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 2fa3969..4f6edd3 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -172,6 +172,8 @@ and a new module-selection defect in the smoke recipe. ### Relocate recovery replacements in one pass without merging selectors +**Completed.** Recovery rewrites relative destinations in one pass. The CLI regression verifies both selectors, the selected tool, the rejected-push status, and later reconciliation. + **Evidence:** The API-only path in [`RecoveryTool`](../../internal/gotool/recovery.go) walks the parsed `go.mod` replacements. It calls `modfile.AddReplace` for each relative diff --git a/internal/gotool/recovery.go b/internal/gotool/recovery.go index ba60535..4f22bb8 100644 --- a/internal/gotool/recovery.go +++ b/internal/gotool/recovery.go @@ -100,9 +100,11 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) (*RecoveryIn // 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 - } + // A local replacement ends in its path token. Edit that token on + // the parsed directive so a wildcard does not absorb versioned ones. + replacement.New.Path = filepath.Join(moduleDir, replacement.New.Path) + tokens := replacement.Syntax.Token + tokens[len(tokens)-1] = modfile.AutoQuote(replacement.New.Path) } } // The original main module's source remains local if a dependency refers