Run API-only recovery independently of consumer vendoring
This commit is contained in:
@@ -14,9 +14,106 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"go.maximumdirect.net/releasekit/internal/publish"
|
"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) {
|
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
|
||||||
if runtime.GOOS != "linux" {
|
if runtime.GOOS != "linux" {
|
||||||
t.Skip("fixture observes the cold executable through /proc")
|
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
|
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.
|
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
|
under `<Git common directory>/releasekit/publication-recovery/`, with private
|
||||||
directory and file permissions. These files add the CLI tool declaration and
|
directory and file permissions. These files add the CLI tool declaration and
|
||||||
prepare its module graph without changing consumer source. They survive a
|
prepare its module graph without changing consumer source. The bootstrap has
|
||||||
launcher's temporary module-file cleanup. The printed command runs Go from the
|
its own module root, so tool resolution is independent of consumer vendoring;
|
||||||
selected module directory; retain that checkout and the private recovery files
|
local replacements retain their original source locations. These files survive
|
||||||
while the entry point is needed. Remove the private recovery directories when
|
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
|
their recovery value expires. Another supported CLI can read the saved record
|
||||||
independently of these launch files.
|
independently of these launch files.
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ Standalone and delivery builds create their own owners. The package uses
|
|||||||
errors.
|
errors.
|
||||||
|
|
||||||
For CLI publication recovery, the package selects a registered module tool or
|
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
|
prepares a durable private module root for an API-only consumer. Go's module
|
||||||
graph query includes transitive dependencies; preparation retains local module
|
graph query includes transitive dependencies; preparation retains module
|
||||||
selection and uses the original module directory for relative replacements.
|
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
|
The CLI formats the later invocation and falls back to its native executable
|
||||||
for consumers without a selected ReleaseKit module. The
|
for consumers without a selected ReleaseKit module. The
|
||||||
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
|
[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
|
command selecting that exact path. Partial records can explain missing
|
||||||
evidence. Earlier failures still report their saved path when available.
|
evidence. Earlier failures still report their saved path when available.
|
||||||
Successful publication reports the path without prescribing a recovery step.
|
Successful publication reports the path without prescribing a recovery step.
|
||||||
4. Build the printed command from the running executable's absolute path and the
|
4. Prefer the consumer module's registered Go tool for the printed command.
|
||||||
absolute record path, quoting each argument for the POSIX-shell runbooks.
|
API-only consumers use durable private module/checksum files under Git
|
||||||
This works for a directly built CLI and a Go-tool-launched cached executable
|
metadata, with a separate bootstrap module root so consumer vendoring does
|
||||||
without assuming `releasekit` is on PATH or that the consumer registered a
|
not govern tool resolution. Retain the selected requirements, checksums,
|
||||||
tool directive. Quote apostrophes safely; print the command without executing
|
versioned replacements, and original locations of local replacements.
|
||||||
a shell. Explain in recovery guidance that operators can use their selected
|
Consumers without a selected ReleaseKit dependency use the native executable
|
||||||
supported CLI if the original executable is no longer available.
|
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,
|
**Verification:** Extend existing publication CLI tests for the default, override,
|
||||||
recording warning with successful status, and original failed/unknown status.
|
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
|
and verify it selects the original record among retries/modules. Test shell
|
||||||
argument quoting with synthetic paths. Keep the existing module-selected Go-tool
|
argument quoting with synthetic paths. Keep the existing module-selected Go-tool
|
||||||
launchers and source bootstrap working without adding consumer-owned recording.
|
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
|
## 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
|
coordinate cancellation/interruption without arbitrary sleeps. Remove or move
|
||||||
the local tag and change branch, HEAD, remote URLs, or transport settings
|
the local tag and change branch, HEAD, remote URLs, or transport settings
|
||||||
before later recovery. Cover root/nested tags and original-record selection.
|
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,
|
2. Establish read-only behavior through resulting refs, worktree/branch state,
|
||||||
saved records, and controlled observations of candidate/hosted boundaries.
|
saved records, and controlled observations of candidate/hosted boundaries.
|
||||||
Reuse lower-level classification and codec tests. Tests use local remotes,
|
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
|
## 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
|
**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.
|
`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.
|
// RecoveryTool returns module-selected Go arguments for a later invocation.
|
||||||
// An empty result means this module does not select ReleaseKit. API-only modules
|
// 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) {
|
func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) {
|
||||||
path := filepath.Join(moduleDir, "go.mod")
|
path := filepath.Join(moduleDir, "go.mod")
|
||||||
data, err := os.ReadFile(path)
|
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 {
|
if err := module.AddTool(releaseKitCommand); err != nil {
|
||||||
return nil, err
|
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()
|
data, err = module.Format()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -85,24 +107,22 @@ func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, e
|
|||||||
_ = os.RemoveAll(directory)
|
_ = os.RemoveAll(directory)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
alternate := filepath.Join(directory, "releasekit.mod")
|
if err := os.WriteFile(filepath.Join(directory, "go.mod"), data, 0600); err != nil {
|
||||||
if err := os.WriteFile(alternate, data, 0600); err != nil {
|
|
||||||
return nil, err
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
// go tool accepts an alternate module file but has no -mod flag. Prepare
|
// Prepare the private graph and sums before the later tool invocation.
|
||||||
// the private graph and sums before the later read-only tool invocation.
|
|
||||||
captured := &combinedWriter{mode: captureStatus}
|
captured := &combinedWriter{mode: captureStatus}
|
||||||
err = process.Run(ctx, process.Invocation{
|
err = process.Run(ctx, process.Invocation{
|
||||||
Args: []string{"go", "list", "-mod=mod", "-modfile=" + alternate, "-deps", releaseKitCommand},
|
Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand},
|
||||||
Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
|
Dir: directory, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
|
||||||
Stdout: captured, Stderr: captured,
|
Stdout: captured, Stderr: captured,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted()))
|
return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted()))
|
||||||
}
|
}
|
||||||
complete = true
|
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