Add publication recovery handoff to CLI
This commit is contained in:
@@ -17,7 +17,7 @@ import (
|
|||||||
"go.maximumdirect.net/releasekit/internal/versioncontract"
|
"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
|
type Command string
|
||||||
|
|
||||||
@@ -104,6 +104,10 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
if action == PublishAction || action == CIDeliveryAction {
|
if action == PublishAction || action == CIDeliveryAction {
|
||||||
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
|
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 := ""
|
expectedCommit := ""
|
||||||
prepared := false
|
prepared := false
|
||||||
if action == CIDeliveryAction {
|
if action == CIDeliveryAction {
|
||||||
@@ -141,7 +145,7 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
if remote != nil {
|
if remote != nil {
|
||||||
parsedRemote = *remote
|
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:
|
default:
|
||||||
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" {
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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"})
|
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
|
||||||
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
|
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
|
||||||
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)
|
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
request.ExpectedPushURL = settings.ExpectedPushURL
|
request.ExpectedPushURL = settings.ExpectedPushURL
|
||||||
|
request.PublicationRecordDir = invocation.RecordDir
|
||||||
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
|
||||||
result, err := releasekit.Publish(ctx, request)
|
result, err := releasekit.Publish(ctx, request)
|
||||||
|
reportPublicationRecord(result, err, stderr)
|
||||||
if err != nil {
|
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))
|
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) {
|
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||||
@@ -99,6 +101,56 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s
|
|||||||
return 0
|
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 {
|
func runBuild(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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")) {
|
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())
|
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 {
|
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
|
||||||
t.Fatalf("remote tag object = %s, want %s", got, tagObject)
|
t.Fatalf("remote tag object = %s, want %s", got, tagObject)
|
||||||
}
|
}
|
||||||
|
|||||||
138
cmd/releasekit/publication_handoff_test.go
Normal file
138
cmd/releasekit/publication_handoff_test.go
Normal 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, "..", ".."))
|
||||||
|
}
|
||||||
@@ -435,6 +435,8 @@ and unavailable profile, with no candidate commands or hosted requests.
|
|||||||
|
|
||||||
## Stage 7: Integrate publication's record override and recovery handoff
|
## Stage 7: Integrate publication's record override and recovery handoff
|
||||||
|
|
||||||
|
**Completed:** 2026-11-02
|
||||||
|
|
||||||
**Outcome:** Existing publication commands expose saved evidence and recording
|
**Outcome:** Existing publication commands expose saved evidence and recording
|
||||||
failures without additional consumer release infrastructure.
|
failures without additional consumer release infrastructure.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user