Run API-only recovery independently of consumer vendoring

This commit is contained in:
2026-11-03 00:05:32 +00:00
parent 16d1c072e3
commit cd08a2892a
6 changed files with 165 additions and 24 deletions

View File

@@ -14,9 +14,106 @@ import (
"testing"
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/releasekit/internal/publish"
)
func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture executes POSIX recovery commands")
}
repo, remote := newSimplePublishRepository(t, false)
source := repositoryRoot(t)
relative, err := filepath.Rel(repo, source)
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.mod", "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(relative)+"\n")
writeRepositoryFile(t, repo, "uses_releasekit.go", "package candidate\nimport _ \"go.maximumdirect.net/releasekit\"\n")
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.sum", string(sums))
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
for _, args := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
command := exec.Command(goBinary, args...)
command.Dir = repo
if out, err := command.CombinedOutput(); err != nil {
t.Fatalf("prepare vendored API: %v: %s", err, out)
}
}
cliGitRun(t, repo, "add", ".")
cliGitRun(t, repo, "commit", "-m", "Vendor API dependency")
cliGitRun(t, repo, "push", "origin", "main")
before := cliGitOutput(t, repo, "rev-parse", "HEAD^{tree}")
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
var out, diagnostics bytes.Buffer
if status := run(t.Context(), []string{"publish", "--repo", repo, "v1.2.3"}, &out, &diagnostics); status != 1 {
t.Fatalf("publication status=%d: %s", status, &diagnostics)
}
command := outputLineValue(diagnostics.String(), "recovery command: ")
if command == "" {
t.Fatalf("no handoff: %s", &diagnostics)
}
recovery := exec.Command("sh", "-c", command)
recovery.Dir = t.TempDir()
recovered, err := recovery.CombinedOutput()
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 || !strings.Contains(string(recovered), "module=example.com/cli-candidate tag=v1.2.3 state=not_published") {
t.Fatalf("vendored recovery: %v: %s", err, recovered)
}
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("handoff changed consumer files: %s", status)
}
if after := cliGitOutput(t, repo, "write-tree"); after != before {
t.Fatalf("handoff changed consumer index: %s != %s", after, before)
}
}
func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) {
proxy := writeReleaseKitModuleProxy(t)
environment := make(map[string]string)
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
for key, value := range environment {
t.Setenv(key, value)
}
module := t.TempDir()
data := "module example.com/recovery-consumer\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n\nreplace go.maximumdirect.net/releasekit v0.2.197 => go.maximumdirect.net/releasekit v0.2.198\n"
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil {
t.Fatal(err)
}
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()
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)
}
after, err := os.ReadFile(filepath.Join(module, "go.mod"))
if err != nil || string(after) != data {
t.Fatalf("consumer metadata changed: %q, %v", after, err)
}
}
func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) {
args, err := gotool.RecoveryTool(t.Context(), repositoryRoot(t), t.TempDir())
if err != nil {
t.Fatal(err)
}
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...)
command.Dir = t.TempDir()
output, err := command.CombinedOutput()
if err != nil || !strings.HasPrefix(string(output), "releasekit ") {
t.Fatalf("source module recovery entry point: %v: %s", err, output)
}
}
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fixture observes the cold executable through /proc")

View File

