Keep publication recovery usable after cold Go tool runs

This commit is contained in:
2026-11-02 22:32:58 +00:00
parent 19a14bfeb6
commit 993866c1d7
8 changed files with 323 additions and 9 deletions

View File

@@ -16,6 +16,7 @@ import (
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
"go.maximumdirect.net/releasekit/internal/artifact"
"go.maximumdirect.net/releasekit/internal/git"
"go.maximumdirect.net/releasekit/internal/gotool"
"go.maximumdirect.net/releasekit/internal/releaseidentity"
)
@@ -75,7 +76,7 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s
request.PublicationRecordDir = invocation.RecordDir
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
result, err := releasekit.Publish(ctx, request)
reportPublicationRecord(result, err, stderr)
reportPublicationRecord(ctx, result, err, stderr)
if err != nil {
fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, git.DisplayURL(result.Endpoint), candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) {
@@ -101,7 +102,7 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s
return 0
}
func reportPublicationRecord(result releasekit.PublicationResult, publicationErr error, stderr io.Writer) {
func reportPublicationRecord(ctx context.Context, result releasekit.PublicationResult, publicationErr error, stderr io.Writer) {
if result.RecordError != nil {
fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError))
}
@@ -112,14 +113,39 @@ func reportPublicationRecord(result releasekit.PublicationResult, publicationErr
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
return
}
command, err := publicationRecoveryCommand(result.RecordPath)
command, err := selectedPublicationRecoveryCommand(ctx, 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 command: %s\n", git.DisplayDiagnostic(command))
fmt.Fprintln(stderr, "If this executable 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.")
}
func selectedPublicationRecoveryCommand(ctx context.Context, result releasekit.PublicationResult) (string, error) {
if result.Evidence.ModuleDir != "" {
common, err := git.CommonDirectory(ctx, result.RepositoryDir)
if err != nil {
return "", err
}
args, err := gotool.RecoveryTool(ctx, result.Evidence.ModuleDir, filepath.Join(common, "releasekit", "publication-recovery"))
if err != nil {
return "", err
}
if len(args) > 0 {
command := "env GOWORK=off GOTOOLCHAIN=local go"
for _, arg := range args {
command += " " + shellQuote(git.DisplayDiagnostic(arg))
}
path, err := filepath.Abs(git.DisplayDiagnostic(result.RecordPath))
if err != nil {
return "", err
}
return command + " reconcile --record " + shellQuote(path), nil
}
}
return publicationRecoveryCommand(result.RecordPath)
}
func publicationOffersRecovery(phase releasekit.PublicationPhase) bool {

View File

@@ -4,10 +4,12 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
@@ -15,6 +17,152 @@ import (
"go.maximumdirect.net/releasekit/internal/publish"
)
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) {
repo, remote := newSimplePublishRepository(t, false)
source := repositoryRoot(t)
// A distinct replacement identity gives this tool a fresh link action
// while retaining the shared compilation cache for its dependencies.
selectedSource := filepath.Join(t.TempDir(), "ReleaseKit source")
if err := os.Symlink(source, selectedSource); err != nil {
t.Fatal(err)
}
if !registered {
relative, err := filepath.Rel(repo, selectedSource)
if err != nil {
t.Fatal(err)
}
selectedSource = relative
}
module := "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(selectedSource) + "\n"
if registered {
module += "\ntool go.maximumdirect.net/releasekit/cmd/releasekit\n"
}
writeRepositoryFile(t, repo, "go.mod", module)
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repo, "go.sum", string(sums))
if !registered {
for _, name := range []string{"release.sh", "select-module.sh"} {
data, err := os.ReadFile(filepath.Join(source, "examples", "multi-module", name))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, name), data, 0700); err != nil {
t.Fatal(err)
}
}
writeRepositoryFile(t, repo, "release-modules.tsv", "root\tv*\t.\treleasekit.json\n")
}
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
if registered {
prepare := exec.Command(goBinary, "list", "-mod=mod", "-deps", "go.maximumdirect.net/releasekit/cmd/releasekit")
prepare.Dir = repo
if out, err := prepare.CombinedOutput(); err != nil {
t.Fatalf("prepare tool: %v: %s", err, out)
}
}
cliGitRun(t, repo, "add", ".")
cliGitRun(t, repo, "commit", "-m", "Select release tool")
cliGitRun(t, repo, "push", "origin", "main")
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
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"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
var launch *exec.Cmd
if registered {
launch = exec.Command(goBinary, "tool", "releasekit", "publish", "--repo", repo, "v1.2.3")
} else {
launch = exec.Command(filepath.Join(repo, "release.sh"), "root", "v1.2.3")
}
launch.Dir = repo
out, err := launch.CombinedOutput()
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
t.Fatalf("publication: %v: %s", err, out)
}
text := string(out)
launchedPath, err := os.ReadFile(executed)
if err != nil {
t.Fatalf("did not exercise a cold temporary executable: %v: %s", err, text)
}
if _, err := os.Stat(string(launchedPath)); !os.IsNotExist(err) {
t.Fatalf("cold executable still exists: %s: %v", launchedPath, err)
}
entries, err := os.ReadDir(filepath.Dir(repo))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
t.Fatalf("launcher bootstrap survived: %s", entry.Name())
}
}
command := outputLineValue(text, "recovery command: ")
if !strings.Contains(command, "go ") || strings.Contains(command, "/exe/releasekit") {
t.Fatalf("unstable handoff: %s", command)
}
if !registered && !strings.Contains(command, "publication-recovery") {
t.Fatalf("API-only handoff lacks durable metadata: %s", command)
}
// Execute from another directory after the original driver and the
// API-only launcher's temporary files are gone.
evidence, err := publish.LoadEvidence(outputLineValue(text, "publication record saved: "))
if err != nil {
t.Fatal(err)
}
evidence.Endpoint = filepath.Join(t.TempDir(), "unavailable.git")
unknownRecord, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
if err != nil {
t.Fatal(err)
}
for _, scenario := range []struct {
record string
status int
}{{"", 1}, {filepath.Join(t.TempDir(), "missing.json"), 2}, {unknownRecord, 3}, {"", 0}} {
if scenario.status == 0 {
if err := os.Remove(filepath.Join(remote, "hooks", "pre-receive")); err != nil {
t.Fatal(err)
}
cliGitRun(t, repo, "push", "origin", "refs/tags/v1.2.3")
}
selected := command
if scenario.record != "" {
selected += " --record " + shellQuote(scenario.record)
}
recovery := exec.Command("sh", "-c", selected)
recovery.Dir = t.TempDir()
recovered, err := recovery.CombinedOutput()
if scenario.status == 0 && err != nil || scenario.status != 0 && (!errors.As(err, &exitError) || exitError.ExitCode() != scenario.status) {
t.Fatalf("recovery status want %d: %v: %s", scenario.status, err, recovered)
}
if scenario.status == 1 && !strings.Contains(string(recovered), "state=not_published") {
t.Fatalf("wrong observation: %s", recovered)
}
}
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
t.Fatalf("handoff modified candidate: %s", status)
}
})
}
}
func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) {
repository, _ := newSimplePublishRepository(t, true)
var stdout, stderr bytes.Buffer

View File

@@ -91,7 +91,7 @@ func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
}
var out bytes.Buffer
reportPublicationRecord(releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
reportPublicationRecord(t.Context(), releasekit.PublicationResult{RecordPath: raw, Phase: releasekit.PublicationPush}, fmt.Errorf("push failed"), &out)
if strings.Contains(out.String(), secret) {
t.Fatalf("handoff leaked credentials: %s", &out)
}

View File

@@ -144,10 +144,14 @@ relative override is resolved from the source repository root. Standard error
reports the absolute saved record path or a recording warning separately from
the publication result. Recording failure does not change publication status.
When publication fails and a record was saved, the CLI also prints a copyable
`reconcile --record` command for that exact record. The command uses the running
executable's absolute path, so it remains usable when the executable is not on
`PATH`; if that executable is no longer available, use another supported
ReleaseKit CLI.
`reconcile --record` command for that exact record. The command selects the
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.
If the selected entry point becomes unavailable, use another supported ReleaseKit
CLI with the saved record.
The command returns status 0 for confirmed publication, 1 for a known
publication failure or conflict, 2 for usage, profile, or invalid-version

View File

@@ -135,6 +135,18 @@ directories privately and creates record files with owner-only permissions;
existing directory permissions are left as they are. The caller owns storage
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.
For API-only consumers, ReleaseKit retains alternate 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
prepare its module graph without changing consumer source. They survive a
launcher's temporary module-file cleanup. The printed command runs Go from the
selected module directory; retain that checkout and the 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
independently of these launch files.
The saved endpoint is retained exactly for later observation and may include
credentials. Treat records, `PublicationEvidence`, and `PublicationResult` as
sensitive. Store overrides in a location with suitable access controls, avoid

View File

@@ -8,6 +8,15 @@ Standalone and delivery builds create their own owners. The package uses
`internal/process` for cancellation, the working directory, output, and wrapped
errors.
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
graph query includes transitive dependencies; preparation retains local module
selection and uses the original module directory for relative replacements.
The CLI formats the later invocation and falls back to its native executable
for consumers without a selected ReleaseKit module. The
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
owns recovery-file placement and retention.
## Command output ownership
`Tool.Run` executes a status-only command. It forwards combined stdout and

View File

@@ -249,6 +249,13 @@ remain open and are preserved rather than duplicated here.
## R07: Make the recovery handoff survive a cold Go-tool invocation — P2
**Completed:** CLI recovery prefers the selected module's Go tool. API-only
consumers receive privately retained alternate module/checksum files under Git
metadata; native consumers retain the executable-path fallback. Assembled tests
passed after cold executables disappeared and launcher files were removed,
covering both adoption styles, relative replacements, clean candidates, and
recovery exit statuses 0–3. CLI, consumer, and internal guidance is updated.
**Relevant code:** `cmd/releasekit/main.go:134` (`publicationRecoveryCommand`);
`examples/multi-module/release.sh`; `docs/consumers/adoption.md`'s module-selected
Go-tool workflow; `cmd/releasekit/publication_handoff_test.go`.

108
internal/gotool/recovery.go Normal file
View File

@@ -0,0 +1,108 @@
package gotool
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"golang.org/x/mod/modfile"
"go.maximumdirect.net/releasekit/internal/process"
)
const releaseKitModule = "go.maximumdirect.net/releasekit"
const releaseKitCommand = releaseKitModule + "/cmd/releasekit"
// RecoveryTool returns module-selected Go arguments for a later invocation.
// An empty result means this module does not select ReleaseKit. API-only modules
// receive private alternate module files that outlive launcher-owned bootstraps.
func RecoveryTool(ctx context.Context, moduleDir, recordDir string) ([]string, error) {
path := filepath.Join(moduleDir, "go.mod")
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
module, err := modfile.Parse(path, data, nil)
if err != nil {
return nil, err
}
for _, tool := range module.Tool {
if tool.Path == releaseKitCommand {
return []string{"-C", moduleDir, "tool", "-modfile=", releaseKitCommand}, nil
}
}
// Ask Go about the selected module graph, including transitive dependencies.
// This reads real module metadata, independently of a launcher's -modfile.
var selection bytes.Buffer
err = process.Run(ctx, process.Invocation{
Args: []string{"go", "list", "-e", "-json", "-mod=readonly", "-modfile=", "-m", releaseKitModule},
Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
Stdout: &selection, Stderr: io.Discard,
})
if err != nil {
return nil, fmt.Errorf("identify recovery tool selection: %w", err)
}
var selected struct {
Main bool
Version string
Error *struct{ Err string }
}
if err := json.Unmarshal(selection.Bytes(), &selected); err != nil {
return nil, err
}
if !selected.Main && selected.Version == "" {
// A source-built CLI also supports consumers without a module dependency.
return nil, nil
}
if selected.Error != nil {
return nil, fmt.Errorf("identify recovery tool selection: %s", selected.Error.Err)
}
if err := module.AddTool(releaseKitCommand); err != nil {
return nil, err
}
data, err = module.Format()
if err != nil {
return nil, err
}
sums, err := os.ReadFile(filepath.Join(moduleDir, "go.sum"))
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if err := os.MkdirAll(recordDir, 0700); err != nil {
return nil, err
}
directory, err := os.MkdirTemp(recordDir, "recovery-")
if err != nil {
return nil, fmt.Errorf("create durable recovery module directory: %w", err)
}
complete := false
defer func() {
if !complete {
_ = os.RemoveAll(directory)
}
}()
alternate := filepath.Join(directory, "releasekit.mod")
if err := os.WriteFile(alternate, data, 0600); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(directory, "releasekit.sum"), sums, 0600); err != nil {
return nil, err
}
// go tool accepts an alternate module file but has no -mod flag. Prepare
// the private graph and sums before the later read-only tool invocation.
captured := &combinedWriter{mode: captureStatus}
err = process.Run(ctx, process.Invocation{
Args: []string{"go", "list", "-mod=mod", "-modfile=" + alternate, "-deps", releaseKitCommand},
Dir: moduleDir, Env: map[string]string{"GOWORK": "off", "GOTOOLCHAIN": "local"},
Stdout: captured, Stderr: captured,
})
if err != nil {
return nil, fmt.Errorf("prepare recovery tool: %w: %s", err, diagnosticText(captured.take(), captured.omitted()))
}
complete = true
return []string{"-C", moduleDir, "tool", "-modfile=" + alternate, releaseKitCommand}, nil
}