Keep publication recovery usable after cold Go tool runs
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user