From 6915a86132c8dac17e9708238354a46a067edf44 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 19:22:23 +0000 Subject: [PATCH] Add publication recovery handoff to CLI --- cmd/releasekit/internal/cli/config.go | 8 +- cmd/releasekit/internal/cli/config_test.go | 7 +- cmd/releasekit/main.go | 52 ++++++++ cmd/releasekit/main_test.go | 20 +++ cmd/releasekit/publication_handoff_test.go | 138 +++++++++++++++++++++ docs/roadmap/implementation.md | 2 + 6 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 cmd/releasekit/publication_handoff_test.go diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 1c1e020..b8732d0 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -17,7 +17,7 @@ import ( "go.maximumdirect.net/releasekit/internal/versioncontract" ) -const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG\n releasekit reconcile --record FILE [--json]\n releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]" +const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--record-dir DIR] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG\n releasekit reconcile --record FILE [--json]\n releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]" type Command string @@ -104,6 +104,10 @@ func Parse(args []string) (Invocation, error) { if action == PublishAction || action == CIDeliveryAction { flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping") } + recordDir := "" + if action == PublishAction { + flags.StringVar(&recordDir, "record-dir", "", "publication attempt-record directory") + } expectedCommit := "" prepared := false if action == CIDeliveryAction { @@ -141,7 +145,7 @@ func Parse(args []string) (Invocation, error) { if remote != nil { parsedRemote = *remote } - return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, nil + return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared, RecordDir: recordDir}, nil default: return Invocation{}, fmt.Errorf("unknown command %q", args[0]) } diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index b25521f..2c5cb38 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -53,13 +53,16 @@ func TestParse(t *testing.T) { if check.Command != CheckAction || check.Repository != "repository with spaces" || check.ConfigPath != "profiles/release config.json" || check.Version != "v1.2.3" { t.Fatalf("Parse(check) = %#v", check) } - publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "v1.2.4"}) + publish, err := Parse([]string{"publish", "--repo", "candidate", "--config", "releasekit.json", "--record-dir", "attempt records", "v1.2.4"}) if err != nil { t.Fatal(err) } - if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" { + if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.RecordDir != "attempt records" || publish.Version != "v1.2.4" { t.Fatalf("Parse(publish) = %#v", publish) } + if _, err := Parse([]string{"check", "--record-dir", "attempt records", "v1.2.4"}); err == nil { + t.Fatal("Parse(check) accepted the publication-only record directory") + } publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"}) if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" { t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err) diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 7e7df90..fb3c0b2 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -72,8 +72,10 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s return 2 } request.ExpectedPushURL = settings.ExpectedPushURL + 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) 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) { @@ -99,6 +101,56 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s return 0 } +func reportPublicationRecord(result releasekit.PublicationResult, publicationErr error, stderr io.Writer) { + if result.RecordError != nil { + fmt.Fprintf(stderr, "publication record warning: %s\n", git.DisplayError(result.RecordError)) + } + if result.RecordPath == "" { + return + } + fmt.Fprintf(stderr, "publication record saved: %s\n", result.RecordPath) + if publicationErr == nil || !publicationOffersRecovery(result.Phase) { + return + } + command, err := publicationRecoveryCommand(result.RecordPath) + 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", command) + fmt.Fprintln(stderr, "If this executable is unavailable, use your selected supported ReleaseKit CLI with the same record.") +} + +func publicationOffersRecovery(phase releasekit.PublicationPhase) bool { + switch phase { + case releasekit.PublicationTagCreation, releasekit.PublicationPush, releasekit.PublicationReconciliation: + return true + default: + return false + } +} + +func publicationRecoveryCommand(recordPath string) (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("find current executable: %w", err) + } + executable, err = filepath.Abs(executable) + if err != nil { + return "", fmt.Errorf("resolve current executable path: %w", err) + } + recordPath, err = filepath.Abs(recordPath) + if err != nil { + return "", fmt.Errorf("resolve publication record path: %w", err) + } + return shellQuote(executable) + " reconcile --record " + shellQuote(recordPath), nil +} + +func shellQuote(argument string) string { + return "'" + strings.ReplaceAll(argument, "'", "'\"'\"'") + "'" +} + func runBuild(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) if err != nil { diff --git a/cmd/releasekit/main_test.go b/cmd/releasekit/main_test.go index cf111a4..6732b2a 100644 --- a/cmd/releasekit/main_test.go +++ b/cmd/releasekit/main_test.go @@ -513,6 +513,26 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG" if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) { t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String()) } + const recordPrefix = "publication record saved: " + var recordPath string + for _, line := range strings.Split(stderr.String(), "\n") { + if strings.HasPrefix(line, recordPrefix) { + recordPath = strings.TrimPrefix(line, recordPrefix) + } + } + if !filepath.IsAbs(recordPath) { + t.Fatalf("default publication record path = %q, stderr = %q", recordPath, stderr.String()) + } + recorded, err := releasekit.LoadPublicationEvidence(recordPath) + if err != nil { + t.Fatalf("load default publication record: %v", err) + } + if recorded.Tag != "v1.2.3" || recorded.CandidateCommit != commit || recorded.TagObject != tagObject || recorded.Endpoint != remote { + t.Fatalf("default publication evidence = %#v", recorded) + } + if strings.Contains(stderr.String(), "recovery command:") { + t.Fatalf("successful publication prescribed a recovery command: %q", stderr.String()) + } if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject { t.Fatalf("remote tag object = %s, want %s", got, tagObject) } diff --git a/cmd/releasekit/publication_handoff_test.go b/cmd/releasekit/publication_handoff_test.go new file mode 100644 index 0000000..bcb698e --- /dev/null +++ b/cmd/releasekit/publication_handoff_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" + "go.maximumdirect.net/releasekit/internal/publish" +) + +func TestPublishRecordOverrideWarningDoesNotChangeSuccess(t *testing.T) { + repository, _ := newSimplePublishRepository(t, true) + var stdout, stderr bytes.Buffer + status := run(t.Context(), []string{"publish", "--repo", repository, "--record-dir", "record-blocker", "v1.2.3"}, &stdout, &stderr) + if status != 0 || !strings.Contains(stdout.String(), "published v1.2.3") { + t.Fatalf("status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "publication record warning:") || strings.Contains(stderr.String(), "publication record saved:") || strings.Contains(stderr.String(), "recovery command:") { + t.Fatalf("recording warning or success handoff output = %q", stderr.String()) + } +} + +func TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts(t *testing.T) { + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_TERMINAL_PROMPT", "0") + repository, remote := newSimplePublishRepository(t, false) + store := filepath.Join(t.TempDir(), "attempt records with ' apostrophe") + 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") + executable := filepath.Join(t.TempDir(), "releasekit's CLI") + build := exec.Command(goBinary, "build", "-o", executable, "./cmd/releasekit") + build.Dir = repositoryRoot(t) + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v\n%s", err, output) + } + + command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3") + command.Dir = repositoryRoot(t) + 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 !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{ + {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"}, + } { + if _, err := publish.SaveEvidence(repository, store, decoy); err != nil { + t.Fatal(err) + } + } + + recovery := exec.Command("/bin/sh", "-c", recoveryCommand) + recovery.Dir = t.TempDir() + recoveryOutput, recoveryErr := recovery.CombinedOutput() + if !errors.As(recoveryErr, &exitError) || exitError.ExitCode() != 1 { + t.Fatalf("recovery command exit = %v, output = %q; want known absence status 1", recoveryErr, 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") { + t.Fatalf("printed command selected the wrong attempt: %q", recoveryText) + } +} + +func TestShellQuoteHandlesApostrophesAndSpaces(t *testing.T) { + got := shellQuote("/tmp/ReleaseKit's recovery record.json") + if got != "'/tmp/ReleaseKit'\"'\"'s recovery record.json'" { + t.Fatalf("shellQuote() = %q", got) + } +} + +func newSimplePublishRepository(t *testing.T, recordBlocker bool) (string, string) { + t.Helper() + repository := newCLIRepository(t) + settings, err := json.Marshal(cli.Settings{Product: "Example Product", Remote: "origin"}) + if err != nil { + t.Fatal(err) + } + writeRepositoryFile(t, repository, "releasekit.json", string(settings)) + files := []string{"releasekit.json"} + if recordBlocker { + writeRepositoryFile(t, repository, "record-blocker", "tracked file\n") + files = append(files, "record-blocker") + } + cliGitRun(t, repository, append([]string{"add"}, files...)...) + cliGitRun(t, repository, "commit", "-m", "configure source release") + remote := filepath.Join(t.TempDir(), "origin.git") + cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote) + cliGitRun(t, repository, "remote", "add", "origin", remote) + cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") + return repository, remote +} + +func outputLineValue(output, prefix string) string { + for _, line := range strings.Split(output, "\n") { + if strings.HasPrefix(line, prefix) { + return strings.TrimPrefix(line, prefix) + } + } + return "" +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + directory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Clean(filepath.Join(directory, "..", "..")) +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 2b17529..05196d3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -435,6 +435,8 @@ and unavailable profile, with no candidate commands or hosted requests. ## Stage 7: Integrate publication's record override and recovery handoff +**Completed:** 2026-11-02 + **Outcome:** Existing publication commands expose saved evidence and recording failures without additional consumer release infrastructure.