Run API-only recovery independently of consumer vendoring
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user