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
}

View File

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

View File

@@ -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 `<Git common directory>/releasekit/publication-recovery/`, with private
directory and file permissions. These files add the CLI tool declaration and

View File

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

View File

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

View File

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

View File

@@ -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,
// <work parent>/go-build<nonce>/b<action>/exe/<name>. 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.

View File

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