diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 77052f5..6462201 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "syscall" + "time" "go.maximumdirect.net/releasekit" "go.maximumdirect.net/releasekit/buildinfo" @@ -113,11 +114,19 @@ func reportPublicationRecord(ctx context.Context, result releasekit.PublicationR if publicationErr == nil || !publicationOffersRecovery(result.Phase) { return } - command, err := selectedPublicationRecoveryCommand(ctx, result) + // Handoff preparation follows the failed publication and has its own bounded + // lifetime, including when publication's context was canceled. + handoffContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + command, err := selectedPublicationRecoveryCommand(handoffContext, result) if err != nil { - fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err)) - fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.") - return + fmt.Fprintf(stderr, "recovery tool preparation warning: %s\n", git.DisplayError(err)) + command, err = publicationRecoveryCommand(result.RecordPath) + if err != nil { + fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err)) + fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.") + return + } } fmt.Fprintf(stderr, "recovery command: %s\n", git.DisplayDiagnostic(command)) fmt.Fprintln(stderr, "If this entry point is unavailable, use your selected supported ReleaseKit CLI with the same record.") @@ -166,6 +175,9 @@ func publicationRecoveryCommand(recordPath string) (string, error) { if err != nil { return "", fmt.Errorf("resolve current executable path: %w", err) } + if gotool.TemporaryExecutable(executable) { + return "", fmt.Errorf("current executable belongs to a temporary Go build; select a supported ReleaseKit CLI for the saved record") + } // Redact before path cleaning can change a pasted URL's recognizable syntax. recordPath, err = filepath.Abs(git.DisplayDiagnostic(recordPath)) if err != nil { diff --git a/cmd/releasekit/publication_handoff_test.go b/cmd/releasekit/publication_handoff_test.go index d815ca0..3cfeaba 100644 --- a/cmd/releasekit/publication_handoff_test.go +++ b/cmd/releasekit/publication_handoff_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -12,6 +13,7 @@ import ( "strconv" "strings" "testing" + "time" "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" "go.maximumdirect.net/releasekit/internal/gotool" @@ -118,8 +120,9 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("fixture observes the cold executable through /proc") } - for _, registered := range []bool{true, false} { - t.Run(fmt.Sprintf("registered=%t", registered), func(t *testing.T) { + for _, mode := range []struct{ registered, canceled bool }{{true, false}, {false, false}, {true, true}, {false, true}} { + registered := mode.registered + t.Run(fmt.Sprintf("registered=%t/canceled=%t", registered, mode.canceled), func(t *testing.T) { repo, remote := newSimplePublishRepository(t, false) source := repositoryRoot(t) // A distinct replacement identity gives this tool a fresh link action @@ -177,7 +180,7 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) { } bin := t.TempDir() executed := filepath.Join(t.TempDir(), "executed-tool") - wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\nexec " + shellQuote(realGit) + " \"$@\"\n" + wrapper := "#!/bin/sh\nparent=$(readlink \"/proc/$PPID/exe\")\ncase \"$parent\" in */exe/releasekit) printf '%s' \"$parent\" > " + shellQuote(executed) + ";; esac\n" + cancelPushScript + "exec " + shellQuote(realGit) + " \"$@\"\n" if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil { t.Fatal(err) } @@ -189,7 +192,12 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) { launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3") } launch.Dir = repo - out, err := launch.CombinedOutput() + var out []byte + if mode.canceled { + out, err = canceledPublicationOutput(t, launch, repo, "") + } else { + out, err = launch.CombinedOutput() + } var exitError *exec.ExitError if !errors.As(err, &exitError) || exitError.ExitCode() != 1 { t.Fatalf("publication: %v: %s", err, out) @@ -273,65 +281,102 @@ func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) { } func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) { - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") - t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) - t.Setenv("GIT_TERMINAL_PROMPT", "0") - repository, remote := newSimplePublishRepository(t, false) - store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe") - hook := filepath.Join(remote, "hooks", "pre-receive") - if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { - t.Fatal(err) - } + for _, mode := range []string{"rejected", "canceled", "preparation failure"} { + t.Run(mode, func(t *testing.T) { + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_TERMINAL_PROMPT", "0") + repository, remote := newSimplePublishRepository(t, false) + store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe") + hook := filepath.Join(remote, "hooks", "pre-receive") + if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { + t.Fatal(err) + } - goBinary := filepath.Join(runtime.GOROOT(), "bin", "go") - executable := filepath.Join(t.TempDir(), "releasekit's CLI") - build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit") - build.Dir = repositoryRoot(t) - if output, err := build.CombinedOutput(); err != nil { - t.Fatalf("build CLI: %v\n%s", err, output) - } + goBinary := filepath.Join(runtime.GOROOT(), "bin", "go") + executable := filepath.Join(t.TempDir(), "releasekit's CLI") + build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit") + build.Dir = repositoryRoot(t) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v\n%s", err, output) + } - command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3") - command.Dir = repositoryRoot(t) - output, err := command.CombinedOutput() - var exitError *exec.ExitError - if !errors.As(err, &exitError) || exitError.ExitCode() != 1 { - t.Fatalf("publication exit = %v, output = %q; want status 1", err, output) - } - text := string(output) - if !strings.Contains(text, "publication state=not_published phase=reconciliation") { - t.Fatalf("publication failure classification changed: %q", text) - } - recordPath := outputLineValue(text, "publication record saved: ") - if !filepath.IsAbs(recordPath) || !strings.Contains(recordPath, "attempt records with ' apostrophe") { - t.Fatalf("saved record path = %q, output = %q", recordPath, text) - } - recoveryCommand := outputLineValue(text, "recovery command: ") - if recoveryCommand == "" || !strings.Contains(recoveryCommand, shellQuote(executable)) || !strings.Contains(recoveryCommand, shellQuote(recordPath)) { - t.Fatalf("copyable recovery command = %q, output = %q", recoveryCommand, text) - } - if !strings.Contains(text, "selected supported ReleaseKit CLI") { - t.Fatalf("output omits guidance for an unavailable original executable: %q", text) - } + if mode == "canceled" { + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\n"+cancelPushScript+"exec "+shellQuote(realGit)+" \"$@\"\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + } + if mode == "preparation failure" { + realGo, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + script := "#!/bin/sh\nif [ \"$1\" = list ] && [ \"$2\" = -e ]; then exit 73; fi\nexec " + shellQuote(realGo) + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + } + command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3") + command.Dir = repositoryRoot(t) + var output []byte + var err error + if mode == "canceled" { + output, err = canceledPublicationOutput(t, command, repository, store) + } else { + output, err = command.CombinedOutput() + } + var exitError *exec.ExitError + if !errors.As(err, &exitError) || exitError.ExitCode() != 1 { + t.Fatalf("publication exit = %v, output = %q; want status 1", err, output) + } + text := string(output) + if mode == "preparation failure" && !strings.Contains(text, "recovery tool preparation warning:") { + t.Fatalf("preparation warning missing: %s", text) + } + if !strings.Contains(text, "publication state=not_published phase=reconciliation") { + t.Fatalf("publication failure classification changed: %q", text) + } + recordPath := outputLineValue(text, "publication record saved: ") + if !filepath.IsAbs(recordPath) || !strings.Contains(recordPath, "attempt records with ' apostrophe") { + t.Fatalf("saved record path = %q, output = %q", recordPath, text) + } + recoveryCommand := outputLineValue(text, "recovery command: ") + if recoveryCommand == "" || !strings.Contains(recoveryCommand, shellQuote(executable)) || !strings.Contains(recoveryCommand, shellQuote(recordPath)) { + t.Fatalf("copyable recovery command = %q, output = %q", recoveryCommand, text) + } + if !strings.Contains(text, "selected supported ReleaseKit CLI") { + t.Fatalf("output omits guidance for an unavailable original executable: %q", text) + } - for _, decoy := range []publish.Evidence{ - {RepositoryDir: repository, ModulePath: "example.com/other-module", Version: "v8.8.8", Tag: "v8.8.8", Endpoint: remote, Remote: "origin"}, - {RepositoryDir: repository, ModulePath: "example.com/retry", Version: "v1.2.4", Tag: "v1.2.4", Endpoint: remote, Remote: "origin"}, - } { - if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil { - t.Fatal(err) - } - } + for _, decoy := range []publish.Evidence{ + {RepositoryDir: repository, ModulePath: "example.com/other-module", Version: "v8.8.8", Tag: "v8.8.8", Endpoint: remote, Remote: "origin"}, + {RepositoryDir: repository, ModulePath: "example.com/retry", Version: "v1.2.4", Tag: "v1.2.4", Endpoint: remote, Remote: "origin"}, + } { + if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil { + t.Fatal(err) + } + } - recovery := exec.Command("/bin/sh", "-c", recoveryCommand) - recovery.Dir = t.TempDir() - recoveryOutput, recoveryErr := recovery.CombinedOutput() - if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 { - t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput) - } - recoveryText := string(recoveryOutput) - if !strings.Contains(recoveryText, "module=example.com/cli-candidate tag=v1.2.3 state=not_published") || strings.Contains(recoveryText, "example.com/other-module") || strings.Contains(recoveryText, "example.com/retry") { - t.Fatalf("printed command selected the wrong attempt: %q", recoveryText) + recovery := exec.Command("/bin/sh", "-c", recoveryCommand) + recovery.Dir = t.TempDir() + recoveryOutput, recoveryErr := recovery.CombinedOutput() + if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 { + t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput) + } + recoveryText := string(recoveryOutput) + if !strings.Contains(recoveryText, "module=example.com/cli-candidate tag=v1.2.3 state=not_published") || strings.Contains(recoveryText, "example.com/other-module") || strings.Contains(recoveryText, "example.com/retry") { + t.Fatalf("printed command selected the wrong attempt: %q", recoveryText) + } + + }) } } @@ -381,3 +426,84 @@ func repositoryRoot(t *testing.T) string { } return filepath.Clean(filepath.Join(directory, "..", "..")) } + +// The shim blocks exactly when Git would start the recorded tag push. The CLI +// PID lets the test cancel the actual operation even behind a Go driver/launcher. +const cancelPushScript = `if [ -n "$RELEASEKIT_CANCEL_PUSH_READY" ]; then + for arg in "$@"; do + if [ "$arg" = push ]; then + printf '%s' "$PPID" > "$RELEASEKIT_CANCEL_PUSH_READY" + exec sleep 600 + fi + done +fi +` + +func canceledPublicationOutput(t *testing.T, command *exec.Cmd, repo, store string) ([]byte, error) { + t.Helper() + ready := filepath.Join(t.TempDir(), "push-ready") + command.Env = append(os.Environ(), "RELEASEKIT_CANCEL_PUSH_READY="+ready) + var output bytes.Buffer + command.Stdout = &output + command.Stderr = &output + if err := command.Start(); err != nil { + t.Fatal(err) + } + defer command.Process.Kill() + done := make(chan error, 1) + go func() { done <- command.Wait() }() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + var operation *os.Process +waiting: + for { + select { + case err := <-done: + t.Fatalf("publication finished before push interception: %v: %s", err, &output) + case <-ctx.Done(): + _ = command.Process.Kill() + <-done + t.Fatal("publication never reached push interception") + case <-ticker.C: + data, err := os.ReadFile(ready) + if err != nil { + continue + } + pid, err := strconv.Atoi(string(data)) + if err != nil { + continue + } + operation, err = os.FindProcess(pid) + if err != nil { + t.Fatal(err) + } + defer operation.Release() + break waiting + } + } + // Check the handoff identity before allowing cancellation to reach publication. + listed, err := publish.ListAttempts(t.Context(), repo, store) + if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].Evidence.TagObject == "" { + _ = operation.Signal(os.Interrupt) + <-done + t.Fatalf("pre-push record is incomplete: %#v, %v", listed, err) + } + if err := operation.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if !strings.Contains(output.String(), "context canceled") { + t.Fatalf("publication lost its cancellation diagnostic: %s", &output) + } + return output.Bytes(), err + case <-ctx.Done(): + _ = operation.Kill() + _ = command.Process.Kill() + <-done + t.Fatal("canceled publication did not return") + } + return nil, nil +} diff --git a/docs/cli.md b/docs/cli.md index 906e791..a7935db 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -149,7 +149,12 @@ consumer module's registered Go tool when available. API-only consumers receive a Go-tool invocation with durable private module files; see [recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence). Consumers without a module-selected ReleaseKit dependency use the running -executable's absolute path. Each entry point preserves the recovery exit status. +executable's absolute path. A module-preparation failure reports a warning and +uses a persistent native executable when available. Recognized temporary Go-build +executables provide no native fallback after their driver exits. Command +preparation has its own bounded context after a failed publication, so publication +cancellation permits preparing the later invocation. Each entry point preserves +the recovery exit status. If the selected entry point becomes unavailable, use another supported ReleaseKit CLI with the saved record. diff --git a/docs/consumers/publish.md b/docs/consumers/publish.md index 5e3a7d7..bd12e23 100644 --- a/docs/consumers/publish.md +++ b/docs/consumers/publish.md @@ -137,6 +137,10 @@ 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. +Command preparation uses a separate bounded context after publication failure, +including cancellation. A module-preparation warning can accompany a persistent +native executable fallback; the saved record and publication result retain their +original meaning. For API-only consumers, ReleaseKit retains private bootstrap module and checksum files under `/releasekit/publication-recovery/`, with private directory and file permissions. These files add the CLI tool declaration and diff --git a/docs/internal/gotool.md b/docs/internal/gotool.md index 2190d55..6850297 100644 --- a/docs/internal/gotool.md +++ b/docs/internal/gotool.md @@ -15,8 +15,10 @@ 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 +The CLI formats the later invocation using a separate bounded context after +publication failure. It falls back to a persistent native executable when module +selection is unavailable or preparation fails, and excludes recognized temporary +Go-work executables from that fallback. The [publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence) owns recovery-file placement and retention. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e1983b4..9943975 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -465,9 +465,15 @@ failures without additional consumer release infrastructure. 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. +5. Prepare the command after publication using a separate bounded context so + publication cancellation leaves handoff construction available. Report + module-preparation failures separately and retain a persistent native + executable fallback. Recognized temporary Go-build executables provide no + durable native fallback. Keep publication's original status/error and + immediate reconciliation context unchanged. -**R14 follow-up completed:** API-only recovery uses an independent private module -root. R15 tracks cancellation and preparation-failure handling separately. +**R14/R15 follow-ups completed:** API-only recovery uses an independent private +module root; post-failure preparation handles cancellation and native fallback. **Verification:** Extend existing publication CLI tests for the default, override, recording warning with successful status, and original failed/unknown status. @@ -479,6 +485,10 @@ 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. +Coordinate cancellation at the recorded push boundary for native, cold +registered-tool, and temporary-launcher invocations. Execute the resulting +handoff after driver/launcher cleanup and verify retained publication diagnostics +and recovery statuses. Exercise native fallback after module-preparation failure. ## Stage 8: Finish assembled coverage and canonical documentation @@ -495,6 +505,8 @@ contracts and a short consumer recovery workflow. 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. + R15 adds canceled publication and preparation-failure handoff cases using + explicit push readiness and fresh later invocations. 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, diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 5b04735..c02db3b 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -648,6 +648,13 @@ Retain existing cold-build, registered/vendored-tool, quoting, and status cases. ## R15: Preserve the recovery handoff after publication cancellation — P2 +**Completed:** Post-failure handoff preparation has a separate bounded context +and retains a persistent native fallback after module-preparation failure. +Coordinated native, cold registered-tool, and temporary-launcher cancellation +tests passed, including execution of the printed command after cleanup and +retained cancellation diagnostics/statuses. Temporary Go-work executable +classification is covered, and Stage 7/8 plus canonical guidance is updated. + **Relevant code:** `cmd/releasekit/main.go:116–134`, especially the caller context passed to `git.CommonDirectory`. Affects Stage 7 and its Stage 8 coverage. diff --git a/internal/gotool/recovery.go b/internal/gotool/recovery.go index f70fd99..bf85083 100644 --- a/internal/gotool/recovery.go +++ b/internal/gotool/recovery.go @@ -8,6 +8,8 @@ import ( "io" "os" "path/filepath" + "strconv" + "strings" "golang.org/x/mod/modfile" @@ -17,6 +19,30 @@ import ( const releaseKitModule = "go.maximumdirect.net/releasekit" const releaseKitCommand = releaseKitModule + "/cmd/releasekit" +// TemporaryExecutable recognizes executables in Go's temporary build layout, +// /go-build/b/exe/. Those paths disappear when +// the Go driver finishes and cannot provide a later native-command fallback. +func TemporaryExecutable(path string) bool { + exeDir := filepath.Dir(path) + if filepath.Base(exeDir) != "exe" { + return false + } + actionDir := filepath.Dir(exeDir) + action, ok := strings.CutPrefix(filepath.Base(actionDir), "b") + if !ok || action == "" { + return false + } + if _, err := strconv.ParseUint(action, 10, 64); err != nil { + return false + } + nonce, ok := strings.CutPrefix(filepath.Base(filepath.Dir(actionDir)), "go-build") + if !ok || nonce == "" { + return false + } + _, err := strconv.ParseUint(nonce, 10, 64) + return err == nil +} + // RecoveryTool returns module-selected Go arguments for a later invocation. // An empty result means this module does not select ReleaseKit. API-only modules // receive a private module root that outlives launcher-owned bootstraps. diff --git a/internal/gotool/recovery_test.go b/internal/gotool/recovery_test.go new file mode 100644 index 0000000..c3b457e --- /dev/null +++ b/internal/gotool/recovery_test.go @@ -0,0 +1,22 @@ +package gotool + +import ( + "path/filepath" + "testing" +) + +func TestRecoveryFallbackExecutableLifetime(t *testing.T) { + for _, test := range []struct { + path string + temporary bool + }{ + {filepath.Join("tmp", "go-build123456", "b001", "exe", "releasekit"), true}, + {filepath.Join("cache", "go-build", "hash-d", "releasekit"), false}, + {filepath.Join("tmp", "tools", "releasekit"), false}, + {filepath.Join("workspace", "go-build-project", "b001", "exe", "releasekit"), false}, + } { + if got := TemporaryExecutable(test.path); got != test.temporary { + t.Errorf("temporary executable %q = %t, want %t", test.path, got, test.temporary) + } + } +}