@@ -137,13 +137,16 @@ 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
For API-only consumers, ReleaseKit retains private bootstrap module and checksum files
under `<Git common directory>/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
prepare its module graph without changing consumer source. The bootstrap has
its own module root, so tool resolution is independent of consumer vendoring;
local replacements retain their original source locations. These files survive
a launcher's temporary module-file cleanup. Registered-tool commands run from
the selected module; API-only commands run from the private bootstrap root.
Retain the source checkout and 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.

View File

@@ -9,9 +9,12 @@ Standalone and delivery builds create their own owners. The package uses
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.
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.
The private root provides tool resolution independently of consumer vendoring.
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)

View File

@@ -454,13 +454,20 @@ failures without additional consumer release infrastructure.
command selecting that exact path. Partial records can explain missing
evidence. Earlier failures still report their saved path when available.
Successful publication reports the path without prescribing a recovery step.
4. Build the printed command from the running executable's absolute path and the
absolute record path, quoting each argument for the POSIX-shell runbooks.
This works for a directly built CLI and a Go-tool-launched cached executable
without assuming `releasekit` is on PATH or that the consumer registered a
tool directive. Quote apostrophes safely; print the command without executing
a shell. Explain in recovery guidance that operators can use their selected
supported CLI if the original executable is no longer available.
4. Prefer the consumer module's registered Go tool for the printed command.
API-only consumers use durable private module/checksum files under Git
metadata, with a separate bootstrap module root so consumer vendoring does
not govern tool resolution. Retain the selected requirements, checksums,
versioned replacements, and original locations of local replacements.
Consumers without a selected ReleaseKit dependency use the native executable
path. Select the absolute record path explicitly and quote every argument
for the POSIX-shell runbooks, including apostrophes. The command survives a
cold Go-tool executable's removal and launcher-owned metadata cleanup.
Explain how to use another selected supported CLI if an entry point becomes
unavailable.
**R14 follow-up completed:** API-only recovery uses an independent private module
root. R15 tracks cancellation and preparation-failure handling separately.
**Verification:** Extend existing publication CLI tests for the default, override,
recording warning with successful status, and original failed/unknown status.
@@ -468,6 +475,10 @@ Use a built CLI in an assembled test to execute the printed recovery command
and verify it selects the original record among retries/modules. Test shell
argument quoting with synthetic paths. Keep the existing module-selected Go-tool
launchers and source bootstrap working without adding consumer-owned recording.
Include an API-only consumer that vendors the library without declaring the CLI
tool; execute its handoff and verify source/vendor contents remain unchanged.
Exercise relative local replacements, versioned replacements, and ReleaseKit's
own source-module recovery entry point.
## Stage 8: Finish assembled coverage and canonical documentation
@@ -482,6 +493,8 @@ contracts and a short consumer recovery workflow.
coordinate cancellation/interruption without arbitrary sleeps. Remove or move
the local tag and change branch, HEAD, remote URLs, or transport settings
before later recovery. Cover root/nested tags and original-record selection.
Retain the assembled vendored API-only handoff coverage added for R14,
alongside cold registered-tool and temporary-launcher recovery.
2. Establish read-only behavior through resulting refs, worktree/branch state,
saved records, and controlled observations of candidate/hosted boundaries.
Reuse lower-level classification and codec tests. Tests use local remotes,

View File

@@ -604,6 +604,11 @@ correctness for these additional combinations.
## R14: Support recovery for vendored API-only consumers — P2
**Completed:** API-only recovery uses a private module root with preserved module
settings and relocated local replacements. Vendored API-only, relative/versioned
replacement, cold Go-tool, native handoff, and source-module entry-point tests
passed. Stage 7/8 instructions and consumer/internal guidance are updated.
**Relevant code:** `internal/gotool/recovery.go:95–107`; the command assembly in
`cmd/releasekit/main.go:126–145`. Affects Stage 7 and its Stage 8 coverage.

View File

@@ -19,7 +19,7 @@ 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.
// receive a private module root that outlives launcher-owned bootstraps.
func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) {
path := filepath.Join(moduleDir, "go.mod")
data, err := os.ReadFile(path)
@@ -64,6 +64,28 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e
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
@@ -85,24 +107,22 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e
_ = os.RemoveAll(directory)
}
}()
alternate := filepath.Join(directory, "releasekit.mod")
if err := os.WriteFile(alternate, data, 0600); err != nil {
if err := os.WriteFile(filepath.Join(directory, "go.mod"), data, 0600); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(directory, "releasekit.sum"), sums, 0600); err != nil {
if err := os.WriteFile(filepath.Join(directory, "go.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.
// Prepare the private graph and sums before the later 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"},
Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand},
Dir: directory, 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
return []string{"-C", directory, "tool", "-modfile=", releaseKitCommand}, nil
}