Add publication recovery handoff to CLI

This commit is contained in:
2026-11-02 19:22:23 +00:00
parent 4afdd9b826
commit 6915a86132
6 changed files with 223 additions and 4 deletions

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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, "..", ".."))
}