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)
|
||||
}
|
||||
|
||||
12
docs/cli.md
12
docs/cli.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
108
internal/gotool/recovery.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user