Keep recovery handoffs available after publication cancellation

This commit is contained in:
2026-11-03 00:12:30 +00:00
parent cd08a2892a
commit dfaa652098
9 changed files with 284 additions and 68 deletions

View File

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

View File

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