Keep recovery handoffs available after publication cancellation
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit"
|
"go.maximumdirect.net/releasekit"
|
||||||
"go.maximumdirect.net/releasekit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
@@ -113,11 +114,19 @@ func reportPublicationRecord(ctx context.Context, result releasekit.PublicationR
|
|||||||
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "recovery command unavailable: %s\n", git.DisplayError(err))
|
fmt.Fprintf(stderr, "recovery tool preparation warning: %s\n", git.DisplayError(err))
|
||||||
fmt.Fprintln(stderr, "Use your selected supported ReleaseKit CLI with this record if the original executable is unavailable.")
|
command, err = publicationRecoveryCommand(result.RecordPath)
|
||||||
return
|
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.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.")
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve current executable path: %w", err)
|
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.
|
// Redact before path cleaning can change a pasted URL's recognizable syntax.
|
||||||
recordPath, err = filepath.Abs(git.DisplayDiagnostic(recordPath))
|
recordPath, err = filepath.Abs(git.DisplayDiagnostic(recordPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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/gotool"
|
||||||
@@ -118,8 +120,9 @@ 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")
|
||||||
}
|
}
|
||||||
for _, registered := range []bool{true, false} {
|
for _, mode := range []struct{ registered, canceled bool }{{true, false}, {false, false}, {true, true}, {false, true}} {
|
||||||
t.Run(fmt.Sprintf("registered=%t", registered), func(t *testing.T) {
|
registered := mode.registered
|
||||||
|
t.Run(fmt.Sprintf("registered=%t/canceled=%t", registered, mode.canceled), func(t *testing.T) {
|
||||||
repo, remote := newSimplePublishRepository(t, false)
|
repo, remote := newSimplePublishRepository(t, false)
|
||||||
source := repositoryRoot(t)
|
source := repositoryRoot(t)
|
||||||
// A distinct replacement identity gives this tool a fresh link action
|
// A distinct replacement identity gives this tool a fresh link action
|
||||||
@@ -177,7 +180,7 @@ func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
bin := t.TempDir()
|
bin := t.TempDir()
|
||||||
executed := filepath.Join(t.TempDir(), "executed-tool")
|
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 {
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
|
||||||
t.Fatal(err)
|
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 = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3")
|
||||||
}
|
}
|
||||||
launch.Dir = repo
|
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
|
var exitError *exec.ExitError
|
||||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
||||||
t.Fatalf("publication: %v: %s", err, out)
|
t.Fatalf("publication: %v: %s", err, out)
|
||||||
@@ -273,65 +281,102 @@ func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) {
|
func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) {
|
||||||
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
for _, mode := range []string{"rejected", "canceled", "preparation failure"} {
|
||||||
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
t.Run(mode, func(t *testing.T) {
|
||||||
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
repository, remote := newSimplePublishRepository(t, false)
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||||
store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe")
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
hook := filepath.Join(remote, "hooks", "pre-receive")
|
repository, remote := newSimplePublishRepository(t, false)
|
||||||
if err := os.WriteFile(hook, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
|
store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe")
|
||||||
t.Fatal(err)
|
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")
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
executable := filepath.Join(t.TempDir(), "releasekit's CLI")
|
executable := filepath.Join(t.TempDir(), "releasekit's CLI")
|
||||||
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
|
build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit")
|
||||||
build.Dir = repositoryRoot(t)
|
build.Dir = repositoryRoot(t)
|
||||||
if output, err := build.CombinedOutput(); err != nil {
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
t.Fatalf("build CLI: %v\n%s", err, output)
|
t.Fatalf("build CLI: %v\n%s", err, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3")
|
if mode == "canceled" {
|
||||||
command.Dir = repositoryRoot(t)
|
realGit, err := exec.LookPath("git")
|
||||||
output, err := command.CombinedOutput()
|
if err != nil {
|
||||||
var exitError *exec.ExitError
|
t.Fatal(err)
|
||||||
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
}
|
||||||
t.Fatalf("publication exit = %v, output = %q; want status 1", err, output)
|
bin := t.TempDir()
|
||||||
}
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\n"+cancelPushScript+"exec "+shellQuote(realGit)+" \"$@\"\n"), 0700); err != nil {
|
||||||
text := string(output)
|
t.Fatal(err)
|
||||||
if !strings.Contains(text, "publication state=not_published phase=reconciliation") {
|
}
|
||||||
t.Fatalf("publication failure classification changed: %q", text)
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
}
|
}
|
||||||
recordPath := outputLineValue(text, "publication record saved: ")
|
if mode == "preparation failure" {
|
||||||
if !filepath.IsAbs(recordPath) || !strings.Contains(recordPath, "attempt records with ' apostrophe") {
|
realGo, err := exec.LookPath("go")
|
||||||
t.Fatalf("saved record path = %q, output = %q", recordPath, text)
|
if err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
recoveryCommand := outputLineValue(text, "recovery command: ")
|
}
|
||||||
if recoveryCommand == "" || !strings.Contains(recoveryCommand, shellQuote(executable)) || !strings.Contains(recoveryCommand, shellQuote(recordPath)) {
|
bin := t.TempDir()
|
||||||
t.Fatalf("copyable recovery command = %q, output = %q", recoveryCommand, text)
|
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 {
|
||||||
if !strings.Contains(text, "selected supported ReleaseKit CLI") {
|
t.Fatal(err)
|
||||||
t.Fatalf("output omits guidance for an unavailable original executable: %q", text)
|
}
|
||||||
}
|
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{
|
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/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"},
|
{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 {
|
if _, err := publish.SaveEvidence(t.Context(), repository, store, decoy); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recovery := exec.Command("/bin/sh", "-c", recoveryCommand)
|
recovery := exec.Command("/bin/sh", "-c", recoveryCommand)
|
||||||
recovery.Dir = t.TempDir()
|
recovery.Dir = t.TempDir()
|
||||||
recoveryOutput, recoveryErr := recovery.CombinedOutput()
|
recoveryOutput, recoveryErr := recovery.CombinedOutput()
|
||||||
if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 {
|
if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 {
|
||||||
t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput)
|
t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, recoveryOutput)
|
||||||
}
|
}
|
||||||
recoveryText := string(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") {
|
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)
|
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, "..", ".."))
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
a Go-tool invocation with durable private module files; see
|
||||||
[recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence).
|
[recovery storage](consumers/publish.md#retain-and-reconcile-publication-evidence).
|
||||||
Consumers without a module-selected ReleaseKit dependency use the running
|
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
|
If the selected entry point becomes unavailable, use another supported ReleaseKit
|
||||||
CLI with the saved record.
|
CLI with the saved record.
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ 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.
|
||||||
|
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
|
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
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ requirements, versioned replacements, and checksums. Local replacements resolve
|
|||||||
against their original module directory before relocation. The original main
|
against their original module directory before relocation. The original main
|
||||||
module remains a local replacement, including when releasing ReleaseKit itself.
|
module remains a local replacement, including when releasing ReleaseKit itself.
|
||||||
The private root provides tool resolution independently of consumer vendoring.
|
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 using a separate bounded context after
|
||||||
for consumers without a selected ReleaseKit module. The
|
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)
|
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
owns recovery-file placement and retention.
|
owns recovery-file placement and retention.
|
||||||
|
|
||||||
|
|||||||
@@ -465,9 +465,15 @@ failures without additional consumer release infrastructure.
|
|||||||
cold Go-tool executable's removal and launcher-owned metadata cleanup.
|
cold Go-tool executable's removal and launcher-owned metadata cleanup.
|
||||||
Explain how to use another selected supported CLI if an entry point becomes
|
Explain how to use another selected supported CLI if an entry point becomes
|
||||||
unavailable.
|
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
|
**R14/R15 follow-ups completed:** API-only recovery uses an independent private
|
||||||
root. R15 tracks cancellation and preparation-failure handling separately.
|
module root; post-failure preparation handles cancellation and native fallback.
|
||||||
|
|
||||||
**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.
|
||||||
@@ -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.
|
tool; execute its handoff and verify source/vendor contents remain unchanged.
|
||||||
Exercise relative local replacements, versioned replacements, and ReleaseKit's
|
Exercise relative local replacements, versioned replacements, and ReleaseKit's
|
||||||
own source-module recovery entry point.
|
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
|
## 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.
|
before later recovery. Cover root/nested tags and original-record selection.
|
||||||
Retain the assembled vendored API-only handoff coverage added for R14,
|
Retain the assembled vendored API-only handoff coverage added for R14,
|
||||||
alongside cold registered-tool and temporary-launcher recovery.
|
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,
|
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,
|
||||||
|
|||||||
@@ -648,6 +648,13 @@ Retain existing cold-build, registered/vendored-tool, quoting, and status cases.
|
|||||||
|
|
||||||
## R15: Preserve the recovery handoff after publication cancellation — P2
|
## 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
|
**Relevant code:** `cmd/releasekit/main.go:116–134`, especially the caller context
|
||||||
passed to `git.CommonDirectory`. Affects Stage 7 and its Stage 8 coverage.
|
passed to `git.CommonDirectory`. Affects Stage 7 and its Stage 8 coverage.
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/mod/modfile"
|
"golang.org/x/mod/modfile"
|
||||||
|
|
||||||
@@ -17,6 +19,30 @@ import (
|
|||||||
const releaseKitModule = "go.maximumdirect.net/releasekit"
|
const releaseKitModule = "go.maximumdirect.net/releasekit"
|
||||||
const releaseKitCommand = releaseKitModule + "/cmd/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.
|
// 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 a private module root that outlives launcher-owned bootstraps.
|
// receive a private module root that outlives launcher-owned bootstraps.
|
||||||
|
|||||||
22
internal/gotool/recovery_test.go
Normal file
22
internal/gotool/recovery_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user