Preserve replacement selectors during recovery

This commit is contained in:
2026-11-04 01:25:34 +00:00
parent ec67496569
commit b5c6638538
4 changed files with 57 additions and 14 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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