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