Add publication recovery handoff to CLI
This commit is contained in:
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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, "..", ".."))
|
||||
}
|
||||
Reference in New Issue
Block a user