Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23203f235c | |||
| 37d6209aec | |||
| ee72566263 | |||
| e0019abe31 | |||
| 2a7bed87db | |||
| dfaa652098 | |||
| cd08a2892a | |||
| 16d1c072e3 | |||
| 363f7cd513 | |||
| cd274512e7 | |||
| 3b8bacf114 | |||
| 781341a0ba | |||
| 109596bc3b | |||
| 6fc140d8dc | |||
| 993866c1d7 | |||
| 19a14bfeb6 | |||
| 648c87bb8a | |||
| 04db23705a | |||
| 9a7b9b9301 | |||
| d2b1329095 | |||
| 0d19a5546f | |||
| da1839c6d2 | |||
| 957e75524c | |||
| 0ef135bfda | |||
| 3280f734ef | |||
| 599ad81bec | |||
| 6915a86132 | |||
| 4afdd9b826 | |||
| fb5947ea3a | |||
| 2a04938a96 | |||
| b11c6d1788 | |||
| 1ddeb2c6dc | |||
| 4dff8086ab | |||
| 447b42a1cd | |||
| 58656dea45 | |||
| 58c20fa863 | |||
| f73f0c1b6a | |||
| bff4ed8a8b | |||
| e0bf5ca57a | |||
| 691926d839 |
5
check.go
5
check.go
@@ -46,6 +46,11 @@ type ReleaseRequest struct {
|
|||||||
// this string exactly before validation or tag creation. Check ignores this
|
// this string exactly before validation or tag creation. Check ignores this
|
||||||
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
||||||
ExpectedPushURL string
|
ExpectedPushURL string
|
||||||
|
// PublicationRecordDir optionally overrides Publish's private attempt-record
|
||||||
|
// directory. Relative paths are resolved from the source repository root;
|
||||||
|
// Check ignores this publication-only setting. Recording failure is returned
|
||||||
|
// separately on PublicationResult and does not change publication status.
|
||||||
|
PublicationRecordDir string
|
||||||
// Validation selects optional checks run against the captured candidate.
|
// Validation selects optional checks run against the captured candidate.
|
||||||
Validation ValidationSpec
|
Validation ValidationSpec
|
||||||
// AdditionalChecks optionally runs product checks after shared validation.
|
// AdditionalChecks optionally runs product checks after shared validation.
|
||||||
|
|||||||
@@ -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"
|
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
|
||||||
|
|
||||||
@@ -28,6 +28,8 @@ const (
|
|||||||
BuildAction Command = "build"
|
BuildAction Command = "build"
|
||||||
DeliveryAction Command = "deliver"
|
DeliveryAction Command = "deliver"
|
||||||
CIDeliveryAction Command = "ci-deliver"
|
CIDeliveryAction Command = "ci-deliver"
|
||||||
|
ReconcileAction Command = "reconcile"
|
||||||
|
PublicationAttemptsAction Command = "publication-attempts"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Invocation contains parsed command settings owned by the CLI adapter.
|
// Invocation contains parsed command settings owned by the CLI adapter.
|
||||||
@@ -43,6 +45,9 @@ type Invocation struct {
|
|||||||
ExpectedModule string
|
ExpectedModule string
|
||||||
ExpectedTag string
|
ExpectedTag string
|
||||||
Prepared bool
|
Prepared bool
|
||||||
|
RecordPath string
|
||||||
|
RecordDir string
|
||||||
|
JSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse accepts only commands that have been implemented by the executable.
|
// Parse accepts only commands that have been implemented by the executable.
|
||||||
@@ -51,6 +56,30 @@ func Parse(args []string) (Invocation, error) {
|
|||||||
return Invocation{}, errors.New("a command is required")
|
return Invocation{}, errors.New("a command is required")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
|
case string(ReconcileAction), string(PublicationAttemptsAction):
|
||||||
|
action := Command(args[0])
|
||||||
|
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
|
||||||
|
flags.SetOutput(io.Discard)
|
||||||
|
record := ""
|
||||||
|
recordDir := ""
|
||||||
|
repository := "."
|
||||||
|
if action == ReconcileAction {
|
||||||
|
flags.StringVar(&record, "record", "", "attempt record file")
|
||||||
|
} else {
|
||||||
|
flags.StringVar(&repository, "repo", ".", "repository directory")
|
||||||
|
flags.StringVar(&recordDir, "record-dir", "", "attempt-record directory")
|
||||||
|
}
|
||||||
|
structured := flags.Bool("json", false, "write structured JSON")
|
||||||
|
if err := flags.Parse(args[1:]); err != nil {
|
||||||
|
return Invocation{}, err
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 {
|
||||||
|
return Invocation{}, fmt.Errorf("%s takes no positional arguments", action)
|
||||||
|
}
|
||||||
|
if action == ReconcileAction && record == "" {
|
||||||
|
return Invocation{}, errors.New("reconcile requires --record FILE")
|
||||||
|
}
|
||||||
|
return Invocation{Command: action, Repository: repository, RecordPath: record, RecordDir: recordDir, JSON: *structured}, nil
|
||||||
case string(VersionCommand):
|
case string(VersionCommand):
|
||||||
if len(args) != 1 {
|
if len(args) != 1 {
|
||||||
return Invocation{}, errors.New("version takes no arguments")
|
return Invocation{}, errors.New("version takes no arguments")
|
||||||
@@ -75,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 {
|
||||||
@@ -112,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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,33 @@ func TestParse(t *testing.T) {
|
|||||||
if invocation.Command != VersionCommand {
|
if invocation.Command != VersionCommand {
|
||||||
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
|
t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand)
|
||||||
}
|
}
|
||||||
|
reconcile, err := Parse([]string{"reconcile", "--record", "records/release record's.json", "--json"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reconcile.Command != ReconcileAction || reconcile.RecordPath != "records/release record's.json" || !reconcile.JSON {
|
||||||
|
t.Fatalf("Parse(reconcile) = %#v", reconcile)
|
||||||
|
}
|
||||||
|
attempts, err := Parse([]string{"publication-attempts", "--repo", "repository with spaces", "--record-dir", "records with spaces", "--json"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if attempts.Command != PublicationAttemptsAction || attempts.Repository != "repository with spaces" || attempts.RecordDir != "records with spaces" || !attempts.JSON {
|
||||||
|
t.Fatalf("Parse(publication-attempts) = %#v", attempts)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"reconcile"},
|
||||||
|
{"reconcile", "record.json"},
|
||||||
|
{"reconcile", "--record", "record.json", "--repo", "."},
|
||||||
|
{"reconcile", "--record", "record.json", "--record-dir", "records"},
|
||||||
|
{"reconcile", "--record", "record.json", "VERSION"},
|
||||||
|
{"publication-attempts", "--record", "record.json"},
|
||||||
|
{"publication-attempts", "VERSION"},
|
||||||
|
} {
|
||||||
|
if _, err := Parse(args); err == nil {
|
||||||
|
t.Errorf("Parse(%q) succeeded, want a usage error", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
|
check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -26,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)
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.maximumdirect.net/releasekit"
|
"go.maximumdirect.net/releasekit"
|
||||||
"go.maximumdirect.net/releasekit/buildinfo"
|
"go.maximumdirect.net/releasekit/buildinfo"
|
||||||
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
"go.maximumdirect.net/releasekit/internal/artifact"
|
"go.maximumdirect.net/releasekit/internal/artifact"
|
||||||
"go.maximumdirect.net/releasekit/internal/git"
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
"go.maximumdirect.net/releasekit/internal/releaseidentity"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +34,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
|
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if invocation.Command == cli.ReconcileAction || invocation.Command == cli.PublicationAttemptsAction {
|
||||||
|
return runRecovery(ctx, invocation, stdout, stderr)
|
||||||
|
}
|
||||||
if invocation.Command != cli.VersionCommand {
|
if invocation.Command != cli.VersionCommand {
|
||||||
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
|
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,8 +74,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(ctx, 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) {
|
||||||
@@ -96,6 +103,93 @@ func runCheckOrPublish(ctx context.Context, invocation cli.Invocation, stdout, s
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
if result.RecordPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "publication record saved: %s\n", git.DisplayDiagnostic(result.RecordPath))
|
||||||
|
if publicationErr == nil || !publicationOffersRecovery(result.Phase) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Handoff preparation follows the failed publication and has its own bounded
|
||||||
|
// lifetime, including when publication's context was canceled.
|
||||||
|
handoffContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
command, err := selectedPublicationRecoveryCommand(handoffContext, result)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "recovery tool preparation warning: %s\n", git.DisplayError(err))
|
||||||
|
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", git.DisplayDiagnostic(command))
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if gotool.TemporaryExecutable(executable) {
|
||||||
|
return "", fmt.Errorf("current executable belongs to a temporary Go build; select a supported ReleaseKit CLI for the saved record")
|
||||||
|
}
|
||||||
|
// Redact before path cleaning can change a pasted URL's recognizable syntax.
|
||||||
|
recordPath, err = filepath.Abs(git.DisplayDiagnostic(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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -80,20 +82,13 @@ func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
|
|||||||
set -eu
|
set -eu
|
||||||
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
|
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
|
||||||
modfile=
|
modfile=
|
||||||
output=
|
|
||||||
previous=
|
|
||||||
for argument in "$@"; do
|
for argument in "$@"; do
|
||||||
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
||||||
if [ "$previous" = -o ]; then output=$argument; fi
|
|
||||||
previous=$argument
|
|
||||||
done
|
done
|
||||||
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
|
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
|
||||||
if [ "$1" = build ]; then
|
if [ "$1" = tool ]; then
|
||||||
cat > "$output" <<'TOOL'
|
shift 3
|
||||||
#!/bin/sh
|
|
||||||
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
|
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
|
||||||
TOOL
|
|
||||||
chmod +x "$output"
|
|
||||||
fi
|
fi
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
|
||||||
@@ -134,7 +129,7 @@ fi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
func TestMultiModuleLauncherRunsToolFromSelectedDependency(t *testing.T) {
|
||||||
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
copyExampleTree(t, example, repository)
|
copyExampleTree(t, example, repository)
|
||||||
@@ -161,11 +156,20 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
for _, status := range []int{0, 1, 2, 3} {
|
||||||
|
t.Run(strconv.Itoa(status), func(t *testing.T) {
|
||||||
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
|
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
|
||||||
|
command.Env = setEnvironment(os.Environ(), "RELEASEKIT_TOOL_EXIT", strconv.Itoa(status))
|
||||||
output, err := command.CombinedOutput()
|
output, err := command.CombinedOutput()
|
||||||
if err != nil {
|
if status == 0 && err != nil {
|
||||||
t.Fatalf("launcher error: %v output=%q", err, output)
|
t.Fatalf("launcher error: %v output=%q", err, output)
|
||||||
}
|
}
|
||||||
|
if status != 0 {
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != status {
|
||||||
|
t.Fatalf("launcher error = %v, want exit %d; output=%q", err, status, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, expected := range []string{
|
for _, expected := range []string{
|
||||||
"tool-version=v0.2.198 helper=tool-only dependency",
|
"tool-version=v0.2.198 helper=tool-only dependency",
|
||||||
`"publish"`, `"--repo"`, `"` + repository + `"`,
|
`"publish"`, `"--repo"`, `"` + repository + `"`,
|
||||||
@@ -177,12 +181,26 @@ func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(string(output), "tool-version=v0.2.197") {
|
if strings.Contains(string(output), "tool-version=v0.2.197") {
|
||||||
t.Fatalf("launcher built the root-selected ReleaseKit version: %s", output)
|
t.Fatalf("launcher ran the root-selected ReleaseKit version: %s", output)
|
||||||
}
|
}
|
||||||
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
|
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
|
||||||
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
|
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
|
||||||
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
|
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
|
||||||
}
|
}
|
||||||
|
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||||
|
t.Fatalf("launcher changed source: %q", status)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repository))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
|
||||||
|
t.Fatalf("temporary bootstrap directory remains: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string {
|
func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string {
|
||||||
|
|||||||
520
cmd/releasekit/publication_handoff_test.go
Normal file
520
cmd/releasekit/publication_handoff_test.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/gotool"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVendoredAPIOnlyPublicationRecovery(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fixture executes POSIX recovery commands")
|
||||||
|
}
|
||||||
|
repo, remote := newSimplePublishRepository(t, false)
|
||||||
|
source := repositoryRoot(t)
|
||||||
|
relative, err := filepath.Rel(repo, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.mod", "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(relative)+"\n")
|
||||||
|
writeRepositoryFile(t, repo, "uses_releasekit.go", "package candidate\nimport _ \"go.maximumdirect.net/releasekit\"\n")
|
||||||
|
sums, err := os.ReadFile(filepath.Join(source, "go.sum"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repo, "go.sum", string(sums))
|
||||||
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
|
for _, args := range [][]string{{"mod", "tidy"}, {"mod", "vendor"}} {
|
||||||
|
command := exec.Command(goBinary, args...)
|
||||||
|
command.Dir = repo
|
||||||
|
if out, err := command.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("prepare vendored API: %v: %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cliGitRun(t, repo, "add", ".")
|
||||||
|
cliGitRun(t, repo, "commit", "-m", "Vendor API dependency")
|
||||||
|
cliGitRun(t, repo, "push", "origin", "main")
|
||||||
|
before := cliGitOutput(t, repo, "rev-parse", "HEAD^{tree}")
|
||||||
|
if err := os.WriteFile(filepath.Join(remote, "hooks", "pre-receive"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
launcherStore := t.TempDir()
|
||||||
|
launcherArgs, err := gotool.RecoveryTool(t.Context(), repo, launcherStore)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
launcher := exec.Command(goBinary, append(launcherArgs, "publish", "--repo", repo, "v1.2.3")...)
|
||||||
|
launcher.Dir = t.TempDir()
|
||||||
|
publicationOutput, err := launcher.CombinedOutput()
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("vendored publication: %v: %s", err, publicationOutput)
|
||||||
|
}
|
||||||
|
// The later handoff owns different launch metadata from the original driver.
|
||||||
|
if err := os.RemoveAll(launcherStore); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := outputLineValue(string(publicationOutput), "recovery command: ")
|
||||||
|
if command == "" {
|
||||||
|
t.Fatalf("no handoff: %s", publicationOutput)
|
||||||
|
}
|
||||||
|
recovery := exec.Command("sh", "-c", command)
|
||||||
|
recovery.Dir = t.TempDir()
|
||||||
|
recovered, err := recovery.CombinedOutput()
|
||||||
|
if !errors.As(err, &exitError) || exitError.ExitCode() != 1 || !strings.Contains(string(recovered), "module=example.com/cli-candidate tag=v1.2.3 state=not_published") {
|
||||||
|
t.Fatalf("vendored recovery: %v: %s", err, recovered)
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repo, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("handoff changed consumer files: %s", status)
|
||||||
|
}
|
||||||
|
if after := cliGitOutput(t, repo, "write-tree"); after != before {
|
||||||
|
t.Fatalf("handoff changed consumer index: %s != %s", after, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryToolPreservesVersionedReplacement(t *testing.T) {
|
||||||
|
proxy := writeReleaseKitModuleProxy(t)
|
||||||
|
environment := make(map[string]string)
|
||||||
|
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
|
||||||
|
for key, value := range environment {
|
||||||
|
t.Setenv(key, value)
|
||||||
|
}
|
||||||
|
module := t.TempDir()
|
||||||
|
data := "module example.com/recovery-consumer\n\ngo 1.26.0\n\nrequire go.maximumdirect.net/releasekit v0.2.197\n\nreplace go.maximumdirect.net/releasekit v0.2.197 => go.maximumdirect.net/releasekit v0.2.198\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte(data), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args, err := gotool.RecoveryTool(t.Context(), module, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), args...)
|
||||||
|
command.Dir = t.TempDir()
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(output), "tool-version=v0.2.198 helper=tool-only dependency") {
|
||||||
|
t.Fatalf("versioned recovery selection: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(filepath.Join(module, "go.mod"))
|
||||||
|
if err != nil || string(after) != data {
|
||||||
|
t.Fatalf("consumer metadata changed: %q, %v", after, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryToolSupportsReleaseKitSourceModule(t *testing.T) {
|
||||||
|
args, err := gotool.RecoveryTool(t.Context(), repositoryRoot(t), t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := exec.Command(filepath.Join(runtime.GOROOT(), "bin", "go"), append(args, "version")...)
|
||||||
|
command.Dir = t.TempDir()
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil || !strings.HasPrefix(string(output), "releasekit ") {
|
||||||
|
t.Fatalf("source module recovery entry point: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColdGoToolRecoverySurvivesLauncherExit(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("fixture observes the cold executable through /proc")
|
||||||
|
}
|
||||||
|
for _, mode := range []struct{ registered, canceled bool }{{true, false}, {false, false}, {true, true}, {false, true}} {
|
||||||
|
registered := mode.registered
|
||||||
|
t.Run(fmt.Sprintf("registered=%t/canceled=%t", registered, mode.canceled), 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\n" + cancelPushScript + "exec " + 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
|
||||||
|
var out []byte
|
||||||
|
if mode.canceled {
|
||||||
|
out, err = canceledPublicationOutput(t, launch, repo, "")
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
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) {
|
||||||
|
for _, mode := range []string{"rejected", "canceled", "preparation failure"} {
|
||||||
|
t.Run(mode, func(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "canceled" {
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\n"+cancelPushScript+"exec "+shellQuote(realGit)+" \"$@\"\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
if mode == "preparation failure" {
|
||||||
|
realGo, err := exec.LookPath("go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
script := "#!/bin/sh\nif [ \"$1\" = list ] && [ \"$2\" = -e ]; then exit 73; fi\nexec " + shellQuote(realGo) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(script), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
}
|
||||||
|
command := exec.Command(executable, "publish", "--repo", repository, "--record-dir", store, "v1.2.3")
|
||||||
|
command.Dir = repositoryRoot(t)
|
||||||
|
var output []byte
|
||||||
|
var err error
|
||||||
|
if mode == "canceled" {
|
||||||
|
output, err = canceledPublicationOutput(t, command, repository, store)
|
||||||
|
} else {
|
||||||
|
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 mode == "preparation failure" && !strings.Contains(text, "recovery tool preparation warning:") {
|
||||||
|
t.Fatalf("preparation warning missing: %s", text)
|
||||||
|
}
|
||||||
|
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(t.Context(), 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, "..", ".."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shim blocks exactly when Git would start the recorded tag push. The CLI
|
||||||
|
// PID lets the test cancel the actual operation even behind a Go driver/launcher.
|
||||||
|
const cancelPushScript = `if [ -n "$RELEASEKIT_CANCEL_PUSH_READY" ]; then
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = push ]; then
|
||||||
|
printf '%s' "$PPID" > "$RELEASEKIT_CANCEL_PUSH_READY"
|
||||||
|
exec sleep 600
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
|
||||||
|
func canceledPublicationOutput(t *testing.T, command *exec.Cmd, repo, store string) ([]byte, error) {
|
||||||
|
t.Helper()
|
||||||
|
ready := filepath.Join(t.TempDir(), "push-ready")
|
||||||
|
command.Env = append(os.Environ(), "RELEASEKIT_CANCEL_PUSH_READY="+ready)
|
||||||
|
var output bytes.Buffer
|
||||||
|
command.Stdout = &output
|
||||||
|
command.Stderr = &output
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer command.Process.Kill()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- command.Wait() }()
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ticker := time.NewTicker(5 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var operation *os.Process
|
||||||
|
waiting:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("publication finished before push interception: %v: %s", err, &output)
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
<-done
|
||||||
|
t.Fatal("publication never reached push interception")
|
||||||
|
case <-ticker.C:
|
||||||
|
data, err := os.ReadFile(ready)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.Atoi(string(data))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
operation, err = os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer operation.Release()
|
||||||
|
break waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check the handoff identity before allowing cancellation to reach publication.
|
||||||
|
listed, err := publish.ListAttempts(t.Context(), repo, store)
|
||||||
|
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].Evidence.TagObject == "" {
|
||||||
|
_ = operation.Signal(os.Interrupt)
|
||||||
|
<-done
|
||||||
|
t.Fatalf("pre-push record is incomplete: %#v, %v", listed, err)
|
||||||
|
}
|
||||||
|
if err := operation.Signal(os.Interrupt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !strings.Contains(output.String(), "context canceled") {
|
||||||
|
t.Fatalf("publication lost its cancellation diagnostic: %s", &output)
|
||||||
|
}
|
||||||
|
return output.Bytes(), err
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = operation.Kill()
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
<-done
|
||||||
|
t.Fatal("canceled publication did not return")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal file
351
cmd/releasekit/publication_recovery_workflow_test.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit"
|
||||||
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicationPushGateHelper(t *testing.T) {
|
||||||
|
address := os.Getenv("RELEASEKIT_PUSH_GATE_ADDR")
|
||||||
|
if address == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connection, err := net.DialTimeout("tcp", address, 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect to pre-receive gate: %v", err)
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
if _, err := fmt.Fprintln(connection, "ready"); err != nil {
|
||||||
|
t.Fatalf("announce pre-receive readiness: %v", err)
|
||||||
|
}
|
||||||
|
response, err := bufio.NewReader(connection).ReadString('\n')
|
||||||
|
if err != nil || strings.TrimSpace(response) != "reject" {
|
||||||
|
t.Fatalf("pre-receive gate response = %q, %v", response, err)
|
||||||
|
}
|
||||||
|
// The test process is the pre-receive hook's gate. Reject only after the
|
||||||
|
// parent has observed the pre-push record and explicitly released it.
|
||||||
|
t.Fatal("controlled rejection after pre-push record observation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLostPublicationOutputCanBeRecoveredFromExplicitRecords(t *testing.T) {
|
||||||
|
setIsolatedGitEnvironment(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
store := filepath.Join(t.TempDir(), "publication records")
|
||||||
|
checkLog := filepath.Join(t.TempDir(), "candidate checks.log")
|
||||||
|
var hostedRequests atomic.Int64
|
||||||
|
hostedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
hostedRequests.Add(1)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer hostedServer.Close()
|
||||||
|
|
||||||
|
settings := cli.Settings{
|
||||||
|
Product: "Example Product",
|
||||||
|
Remote: "origin",
|
||||||
|
Checks: []cli.CheckCommand{{
|
||||||
|
Args: []string{"/bin/sh", "-c", "printf 'candidate\\n' >> \"$RELEASEKIT_CANDIDATE_LOG\""},
|
||||||
|
Env: map[string]string{"RELEASEKIT_CANDIDATE_LOG": checkLog},
|
||||||
|
}},
|
||||||
|
Delivery: &cli.DeliverySettings{Forge: "gitea", BaseURL: hostedServer.URL, Owner: "example", Repository: "candidate"},
|
||||||
|
}
|
||||||
|
writeSettings := func() {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeRepositoryFile(t, repository, "releasekit.json", string(contents))
|
||||||
|
cliGitRun(t, repository, "add", "releasekit.json")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "update publication profile")
|
||||||
|
}
|
||||||
|
writeSettings()
|
||||||
|
remote := filepath.Join(t.TempDir(), "original.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")
|
||||||
|
|
||||||
|
nestedDir := "integrations/recovery"
|
||||||
|
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "go.mod")), "module example.com/cli-candidate/recovery\n\ngo 1.25.0\n")
|
||||||
|
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "candidate_test.go")), "package recovery\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
|
||||||
|
writeRepositoryFile(t, repository, filepath.ToSlash(filepath.Join(nestedDir, "docs/releases/v1.2.3.md")), "# Example Product v1.2.3\n\nRecovery fixture.\n")
|
||||||
|
cliGitRun(t, repository, "add", nestedDir)
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "add nested module")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "main")
|
||||||
|
installPushGate(t, remote)
|
||||||
|
|
||||||
|
goBinary := filepath.Join(runtime.GOROOT(), "bin", "go")
|
||||||
|
executable := filepath.Join(t.TempDir(), "releasekit recovery workflow")
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
testBinary, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootTag := "v1.2.3"
|
||||||
|
rootRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, rootTag, "")
|
||||||
|
if rootRecord.Evidence.ModuleDir != repository || rootRecord.Evidence.Tag != rootTag {
|
||||||
|
t.Fatalf("root attempt evidence = %#v", rootRecord.Evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.ModuleDir = nestedDir
|
||||||
|
writeSettings()
|
||||||
|
cliGitRun(t, repository, "push", "origin", "main")
|
||||||
|
nestedTag := nestedDir + "/v1.2.3"
|
||||||
|
nestedRecord := rejectPublicationAfterRecording(t, executable, testBinary, repository, store, nestedTag, nestedDir)
|
||||||
|
if nestedRecord.Evidence.ModuleDir != filepath.Join(repository, nestedDir) || nestedRecord.Evidence.Tag != nestedTag {
|
||||||
|
t.Fatalf("nested attempt evidence = %#v", nestedRecord.Evidence)
|
||||||
|
}
|
||||||
|
if rootRecord.RecordPath == nestedRecord.RecordPath {
|
||||||
|
t.Fatalf("publication attempts reused record path %q", rootRecord.RecordPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the rejected root tag on the original endpoint with a different
|
||||||
|
// annotated object, then make the current checkout point somewhere else.
|
||||||
|
hook := filepath.Join(remote, "hooks", "pre-receive")
|
||||||
|
if err := os.Remove(hook); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, "tag", "-f", "-a", rootTag, "-m", "remote replacement after failed attempt", "HEAD")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/"+rootTag)
|
||||||
|
decoyRemote := filepath.Join(t.TempDir(), "replacement-origin.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", decoyRemote)
|
||||||
|
cliGitRun(t, repository, "remote", "set-url", "origin", decoyRemote)
|
||||||
|
cliGitRun(t, repository, "switch", "-c", "recovery-inspection")
|
||||||
|
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "move recovery checkout")
|
||||||
|
cliGitRun(t, repository, "tag", "-d", rootTag, nestedTag)
|
||||||
|
writeRepositoryFile(t, repository, "untracked recovery marker", "preserve this file\n")
|
||||||
|
|
||||||
|
before := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
|
||||||
|
listing := runCLIJSON(t, executable, t.TempDir(), "publication-attempts", "--repo", repository, "--record-dir", store, "--json")
|
||||||
|
var listed struct {
|
||||||
|
Attempts []struct {
|
||||||
|
RecordPath string `json:"record_path"`
|
||||||
|
Evidence struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
ModuleDir string `json:"module_dir"`
|
||||||
|
} `json:"evidence"`
|
||||||
|
} `json:"attempts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(listing, &listed); err != nil {
|
||||||
|
t.Fatalf("decode publication-attempts output: %v\n%s", err, listing)
|
||||||
|
}
|
||||||
|
selectedRoot := ""
|
||||||
|
selectedNested := ""
|
||||||
|
for _, attempt := range listed.Attempts {
|
||||||
|
switch attempt.Evidence.Tag {
|
||||||
|
case rootTag:
|
||||||
|
selectedRoot = attempt.RecordPath
|
||||||
|
case nestedTag:
|
||||||
|
selectedNested = attempt.RecordPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedRoot != rootRecord.RecordPath || selectedNested != nestedRecord.RecordPath {
|
||||||
|
t.Fatalf("discovery selected root=%q nested=%q; want %q and %q; listing=%s", selectedRoot, selectedNested, rootRecord.RecordPath, nestedRecord.RecordPath, listing)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedRoot, "--json")
|
||||||
|
if rootResult.status != 1 || rootResult.document["state"] != "conflict" {
|
||||||
|
t.Fatalf("explicit root reconciliation status=%d document=%s; want conflict on the recorded original endpoint", rootResult.status, rootResult.output)
|
||||||
|
}
|
||||||
|
nestedResult := runCLIStatusJSON(t, executable, t.TempDir(), "reconcile", "--record", selectedNested, "--json")
|
||||||
|
if nestedResult.status != 1 || nestedResult.document["state"] != "not_published" {
|
||||||
|
t.Fatalf("explicit nested reconciliation status=%d document=%s; want not_published", nestedResult.status, nestedResult.output)
|
||||||
|
}
|
||||||
|
if got := hostedRequests.Load(); got != 0 {
|
||||||
|
t.Fatalf("later recovery made %d hosted delivery requests", got)
|
||||||
|
}
|
||||||
|
after := snapshotRecoveryRepository(t, repository, remote, rootRecord.RecordPath, nestedRecord.RecordPath)
|
||||||
|
if before != after {
|
||||||
|
t.Fatalf("reconciliation changed local or saved state:\nbefore: %#v\nafter: %#v", before, after)
|
||||||
|
}
|
||||||
|
checkBytes, err := os.ReadFile(checkLog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := strings.Count(string(checkBytes), "candidate\n"); got != 2 {
|
||||||
|
t.Fatalf("candidate checks ran %d times; want once per publication and none during recovery: %q", got, checkBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type listedAttempt struct {
|
||||||
|
RecordPath string
|
||||||
|
Evidence releasekit.PublicationEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectPublicationAfterRecording(t *testing.T, executable, testBinary, repository, store, tag, expectedModule string) listedAttempt {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
commandArgs := []string{"publish", "--repo", repository, "--record-dir", store}
|
||||||
|
if expectedModule != "" {
|
||||||
|
commandArgs = append(commandArgs, "--expected-module", expectedModule)
|
||||||
|
}
|
||||||
|
commandArgs = append(commandArgs, "v1.2.3")
|
||||||
|
command := exec.Command(executable, commandArgs...)
|
||||||
|
command.Dir = repositoryRoot(t)
|
||||||
|
command.Env = append(os.Environ(), "RELEASEKIT_PUSH_GATE_ADDR="+listener.Addr().String(), "RELEASEKIT_PUSH_GATE_TEST_BINARY="+testBinary)
|
||||||
|
command.Stdout = io.Discard
|
||||||
|
command.Stderr = io.Discard
|
||||||
|
if err := command.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if command.Process != nil {
|
||||||
|
_ = command.Process.Kill()
|
||||||
|
_ = command.Wait()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if tcpListener, ok := listener.(*net.TCPListener); ok {
|
||||||
|
if err := tcpListener.SetDeadline(time.Now().Add(90 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wait for pre-receive gate for %s: %v", tag, err)
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
if tcpConnection, ok := connection.(*net.TCPConn); ok {
|
||||||
|
if err := tcpConnection.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line, err := bufio.NewReader(connection).ReadString('\n')
|
||||||
|
if err != nil || strings.TrimSpace(line) != "ready" {
|
||||||
|
t.Fatalf("pre-receive gate readiness for %s = %q, %v", tag, line, err)
|
||||||
|
}
|
||||||
|
listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RepositoryDir: repository, RecordDir: store})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list attempts while tag push is held: %v", err)
|
||||||
|
}
|
||||||
|
var selected *releasekit.PublicationAttempt
|
||||||
|
for i := range listed.Attempts {
|
||||||
|
if listed.Attempts[i].Evidence.Tag == tag {
|
||||||
|
selected = &listed.Attempts[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected == nil {
|
||||||
|
t.Fatalf("pre-push record for %s was not visible before the hook continued: %#v", tag, listed.Attempts)
|
||||||
|
}
|
||||||
|
if selected.Evidence.CandidateCommit == "" || selected.Evidence.TagObject == "" || selected.Evidence.Endpoint == "" {
|
||||||
|
t.Fatalf("pre-push evidence for %s is incomplete: %#v", tag, selected.Evidence)
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(connection, "reject"); err != nil {
|
||||||
|
t.Fatalf("release pre-receive gate for %s: %v", tag, err)
|
||||||
|
}
|
||||||
|
waitErr := command.Wait()
|
||||||
|
command.Process = nil
|
||||||
|
if waitErr == nil {
|
||||||
|
t.Fatalf("publish %s succeeded despite the controlled rejection", tag)
|
||||||
|
} else {
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(waitErr, &exitError) || exitError.ExitCode() != 1 {
|
||||||
|
t.Fatalf("publish %s exit = %v; want publication failure status 1", tag, waitErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listedAttempt{RecordPath: selected.RecordPath, Evidence: selected.Evidence}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installPushGate(t *testing.T, remote string) {
|
||||||
|
t.Helper()
|
||||||
|
hook := filepath.Join(remote, "hooks", "pre-receive")
|
||||||
|
script := "#!/bin/sh\nwhile read old new ref; do\n case \"$ref\" in\n refs/tags/*) \"$RELEASEKIT_PUSH_GATE_TEST_BINARY\" -test.run='^TestPublicationPushGateHelper$'; exit $? ;;\n esac\ndone\nexit 0\n"
|
||||||
|
if err := os.WriteFile(hook, []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIsolatedGitEnvironment(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
|
||||||
|
t.Setenv("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLIJSON(t *testing.T, executable, directory string, args ...string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
result := runCLIStatusJSON(t, executable, directory, args...)
|
||||||
|
if result.status != 0 {
|
||||||
|
t.Fatalf("CLI %v exited with status %d: %s", args, result.status, result.output)
|
||||||
|
}
|
||||||
|
return result.output
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliJSONResult struct {
|
||||||
|
status int
|
||||||
|
output []byte
|
||||||
|
document map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCLIStatusJSON(t *testing.T, executable, directory string, args ...string) cliJSONResult {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command(executable, args...)
|
||||||
|
command.Dir = directory
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
status := 0
|
||||||
|
if err != nil {
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) {
|
||||||
|
t.Fatalf("run CLI %v: %v\n%s", args, err, output)
|
||||||
|
}
|
||||||
|
status = exitError.ExitCode()
|
||||||
|
}
|
||||||
|
var document map[string]any
|
||||||
|
if err := json.Unmarshal(output, &document); err != nil {
|
||||||
|
t.Fatalf("decode CLI JSON for %v: %v\n%s", args, err, output)
|
||||||
|
}
|
||||||
|
return cliJSONResult{status: status, output: output, document: document}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recoverySnapshot struct {
|
||||||
|
Branch string
|
||||||
|
Head string
|
||||||
|
Worktree string
|
||||||
|
LocalRefs string
|
||||||
|
RemoteRefs string
|
||||||
|
Records string
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotRecoveryRepository(t *testing.T, repository, remote string, recordPaths ...string) recoverySnapshot {
|
||||||
|
t.Helper()
|
||||||
|
branch := cliGitOutput(t, repository, "branch", "--show-current")
|
||||||
|
head := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
worktree := cliGitOutput(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
|
||||||
|
localRefs := cliGitOutput(t, repository, "show-ref")
|
||||||
|
remoteRefs := cliGitOutput(t, remote, "show-ref")
|
||||||
|
var records strings.Builder
|
||||||
|
for _, path := range recordPaths {
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read saved record %s: %v", path, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&records, "%s\x00%s\n", path, contents)
|
||||||
|
}
|
||||||
|
return recoverySnapshot{Branch: branch, Head: head, Worktree: worktree, LocalRefs: localRefs, RemoteRefs: remoteRefs, Records: records.String()}
|
||||||
|
}
|
||||||
259
cmd/releasekit/recovery.go
Normal file
259
cmd/releasekit/recovery.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit"
|
||||||
|
"go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recoveryDiagnostic struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RecordPath string `json:"record_path,omitempty"`
|
||||||
|
MissingFields []string `json:"missing_fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type safeEvidence struct {
|
||||||
|
RepositoryDir string `json:"repository_dir,omitempty"`
|
||||||
|
ModuleDir string `json:"module_dir,omitempty"`
|
||||||
|
ModulePath string `json:"module_path,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
CandidateCommit string `json:"candidate_commit,omitempty"`
|
||||||
|
TagObject string `json:"tag_object,omitempty"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Remote string `json:"remote,omitempty"`
|
||||||
|
RecordedAt string `json:"recorded_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type attemptDocument struct {
|
||||||
|
RecordPath string `json:"record_path"`
|
||||||
|
Evidence safeEvidence `json:"evidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reconciliationDocument struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
RecordPath string `json:"record_path"`
|
||||||
|
Evidence *safeEvidence `json:"evidence,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
ObservedTagObject string `json:"observed_tag_object,omitempty"`
|
||||||
|
ObservedCommit string `json:"observed_commit,omitempty"`
|
||||||
|
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type attemptsDocument struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
RecordDir string `json:"record_dir"`
|
||||||
|
Attempts []attemptDocument `json:"attempts"`
|
||||||
|
Diagnostics []*recoveryDiagnostic `json:"diagnostics"`
|
||||||
|
Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func safe(e releasekit.PublicationEvidence) safeEvidence {
|
||||||
|
return safeEvidence{
|
||||||
|
RepositoryDir: git.DisplayDiagnostic(e.RepositoryDir),
|
||||||
|
ModuleDir: git.DisplayDiagnostic(e.ModuleDir),
|
||||||
|
ModulePath: git.DisplayDiagnostic(e.ModulePath),
|
||||||
|
Version: git.DisplayDiagnostic(e.Version),
|
||||||
|
Tag: git.DisplayDiagnostic(e.Tag),
|
||||||
|
CandidateCommit: git.DisplayDiagnostic(e.CandidateCommit),
|
||||||
|
TagObject: git.DisplayDiagnostic(e.TagObject),
|
||||||
|
Endpoint: git.DisplayDiagnostic(e.Endpoint),
|
||||||
|
Remote: git.DisplayDiagnostic(e.Remote),
|
||||||
|
RecordedAt: git.DisplayDiagnostic(e.RecordedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnostic(err error) *recoveryDiagnostic {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if errors.As(err, &typed) {
|
||||||
|
missing := make([]string, len(typed.MissingFields))
|
||||||
|
for i, field := range typed.MissingFields {
|
||||||
|
missing[i] = git.DisplayDiagnostic(field)
|
||||||
|
}
|
||||||
|
return &recoveryDiagnostic{Kind: git.DisplayDiagnostic(typed.Kind), Message: git.DisplayError(err).Error(), RecordPath: git.DisplayDiagnostic(typed.RecordPath), MissingFields: missing}
|
||||||
|
}
|
||||||
|
return &recoveryDiagnostic{Kind: "invocation", Message: git.DisplayError(err).Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
|
if invocation.Command == cli.ReconcileAction {
|
||||||
|
return runReconcile(ctx, invocation, stdout, stderr)
|
||||||
|
}
|
||||||
|
listed, err := releasekit.ListPublicationAttempts(ctx, releasekit.PublicationAttemptsRequest{RepositoryDir: invocation.Repository, RecordDir: invocation.RecordDir})
|
||||||
|
return reportAttempts(listed, err, invocation.JSON, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportAttempts(listed releasekit.PublicationAttemptsResult, err error, jsonOutput bool, stdout, stderr io.Writer) int {
|
||||||
|
if jsonOutput {
|
||||||
|
attempts := make([]attemptDocument, 0, len(listed.Attempts))
|
||||||
|
for _, item := range listed.Attempts {
|
||||||
|
attempts = append(attempts, attemptDocument{RecordPath: git.DisplayDiagnostic(item.RecordPath), Evidence: safe(item.Evidence)})
|
||||||
|
}
|
||||||
|
diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics))
|
||||||
|
for _, item := range listed.Diagnostics {
|
||||||
|
diagnostics = append(diagnostics, diagnostic(item))
|
||||||
|
}
|
||||||
|
if writeErr := writeJSON(stdout, attemptsDocument{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
Command: "publication-attempts",
|
||||||
|
RecordDir: git.DisplayDiagnostic(listed.RecordDir),
|
||||||
|
Attempts: attempts,
|
||||||
|
Diagnostics: diagnostics,
|
||||||
|
Diagnostic: diagnostic(err),
|
||||||
|
}); writeErr != nil {
|
||||||
|
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(stdout, "publication records in %s\n", git.DisplayDiagnostic(listed.RecordDir))
|
||||||
|
for _, item := range listed.Attempts {
|
||||||
|
fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", git.DisplayDiagnostic(item.RecordPath), git.DisplayDiagnostic(item.Evidence.ModulePath), git.DisplayDiagnostic(item.Evidence.Tag), git.DisplayDiagnostic(item.Evidence.CandidateCommit), git.DisplayDiagnostic(item.Evidence.RecordedAt))
|
||||||
|
}
|
||||||
|
if err != nil || len(listed.Diagnostics) > 0 {
|
||||||
|
fmt.Fprintln(stderr, "discovery is incomplete")
|
||||||
|
}
|
||||||
|
for _, item := range listed.Diagnostics {
|
||||||
|
fmt.Fprintf(stderr, "record %s: %s\n", git.DisplayDiagnostic(item.RecordPath), humanDiagnostic(item))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "discovery failed: %v\n", git.DisplayError(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil || len(listed.Diagnostics) > 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
|
||||||
|
evidence, loadErr := releasekit.LoadPublicationEvidence(invocation.RecordPath)
|
||||||
|
var result releasekit.PublicationReconciliationResult
|
||||||
|
err := loadErr
|
||||||
|
if err == nil {
|
||||||
|
result, err = releasekit.ReconcilePublication(ctx, evidence)
|
||||||
|
}
|
||||||
|
status := reconciliationStatus(result.State, err)
|
||||||
|
if invocation.JSON {
|
||||||
|
dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: git.DisplayDiagnostic(invocation.RecordPath)}
|
||||||
|
if loadErr == nil {
|
||||||
|
safeEvidence := safe(evidence)
|
||||||
|
dto.Evidence = &safeEvidence
|
||||||
|
dto.State = string(result.State)
|
||||||
|
dto.ObservedTagObject = result.ObservedTagObject
|
||||||
|
dto.ObservedCommit = result.ObservedCommit
|
||||||
|
}
|
||||||
|
dto.Diagnostic = diagnostic(err)
|
||||||
|
if writeErr := writeJSON(stdout, dto); writeErr != nil {
|
||||||
|
fmt.Fprintf(stderr, "write recovery output: %v\n", git.DisplayError(writeErr))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else if err == nil {
|
||||||
|
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
|
||||||
|
if result.State == releasekit.PublicationConflict {
|
||||||
|
fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", git.DisplayDiagnostic(evidence.TagObject), git.DisplayDiagnostic(evidence.CandidateCommit), result.ObservedTagObject, result.ObservedCommit)
|
||||||
|
} else if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
||||||
|
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
||||||
|
}
|
||||||
|
if guidance := reconciliationGuidance(result.State); guidance != "" {
|
||||||
|
fmt.Fprintf(stdout, "%s\n", guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil && !invocation.JSON {
|
||||||
|
if loadErr == nil {
|
||||||
|
fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", git.DisplayDiagnostic(invocation.RecordPath), git.DisplayDiagnostic(evidence.ModulePath), git.DisplayDiagnostic(evidence.Tag), result.State)
|
||||||
|
if result.ObservedTagObject != "" || result.ObservedCommit != "" {
|
||||||
|
fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "reconcile %s: %s\n", git.DisplayDiagnostic(invocation.RecordPath), humanDiagnostic(err))
|
||||||
|
if guidance := recoveryGuidance(err); guidance != "" {
|
||||||
|
fmt.Fprintf(stderr, "%s\n", guidance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconciliationStatus(state releasekit.PublicationState, err error) int {
|
||||||
|
if err == nil {
|
||||||
|
switch state {
|
||||||
|
case releasekit.PublicationPublished:
|
||||||
|
return 0
|
||||||
|
case releasekit.PublicationNotPublished, releasekit.PublicationConflict:
|
||||||
|
return 1
|
||||||
|
case releasekit.PublicationUnknown:
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if errors.As(err, &typed) {
|
||||||
|
switch typed.Kind {
|
||||||
|
case "record_read", "record_format":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoveryGuidance(err error) string {
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if !errors.As(err, &typed) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch typed.Kind {
|
||||||
|
case "missing_evidence":
|
||||||
|
return "Select a record with the missing identity fields, or provide complete typed evidence through the Go API."
|
||||||
|
case "invalid_evidence":
|
||||||
|
return "Use a record with a valid full tag and Git object ID."
|
||||||
|
case "endpoint_changed":
|
||||||
|
return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying."
|
||||||
|
case "transport_configuration":
|
||||||
|
return "Review Git configuration and the supported transport settings, then retry observation."
|
||||||
|
case "canceled":
|
||||||
|
return "Retry observation with a context that allows it to complete."
|
||||||
|
case "remote_observation", "inconsistent_observation":
|
||||||
|
return "Review the Git diagnostic and the remote tag refs before taking any corrective action."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconciliationGuidance(state releasekit.PublicationState) string {
|
||||||
|
switch state {
|
||||||
|
case releasekit.PublicationNotPublished:
|
||||||
|
return "This reports current absence only; review publication history and version policy before retrying."
|
||||||
|
case releasekit.PublicationConflict:
|
||||||
|
return "Do not replace a conflicting remote tag; verify the observed IDs and follow the repository's immutable-tag procedure."
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanDiagnostic(err error) string {
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if errors.As(err, &typed) && len(typed.MissingFields) > 0 {
|
||||||
|
return git.DisplayDiagnostic(fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error()))
|
||||||
|
}
|
||||||
|
return git.DisplayError(err).Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(writer io.Writer, value any) error {
|
||||||
|
encoder := json.NewEncoder(writer)
|
||||||
|
return encoder.Encode(value)
|
||||||
|
}
|
||||||
487
cmd/releasekit/recovery_test.go
Normal file
487
cmd/releasekit/recovery_test.go
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryEndpointSetupFailureDiagnostics(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fixture injects Git failures through a POSIX launcher")
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, remote, kind, guidance string
|
||||||
|
exitCode int
|
||||||
|
}{
|
||||||
|
{"unnamed configuration", "", "transport_configuration", "Git configuration", 128},
|
||||||
|
{"named configuration", "origin", "transport_configuration", "Git configuration", 128},
|
||||||
|
{"rewrite", "", "endpoint_changed", "URL rewrite", 0},
|
||||||
|
{"tooling", "", "remote_observation", "Git diagnostic", 71},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repo := newCLIRepository(t)
|
||||||
|
evidence := publish.Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3", Remote: test.remote}
|
||||||
|
path, err := publish.SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if test.kind == "transport_configuration" {
|
||||||
|
file, err := os.OpenFile(filepath.Join(repo, ".git", "config"), os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := file.WriteString("\n[broken\n"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else if test.kind == "endpoint_changed" {
|
||||||
|
cliGitRun(t, repo, "config", "url."+t.TempDir()+".insteadOf", repo)
|
||||||
|
}
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
marker := filepath.Join(t.TempDir(), "queried")
|
||||||
|
bin := t.TempDir()
|
||||||
|
failValidation := ""
|
||||||
|
if test.name == "tooling" {
|
||||||
|
failValidation = "[ \"$arg\" = check-ref-format ] && exit 71\n"
|
||||||
|
}
|
||||||
|
script := "#!/bin/sh\nquery=0\nget_url=0\nfor arg in \"$@\"; do\n" + failValidation + "[ \"$arg\" = ls-remote ] && query=1\n[ \"$arg\" = --get-url ] && get_url=1\ndone\nif [ \"$query\" = 1 ] && [ \"$get_url\" = 0 ]; then : > " + shellQuote(marker) + "; exit 72; fi\nexec " + shellQuote(realGit) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
loaded, err := releasekit.LoadPublicationEvidence(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.ReconcilePublication(t.Context(), loaded)
|
||||||
|
var typed *releasekit.PublicationRecoveryError
|
||||||
|
if !errors.As(err, &typed) || typed.Kind != test.kind || result.State != releasekit.PublicationUnknown {
|
||||||
|
t.Fatalf("setup classification = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if test.exitCode != 0 {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) || exitErr.ExitCode() != test.exitCode {
|
||||||
|
t.Fatalf("lost Git cause: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, structured := range []bool{false, true} {
|
||||||
|
args := []string{"reconcile", "--record", path}
|
||||||
|
if structured {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := run(t.Context(), args, &stdout, &stderr); status != 3 {
|
||||||
|
t.Fatalf("setup status=%d: %s%s", status, &stdout, &stderr)
|
||||||
|
}
|
||||||
|
if structured {
|
||||||
|
var document reconciliationDocument
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if document.State != "unknown" || document.Diagnostic == nil || document.Diagnostic.Kind != test.kind {
|
||||||
|
t.Fatalf("setup JSON: %s", &stdout)
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(stderr.String(), test.kind) || !strings.Contains(stderr.String(), test.guidance) {
|
||||||
|
t.Fatalf("setup guidance: %s", &stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("setup failure reached remote query: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingRecoveryWriter struct{ err error }
|
||||||
|
|
||||||
|
func (writer failingRecoveryWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||||
|
|
||||||
|
func TestRecoveryCommandsReportJSONWriteFailure(t *testing.T) {
|
||||||
|
repo := newCLIRepository(t)
|
||||||
|
cliGitRun(t, repo, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||||
|
store := t.TempDir()
|
||||||
|
path, err := publish.SaveEvidence(t.Context(), repo, store, publish.Evidence{
|
||||||
|
RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3",
|
||||||
|
CandidateCommit: cliGitOutput(t, repo, "rev-parse", "HEAD"),
|
||||||
|
TagObject: cliGitOutput(t, repo, "rev-parse", "v1.2.3"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failure := errors.New("output unavailable: https://user:audit-secret@example.invalid/output")
|
||||||
|
for _, args := range [][]string{{"reconcile", "--record", path, "--json"}, {"publication-attempts", "--record-dir", store, "--json"}} {
|
||||||
|
var normal, stderr bytes.Buffer
|
||||||
|
if status := run(t.Context(), args, &normal, &stderr); status != 0 {
|
||||||
|
t.Fatalf("normal output status=%d: %s", status, &stderr)
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if status := run(t.Context(), args, failingRecoveryWriter{failure}, &stderr); status != 1 {
|
||||||
|
t.Fatalf("failed output status=%d: %s", status, &stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "output unavailable") || strings.Contains(stderr.String(), "audit-secret") {
|
||||||
|
t.Fatalf("output diagnostic: %s", &stderr)
|
||||||
|
}
|
||||||
|
if args[0] == "reconcile" && !strings.Contains(normal.String(), `"state":"published"`) {
|
||||||
|
t.Fatalf("observation changed: %s", &normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoveryReportsPartialAttemptsAndEnumerationFailure(t *testing.T) {
|
||||||
|
listed := releasekit.PublicationAttemptsResult{
|
||||||
|
RecordDir: "records",
|
||||||
|
Attempts: []releasekit.PublicationAttempt{{RecordPath: "good.json", Evidence: releasekit.PublicationEvidence{Tag: "v1.2.3"}}},
|
||||||
|
Diagnostics: []*releasekit.PublicationRecoveryError{{Kind: "record_format", RecordPath: "broken.json"}},
|
||||||
|
}
|
||||||
|
failure := &releasekit.PublicationRecoveryError{Kind: "storage_enumeration"}
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if status := reportAttempts(listed, failure, jsonOutput, &stdout, &stderr); status != 1 {
|
||||||
|
t.Fatalf("partial discovery status=%d", status)
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
var document attemptsDocument
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &document); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(document.Attempts) != 1 || document.Attempts[0].RecordPath != "good.json" || len(document.Diagnostics) != 1 || document.Diagnostics[0].RecordPath != "broken.json" || document.Diagnostic == nil || document.Diagnostic.Kind != "storage_enumeration" {
|
||||||
|
t.Fatalf("partial JSON lost data: %s", &stdout)
|
||||||
|
}
|
||||||
|
} else if !strings.Contains(stdout.String(), "good.json") || !strings.Contains(stderr.String(), "broken.json") || !strings.Contains(stderr.String(), "storage_enumeration") {
|
||||||
|
t.Fatalf("partial human output lost data: %s%s", &stdout, &stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryDisplaysRedactCredentialPathsAndMetadata(t *testing.T) {
|
||||||
|
secret := "audit-secret"
|
||||||
|
raw := "https://audit-user:" + secret + "@example.invalid/attempt.json"
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
args := []string{"reconcile", "--record", raw}
|
||||||
|
if jsonOutput {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var out, diagnostics bytes.Buffer
|
||||||
|
if status := run(t.Context(), args, &out, &diagnostics); status != 2 {
|
||||||
|
t.Fatalf("load status=%d", status)
|
||||||
|
}
|
||||||
|
if strings.Contains(out.String()+diagnostics.String(), secret) {
|
||||||
|
t.Fatalf("credential leaked: %s%s", &out, &diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := newCLIRepository(t)
|
||||||
|
store := t.TempDir()
|
||||||
|
evidence := publish.Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Remote: raw, RecordedAt: raw}
|
||||||
|
path, err := publish.SaveEvidence(t.Context(), repo, store, evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, command := range [][]string{{"reconcile", "--record", path}, {"publication-attempts", "--record-dir", store}} {
|
||||||
|
for _, jsonOutput := range []bool{false, true} {
|
||||||
|
args := append([]string{}, command...)
|
||||||
|
if jsonOutput {
|
||||||
|
args = append(args, "--json")
|
||||||
|
}
|
||||||
|
var out, diagnostics bytes.Buffer
|
||||||
|
run(t.Context(), args, &out, &diagnostics)
|
||||||
|
if strings.Contains(out.String()+diagnostics.String(), secret) {
|
||||||
|
t.Fatalf("metadata leaked: %s%s", &out, &diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded, err := publish.LoadEvidence(path)
|
||||||
|
if err != nil || loaded != evidence {
|
||||||
|
t.Fatalf("private metadata changed: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
remote := filepath.Join(t.TempDir(), "bare remote.git")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote)
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "tag", "-a", "v1.2.3", "-m", "first tag object")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
||||||
|
|
||||||
|
store := filepath.Join(t.TempDir(), "records with ' punctuation")
|
||||||
|
evidence := publish.Evidence{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModuleDir: repository,
|
||||||
|
ModulePath: "example.com/cli-candidate",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Tag: "v1.2.3",
|
||||||
|
CandidateCommit: commit,
|
||||||
|
TagObject: tagObject,
|
||||||
|
Endpoint: remote,
|
||||||
|
Remote: "origin",
|
||||||
|
RecordedAt: "2026-11-02T00:00:00Z",
|
||||||
|
}
|
||||||
|
record, err := publish.SaveEvidence(t.Context(), repository, store, evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cliGitRun(t, repository, "switch", "-c", "changed-head")
|
||||||
|
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "change current HEAD")
|
||||||
|
cliGitRun(t, repository, "tag", "-d", "v1.2.3")
|
||||||
|
if err := os.WriteFile(filepath.Join(repository, "uncommitted recovery check.txt"), []byte("dirty"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherDirectory := t.TempDir()
|
||||||
|
relativeRecord, err := filepath.Rel(otherDirectory, record)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
previousDirectory, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(otherDirectory); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chdir(previousDirectory); err != nil {
|
||||||
|
t.Errorf("restore working directory: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"reconcile", "--record", relativeRecord, "--json"}, &stdout, &stderr)
|
||||||
|
if status != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("published status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
var published map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &published); err != nil {
|
||||||
|
t.Fatalf("decode published JSON: %v; output = %q", err, stdout.String())
|
||||||
|
}
|
||||||
|
if published["state"] != string("published") || published["record_path"] != relativeRecord {
|
||||||
|
t.Fatalf("published JSON = %#v", published)
|
||||||
|
}
|
||||||
|
if _, ok := published["diagnostic"]; ok {
|
||||||
|
t.Fatalf("successful reconciliation includes a diagnostic: %#v", published)
|
||||||
|
}
|
||||||
|
evidenceJSON, ok := published["evidence"].(map[string]any)
|
||||||
|
if !ok || evidenceJSON["candidate_commit"] != commit || evidenceJSON["tag_object"] != tagObject {
|
||||||
|
t.Fatalf("safe evidence = %#v", published["evidence"])
|
||||||
|
}
|
||||||
|
if evidenceJSON["module_path"] != evidence.ModulePath || evidenceJSON["candidateCommit"] != nil {
|
||||||
|
t.Fatalf("evidence does not use the versioned snake-case shape: %#v", evidenceJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
|
||||||
|
cliGitRun(t, repository, "tag", "-f", "-a", "v1.2.3", "-m", "replacement tag object")
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
var conflictOut, conflictErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &conflictOut, &conflictErr)
|
||||||
|
if status != 1 || conflictErr.Len() != 0 {
|
||||||
|
t.Fatalf("conflict status = %d, stdout = %q, stderr = %q", status, conflictOut.String(), conflictErr.String())
|
||||||
|
}
|
||||||
|
var conflict map[string]any
|
||||||
|
if err := json.Unmarshal(conflictOut.Bytes(), &conflict); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if conflict["state"] != string("conflict") || conflict["observed_tag_object"] == tagObject {
|
||||||
|
t.Fatalf("conflict JSON = %#v", conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
var humanOut, humanErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record}, &humanOut, &humanErr)
|
||||||
|
if status != 1 || !strings.Contains(humanOut.String(), "expected tag-object="+tagObject) || !strings.Contains(humanOut.String(), "observed tag-object=") || !strings.Contains(humanOut.String(), "Do not replace a conflicting remote tag") {
|
||||||
|
t.Fatalf("human conflict status = %d, stdout = %q, stderr = %q", status, humanOut.String(), humanErr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
partialEvidence := evidence
|
||||||
|
partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
|
||||||
|
partialEvidence.CandidateCommit = ""
|
||||||
|
partialRecord, err := publish.SaveEvidence(t.Context(), repository, store, partialEvidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var unknownOut, unknownErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", partialRecord, "--json"}, &unknownOut, &unknownErr)
|
||||||
|
if status != 3 || unknownErr.Len() != 0 {
|
||||||
|
t.Fatalf("unknown status = %d, stdout = %q, stderr = %q", status, unknownOut.String(), unknownErr.String())
|
||||||
|
}
|
||||||
|
var unknown map[string]any
|
||||||
|
if err := json.Unmarshal(unknownOut.Bytes(), &unknown); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unknownDiagnostic, ok := unknown["diagnostic"].(map[string]any)
|
||||||
|
if unknown["state"] != "unknown" || !ok || unknownDiagnostic["kind"] != "missing_evidence" {
|
||||||
|
t.Fatalf("unknown JSON = %#v", unknown)
|
||||||
|
}
|
||||||
|
var unknownHumanOut, unknownHumanErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", partialRecord}, &unknownHumanOut, &unknownHumanErr)
|
||||||
|
if status != 3 || !strings.Contains(unknownHumanOut.String(), "state=unknown") || !strings.Contains(unknownHumanErr.String(), "candidate_commit") {
|
||||||
|
t.Fatalf("human unknown status = %d, stdout = %q, stderr = %q", status, unknownHumanOut.String(), unknownHumanErr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3")
|
||||||
|
var absentOut, absentErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &absentOut, &absentErr)
|
||||||
|
if status != 1 || absentErr.Len() != 0 {
|
||||||
|
t.Fatalf("absent status = %d, stdout = %q, stderr = %q", status, absentOut.String(), absentErr.String())
|
||||||
|
}
|
||||||
|
var absent map[string]any
|
||||||
|
if err := json.Unmarshal(absentOut.Bytes(), &absent); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if absent["state"] != "not_published" {
|
||||||
|
t.Fatalf("absence JSON = %#v", absent)
|
||||||
|
}
|
||||||
|
if _, ok := absent["diagnostic"]; ok {
|
||||||
|
t.Fatalf("known absence includes an unresolved diagnostic: %#v", absent)
|
||||||
|
}
|
||||||
|
var absentHumanOut, absentHumanErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", record}, &absentHumanOut, &absentHumanErr)
|
||||||
|
if status != 1 || !strings.Contains(absentHumanOut.String(), "state=not_published") || !strings.Contains(absentHumanOut.String(), "current absence only") {
|
||||||
|
t.Fatalf("human absence status = %d, stdout = %q, stderr = %q", status, absentHumanOut.String(), absentHumanErr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
brokenRecord := filepath.Join(store, "broken record's.json")
|
||||||
|
if err := os.WriteFile(brokenRecord, []byte("{"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var brokenOut, brokenErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"reconcile", "--record", brokenRecord, "--json"}, &brokenOut, &brokenErr)
|
||||||
|
if status != 2 || brokenErr.Len() != 0 {
|
||||||
|
t.Fatalf("record-format status = %d, stdout = %q, stderr = %q", status, brokenOut.String(), brokenErr.String())
|
||||||
|
}
|
||||||
|
var broken map[string]any
|
||||||
|
if err := json.Unmarshal(brokenOut.Bytes(), &broken); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
brokenDiagnostic, ok := broken["diagnostic"].(map[string]any)
|
||||||
|
if !ok || brokenDiagnostic["kind"] != "record_format" || broken["record_path"] != brokenRecord {
|
||||||
|
t.Fatalf("record-format JSON = %#v", broken)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"evidence", "state", "observed_tag_object", "observed_commit"} {
|
||||||
|
if _, ok := broken[field]; ok {
|
||||||
|
t.Errorf("record-format JSON includes %q: %#v", field, broken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery(t *testing.T) {
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
store := filepath.Join(t.TempDir(), "external records")
|
||||||
|
evidence := publish.Evidence{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
ModulePath: "example.com/cli-candidate",
|
||||||
|
Version: "v1.2.3",
|
||||||
|
Tag: "v1.2.3",
|
||||||
|
Endpoint: "https://cli-user:cli-secret@example.test/repository.git",
|
||||||
|
Remote: "origin",
|
||||||
|
RecordedAt: "2026-11-02T00:00:00Z",
|
||||||
|
}
|
||||||
|
if _, err := publish.SaveEvidence(t.Context(), repository, store, evidence); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
brokenPath := filepath.Join(store, "damaged.json")
|
||||||
|
if err := os.WriteFile(brokenPath, []byte(`{"format_version":99,"evidence":{}}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"publication-attempts", "--record-dir", store, "--json"}, &stdout, &stderr)
|
||||||
|
if status != 1 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("partial discovery status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
output := stdout.String()
|
||||||
|
if strings.Contains(output, "cli-user") || strings.Contains(output, "cli-secret") || !strings.Contains(output, "https://redacted@example.test/repository.git") {
|
||||||
|
t.Fatalf("structured discovery exposed or failed to redact endpoint credentials: %q", output)
|
||||||
|
}
|
||||||
|
var listed map[string]any
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
attempts, ok := listed["attempts"].([]any)
|
||||||
|
if !ok || len(attempts) != 1 {
|
||||||
|
t.Fatalf("attempts = %#v", listed["attempts"])
|
||||||
|
}
|
||||||
|
diagnostics, ok := listed["diagnostics"].([]any)
|
||||||
|
if !ok || len(diagnostics) != 1 {
|
||||||
|
t.Fatalf("diagnostics = %#v", listed["diagnostics"])
|
||||||
|
}
|
||||||
|
if listed["schema_version"] != float64(1) || listed["command"] != "publication-attempts" || listed["diagnostic"] != nil {
|
||||||
|
t.Fatalf("listing document = %#v", listed)
|
||||||
|
}
|
||||||
|
entry := attempts[0].(map[string]any)
|
||||||
|
if entry["record_path"] == "" {
|
||||||
|
t.Fatalf("attempt is missing record_path: %#v", entry)
|
||||||
|
}
|
||||||
|
entryEvidence := entry["evidence"].(map[string]any)
|
||||||
|
if entryEvidence["module_path"] != evidence.ModulePath || entryEvidence["recorded_at"] != evidence.RecordedAt {
|
||||||
|
t.Fatalf("attempt evidence = %#v", entryEvidence)
|
||||||
|
}
|
||||||
|
diagnostic := diagnostics[0].(map[string]any)
|
||||||
|
if diagnostic["kind"] != "record_format" || diagnostic["record_path"] != brokenPath {
|
||||||
|
t.Fatalf("record diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyStore := filepath.Join(t.TempDir(), "empty store")
|
||||||
|
if err := os.Mkdir(emptyStore, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var emptyOut, emptyErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"publication-attempts", "--record-dir", emptyStore, "--json"}, &emptyOut, &emptyErr)
|
||||||
|
if status != 0 || emptyErr.Len() != 0 {
|
||||||
|
t.Fatalf("empty discovery status = %d, stdout = %q, stderr = %q", status, emptyOut.String(), emptyErr.String())
|
||||||
|
}
|
||||||
|
var empty map[string]any
|
||||||
|
if err := json.Unmarshal(emptyOut.Bytes(), &empty); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(empty["attempts"].([]any)) != 0 || len(empty["diagnostics"].([]any)) != 0 || empty["diagnostic"] != nil {
|
||||||
|
t.Fatalf("empty listing document = %#v", empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
var failedOut, failedErr bytes.Buffer
|
||||||
|
status = run(context.Background(), []string{"publication-attempts", "--record-dir", filepath.Join(emptyStore, "missing"), "--json"}, &failedOut, &failedErr)
|
||||||
|
if status != 1 || failedErr.Len() != 0 {
|
||||||
|
t.Fatalf("enumeration failure status = %d, stdout = %q, stderr = %q", status, failedOut.String(), failedErr.String())
|
||||||
|
}
|
||||||
|
var failed map[string]any
|
||||||
|
if err := json.Unmarshal(failedOut.Bytes(), &failed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failedDiagnostic, ok := failed["diagnostic"].(map[string]any)
|
||||||
|
if !ok || failedDiagnostic["kind"] != "storage_enumeration" {
|
||||||
|
t.Fatalf("enumeration failure JSON = %#v", failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryArgumentErrorsUseExistingUsagePath(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
status := run(context.Background(), []string{"reconcile", "--json"}, &stdout, &stderr)
|
||||||
|
if status != 2 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "reconcile requires --record FILE") {
|
||||||
|
t.Fatalf("argument failure status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String(), "schema_version") || strings.Contains(stderr.String(), fmt.Sprint("\"command\"")) {
|
||||||
|
t.Fatalf("argument failure unexpectedly used structured output: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,6 +236,26 @@ func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
|
|||||||
assertToolOnlyChecksums(t, sumLog)
|
assertToolOnlyChecksums(t, sumLog)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("vendored tool dependency", func(t *testing.T) {
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const module = "go.maximumdirect.net/releasekit"
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n\ntool "+module+"/cmd/releasekit\n")
|
||||||
|
env := templateEnvironment(repository, "", goDir, goLog)
|
||||||
|
useSyntheticModuleProxy(t, env, "file://"+filepath.ToSlash(proxy))
|
||||||
|
runWoodpeckerBlock(t, "go mod tidy\ngo mod vendor", repository, env)
|
||||||
|
cliGitRun(t, repository, "add", "go.mod", "go.sum", "vendor")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "vendor the selected release tool")
|
||||||
|
env["CI_COMMIT_SHA"] = pushTaggedCLIRepository(t, repository)
|
||||||
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
|
sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum"))
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.197 helper=tool-only dependency") {
|
||||||
|
t.Fatalf("vendored tool was not selected: %q", output)
|
||||||
|
}
|
||||||
|
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
|
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
|
||||||
goDir, goLog := installGoBootstrapShim(t)
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
repository := newCLIRepository(t)
|
repository := newCLIRepository(t)
|
||||||
@@ -400,7 +420,7 @@ func installGoBootstrapShim(t *testing.T) (string, string) {
|
|||||||
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
|
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
|
||||||
"$RELEASEKIT_REAL_GO" "$@"
|
"$RELEASEKIT_REAL_GO" "$@"
|
||||||
status=$?
|
status=$?
|
||||||
if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
|
if [ "$status" -eq 0 ] && [ "$1" = "tool" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
|
||||||
modfile=
|
modfile=
|
||||||
for argument in "$@"; do
|
for argument in "$@"; do
|
||||||
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
|
||||||
@@ -433,20 +453,12 @@ case "$1" in
|
|||||||
list)
|
list)
|
||||||
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
|
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
|
||||||
;;
|
;;
|
||||||
build)
|
mod)
|
||||||
output=
|
;;
|
||||||
previous=
|
tool)
|
||||||
for argument in "$@"; do
|
shift 3
|
||||||
if [ "$previous" = "-o" ]; then output=$argument; break; fi
|
|
||||||
previous=$argument
|
|
||||||
done
|
|
||||||
[ -n "$output" ] || { echo 'go build omitted output path' >&2; exit 90; }
|
|
||||||
cat > "$output" <<'TOOL'
|
|
||||||
#!/bin/sh
|
|
||||||
printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
|
printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
|
||||||
exit "${RELEASEKIT_TOOL_EXIT:-0}"
|
exit "${RELEASEKIT_TOOL_EXIT:-0}"
|
||||||
TOOL
|
|
||||||
chmod +x "$output"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "unexpected Go command: $1" >&2
|
echo "unexpected Go command: $1" >&2
|
||||||
@@ -532,7 +544,7 @@ func writeReleaseKitModuleProxy(t *testing.T) string {
|
|||||||
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
|
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
|
||||||
for _, version := range []string{"v0.2.197", "v0.2.198"} {
|
for _, version := range []string{"v0.2.197", "v0.2.198"} {
|
||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n",
|
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; \"strconv\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n\tstatus, _ := strconv.Atoi(os.Getenv(\"RELEASEKIT_TOOL_EXIT\"))\n\tos.Exit(status)\n}\n",
|
||||||
}
|
}
|
||||||
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
|
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
|
||||||
}
|
}
|
||||||
@@ -633,16 +645,16 @@ func assertGoSelection(t *testing.T, repository, goLog string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
log := readWoodpeckerFixtureFile(t, goLog)
|
log := readWoodpeckerFixtureFile(t, goLog)
|
||||||
list := "list -mod=mod -modfile="
|
list := "list -mod=mod -modfile="
|
||||||
build := "build -mod=mod -modfile="
|
tool := "tool -modfile="
|
||||||
module := "go.maximumdirect.net/releasekit"
|
module := "go.maximumdirect.net/releasekit"
|
||||||
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
|
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
|
||||||
t.Fatalf("Go module selection command is missing required settings: %q", log)
|
t.Fatalf("Go module selection command is missing required settings: %q", log)
|
||||||
}
|
}
|
||||||
if !strings.Contains(log, build) || !strings.Contains(log, module+"/cmd/releasekit") || strings.Contains(log, "@v0.2.0") {
|
if !strings.Contains(log, tool) || !strings.Contains(log, " releasekit ci-deliver ") || strings.Contains(log, "@v0.2.0") {
|
||||||
t.Fatalf("Go build did not use the selected module without a version suffix: %q", log)
|
t.Fatalf("Go tool did not run the module-selected ReleaseKit: %q", log)
|
||||||
}
|
}
|
||||||
if strings.Index(log, list) > strings.Index(log, build) {
|
if strings.Index(log, list) > strings.Index(log, tool) {
|
||||||
t.Fatalf("Go build ran before module selection: %q", log)
|
t.Fatalf("Go tool ran before module selection: %q", log)
|
||||||
}
|
}
|
||||||
var modfile string
|
var modfile string
|
||||||
for _, field := range strings.Fields(log) {
|
for _, field := range strings.Fields(log) {
|
||||||
|
|||||||
75
docs/cli.md
75
docs/cli.md
@@ -1,6 +1,6 @@
|
|||||||
# Command line
|
# Command line
|
||||||
|
|
||||||
ReleaseKit currently provides six commands. The exit statuses below belong
|
ReleaseKit currently provides eight commands. The exit statuses below belong
|
||||||
to the native executable. `go run` examples are convenient for source execution,
|
to the native executable. `go run` examples are convenient for source execution,
|
||||||
but the Go driver maps a nonzero child exit status to 1. Use the built executable
|
but the Go driver maps a nonzero child exit status to 1. Use the built executable
|
||||||
or `./scripts/release` for publication when distinct statuses are needed.
|
or `./scripts/release` for publication when distinct statuses are needed.
|
||||||
@@ -75,7 +75,7 @@ Unsupported argument sequences print usage to standard error.
|
|||||||
Publish a source tag:
|
Publish a source tag:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION
|
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] [--record-dir DIR] VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
`publish` reads the same product, remote, validation, and additional-check
|
`publish` reads the same product, remote, validation, and additional-check
|
||||||
@@ -138,13 +138,76 @@ mismatches fail during preflight before Go validation or tag creation. Git URL
|
|||||||
rewrites are resolved first, and the push URL is used when fetch and push URLs
|
rewrites are resolved first, and the push URL is used when fetch and push URLs
|
||||||
differ. `check` ignores this field.
|
differ. `check` ignores this field.
|
||||||
|
|
||||||
|
The command automatically saves available publication evidence in the repository's
|
||||||
|
private attempt store. Use `--record-dir DIR` to select another directory; a
|
||||||
|
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 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. A module-preparation failure reports a warning and
|
||||||
|
uses a persistent native executable when available. Recognized temporary Go-build
|
||||||
|
executables provide no native fallback after their driver exits. Command
|
||||||
|
preparation has its own bounded context after a failed publication, so publication
|
||||||
|
cancellation permits preparing the later invocation. 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
|
The command returns status 0 for confirmed publication, 1 for a known
|
||||||
publication failure or conflict, 2 for usage, profile, or invalid-version
|
publication failure or conflict, 2 for usage, profile, or invalid-version
|
||||||
errors, and 3 when the push outcome remains unknown. Unknown status must be
|
errors, and 3 when the push outcome remains unknown. Unknown status must be
|
||||||
resolved against the authoritative remote before retrying. The
|
resolved with the explicit-record recovery operation below before retrying. The
|
||||||
[maintainer procedure](release.md) selects the repository profile and gives
|
[maintainer procedure](release.md) selects the repository profile and gives
|
||||||
the source-release workflow.
|
the source-release workflow.
|
||||||
|
|
||||||
|
## Recover a saved publication attempt or discover local attempts
|
||||||
|
|
||||||
|
```sh
|
||||||
|
releasekit reconcile --record FILE [--json]
|
||||||
|
releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
`reconcile` requires one explicitly selected record and reads a relative `FILE`
|
||||||
|
from the invoking directory. It observes the endpoint and tag stored in that
|
||||||
|
record; it does not load a profile, inspect the current candidate or local tag,
|
||||||
|
run checks, or change repository state. `publication-attempts` lists records in
|
||||||
|
the selected store without querying remotes or choosing an attempt. An omitted
|
||||||
|
`--repo` means the current directory. Its relative `--record-dir` is resolved
|
||||||
|
from the selected repository root; an absolute directory can be listed outside
|
||||||
|
a checkout. The selected directory must already exist. Discovery reports usable
|
||||||
|
records and per-record diagnostics together when some records cannot be read.
|
||||||
|
|
||||||
|
By default, both operations print human-readable output. `--json` writes one
|
||||||
|
structured document to standard output after valid arguments, including for
|
||||||
|
reconciliation and discovery failures. Recovery does not run candidate checks
|
||||||
|
or make hosted delivery requests. It reads the record's captured endpoint, while
|
||||||
|
using current supported transport settings for its recorded remote name.
|
||||||
|
|
||||||
|
| Recovery command result | Status |
|
||||||
|
| --- | --- |
|
||||||
|
| Reconciliation observes the intended publication | 0 |
|
||||||
|
| Reconciliation observes absence or conflict | 1 |
|
||||||
|
| Reconciliation cannot read or decode the selected record, or has an invocation error | 2 |
|
||||||
|
| Reconciliation remains unknown, including missing evidence or cancellation | 3 |
|
||||||
|
| Discovery completes without record diagnostics, including an existing empty store | 0 |
|
||||||
|
| Discovery is incomplete or fails, including cancellation | 1 |
|
||||||
|
| Discovery argument or usage error | 2 |
|
||||||
|
| Either command cannot write its JSON output | 1 |
|
||||||
|
|
||||||
|
A JSON write failure reports a local output diagnostic on standard error. This
|
||||||
|
status describes reporting failure and leaves the observed publication state
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
The CLI JSON schema and safe evidence fields are documented in the
|
||||||
|
[publication record format](integrations/publication-records.md). Recovery
|
||||||
|
guidance for storage, older evidence, and retry decisions is in the
|
||||||
|
[maintainer procedure](release.md#resolve-publication-failures).
|
||||||
|
|
||||||
Build selected application targets:
|
Build selected application targets:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -275,9 +338,9 @@ status 2. Final delivery uses the same statuses as `deliver`: 0 for complete,
|
|||||||
state, including when cancellation prevents reconciliation.
|
state, including when cancellation prevents reconciliation.
|
||||||
|
|
||||||
The maintained module-based tag workflow verifies and checks out the event tag
|
The maintained module-based tag workflow verifies and checks out the event tag
|
||||||
before resolving the consumer module. It builds the ReleaseKit version already
|
before resolving the consumer module. It runs `go tool releasekit` for the version
|
||||||
selected by that tagged `go.mod`, using temporary alternate module files outside
|
already selected by that tagged `go.mod`, using temporary alternate module files
|
||||||
the source checkout. This tool bootstrap is separate from `ci-deliver`'s
|
outside the source checkout. This tool bootstrap is separate from `ci-deliver`'s
|
||||||
application dependency preparation for binary profiles. ReleaseKit's own
|
application dependency preparation for binary profiles. ReleaseKit's own
|
||||||
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
|
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
|
||||||
ReleaseKit module dependency can use the separately installed-tool command.
|
ReleaseKit module dependency can use the separately installed-tool command.
|
||||||
|
|||||||
@@ -34,8 +34,21 @@ Go minimum. See the
|
|||||||
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
|
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
|
||||||
|
|
||||||
Run `go tool releasekit` from within the consumer module to use its selected
|
Run `go tool releasekit` from within the consumer module to use its selected
|
||||||
version. Follow the [CLI contract](../cli.md) for commands and exit statuses,
|
version. Go builds and caches the executable and preserves its exit status.
|
||||||
and the [configuration reference](../config.md) for the release profile.
|
A launcher invoked from that module can forward commands directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
exec go tool releasekit "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
For a launcher callable from other directories, first change to its consumer
|
||||||
|
module directory. Follow the [CLI contract](../cli.md) for commands and exit
|
||||||
|
statuses, and the [configuration reference](../config.md) for the release profile.
|
||||||
|
|
||||||
|
For consumers using vendoring, register the CLI before running `go mod vendor`
|
||||||
|
so the vendor tree includes its dependencies. `go tool` follows Go's normal
|
||||||
|
vendor selection.
|
||||||
|
|
||||||
## Go API consumers
|
## Go API consumers
|
||||||
|
|
||||||
@@ -89,9 +102,10 @@ Go module identity. The Go consumer minimum remains 1.26.0.
|
|||||||
## CI adoption
|
## CI adoption
|
||||||
|
|
||||||
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
|
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
|
||||||
supports both tool and ordinary package dependencies. It builds ReleaseKit from
|
supports both tool and ordinary package dependencies. It runs `go tool releasekit`
|
||||||
the tagged consumer's module selection, so the committed module files also
|
with temporary module metadata derived from the tagged consumer's module
|
||||||
govern CI delivery and retries. Registering the CLI as a tool is sufficient for
|
selection, so the committed module files also govern CI delivery and retries.
|
||||||
|
Registering the CLI as a tool is sufficient for
|
||||||
consumers that use ReleaseKit only for maintainer commands.
|
consumers that use ReleaseKit only for maintainer commands.
|
||||||
|
|
||||||
Upgrade the module-selected tool and workflow together. For a tag created before
|
Upgrade the module-selected tool and workflow together. For a tag created before
|
||||||
|
|||||||
@@ -293,11 +293,13 @@ identify an annotated tag object or a commit. The template requires the resolved
|
|||||||
commits to match, checks out that commit detached, and confirms the source tree
|
commits to match, checks out that commit detached, and confirms the source tree
|
||||||
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
|
is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
|
||||||
directory outside the checkout. `go list -m` confirms that the module selects
|
directory outside the checkout. `go list -m` confirms that the module selects
|
||||||
ReleaseKit; Go uses
|
ReleaseKit. The template adds its CLI tool declaration to that temporary copy
|
||||||
an alternate `-modfile` to prepare dependencies and build the selected
|
and prepares tool dependencies with `go list -deps`. It then uses
|
||||||
`cmd/releasekit` package without changing the tagged module files. The template
|
`go tool -modfile=... releasekit` to build, cache, and run the selected tool with
|
||||||
invokes that executable with `CI_WORKSPACE`, the resolved event commit, and
|
`CI_WORKSPACE`, the resolved event commit, and `CI_COMMIT_TAG`. The tagged module
|
||||||
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable.
|
files stay unchanged, including for consumers with an ordinary package
|
||||||
|
dependency. A cleanup trap removes the temporary metadata; Go manages the
|
||||||
|
cached executable. The command preserves the tool's exit status.
|
||||||
|
|
||||||
Tool bootstrap and application artifact preparation are separate. The bootstrap
|
Tool bootstrap and application artifact preparation are separate. The bootstrap
|
||||||
resolves and builds ReleaseKit from the tagged consumer's module configuration.
|
resolves and builds ReleaseKit from the tagged consumer's module configuration.
|
||||||
|
|||||||
@@ -123,3 +123,72 @@ against the captured endpoint before retrying; see the [maintainer procedure](..
|
|||||||
for the recovery steps. The result's endpoint is the exact URL and can contain
|
for the recovery steps. The result's endpoint is the exact URL and can contain
|
||||||
credentials; handle it accordingly. The [CLI contract](../cli.md) documents
|
credentials; handle it accordingly. The [CLI contract](../cli.md) documents
|
||||||
command output and exit statuses.
|
command output and exit statuses.
|
||||||
|
|
||||||
|
## Retain and reconcile publication evidence
|
||||||
|
|
||||||
|
`Publish` automatically attempts to save one private record for each invocation.
|
||||||
|
The default store is under the repository's Git common directory, so linked
|
||||||
|
worktrees share it. `ReleaseRequest.PublicationRecordDir` can override the
|
||||||
|
directory; relative overrides resolve from the source repository root. The CLI
|
||||||
|
equivalent is `publish --record-dir DIR`. ReleaseKit creates its owned storage
|
||||||
|
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.
|
||||||
|
Command preparation uses a separate bounded context after publication failure,
|
||||||
|
including cancellation. A module-preparation warning can accompany a persistent
|
||||||
|
native executable fallback; the saved record and publication result retain their
|
||||||
|
original meaning.
|
||||||
|
For API-only consumers, ReleaseKit retains private bootstrap 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. The bootstrap has
|
||||||
|
its own module root, so tool resolution is independent of consumer vendoring;
|
||||||
|
local replacements retain their original source locations. These files survive
|
||||||
|
a launcher's temporary module-file cleanup. Registered-tool commands run from
|
||||||
|
the selected module; API-only commands run from the private bootstrap root.
|
||||||
|
Retain the source checkout and 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
|
||||||
|
printing raw endpoint values, and remove records when their recovery value has
|
||||||
|
expired. Diagnostic errors and CLI output apply credential redaction; the
|
||||||
|
private record and typed evidence preserve the raw endpoint.
|
||||||
|
|
||||||
|
Recording failure is reported in `PublicationResult.RecordError`, separately
|
||||||
|
from `Publish`'s return error and publication state. A successful push remains
|
||||||
|
successful if storage fails, but the saved handoff is unavailable. Callers can
|
||||||
|
still pass `result.Evidence` directly to `ReconcilePublication` when it contains
|
||||||
|
the required identity. For example:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func observe(ctx context.Context, result releasekit.PublicationResult) (releasekit.PublicationReconciliationResult, error) {
|
||||||
|
return releasekit.ReconcilePublication(ctx, result.Evidence)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The evidence is populated as publication identity becomes available, including
|
||||||
|
partial evidence on some earlier failures. An abrupt process termination before
|
||||||
|
the save point can leave no record; removed storage also removes the handoff.
|
||||||
|
Records from older attempts may lack the tag object, endpoint, or other identity
|
||||||
|
needed for a conclusive observation.
|
||||||
|
|
||||||
|
Use `LoadPublicationEvidence(path)` to read one explicit record,
|
||||||
|
`ListPublicationAttempts(ctx, request)` to discover readable records and
|
||||||
|
per-record diagnostics in a local directory, or `ReconcilePublication(ctx,
|
||||||
|
evidence)` to observe a selected endpoint. Listing does not query remotes, and
|
||||||
|
neither listing nor reconciliation changes saved records. Recovery does not
|
||||||
|
infer the attempt from current HEAD, local tags, module files, or profiles.
|
||||||
|
`ReconcilePublication` returns a nil error for a completed `published`,
|
||||||
|
`not_published`, or `conflict` observation; unresolved `unknown` results include
|
||||||
|
a typed error that retains the underlying cause. See the
|
||||||
|
[GoDoc](../../recovery.go) for the exported fields and error kinds, the
|
||||||
|
[CLI guide](../cli.md) for command statuses, and the
|
||||||
|
[record format guide](../integrations/publication-records.md) for supported
|
||||||
|
stored and structured formats.
|
||||||
|
|||||||
123
docs/integrations/publication-records.md
Normal file
123
docs/integrations/publication-records.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Publication records and recovery output
|
||||||
|
|
||||||
|
This guide describes the supported private publication-record envelope and the
|
||||||
|
versioned JSON documents emitted by the recovery CLI. The
|
||||||
|
[publication consumer guide](../consumers/publish.md) owns storage placement,
|
||||||
|
permissions, retention, and sensitive-data handling. The
|
||||||
|
[CLI reference](../cli.md) owns command behavior, path selection, and exit
|
||||||
|
statuses; the exported Go contract is documented in [GoDoc](../../recovery.go).
|
||||||
|
|
||||||
|
## Private attempt record
|
||||||
|
|
||||||
|
Each saved attempt is one complete JSON document with this versioned envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"format_version": 1,
|
||||||
|
"evidence": {
|
||||||
|
"repository_dir": "/work/project",
|
||||||
|
"module_dir": "/work/project",
|
||||||
|
"module_path": "example.com/project",
|
||||||
|
"version": "v1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"candidate_commit": "<full Git object ID>",
|
||||||
|
"tag_object": "<full Git object ID>",
|
||||||
|
"endpoint": "ssh://git.example/owner/project.git",
|
||||||
|
"remote": "origin",
|
||||||
|
"recorded_at": "2026-11-02T12:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The format version is an integer. Evidence fields may be absent when publication
|
||||||
|
stopped before they were captured; loading preserves that partial evidence and
|
||||||
|
does not fill fields from the current checkout. Additional fields in a
|
||||||
|
supported version are ignored. A missing or unsupported version, malformed or
|
||||||
|
multiple JSON documents, or fields with invalid structural types are reported
|
||||||
|
as record-format errors. The codec supports versions 1 and 2; no migration from
|
||||||
|
an unsupported version is performed.
|
||||||
|
|
||||||
|
Ordinary UTF-8 evidence uses version 1. Evidence containing other pathname or
|
||||||
|
endpoint bytes uses version 2 with the same `evidence` object and an additional
|
||||||
|
`evidence_bytes` object. Each entry in `evidence_bytes` uses an evidence field
|
||||||
|
name and a standard base64 string containing its exact bytes. That entry
|
||||||
|
supplies the field's value when loading; the writer omits its lossy string form
|
||||||
|
from `evidence`. Invalid known-field types or base64 encodings produce a
|
||||||
|
record-format error with the available decoding cause. Unknown field names are
|
||||||
|
ignored. This representation applies
|
||||||
|
to every evidence field and preserves the same identity as typed Go evidence.
|
||||||
|
Older CLIs that support only version 1 report an unsupported version for these
|
||||||
|
byte-containing records; use a CLI supporting version 2 to load them.
|
||||||
|
|
||||||
|
The `endpoint` value is stored exactly as captured and may contain credentials.
|
||||||
|
Private records are sensitive. Do not copy them into public logs or source
|
||||||
|
control. For their location, permissions, and lifecycle, see the
|
||||||
|
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence).
|
||||||
|
|
||||||
|
## Recovery CLI JSON
|
||||||
|
|
||||||
|
After valid command arguments, `--json` emits one JSON document to standard
|
||||||
|
output for both successful and failed recovery operations. Each document has
|
||||||
|
`schema_version: 1` and a `command` name. These are CLI presentation documents,
|
||||||
|
not private attempt records or a Go serialization of public structs.
|
||||||
|
|
||||||
|
`reconcile` includes `record_path`, safe `evidence`, and `state` when a record
|
||||||
|
was read. Available `observed_tag_object` and `observed_commit` fields are
|
||||||
|
included when present. A `diagnostic` is included for unresolved observation
|
||||||
|
or record loading failure. Loading failure omits evidence, state, and observed
|
||||||
|
IDs.
|
||||||
|
|
||||||
|
`publication-attempts` includes `record_dir`, an `attempts` array with each
|
||||||
|
`record_path` and safe `evidence`, and a `diagnostics` array. A top-level
|
||||||
|
`diagnostic` describes enumeration or cancellation failure. Both arrays are
|
||||||
|
present when empty so consumers can decode the same shape for empty stores.
|
||||||
|
|
||||||
|
For example, an unresolved reconciliation can have this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"command": "reconcile",
|
||||||
|
"record_path": "/private/attempt-abc.json",
|
||||||
|
"evidence": {
|
||||||
|
"repository_dir": "/work/project",
|
||||||
|
"module_path": "example.com/project",
|
||||||
|
"version": "v1.2.3",
|
||||||
|
"tag": "v1.2.3",
|
||||||
|
"candidate_commit": "<full Git object ID>",
|
||||||
|
"tag_object": "<full Git object ID>",
|
||||||
|
"endpoint": "ssh://git.example/owner/project.git",
|
||||||
|
"remote": "origin"
|
||||||
|
},
|
||||||
|
"state": "unknown",
|
||||||
|
"diagnostic": {
|
||||||
|
"kind": "remote_observation",
|
||||||
|
"message": "<credential-safe diagnostic>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A discovery document always contains both arrays, including for an empty store:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"command": "publication-attempts",
|
||||||
|
"record_dir": "/private/publication-attempts",
|
||||||
|
"attempts": [],
|
||||||
|
"diagnostics": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI evidence uses the private record's snake-case field names. Endpoint strings
|
||||||
|
in CLI output are credential-redacted; the private record and typed
|
||||||
|
`PublicationEvidence` retain the exact endpoint. A diagnostic contains `kind`
|
||||||
|
and safe `message`, with `record_path` and `missing_fields` when applicable.
|
||||||
|
Raw Go errors and private record contents are not emitted as JSON fields.
|
||||||
|
|
||||||
|
Consumers should select attempts by their returned record paths and evidence.
|
||||||
|
The listing order is deterministic by path, not a recommendation to choose the
|
||||||
|
first or newest attempt. Unknown fields may be added in later CLI schema
|
||||||
|
versions; reject unsupported `schema_version` values rather than interpreting
|
||||||
|
them as version 1. See the [CLI reference](../cli.md#recover-a-saved-publication-attempt-or-discover-local-attempts)
|
||||||
|
for invocation and status behavior.
|
||||||
@@ -8,6 +8,20 @@ Standalone and delivery builds create their own owners. The package uses
|
|||||||
`internal/process` for cancellation, the working directory, output, and wrapped
|
`internal/process` for cancellation, the working directory, output, and wrapped
|
||||||
errors.
|
errors.
|
||||||
|
|
||||||
|
For CLI publication recovery, the package selects a registered module tool or
|
||||||
|
prepares a durable private module root for an API-only consumer. Go's module
|
||||||
|
graph query includes transitive dependencies; preparation retains module
|
||||||
|
requirements, versioned replacements, and checksums. Local replacements resolve
|
||||||
|
against their original module directory before relocation. The original main
|
||||||
|
module remains a local replacement, including when releasing ReleaseKit itself.
|
||||||
|
The private root provides tool resolution independently of consumer vendoring.
|
||||||
|
The CLI formats the later invocation using a separate bounded context after
|
||||||
|
publication failure. It falls back to a persistent native executable when module
|
||||||
|
selection is unavailable or preparation fails, and excludes recognized temporary
|
||||||
|
Go-work executables from that fallback. The
|
||||||
|
[publication consumer guide](../consumers/publish.md#retain-and-reconcile-publication-evidence)
|
||||||
|
owns recovery-file placement and retention.
|
||||||
|
|
||||||
## Command output ownership
|
## Command output ownership
|
||||||
|
|
||||||
`Tool.Run` executes a status-only command. It forwards combined stdout and
|
`Tool.Run` executes a status-only command. It forwards combined stdout and
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ two Go facades and a command-line interface:
|
|||||||
- `buildinfo` is the small runtime facade. `Version` reports the injected
|
- `buildinfo` is the small runtime facade. `Version` reports the injected
|
||||||
application version, the main-module version when available, or `dev`.
|
application version, the main-module version when available, or `dev`.
|
||||||
- `cmd/releasekit` is the executable interface. Its `version`, `check`,
|
- `cmd/releasekit` is the executable interface. Its `version`, `check`,
|
||||||
`publish`, `build`, `deliver`, and `ci-deliver` commands adapt profiles and
|
`publish`, `reconcile`, `publication-attempts`, `build`, `deliver`, and
|
||||||
command output to the supported library operations.
|
`ci-deliver` commands adapt profiles and command output to the supported
|
||||||
|
library operations.
|
||||||
|
|
||||||
See the [CLI contract](../cli.md), [configuration reference](../config.md),
|
See the [CLI contract](../cli.md), [configuration reference](../config.md),
|
||||||
[candidate check guide](../consumers/check.md),
|
[candidate check guide](../consumers/check.md),
|
||||||
[source publication guide](../consumers/publish.md),
|
[source publication guide](../consumers/publish.md),
|
||||||
|
[publication record format](../integrations/publication-records.md),
|
||||||
[build guide](../consumers/build.md),
|
[build guide](../consumers/build.md),
|
||||||
[delivery guide](../consumers/delivery.md), and
|
[delivery guide](../consumers/delivery.md), and
|
||||||
[runtime version guide](../consumers/runtime-version.md) for task-oriented use.
|
[runtime version guide](../consumers/runtime-version.md) for task-oriented use.
|
||||||
@@ -49,7 +51,7 @@ reports these implemented packages:
|
|||||||
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
|
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
|
||||||
| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. |
|
| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. |
|
||||||
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
|
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
|
||||||
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
|
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, pre-push evidence recording, saved-attempt discovery, later observation, and recovery state. |
|
||||||
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
|
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
|
||||||
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
||||||
|
|
||||||
@@ -75,6 +77,18 @@ it does not consult neighboring files or traverse submodule checkouts.
|
|||||||
See the [shared validation guide](validation.md) for checker, Go command, and
|
See the [shared validation guide](validation.md) for checker, Go command, and
|
||||||
candidate-file integration mechanics.
|
candidate-file integration mechanics.
|
||||||
|
|
||||||
|
Publication records and recovery follow a separate path from candidate
|
||||||
|
validation. `internal/publish` captures and saves attempt evidence before the
|
||||||
|
tag push, lists local records, and observes explicitly selected evidence through
|
||||||
|
`internal/git`; the root facade owns the reusable Go evidence and result types.
|
||||||
|
The CLI's `reconcile` and `publication-attempts` commands load, present, and
|
||||||
|
select records without loading a profile or choosing an attempt automatically.
|
||||||
|
These operations do not rerun candidate checks or hosted delivery and do not
|
||||||
|
change local or remote state. The [publication consumer guide](../consumers/publish.md)
|
||||||
|
owns storage and API use; the [record format guide](../integrations/publication-records.md)
|
||||||
|
owns the private and CLI JSON formats; the [CLI contract](../cli.md) owns command
|
||||||
|
semantics and statuses.
|
||||||
|
|
||||||
The root facade selects the backend explicitly and passes a configured
|
The root facade selects the backend explicitly and passes a configured
|
||||||
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
|
`internal/forge.Backend` into `internal/delivery`. Delivery performs cheap
|
||||||
request and source checks, then read-only backend preflight before managed
|
request and source checks, then read-only backend preflight before managed
|
||||||
@@ -102,6 +116,7 @@ not a separate release matrix:
|
|||||||
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
||||||
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
|
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
|
||||||
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
|
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
|
||||||
|
| Saved attempt recording, discovery, explicit later reconciliation, and read-only recovery | `releasekit`, `internal/publish`, `internal/git`, `cmd/releasekit` | `TestLostPublicationOutputCanBeRecoveredFromExplicitRecords`, `TestBuiltPublishRecoveryCommandSelectsItsRecordAmongOtherAttempts`, `TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause`, `TestRecoveryFacadeErrorUnwrapsCancellation`, `TestListPublicationAttemptsReadsExplicitExternalStore` |
|
||||||
| Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
|
| Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
|
||||||
| Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` |
|
| Explicit destination selection, standalone and automatic preflight, and delivery ordering | `releasekit`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliveryForgeSelectionIsExplicit`, `TestPreflightDeliveryChecksAuthenticatedRepositoryWithoutCheckout`, `TestPreflightDeliveryPreservesPublicFailureKindsAndCauses`, `TestAutomaticPreflightFailurePrecedesPrebuiltArtifactHashing`, `TestDeliverUnavailablePreflightStopsBeforeManagedBuild`, `TestCIDeliverUnavailablePreflightStopsBeforeDependencyPreparation`, `TestCIDeliverPageOnlyReportsUnavailablePreflightWithStatusOne` |
|
||||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
|
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
|
||||||
|
|||||||
@@ -124,48 +124,65 @@ the complete checked-in release note. Report its result separately from the
|
|||||||
completed source release. For failed or uncertain delivery, follow the recovery
|
completed source release. For failed or uncertain delivery, follow the recovery
|
||||||
instructions below using the same immutable version.
|
instructions below using the same immutable version.
|
||||||
|
|
||||||
## Resolve failures
|
## Resolve publication failures
|
||||||
|
|
||||||
A failed push triggers a URL-resolution check against the captured endpoint,
|
Each publication invocation attempts to save its captured identity before the
|
||||||
then at most one exact authoritative tag query. If the endpoint no longer
|
tag push. The CLI reports the saved record path separately from publication
|
||||||
resolves to itself, recovery reports `unknown` without querying the rewritten
|
success or failure and, after a push or immediate-reconciliation error, prints a
|
||||||
destination. An exact tag-object match confirms publication even when Git
|
copyable command that selects that exact record. Run that command, or use the
|
||||||
reported an error. A proven absent tag is `not_published`; a different object
|
source-built executable prepared above, to make a separate later observation:
|
||||||
is a conflict; an unreadable query is `unknown`. The command retains its local
|
|
||||||
tag and reports the state, phase,
|
|
||||||
candidate commit, tag object, and captured publication endpoint when available.
|
|
||||||
Do not retry an unknown result until you resolve the authoritative ref at that
|
|
||||||
exact endpoint. Copy the reported endpoint value into the variable below using
|
|
||||||
normal shell quoting; retain the version and tag object from the same result.
|
|
||||||
The command checks that Git URL rewriting still resolves this value to itself;
|
|
||||||
the manual check below applies the same rule before querying:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_endpoint='COPY_CAPTURED_ENDPOINT'
|
./.releasekit/bin/releasekit reconcile --record '/absolute/path/to/attempt-record.json'
|
||||||
releasekit_version='vX.Y.Z'
|
|
||||||
test "$(git ls-remote --get-url -- "$releasekit_endpoint")" = "$releasekit_endpoint" &&
|
|
||||||
git ls-remote --refs -- "$releasekit_endpoint" "refs/tags/$releasekit_version"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A failed URL-equality check or remote query leaves the outcome unresolved.
|
The saved evidence fixes the original endpoint and tag identity. Later recovery
|
||||||
Correct transport configuration before continuing. The reported endpoint may
|
uses current supported transport settings for the recorded remote name, but it
|
||||||
be different from `origin`'s fetch URL or its current push URL; querying either
|
does not substitute the current remote URL, inspect the local tag or current
|
||||||
of those does not establish absence at the captured endpoint.
|
HEAD, load a profile, rerun checks, or change repository state. A completed
|
||||||
|
observation reports `published`, `not_published`, or `conflict`; an incomplete
|
||||||
|
observation reports `unknown` with diagnostic context. Resolve an unknown result
|
||||||
|
by correcting the identified endpoint rewrite, transport configuration, or
|
||||||
|
missing evidence. For incomplete saved identity, select another record with the
|
||||||
|
required fields or use complete typed evidence through the Go API.
|
||||||
|
|
||||||
Compare the returned object ID with the tag object reported by the command.
|
If command output was lost, list local attempts and explicitly select the
|
||||||
An exact match confirms publication. If the ref is absent, the version was not
|
matching record by its module, full tag, and saved identity:
|
||||||
published; only then may you deliberately remove the unpublished local tag
|
|
||||||
with `git tag -d vX.Y.Z` before correcting the candidate or retrying. Do not
|
|
||||||
move, overwrite, or delete a tag that exists remotely. If a different object
|
|
||||||
uses the version, choose a new version after resolving the conflict.
|
|
||||||
|
|
||||||
If local tag inspection fails after creation, the reported ref may remain even
|
```sh
|
||||||
when no tag object ID was obtained. Inspect that ref before recovery.
|
./.releasekit/bin/releasekit publication-attempts --repo "$PWD"
|
||||||
|
./.releasekit/bin/releasekit reconcile --record '/absolute/path/from-the-list/attempt-record.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
Discovery only reads the local store. It neither queries remotes nor chooses an
|
||||||
|
attempt automatically. If the original recovery entry point is unavailable,
|
||||||
|
select another supported ReleaseKit CLI and pass the same record path explicitly.
|
||||||
|
An older record may lack the endpoint, tag-object ID, candidate commit, or
|
||||||
|
another field required for a conclusive result.
|
||||||
|
|
||||||
|
A later observation that the tag is absent describes the remote at that time;
|
||||||
|
it does not prove the version was never published. Never reuse a version known
|
||||||
|
to have been published, and preserve published tags and contents as immutable.
|
||||||
|
Keep removal of an unpublished local tag, candidate correction, and retry as
|
||||||
|
explicit operator decisions after reviewing the record and authoritative
|
||||||
|
remote state. A conflicting object requires resolving the conflict and selecting
|
||||||
|
an unused version when appropriate; do not move, overwrite, or delete a
|
||||||
|
published tag.
|
||||||
|
|
||||||
|
If record storage fails, ReleaseKit reports the recording warning separately;
|
||||||
|
the warning does not change the actual publication outcome. The saved handoff is
|
||||||
|
then unavailable. A Go caller that retained `PublicationResult.Evidence` can
|
||||||
|
still pass that typed evidence directly to `ReconcilePublication`. Process
|
||||||
|
termination before the record is written, removed storage, and older incomplete
|
||||||
|
records can leave no sufficient saved evidence for later recovery.
|
||||||
|
|
||||||
|
Later reconciliation adds no successful-push read-back, polling, CI, consumer
|
||||||
|
installation, or hosted-delivery gate. Hosted page delivery remains separate.
|
||||||
|
|
||||||
If validation or a guard fails before this invocation pushes, correct and
|
If validation or a guard fails before this invocation pushes, correct and
|
||||||
commit the candidate, publish `main`, and run the command again. A retained
|
commit the candidate, publish `main`, and run the command again. A retained
|
||||||
local tag still requires the authoritative-state check above before removal;
|
local tag still requires reviewing the selected record and authoritative state
|
||||||
a concurrent publisher may already have published the version. The prepared
|
before deciding whether to remove it. The prepared
|
||||||
Woodpecker workflow builds the CLI from the tagged checkout, then uses
|
Woodpecker workflow builds the CLI from the tagged checkout, then uses
|
||||||
`ci-deliver` for page-only delivery. To activate it after configuring the
|
`ci-deliver` for page-only delivery. To activate it after configuring the
|
||||||
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
|
destination and secret, copy it to `.woodpecker/releasekit-source-delivery.yml`
|
||||||
|
|||||||
54
docs/releases/v0.8.0.md
Normal file
54
docs/releases/v0.8.0.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ReleaseKit v0.8.0
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ReleaseKit adds read-only recovery for source-publication attempts. It attempts
|
||||||
|
to retain evidence before a tag push, lets operators discover and explicitly
|
||||||
|
select a saved attempt, and observes the original destination without repeating
|
||||||
|
candidate validation or changing tags. Source publication and hosted delivery
|
||||||
|
continue to have separate outcomes.
|
||||||
|
|
||||||
|
## Publication recovery
|
||||||
|
|
||||||
|
- `Publish` returns reusable `PublicationEvidence` and attempts to save a
|
||||||
|
private record. The result reports the saved path or a recording error
|
||||||
|
separately from the publication outcome. Go callers can load or list records
|
||||||
|
and reconcile retained evidence through the root facade.
|
||||||
|
- The CLI adds `publication-attempts` for local discovery and `reconcile` for
|
||||||
|
an explicitly selected record. Both support human-readable and structured
|
||||||
|
output. `publish --record-dir` selects an alternate record directory, and
|
||||||
|
failed publication can print a recovery command for the saved attempt.
|
||||||
|
- Recovery uses the captured endpoint and full tag with current supported Git
|
||||||
|
transport settings. It reports publication, current absence, conflict, or an
|
||||||
|
unresolved observation. Current absence does not prove that a version was
|
||||||
|
never published; published versions and their contents remain immutable.
|
||||||
|
- Records preserve exact endpoint and path bytes. Ordinary UTF-8 evidence uses
|
||||||
|
record format 1; format 2 preserves other bytes. Recovery diagnostics redact
|
||||||
|
HTTP(S) URL userinfo, while private records and typed evidence can contain
|
||||||
|
sensitive endpoints. Operators control record retention and removal.
|
||||||
|
|
||||||
|
## Consumer tools and compatibility
|
||||||
|
|
||||||
|
The module path remains `go.maximumdirect.net/releasekit`, and the Go consumer
|
||||||
|
minimum remains 1.26.0. Existing publication calls and release profiles remain
|
||||||
|
supported. Publication now attempts to write a private record by default; a
|
||||||
|
storage failure is reported separately and does not change the publication
|
||||||
|
result. Applications handling results should inspect `RecordError` when a saved
|
||||||
|
recovery handoff matters.
|
||||||
|
|
||||||
|
Consumers with the CLI registered as a Go tool in their module can run it with
|
||||||
|
`go tool releasekit`.
|
||||||
|
The maintained multi-module launcher and Woodpecker examples use the selected
|
||||||
|
module's tool version. Register the CLI before generating a vendor tree when
|
||||||
|
using a vendored tool. Older recovery CLIs that only support record format 1
|
||||||
|
cannot load format 2 records; use a CLI that supports format 2.
|
||||||
|
|
||||||
|
See the [publication guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/publish.md),
|
||||||
|
[CLI reference](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/cli.md),
|
||||||
|
[publication record formats](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/integrations/publication-records.md),
|
||||||
|
and [consumer adoption guide](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/consumers/adoption.md)
|
||||||
|
for usage and recovery details.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/COPYRIGHT) |
|
||||||
|
[LICENSE](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/LICENSE) |
|
||||||
|
[ATTRIBUTION](https://forge.maximumdirect.net/base-layer/releasekit/src/tag/v0.8.0/docs/oss/attribution.md)
|
||||||
@@ -72,11 +72,14 @@ Then select a module and semantic version with the shared launcher:
|
|||||||
|
|
||||||
The launcher copies the selected module's `go.mod` and `go.sum` to temporary
|
The launcher copies the selected module's `go.mod` and `go.sum` to temporary
|
||||||
alternate module files outside the checkout, resolves ReleaseKit from that
|
alternate module files outside the checkout, resolves ReleaseKit from that
|
||||||
module's local dependency selection, and builds the tool there. It invokes
|
module's local dependency selection, adds the CLI tool declaration to that
|
||||||
`publish` with the repository profile and `--expected-module` so the library
|
temporary copy, and prepares its dependencies. It uses `go tool releasekit`
|
||||||
can verify that the profile agrees with the selected table row. The temporary
|
with the alternate module file to invoke `publish` with the repository profile
|
||||||
files and tool are removed on exit. As with any `publish` invocation, commit the
|
and `--expected-module` so the library can verify that the profile agrees with
|
||||||
selected module's release note and prepare its repository before publishing.
|
the selected table row. Go builds and caches the executable and preserves its
|
||||||
|
exit status; the launcher removes the temporary metadata on exit. As with any
|
||||||
|
`publish` invocation, commit the selected module's release note and prepare its
|
||||||
|
repository before publishing.
|
||||||
|
|
||||||
If an integration depends on a sibling release, publish the dependency first,
|
If an integration depends on a sibling release, publish the dependency first,
|
||||||
then publish the integration with the version that consumes it. For example,
|
then publish the integration with the version that consumes it. For example,
|
||||||
@@ -86,8 +89,9 @@ This declaration does not infer dependency order or rerun the standard suite.
|
|||||||
The maintained [Woodpecker example](woodpecker.yml) uses the same map and
|
The maintained [Woodpecker example](woodpecker.yml) uses the same map and
|
||||||
selector for root, `ckconfig`, and major-version tags. It verifies and detaches
|
selector for root, `ckconfig`, and major-version tags. It verifies and detaches
|
||||||
the literal event tag before selecting the module, copies that module's metadata
|
the literal event tag before selecting the module, copies that module's metadata
|
||||||
outside the checkout, and builds the ReleaseKit version selected there. It then
|
outside the checkout, and runs the ReleaseKit version selected there through
|
||||||
uses `ci-deliver --prepared` with the mapped profile and expected physical module.
|
`go tool releasekit`. It then uses `ci-deliver --prepared` with the mapped profile
|
||||||
|
and expected physical module.
|
||||||
The `core-v2` profile demonstrates binary delivery; the other profiles are
|
The `core-v2` profile demonstrates binary delivery; the other profiles are
|
||||||
page-only. Before enabling the workflow, configure each selected profile's
|
page-only. Before enabling the workflow, configure each selected profile's
|
||||||
`delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository,
|
`delivery` object with `"forge": "gitea"`, its `base_url`, owner, and repository,
|
||||||
|
|||||||
@@ -37,12 +37,11 @@ releasekit_module=go.maximumdirect.net/releasekit
|
|||||||
export GOWORK=off
|
export GOWORK=off
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
|
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
|
||||||
go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \
|
go mod edit -modfile="$bootstrap_dir/releasekit.mod" -tool="$releasekit_module/cmd/releasekit"
|
||||||
-o "$bootstrap_dir/releasekit" "$releasekit_module/cmd/releasekit"
|
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -deps "$releasekit_module/cmd/releasekit" > /dev/null
|
||||||
)
|
go tool -modfile="$bootstrap_dir/releasekit.mod" releasekit publish \
|
||||||
|
|
||||||
"$bootstrap_dir/releasekit" publish \
|
|
||||||
--repo "$repository_dir" \
|
--repo "$repository_dir" \
|
||||||
--config "$profile_path" \
|
--config "$profile_path" \
|
||||||
--expected-module "$module_dir" \
|
--expected-module "$module_dir" \
|
||||||
"$version"
|
"$version"
|
||||||
|
)
|
||||||
|
|||||||
@@ -40,14 +40,12 @@ steps:
|
|||||||
if [ "$${module_dir}" != . ]; then
|
if [ "$${module_dir}" != . ]; then
|
||||||
module_path="$${CI_WORKSPACE}/$${module_dir}"
|
module_path="$${CI_WORKSPACE}/$${module_dir}"
|
||||||
fi
|
fi
|
||||||
cp "$${module_path}/go.mod" "$${bootstrap_dir}/go.mod"
|
cp "$${module_path}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
||||||
if [ -f "$${module_path}/go.sum" ]; then
|
if [ -f "$${module_path}/go.sum" ]; then
|
||||||
cp "$${module_path}/go.sum" "$${bootstrap_dir}/go.sum"
|
cp "$${module_path}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
||||||
else
|
else
|
||||||
: > "$${bootstrap_dir}/go.sum"
|
: > "$${bootstrap_dir}/releasekit.sum"
|
||||||
fi
|
fi
|
||||||
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
|
||||||
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
|
||||||
|
|
||||||
export GOWORK=off
|
export GOWORK=off
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
@@ -55,13 +53,13 @@ steps:
|
|||||||
(
|
(
|
||||||
cd "$${module_path}"
|
cd "$${module_path}"
|
||||||
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
||||||
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
|
||||||
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
|
||||||
)
|
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
|
||||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
|
||||||
--repo "$${CI_WORKSPACE}" \
|
--repo "$${CI_WORKSPACE}" \
|
||||||
--config "$${profile_path}" \
|
--config "$${profile_path}" \
|
||||||
--expected-module "$${module_dir}" \
|
--expected-module "$${module_dir}" \
|
||||||
--prepared \
|
--prepared \
|
||||||
--commit "$${expected_commit}" \
|
--commit "$${expected_commit}" \
|
||||||
"$${CI_COMMIT_TAG}"
|
"$${CI_COMMIT_TAG}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,22 +32,20 @@ steps:
|
|||||||
|
|
||||||
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
|
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
|
||||||
trap 'rm -rf "$${bootstrap_dir}"' EXIT
|
trap 'rm -rf "$${bootstrap_dir}"' EXIT
|
||||||
cp go.mod "$${bootstrap_dir}/go.mod"
|
cp go.mod "$${bootstrap_dir}/releasekit.mod"
|
||||||
if [ -f go.sum ]; then
|
if [ -f go.sum ]; then
|
||||||
cp go.sum "$${bootstrap_dir}/go.sum"
|
cp go.sum "$${bootstrap_dir}/releasekit.sum"
|
||||||
else
|
else
|
||||||
: > "$${bootstrap_dir}/go.sum"
|
: > "$${bootstrap_dir}/releasekit.sum"
|
||||||
fi
|
fi
|
||||||
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
|
|
||||||
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
|
|
||||||
|
|
||||||
export GOWORK=off
|
export GOWORK=off
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
releasekit_module=go.maximumdirect.net/releasekit
|
releasekit_module=go.maximumdirect.net/releasekit
|
||||||
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
|
||||||
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
|
go mod edit -modfile="$${bootstrap_dir}/releasekit.mod" -tool="$${releasekit_module}/cmd/releasekit"
|
||||||
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -deps "$${releasekit_module}/cmd/releasekit" > /dev/null
|
||||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
go tool -modfile="$${bootstrap_dir}/releasekit.mod" releasekit ci-deliver \
|
||||||
--repo "$${CI_WORKSPACE}" \
|
--repo "$${CI_WORKSPACE}" \
|
||||||
--prepared \
|
--prepared \
|
||||||
--commit "$${expected_commit}" \
|
--commit "$${expected_commit}" \
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func displayDiagnostic(message string) string {
|
|||||||
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
|
return httpURLSpan.ReplaceAllStringFunc(message, DisplayURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayDiagnostic redacts recognized HTTP credentials in a display string.
|
||||||
|
func DisplayDiagnostic(message string) string { return displayDiagnostic(message) }
|
||||||
|
|
||||||
type displayError struct{ cause error }
|
type displayError struct{ cause error }
|
||||||
|
|
||||||
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
|
func (err displayError) Error() string { return displayDiagnostic(err.cause.Error()) }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ var (
|
|||||||
ErrInvalidRemote = errors.New("invalid Git remote")
|
ErrInvalidRemote = errors.New("invalid Git remote")
|
||||||
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
|
ErrMultiplePushURLs = errors.New("Git remote has multiple push URLs")
|
||||||
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
||||||
|
ErrTransportConfiguration = errors.New("Git transport configuration is unreadable")
|
||||||
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
||||||
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
|
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
|
||||||
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
|
ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
|
||||||
@@ -84,6 +85,47 @@ func Resolve(ctx context.Context, repositoryDir, remoteName string) (Endpoint, e
|
|||||||
return endpoint, nil
|
return endpoint, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ObserveEndpoint creates a read-only command-scoped endpoint from previously
|
||||||
|
// captured publication evidence. The recorded URL is used for both directions;
|
||||||
|
// current transport settings are copied only when the original remote exists.
|
||||||
|
func ObserveEndpoint(ctx context.Context, repositoryDir, endpointURL, remoteName string) (Endpoint, error) {
|
||||||
|
if endpointURL == "" {
|
||||||
|
return Endpoint{}, fmt.Errorf("empty captured endpoint")
|
||||||
|
}
|
||||||
|
scopedName, err := newScopedName()
|
||||||
|
if err != nil {
|
||||||
|
return Endpoint{}, fmt.Errorf("create command-scoped remote name: %w", err)
|
||||||
|
}
|
||||||
|
endpoint := Endpoint{RemoteName: remoteName, PushURL: endpointURL, ScopedName: scopedName}
|
||||||
|
if remoteName != "" {
|
||||||
|
config, configErr := transportConfig(ctx, repositoryDir, remoteName, scopedName)
|
||||||
|
if configErr != nil {
|
||||||
|
return Endpoint{}, configErr
|
||||||
|
}
|
||||||
|
endpoint.transport = config
|
||||||
|
}
|
||||||
|
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
||||||
|
return Endpoint{}, err
|
||||||
|
}
|
||||||
|
return endpoint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EqualObjectID compares supported full object IDs without regard to hex case.
|
||||||
|
func EqualObjectID(left, right string) bool {
|
||||||
|
return validObjectID(left) && validObjectID(right) && strings.EqualFold(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidObjectID reports whether value is a full supported Git object ID.
|
||||||
|
func ValidObjectID(value string) bool { return validObjectID(value) }
|
||||||
|
|
||||||
|
// DisplayError exposes credential-safe rendering for shared recovery errors.
|
||||||
|
func DisplayError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return displayError{cause: err}
|
||||||
|
}
|
||||||
|
|
||||||
// Recheck confirms both the selected remote's effective push destination and
|
// Recheck confirms both the selected remote's effective push destination and
|
||||||
// the scoped remote's effective read and push URLs still identify this endpoint.
|
// the scoped remote's effective read and push URLs still identify this endpoint.
|
||||||
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) error {
|
||||||
@@ -161,7 +203,7 @@ func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, ta
|
|||||||
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
|
func (endpoint Endpoint) verifyScopedURLs(ctx context.Context, repositoryDir string) error {
|
||||||
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
|
readURL, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", "--get-url", endpoint.ScopedName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve scoped read URL: %w", err)
|
return fmt.Errorf("%w: resolve scoped read URL: %w", ErrTransportConfiguration, err)
|
||||||
}
|
}
|
||||||
resolved := outputLines(readURL)
|
resolved := outputLines(readURL)
|
||||||
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
|
if len(resolved) != 1 || resolved[0] != endpoint.PushURL {
|
||||||
@@ -197,7 +239,7 @@ func transportConfig(ctx context.Context, repositoryDir, remoteName, scopedName
|
|||||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read transport setting remote.%s.%s: %w", DisplayURL(remoteName), key, err)
|
return nil, fmt.Errorf("%w: read transport setting remote.%s.%s: %w", ErrTransportConfiguration, DisplayURL(remoteName), key, err)
|
||||||
}
|
}
|
||||||
for _, value := range outputLines(output) {
|
for _, value := range outputLines(output) {
|
||||||
config = append(config, "remote."+scopedName+"."+key+"="+value)
|
config = append(config, "remote."+scopedName+"."+key+"="+value)
|
||||||
@@ -305,15 +347,33 @@ func finalizeRef(ref RemoteRef) RemoteRef {
|
|||||||
|
|
||||||
// CheckTagRef validates a complete tag name using Git ref rules.
|
// CheckTagRef validates a complete tag name using Git ref rules.
|
||||||
func CheckTagRef(ctx context.Context, repositoryDir, tag string) error {
|
func CheckTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||||
if tag == "" {
|
valid, err := ValidateTagRef(ctx, repositoryDir, tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
return fmt.Errorf("invalid Git tag name %q", tag)
|
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
|
|
||||||
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateTagRef distinguishes a rejected ref from failures to run Git or
|
||||||
|
// access the selected repository.
|
||||||
|
func ValidateTagRef(ctx context.Context, repositoryDir, tag string) (bool, error) {
|
||||||
|
if tag == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
_, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var exitErr interface{ ExitCode() int }
|
||||||
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("check Git tag name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
func validObjectID(object string) bool {
|
func validObjectID(object string) bool {
|
||||||
if len(object) != 40 && len(object) != 64 {
|
if len(object) != 40 && len(object) != 64 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -42,6 +42,46 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestObserveEndpointUsesCapturedURLAndCurrentNamedTransportSettings(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
gitRun(t, repo.work, "remote", "remove", "origin")
|
||||||
|
gitRun(t, repo.work, "config", "remote.renamed.receivepack", "git-receive-pack")
|
||||||
|
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "renamed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if endpoint.PushURL != repo.bare || len(endpoint.transport) != 1 || endpoint.transport[0] != "remote."+endpoint.ScopedName+".receivepack=git-receive-pack" {
|
||||||
|
t.Fatalf("observed endpoint = %#v", endpoint)
|
||||||
|
}
|
||||||
|
ref, err := endpoint.QueryTag(context.Background(), repo.work, "v1.2.3")
|
||||||
|
if err != nil || ref.State != RefAbsent {
|
||||||
|
t.Fatalf("QueryTag() = %#v, %v", ref, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveEndpointDoesNotSelectOriginWithoutAName(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
gitRun(t, repo.work, "config", "remote.origin.receivepack", "custom-receive-pack")
|
||||||
|
endpoint, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(endpoint.transport) != 0 {
|
||||||
|
t.Fatalf("transport settings = %v; unnamed endpoint must not select origin", endpoint.transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveEndpointRejectsCapturedURLRewriting(t *testing.T) {
|
||||||
|
repo := newRemoteFixture(t)
|
||||||
|
replacement := filepath.Join(t.TempDir(), "replacement.git")
|
||||||
|
gitRun(t, repo.work, "init", "--bare", replacement)
|
||||||
|
gitRun(t, repo.work, "config", "url."+replacement+".insteadOf", repo.bare)
|
||||||
|
_, err := ObserveEndpoint(context.Background(), repo.work, repo.bare, "")
|
||||||
|
if !errors.Is(err, ErrEndpointChanged) {
|
||||||
|
t.Fatalf("ObserveEndpoint() error = %v, want ErrEndpointChanged", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
|
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
|
||||||
fixture := newRemoteFixture(t)
|
fixture := newRemoteFixture(t)
|
||||||
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
|
||||||
|
|||||||
@@ -56,6 +56,45 @@ func normalizeRepositoryRoot(root string) (string, error) {
|
|||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MetadataDirectories returns the checkout's private and shared Git directories.
|
||||||
|
func MetadataDirectories(ctx context.Context, directory string) ([]string, error) {
|
||||||
|
var directories []string
|
||||||
|
for _, flag := range []string{"--absolute-git-dir", "--git-common-dir"} {
|
||||||
|
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", flag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve Git metadata directory: %w", err)
|
||||||
|
}
|
||||||
|
resolved, err := normalizeRepositoryRoot(output)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
directories = append(directories, resolved)
|
||||||
|
}
|
||||||
|
return directories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonDirectory resolves the shared Git metadata directory.
|
||||||
|
func CommonDirectory(ctx context.Context, directory string) (string, error) {
|
||||||
|
output, err := run(ctx, directory, nil, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve shared Git metadata directory: %w", err)
|
||||||
|
}
|
||||||
|
return normalizeRepositoryRoot(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIgnored checks whether Git excludes the selected worktree path.
|
||||||
|
func IsIgnored(ctx context.Context, directory, path string) (bool, error) {
|
||||||
|
_, err := run(ctx, directory, nil, "check-ignore", "-q", "--", path)
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
|
// DiscoverRepositoryRoot finds a containing checkout or confirms standalone use.
|
||||||
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
|
func DiscoverRepositoryRoot(ctx context.Context, directory string) (string, bool, error) {
|
||||||
if directory == "" {
|
if directory == "" {
|
||||||
|
|||||||
154
internal/gotool/recovery.go
Normal file
154
internal/gotool/recovery.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package gotool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/process"
|
||||||
|
)
|
||||||
|
|
||||||
|
const releaseKitModule = "go.maximumdirect.net/releasekit"
|
||||||
|
const releaseKitCommand = releaseKitModule + "/cmd/releasekit"
|
||||||
|
|
||||||
|
// TemporaryExecutable recognizes executables in Go's temporary build layout,
|
||||||
|
// <work parent>/go-build<nonce>/b<action>/exe/<name>. Those paths disappear when
|
||||||
|
// the Go driver finishes and cannot provide a later native-command fallback.
|
||||||
|
func TemporaryExecutable(path string) bool {
|
||||||
|
exeDir := filepath.Dir(path)
|
||||||
|
if filepath.Base(exeDir) != "exe" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
actionDir := filepath.Dir(exeDir)
|
||||||
|
action, ok := strings.CutPrefix(filepath.Base(actionDir), "b")
|
||||||
|
if !ok || action == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := strconv.ParseUint(action, 10, 64); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nonce, ok := strings.CutPrefix(filepath.Base(filepath.Dir(actionDir)), "go-build")
|
||||||
|
if !ok || nonce == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := strconv.ParseUint(nonce, 10, 64)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryTool returns module-selected Go arguments for a later invocation.
|
||||||
|
// An empty result means this module does not select ReleaseKit. API-only modules
|
||||||
|
// receive a private module root that outlives 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
|
||||||
|
}
|
||||||
|
// Relocating the root avoids the consumer's vendor tree. Preserve local
|
||||||
|
// replacements relative to the directory that originally owned go.mod.
|
||||||
|
for _, replacement := range module.Replace {
|
||||||
|
if replacement.New.Version == "" && !filepath.IsAbs(replacement.New.Path) {
|
||||||
|
if err := module.AddReplace(replacement.Old.Path, replacement.Old.Version, filepath.Join(moduleDir, replacement.New.Path), ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The original main module's source remains local if a dependency refers
|
||||||
|
// to it. This also permits recovery when releasing ReleaseKit itself.
|
||||||
|
if err := module.AddReplace(module.Module.Mod.Path, "", moduleDir, ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if selected.Main {
|
||||||
|
if err := module.AddRequire(releaseKitModule, "v0.0.0"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := module.AddModuleStmt("releasekit.local/recovery"); 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "go.mod"), data, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "go.sum"), sums, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Prepare the private graph and sums before the later tool invocation.
|
||||||
|
captured := &combinedWriter{mode: captureStatus}
|
||||||
|
err = process.Run(ctx, process.Invocation{
|
||||||
|
Args: []string{"go", "list", "-mod=mod", "-modfile=", "-deps", releaseKitCommand},
|
||||||
|
Dir: directory, 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", directory, "tool", "-modfile=", releaseKitCommand}, nil
|
||||||
|
}
|
||||||
22
internal/gotool/recovery_test.go
Normal file
22
internal/gotool/recovery_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package gotool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryFallbackExecutableLifetime(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
path string
|
||||||
|
temporary bool
|
||||||
|
}{
|
||||||
|
{filepath.Join("tmp", "go-build123456", "b001", "exe", "releasekit"), true},
|
||||||
|
{filepath.Join("cache", "go-build", "hash-d", "releasekit"), false},
|
||||||
|
{filepath.Join("tmp", "tools", "releasekit"), false},
|
||||||
|
{filepath.Join("workspace", "go-build-project", "b001", "exe", "releasekit"), false},
|
||||||
|
} {
|
||||||
|
if got := TemporaryExecutable(test.path); got != test.temporary {
|
||||||
|
t.Errorf("temporary executable %q = %t, want %t", test.path, got, test.temporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/publish/attempts.go
Normal file
112
internal/publish/attempts.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Attempt struct {
|
||||||
|
RecordPath string
|
||||||
|
Evidence Evidence
|
||||||
|
}
|
||||||
|
type AttemptsResult struct {
|
||||||
|
RecordDir string
|
||||||
|
Attempts []Attempt
|
||||||
|
Diagnostics []*RecoveryError
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListAttempts(ctx context.Context, repositoryDir, override string) (AttemptsResult, error) {
|
||||||
|
return listAttempts(ctx, repositoryDir, override, recordPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listAttempts(ctx context.Context, repositoryDir, override string, enumerate func(context.Context, string) ([]string, error)) (AttemptsResult, error) {
|
||||||
|
if repositoryDir == "" {
|
||||||
|
repositoryDir = "."
|
||||||
|
}
|
||||||
|
result := AttemptsResult{Attempts: []Attempt{}, Diagnostics: []*RecoveryError{}}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, recoveryFailure(ctx, "storage_enumeration", err)
|
||||||
|
}
|
||||||
|
var directory string
|
||||||
|
{
|
||||||
|
resolved, err := recordDirectoryFor(ctx, repositoryDir, override)
|
||||||
|
if err != nil {
|
||||||
|
return result, recoveryFailure(ctx, "storage_enumeration", err)
|
||||||
|
}
|
||||||
|
directory = resolved
|
||||||
|
}
|
||||||
|
result.RecordDir = directory
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, &RecoveryError{Kind: "canceled", cause: err}
|
||||||
|
}
|
||||||
|
paths, enumerationErr := enumerate(ctx, directory)
|
||||||
|
sort.Strings(paths)
|
||||||
|
for _, path := range paths {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, &RecoveryError{Kind: "canceled", cause: err}
|
||||||
|
}
|
||||||
|
absolute, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
result.Diagnostics = append(result.Diagnostics, &RecoveryError{Kind: "record_read", RecordPath: path, cause: err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evidence, err := LoadEvidence(absolute)
|
||||||
|
if err != nil {
|
||||||
|
var recordError *RecoveryError
|
||||||
|
if errors.As(err, &recordError) {
|
||||||
|
result.Diagnostics = append(result.Diagnostics, recordError)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Attempts = append(result.Attempts, Attempt{RecordPath: absolute, Evidence: evidence})
|
||||||
|
}
|
||||||
|
if enumerationErr != nil {
|
||||||
|
return result, recoveryFailure(ctx, "storage_enumeration", fmt.Errorf("enumerate records in %q: %w", directory, enumerationErr))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPaths filters unsorted batches, retaining only selected record names.
|
||||||
|
func recordPaths(ctx context.Context, directory string) ([]string, error) {
|
||||||
|
file, err := os.Open(directory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return readRecordPaths(ctx, directory, file.ReadDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRecordPaths(ctx context.Context, directory string, read func(int) ([]os.DirEntry, error)) ([]string, error) {
|
||||||
|
var paths []string
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return paths, err
|
||||||
|
}
|
||||||
|
entries, err := read(128)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
|
||||||
|
paths = append(paths, filepath.Join(directory, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return paths, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDirectoryFor(ctx context.Context, repositoryDir, override string) (string, error) {
|
||||||
|
if filepath.IsAbs(override) {
|
||||||
|
return filepath.Clean(override), nil
|
||||||
|
}
|
||||||
|
directory, _, err := resolveRecordDir(ctx, repositoryDir, override)
|
||||||
|
return directory, err
|
||||||
|
}
|
||||||
147
internal/publish/cancellation_test.go
Normal file
147
internal/publish/cancellation_test.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryAlreadyCanceledDiscovery(t *testing.T) {
|
||||||
|
for _, override := range []string{"", "relative"} {
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
result, err := ListAttempts(ctx, t.TempDir(), override)
|
||||||
|
requireCanceledRecovery(t, err)
|
||||||
|
if len(result.Attempts) != 0 {
|
||||||
|
t.Fatalf("unexpected attempts: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireCanceledRecovery(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation = %v; want canceled kind and cause", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryCancelsActiveGitOperations(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("fixture uses a POSIX Git launcher")
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name, command string
|
||||||
|
run func(context.Context, string) error
|
||||||
|
}{
|
||||||
|
{"default discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }},
|
||||||
|
{"relative discovery root", "--show-toplevel", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, "records"); return err }},
|
||||||
|
{"default discovery metadata", "--git-common-dir", func(ctx context.Context, repo string) error { _, err := ListAttempts(ctx, repo, ""); return err }},
|
||||||
|
{"record metadata", "--git-common-dir", func(ctx context.Context, repo string) error {
|
||||||
|
_, err := SaveEvidence(ctx, repo, "", Evidence{})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"record ignore guard", "check-ignore", func(ctx context.Context, repo string) error {
|
||||||
|
_, err := SaveEvidence(ctx, repo, "records", Evidence{})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"publication metadata", "--git-common-dir", func(ctx context.Context, repo string) error {
|
||||||
|
result, err := Publish(ctx, Request{RepositoryDir: repo, Product: "Sample", Version: "v1.2.3"})
|
||||||
|
if !errors.Is(result.RecordError, context.Canceled) {
|
||||||
|
return fmt.Errorf("record cancellation lost: %v", result.RecordError)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"tag validation", "check-ref-format", func(ctx context.Context, repo string) error {
|
||||||
|
_, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"tag query", "ls-remote", func(ctx context.Context, repo string) error {
|
||||||
|
_, err := Reconcile(ctx, Evidence{RepositoryDir: repo, Endpoint: repo, Tag: "v1.2.3"})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
if test.name == "publication metadata" {
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module example.com/sample\n\ngo 1.26.0\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(repo, "docs", "releases"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, "docs", "releases", "v1.2.3.md"), []byte("# Sample v1.2.3\n\nChanges.\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recordGit(t, repo, "checkout", "-B", "main")
|
||||||
|
recordGit(t, repo, "add", ".")
|
||||||
|
recordGit(t, repo, "commit", "-qm", "Release candidate")
|
||||||
|
}
|
||||||
|
realGit, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
ready := filepath.Join(t.TempDir(), "ready")
|
||||||
|
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
|
||||||
|
script := "#!/bin/sh\nfor arg in \"$@\"; do\n if [ \"$arg\" = " + quote(test.command) + " ]; then\n echo $$ > " + quote(ready) + "\n exec sleep 600\n fi\ndone\nexec " + quote(realGit) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- test.run(ctx, repo) }()
|
||||||
|
// Synchronize cancellation with the intercepted operation, rather than timing its start.
|
||||||
|
ticker := time.NewTicker(5 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(10 * time.Second)
|
||||||
|
defer timeout.Stop()
|
||||||
|
var pid int
|
||||||
|
waiting:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("operation finished before interception: %v", err)
|
||||||
|
case <-timeout.C:
|
||||||
|
t.Fatal("Git operation was never intercepted")
|
||||||
|
case <-ticker.C:
|
||||||
|
data, err := os.ReadFile(ready)
|
||||||
|
if err == nil {
|
||||||
|
pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
|
||||||
|
if err == nil {
|
||||||
|
break waiting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("lost context cause: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(test.name, "discovery") || test.command == "check-ref-format" || test.command == "ls-remote" {
|
||||||
|
requireCanceledRecovery(t, err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
process, _ := os.FindProcess(pid)
|
||||||
|
if process != nil {
|
||||||
|
_ = process.Kill()
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
t.Fatal("cancellation did not stop the Git operation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ type Request struct {
|
|||||||
Version string
|
Version string
|
||||||
Remote string
|
Remote string
|
||||||
ExpectedPushURL string
|
ExpectedPushURL string
|
||||||
|
RecordDir string
|
||||||
Validation validation.Settings
|
Validation validation.Settings
|
||||||
AdditionalChecks func(context.Context, candidate.Candidate) error
|
AdditionalChecks func(context.Context, candidate.Candidate) error
|
||||||
AfterValidate func(context.Context, candidate.Candidate, *gotool.Owner) error
|
AfterValidate func(context.Context, candidate.Candidate, *gotool.Owner) error
|
||||||
@@ -65,6 +66,9 @@ type Result struct {
|
|||||||
Phase Phase
|
Phase Phase
|
||||||
TagObject string
|
TagObject string
|
||||||
State State
|
State State
|
||||||
|
Evidence Evidence
|
||||||
|
RecordPath string
|
||||||
|
RecordError error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish captures a candidate, resolves and checks the optional expected push
|
// Publish captures a candidate, resolves and checks the optional expected push
|
||||||
@@ -74,8 +78,22 @@ type Result struct {
|
|||||||
// A failed push receives one bounded authoritative tag query. Exact
|
// A failed push receives one bounded authoritative tag query. Exact
|
||||||
// object equality confirms publication; absence and conflict are classified,
|
// object equality confirms publication; absence and conflict are classified,
|
||||||
// while an unreadable result remains Unknown and preserves the push error.
|
// while an unreadable result remains Unknown and preserves the push error.
|
||||||
func Publish(ctx context.Context, request Request) (Result, error) {
|
func Publish(ctx context.Context, request Request) (result Result, resultErr error) {
|
||||||
result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
|
result = Result{Version: request.Version, Phase: Preflight, State: NotAttempted}
|
||||||
|
var recordDir, repositoryRoot string
|
||||||
|
initialized, recorded := false, false
|
||||||
|
defer func() {
|
||||||
|
if !initialized || recorded || result.RecordError != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recorded = true
|
||||||
|
path, err := saveEvidenceInDir(ctx, result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence)
|
||||||
|
if err != nil {
|
||||||
|
result.RecordError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.RecordPath = path
|
||||||
|
}()
|
||||||
remoteName := request.Remote
|
remoteName := request.Remote
|
||||||
if remoteName == "" {
|
if remoteName == "" {
|
||||||
remoteName = "origin"
|
remoteName = "origin"
|
||||||
@@ -89,14 +107,26 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
AdditionalChecks: request.AdditionalChecks,
|
AdditionalChecks: request.AdditionalChecks,
|
||||||
Output: request.Output,
|
Output: request.Output,
|
||||||
}
|
}
|
||||||
captured, err := candidate.Capture(ctx, checks)
|
captured, captureErr := candidate.Capture(ctx, checks)
|
||||||
result.RepositoryDir = captured.RepositoryDir
|
result.RepositoryDir = captured.RepositoryDir
|
||||||
result.ModuleDir = captured.ModuleDir
|
result.ModuleDir = captured.ModuleDir
|
||||||
result.ModulePath = captured.ModulePath
|
result.ModulePath = captured.ModulePath
|
||||||
result.Tag = captured.Tag
|
result.Tag = captured.Tag
|
||||||
result.CandidateCommit = captured.Commit
|
result.CandidateCommit = captured.Commit
|
||||||
if err != nil {
|
result.Evidence = Evidence{RepositoryDir: captured.RepositoryDir, ModuleDir: captured.ModuleDir, ModulePath: captured.ModulePath, Version: captured.Version, Tag: captured.Tag, CandidateCommit: captured.Commit, Remote: remoteName}
|
||||||
return result, fmt.Errorf("capture release candidate: %w", err)
|
if captured.RepositoryDir != "" {
|
||||||
|
initialized = true
|
||||||
|
result.Evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
var storageErr error
|
||||||
|
recordDir, repositoryRoot, storageErr = resolveRecordDir(ctx, captured.RepositoryDir, request.RecordDir)
|
||||||
|
if storageErr != nil {
|
||||||
|
result.RecordError = &RecoveryError{Kind: "record_write", cause: storageErr}
|
||||||
|
recordDir = ""
|
||||||
|
recorded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if captureErr != nil {
|
||||||
|
return result, fmt.Errorf("capture release candidate: %w", captureErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
|
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
|
||||||
@@ -104,21 +134,22 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
return result, fmt.Errorf("resolve publication endpoint: %w", err)
|
return result, fmt.Errorf("resolve publication endpoint: %w", err)
|
||||||
}
|
}
|
||||||
result.Endpoint = endpoint.PushURL
|
result.Endpoint = endpoint.PushURL
|
||||||
|
result.Evidence.Endpoint = endpoint.PushURL
|
||||||
if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL {
|
if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL {
|
||||||
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, git.DisplayURL(request.ExpectedPushURL), git.DisplayURL(endpoint.PushURL))
|
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, git.DisplayURL(request.ExpectedPushURL), git.DisplayURL(endpoint.PushURL))
|
||||||
}
|
}
|
||||||
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
|
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
|
||||||
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
|
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := candidate.Recheck(ctx, captured, ""); err != nil {
|
||||||
|
return result, fmt.Errorf("pre-validation local guard: candidate worktree and index must remain clean: %w", err)
|
||||||
|
}
|
||||||
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil {
|
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil {
|
||||||
if isRemoteConflict(err) {
|
if isRemoteConflict(err) {
|
||||||
result.State = Conflict
|
result.State = Conflict
|
||||||
}
|
}
|
||||||
return result, fmt.Errorf("pre-validation remote guard: %w", err)
|
return result, fmt.Errorf("pre-validation remote guard: %w", err)
|
||||||
}
|
}
|
||||||
if err := candidate.Recheck(ctx, captured, ""); err != nil {
|
|
||||||
return result, fmt.Errorf("pre-validation local guard: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Phase = Validation
|
result.Phase = Validation
|
||||||
checks.ToolOwner = gotool.NewOwner(captured.ModuleDir)
|
checks.ToolOwner = gotool.NewOwner(captured.ModuleDir)
|
||||||
@@ -148,6 +179,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
result.State = NotPublished
|
result.State = NotPublished
|
||||||
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
|
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
|
||||||
result.TagObject = tagObject
|
result.TagObject = tagObject
|
||||||
|
result.Evidence.TagObject = tagObject
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
@@ -166,6 +198,15 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
|||||||
|
|
||||||
result.Phase = Push
|
result.Phase = Push
|
||||||
result.State = Unknown
|
result.State = Unknown
|
||||||
|
if !recorded {
|
||||||
|
recorded = true
|
||||||
|
path, recordErr := saveEvidenceInDir(ctx, result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence)
|
||||||
|
if recordErr != nil {
|
||||||
|
result.RecordError = recordErr
|
||||||
|
} else {
|
||||||
|
result.RecordPath = path
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Tag, tagObject); err != nil {
|
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Tag, tagObject); err != nil {
|
||||||
return reconcilePush(endpoint, captured, tagObject, result, err)
|
return reconcilePush(endpoint, captured, tagObject, result, err)
|
||||||
}
|
}
|
||||||
@@ -179,22 +220,23 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
|||||||
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
|
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag)
|
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag)
|
||||||
if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict {
|
observation, classifyErr := classify(Evidence{RepositoryDir: captured.RepositoryDir, Tag: captured.Tag, CandidateCommit: captured.Commit, TagObject: tagObject, Endpoint: endpoint.PushURL}, remoteTag)
|
||||||
|
if queryErr != nil || classifyErr != nil {
|
||||||
result.State = Unknown
|
result.State = Unknown
|
||||||
if queryErr == nil {
|
if queryErr == nil {
|
||||||
if remoteTag.State == git.RefConflict {
|
if remoteTag.State == git.RefConflict {
|
||||||
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
|
queryErr = errors.New("authoritative tag query returned inconsistent exact ref records")
|
||||||
} else {
|
} else {
|
||||||
queryErr = git.ErrRemoteStateUnreadable
|
queryErr = classifyErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, git.DisplayURL(endpoint.PushURL), queryErr, pushErr)
|
return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, git.DisplayURL(endpoint.PushURL), queryErr, pushErr)
|
||||||
}
|
}
|
||||||
if remoteTag.State == git.RefAbsent {
|
if observation.State == NotPublished {
|
||||||
result.State = NotPublished
|
result.State = NotPublished
|
||||||
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, git.DisplayURL(endpoint.PushURL), pushErr)
|
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, git.DisplayURL(endpoint.PushURL), pushErr)
|
||||||
}
|
}
|
||||||
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
|
if observation.State == Published {
|
||||||
result.Phase = Complete
|
result.Phase = Complete
|
||||||
result.State = Published
|
result.State = Published
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
153
internal/publish/reconcile.go
Normal file
153
internal/publish/reconcile.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Evidence is the identity captured when one publication was attempted.
|
||||||
|
type Evidence struct {
|
||||||
|
RepositoryDir string `json:"repository_dir,omitempty"`
|
||||||
|
ModuleDir string `json:"module_dir,omitempty"`
|
||||||
|
ModulePath string `json:"module_path,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
CandidateCommit string `json:"candidate_commit,omitempty"`
|
||||||
|
TagObject string `json:"tag_object,omitempty"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Remote string `json:"remote,omitempty"`
|
||||||
|
RecordedAt string `json:"recorded_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observation combines the original evidence with authoritative remote state.
|
||||||
|
type Observation struct {
|
||||||
|
Evidence Evidence
|
||||||
|
State State
|
||||||
|
ObservedTagObject string
|
||||||
|
ObservedCommit string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryError describes why a later publication observation is unresolved.
|
||||||
|
type RecoveryError struct {
|
||||||
|
Kind string
|
||||||
|
RecordPath string
|
||||||
|
MissingFields []string
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RecoveryError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
message := "publication recovery " + e.Kind
|
||||||
|
if e.cause != nil {
|
||||||
|
return message + ": " + git.DisplayError(e.cause).Error()
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
func (e *RecoveryError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func classify(evidence Evidence, remoteTag git.RemoteRef) (Observation, error) {
|
||||||
|
result := Observation{Evidence: evidence, State: Unknown, ObservedTagObject: remoteTag.Object, ObservedCommit: remoteTag.Peeled}
|
||||||
|
missing := []string{}
|
||||||
|
if evidence.Endpoint == "" {
|
||||||
|
missing = append(missing, "endpoint")
|
||||||
|
}
|
||||||
|
if evidence.Tag == "" {
|
||||||
|
missing = append(missing, "tag")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: missing}
|
||||||
|
}
|
||||||
|
if remoteTag.State == git.RefAbsent {
|
||||||
|
result.State = NotPublished
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if evidence.TagObject == "" {
|
||||||
|
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"tag_object"}}
|
||||||
|
}
|
||||||
|
if remoteTag.State != git.RefPresent {
|
||||||
|
return result, &RecoveryError{Kind: "inconsistent_observation", cause: git.ErrRemoteStateUnreadable}
|
||||||
|
}
|
||||||
|
if !git.EqualObjectID(remoteTag.Object, evidence.TagObject) {
|
||||||
|
result.State = Conflict
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if remoteTag.Peeled != "" {
|
||||||
|
if evidence.CandidateCommit == "" {
|
||||||
|
return result, &RecoveryError{Kind: "missing_evidence", MissingFields: []string{"candidate_commit"}}
|
||||||
|
}
|
||||||
|
if !git.EqualObjectID(remoteTag.Peeled, evidence.CandidateCommit) {
|
||||||
|
result.State = Conflict
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.State = Published
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func observe(ctx context.Context, evidence Evidence) (Observation, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "canceled", cause: err}
|
||||||
|
}
|
||||||
|
if evidence.Endpoint == "" || evidence.Tag == "" {
|
||||||
|
return classify(evidence, git.RemoteRef{})
|
||||||
|
}
|
||||||
|
if evidence.TagObject != "" && !git.ValidObjectID(evidence.TagObject) {
|
||||||
|
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
|
||||||
|
}
|
||||||
|
if evidence.CandidateCommit != "" && !git.ValidObjectID(evidence.CandidateCommit) {
|
||||||
|
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
|
||||||
|
}
|
||||||
|
valid, err := git.ValidateTagRef(ctx, evidence.RepositoryDir, evidence.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return Observation{Evidence: evidence, State: Unknown}, &RecoveryError{Kind: "invalid_evidence"}
|
||||||
|
}
|
||||||
|
endpoint, err := git.ObserveEndpoint(ctx, evidence.RepositoryDir, evidence.Endpoint, evidence.Remote)
|
||||||
|
if err != nil {
|
||||||
|
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
|
||||||
|
}
|
||||||
|
ref, err := endpoint.QueryTag(ctx, evidence.RepositoryDir, evidence.Tag)
|
||||||
|
if err != nil {
|
||||||
|
return Observation{Evidence: evidence, State: Unknown}, observationFailure(ctx, err)
|
||||||
|
}
|
||||||
|
result, err := classify(evidence, ref)
|
||||||
|
if err != nil && ctx.Err() != nil {
|
||||||
|
return result, &RecoveryError{Kind: "canceled", cause: ctx.Err()}
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func observationFailure(ctx context.Context, err error) *RecoveryError {
|
||||||
|
kind := "remote_observation"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, git.ErrEndpointChanged):
|
||||||
|
kind = "endpoint_changed"
|
||||||
|
case errors.Is(err, git.ErrTransportConfiguration):
|
||||||
|
kind = "transport_configuration"
|
||||||
|
}
|
||||||
|
return recoveryFailure(ctx, kind, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile observes the recorded endpoint and classifies its exact tag.
|
||||||
|
func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) {
|
||||||
|
return observe(ctx, evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoveryFailure classifies failed operations while preserving caller cancellation.
|
||||||
|
func recoveryFailure(ctx context.Context, kind string, err error) *RecoveryError {
|
||||||
|
if contextErr := ctx.Err(); contextErr != nil {
|
||||||
|
return &RecoveryError{Kind: "canceled", cause: contextErr}
|
||||||
|
}
|
||||||
|
return &RecoveryError{Kind: kind, cause: err}
|
||||||
|
}
|
||||||
59
internal/publish/reconcile_test.go
Normal file
59
internal/publish/reconcile_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifyPublicationObservation(t *testing.T) {
|
||||||
|
tagObject, commit := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||||
|
base := Evidence{Endpoint: "file:///remote.git", Tag: "v1.2.3", TagObject: tagObject, CandidateCommit: commit}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
evidence Evidence
|
||||||
|
ref git.RemoteRef
|
||||||
|
want State
|
||||||
|
wantKind string
|
||||||
|
}{
|
||||||
|
{"absent with partial identity", Evidence{Endpoint: base.Endpoint, Tag: base.Tag}, git.RemoteRef{State: git.RefAbsent}, NotPublished, ""},
|
||||||
|
{"matching tag without peel", base, git.RemoteRef{State: git.RefPresent, Object: tagObject}, Published, ""},
|
||||||
|
{"matching tag and commit", base, git.RemoteRef{State: git.RefPresent, Object: strings.ToUpper(tagObject), Peeled: strings.ToUpper(commit)}, Published, ""},
|
||||||
|
{"tag conflict", base, git.RemoteRef{State: git.RefPresent, Object: strings.Repeat("c", 40), Peeled: commit}, Conflict, ""},
|
||||||
|
{"commit conflict", base, git.RemoteRef{State: git.RefPresent, Object: tagObject, Peeled: strings.Repeat("c", 40)}, Conflict, ""},
|
||||||
|
{"missing tag object", Evidence{Endpoint: base.Endpoint, Tag: base.Tag}, git.RemoteRef{State: git.RefPresent, Object: tagObject}, Unknown, "missing_evidence"},
|
||||||
|
{"missing commit with peel", Evidence{Endpoint: base.Endpoint, Tag: base.Tag, TagObject: tagObject}, git.RemoteRef{State: git.RefPresent, Object: tagObject, Peeled: commit}, Unknown, "missing_evidence"},
|
||||||
|
{"unreadable", base, git.RemoteRef{State: git.RefUnreadable}, Unknown, "inconsistent_observation"},
|
||||||
|
{"inconsistent", base, git.RemoteRef{State: git.RefConflict}, Unknown, "inconsistent_observation"},
|
||||||
|
{"missing endpoint", Evidence{Tag: base.Tag}, git.RemoteRef{State: git.RefAbsent}, Unknown, "missing_evidence"},
|
||||||
|
{"missing tag", Evidence{Endpoint: base.Endpoint}, git.RemoteRef{State: git.RefAbsent}, Unknown, "missing_evidence"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := classify(test.evidence, test.ref)
|
||||||
|
if got.State != test.want {
|
||||||
|
t.Fatalf("state=%q want %q", got.State, test.want)
|
||||||
|
}
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if test.wantKind == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != test.wantKind {
|
||||||
|
t.Fatalf("error=%v want kind %q", err, test.wantKind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyReportsAllMissingIdentityFields(t *testing.T) {
|
||||||
|
_, err := classify(Evidence{}, git.RemoteRef{State: git.RefPresent})
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if !errors.As(err, &recovery) || strings.Join(recovery.MissingFields, ",") != "endpoint,tag" {
|
||||||
|
t.Fatalf("classification error = %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
224
internal/publish/record.go
Normal file
224
internal/publish/record.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
const recordFormatVersion = 1
|
||||||
|
const recordByteFormatVersion = 2
|
||||||
|
|
||||||
|
type recordEnvelope struct {
|
||||||
|
FormatVersion int `json:"format_version"`
|
||||||
|
Evidence json.RawMessage `json:"evidence"`
|
||||||
|
EvidenceBytes json.RawMessage `json:"evidence_bytes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadEvidence reads one complete supported private publication record.
|
||||||
|
func LoadEvidence(path string) (Evidence, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_read", RecordPath: path, cause: fmt.Errorf("read record %q: %w", path, err)}
|
||||||
|
}
|
||||||
|
var envelope recordEnvelope
|
||||||
|
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record %q: %w", path, err)}
|
||||||
|
}
|
||||||
|
if envelope.FormatVersion != recordFormatVersion && envelope.FormatVersion != recordByteFormatVersion {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)}
|
||||||
|
}
|
||||||
|
if len(envelope.Evidence) == 0 || string(envelope.Evidence) == "null" {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has no evidence object", path)}
|
||||||
|
}
|
||||||
|
var evidence Evidence
|
||||||
|
if err := json.Unmarshal(envelope.Evidence, &evidence); err != nil {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence %q: %w", path, err)}
|
||||||
|
}
|
||||||
|
if envelope.FormatVersion == recordByteFormatVersion && len(envelope.EvidenceBytes) > 0 {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(envelope.EvidenceBytes, &fields); err != nil {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence bytes %q: %w", path, err)}
|
||||||
|
}
|
||||||
|
for _, field := range evidenceFields(&evidence) {
|
||||||
|
if raw, ok := fields[field.name]; ok {
|
||||||
|
var encoded *string
|
||||||
|
if err := json.Unmarshal(raw, &encoded); err != nil {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record field %s bytes %q: %w", field.name, path, err)}
|
||||||
|
}
|
||||||
|
if encoded == nil {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record field %s bytes %q must be a base64 string", field.name, path)}
|
||||||
|
}
|
||||||
|
bytes, err := base64.StdEncoding.DecodeString(*encoded)
|
||||||
|
if err != nil {
|
||||||
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record field %s bytes %q: %w", field.name, path, err)}
|
||||||
|
}
|
||||||
|
*field.value = string(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return evidence, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type evidenceField struct {
|
||||||
|
name string
|
||||||
|
value *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func evidenceFields(e *Evidence) [10]evidenceField {
|
||||||
|
return [10]evidenceField{
|
||||||
|
{"repository_dir", &e.RepositoryDir}, {"module_dir", &e.ModuleDir},
|
||||||
|
{"module_path", &e.ModulePath}, {"version", &e.Version}, {"tag", &e.Tag},
|
||||||
|
{"candidate_commit", &e.CandidateCommit}, {"tag_object", &e.TagObject},
|
||||||
|
{"endpoint", &e.Endpoint}, {"remote", &e.Remote}, {"recorded_at", &e.RecordedAt},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeRecord(evidence Evidence) ([]byte, error) {
|
||||||
|
envelope := recordEnvelope{FormatVersion: recordFormatVersion}
|
||||||
|
var bytes map[string][]byte
|
||||||
|
for _, field := range evidenceFields(&evidence) {
|
||||||
|
if !utf8.ValidString(*field.value) {
|
||||||
|
if bytes == nil {
|
||||||
|
bytes = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
bytes[field.name] = []byte(*field.value)
|
||||||
|
*field.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if bytes != nil {
|
||||||
|
envelope.FormatVersion = recordByteFormatVersion
|
||||||
|
envelope.EvidenceBytes, err = json.Marshal(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
envelope.Evidence, err = json.Marshal(evidence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveEvidence writes one private attempt record and returns its absolute path.
|
||||||
|
func SaveEvidence(ctx context.Context, repositoryDir, override string, evidence Evidence) (string, error) {
|
||||||
|
recordDir, root, err := resolveRecordDir(ctx, repositoryDir, override)
|
||||||
|
if err != nil {
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: err}
|
||||||
|
}
|
||||||
|
return saveEvidenceInDir(ctx, repositoryDir, root, override, recordDir, evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveEvidenceInDir(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence) (string, error) {
|
||||||
|
return saveEvidenceNamed(ctx, repositoryDir, root, override, recordDir, evidence, proposedRecordPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveEvidenceNamed(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence, nextPath func(string) (string, error)) (string, error) {
|
||||||
|
if evidence.RecordedAt == "" {
|
||||||
|
evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(recordDir, 0o700); err != nil {
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record directory %q: %w", recordDir, err)}
|
||||||
|
}
|
||||||
|
data, err := encodeRecord(evidence)
|
||||||
|
if err != nil {
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("encode publication evidence: %w", err)}
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
path, err := nextPath(recordDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("name publication record: %w", err)}
|
||||||
|
}
|
||||||
|
if override != "" && root != "" {
|
||||||
|
if err := rejectUnignoredWorktreePath(ctx, repositoryDir, root, path); err != nil {
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record %q: %w", path, err)}
|
||||||
|
}
|
||||||
|
if _, err = file.Write(data); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("write record %q: %w", path, err)}
|
||||||
|
}
|
||||||
|
if err = file.Close(); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("close record %q: %w", path, err)}
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("could not allocate a unique record name in %q", recordDir)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRecordDir(ctx context.Context, repositoryDir, override string) (string, string, error) {
|
||||||
|
root, err := git.RepositoryRoot(ctx, repositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if override != "" {
|
||||||
|
if filepath.IsAbs(override) {
|
||||||
|
return filepath.Clean(override), root, nil
|
||||||
|
}
|
||||||
|
return filepath.Join(root, override), root, nil
|
||||||
|
}
|
||||||
|
common, err := git.CommonDirectory(ctx, repositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(common, "releasekit", "publication-attempts"), root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func proposedRecordPath(directory string) (string, error) {
|
||||||
|
var nonce [16]byte
|
||||||
|
if _, err := rand.Read(nonce[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(directory, "attempt-"+hex.EncodeToString(nonce[:])+".json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectUnignoredWorktreePath(ctx context.Context, repositoryDir, root, proposed string) error {
|
||||||
|
parent, err := filepath.EvalSymlinks(filepath.Dir(proposed))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve record directory %q: %w", filepath.Dir(proposed), err)
|
||||||
|
}
|
||||||
|
actual := filepath.Join(parent, filepath.Base(proposed))
|
||||||
|
rel, err := filepath.Rel(root, actual)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
metadata, err := git.MetadataDirectories(ctx, repositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, directory := range metadata {
|
||||||
|
rel, err := filepath.Rel(directory, actual)
|
||||||
|
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ignored, err := git.IsIgnored(ctx, repositoryDir, actual)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err)
|
||||||
|
}
|
||||||
|
if !ignored {
|
||||||
|
return fmt.Errorf("record path %q is inside the worktree and is not ignored", actual)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
537
internal/publish/record_test.go
Normal file
537
internal/publish/record_test.go
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadEvidenceValidatesKnownByteFieldsAsBase64Strings(t *testing.T) {
|
||||||
|
for _, test := range []struct{ name, value, want, failure string }{
|
||||||
|
{"valid", `"djEuMi4z"`, "v1.2.3", ""},
|
||||||
|
{"empty", `""`, "", ""},
|
||||||
|
{"malformed base64", `"not base64!"`, "", "base64"},
|
||||||
|
{"numeric array", `[118,49,46,50,46,51]`, "", "type"},
|
||||||
|
{"object", `{}`, "", "type"},
|
||||||
|
{"boolean", `true`, "", "type"},
|
||||||
|
{"number", `123`, "", "type"},
|
||||||
|
{"null", `null`, "", "schema"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "record.json")
|
||||||
|
contents := `{"format_version":2,"evidence":{},"evidence_bytes":{"tag":` + test.value + `}}`
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadEvidence(path)
|
||||||
|
if test.failure == "" {
|
||||||
|
if err != nil || loaded.Tag != test.want {
|
||||||
|
t.Fatalf("byte string = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var typed *RecoveryError
|
||||||
|
if !errors.As(err, &typed) || typed.Kind != "record_format" || typed.Unwrap() == nil {
|
||||||
|
t.Fatalf("byte format failure = %v", err)
|
||||||
|
}
|
||||||
|
switch test.failure {
|
||||||
|
case "type":
|
||||||
|
var cause *json.UnmarshalTypeError
|
||||||
|
if !errors.As(err, &cause) {
|
||||||
|
t.Fatalf("lost JSON type cause: %v", err)
|
||||||
|
}
|
||||||
|
case "base64":
|
||||||
|
var cause base64.CorruptInputError
|
||||||
|
if !errors.As(err, &cause) {
|
||||||
|
t.Fatalf("lost base64 cause: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordsRoundTripNonUTF8Evidence(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
raw := "path-\xff"
|
||||||
|
evidence := Evidence{RepositoryDir: raw, ModuleDir: raw, ModulePath: raw, Version: raw, Tag: raw, CandidateCommit: raw, TagObject: raw, Endpoint: raw, Remote: raw, RecordedAt: raw}
|
||||||
|
path, err := SaveEvidence(t.Context(), repo, t.TempDir(), evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadEvidence(path)
|
||||||
|
if err != nil || loaded != evidence {
|
||||||
|
t.Fatalf("lossy evidence: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var envelope recordEnvelope
|
||||||
|
if err := json.Unmarshal(data, &envelope); err != nil || envelope.FormatVersion != recordByteFormatVersion {
|
||||||
|
t.Fatalf("byte record envelope: %#v, %v", envelope, err)
|
||||||
|
}
|
||||||
|
// Unknown fields remain ignorable; known byte fields retain real codec errors.
|
||||||
|
for _, test := range []struct {
|
||||||
|
field string
|
||||||
|
wantError bool
|
||||||
|
}{{"endpoint", true}, {"future_field", false}} {
|
||||||
|
envelope.EvidenceBytes = json.RawMessage(`{"` + test.field + `":"not base64!"}`)
|
||||||
|
data, err := json.Marshal(envelope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = LoadEvidence(path)
|
||||||
|
if (err != nil) != test.wantError {
|
||||||
|
t.Fatalf("byte field %s error=%v", test.field, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordLoadedObservationKeepsNonUTF8EndpointIdentity(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("raw pathname bytes are a POSIX behavior")
|
||||||
|
}
|
||||||
|
repo := recordRepo(t)
|
||||||
|
base := t.TempDir()
|
||||||
|
original := filepath.Join(base, "remote-\xff.git")
|
||||||
|
decoy := filepath.Join(base, "remote-\uFFFD.git")
|
||||||
|
for _, remote := range []string{original, decoy} {
|
||||||
|
recordGit(t, repo, "init", "--bare", remote)
|
||||||
|
}
|
||||||
|
recordGit(t, repo, "tag", "-a", "v1.2.3", "-m", "Release")
|
||||||
|
recordGit(t, repo, "push", decoy, "refs/tags/v1.2.3")
|
||||||
|
evidence := Evidence{RepositoryDir: repo, ModuleDir: repo, Endpoint: original, Tag: "v1.2.3", TagObject: strings.TrimSpace(recordGit(t, repo, "rev-parse", "v1.2.3")), CandidateCommit: strings.TrimSpace(recordGit(t, repo, "rev-parse", "HEAD"))}
|
||||||
|
path, err := SaveEvidence(t.Context(), repo, "", evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadEvidence(path)
|
||||||
|
if err != nil || loaded.Endpoint != original {
|
||||||
|
t.Fatalf("endpoint changed: %q, %v", loaded.Endpoint, err)
|
||||||
|
}
|
||||||
|
for _, selected := range []Evidence{evidence, loaded} {
|
||||||
|
observed, err := Reconcile(t.Context(), selected)
|
||||||
|
if err != nil || observed.State != NotPublished {
|
||||||
|
t.Fatalf("original endpoint observation = %#v, %v", observed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded.Endpoint = decoy
|
||||||
|
observed, err := Reconcile(t.Context(), loaded)
|
||||||
|
if err != nil || observed.State != Published {
|
||||||
|
t.Fatalf("decoy does not distinguish identity: %#v, %v", observed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordGit(t *testing.T, dir string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
recordGit(t, dir, "init", "-q")
|
||||||
|
recordGit(t, dir, "config", "user.name", "Test")
|
||||||
|
recordGit(t, dir, "config", "user.email", "test@example.invalid")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "file"), []byte("x"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recordGit(t, dir, "add", "file")
|
||||||
|
recordGit(t, dir, "commit", "-qm", "init")
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEvidenceAcceptsPartialAndRejectsMalformedRecords(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
partial := filepath.Join(dir, "partial.json")
|
||||||
|
if err := os.WriteFile(partial, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := LoadEvidence(partial)
|
||||||
|
if err != nil || got != (Evidence{}) {
|
||||||
|
t.Fatalf("partial record = %#v, %v", got, err)
|
||||||
|
}
|
||||||
|
for name, data := range map[string]string{"bad-json": "{", "unsupported": `{"format_version":3,"evidence":{}}`, "type": `{"format_version":1,"evidence":{"tag":false}}`, "trailing": `{"format_version":1,"evidence":{}} {}`} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := LoadEvidence(path)
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "record_format" {
|
||||||
|
t.Fatalf("LoadEvidence() error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEvidenceRequiresOneDocumentAndPreservesSyntaxCause(t *testing.T) {
|
||||||
|
const valid = `{"format_version":1,"evidence":{"tag":"v1.2.3","future":{}},"future":true}`
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, data string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"single", valid, true}, {"whitespace", valid + "\n \t\r", true},
|
||||||
|
{"first incomplete", "{", false}, {"first syntax", "x", false},
|
||||||
|
{"second object", valid + ` {"unused":[1,2,3]}`, false},
|
||||||
|
{"incomplete suffix", valid + " {", false}, {"invalid suffix", valid + " x", false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "record.json")
|
||||||
|
if err := os.WriteFile(path, []byte(test.data), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := LoadEvidence(path)
|
||||||
|
if test.valid {
|
||||||
|
if err != nil || loaded.Tag != "v1.2.3" {
|
||||||
|
t.Fatalf("valid record = %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var recovery *RecoveryError
|
||||||
|
var syntax *json.SyntaxError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "record_format" || !errors.As(err, &syntax) {
|
||||||
|
t.Fatalf("lost format/parser cause: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEvidenceReadFailureHasTypedError(t *testing.T) {
|
||||||
|
_, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json"))
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "record_read" || !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("LoadEvidence() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveEvidenceUsesPrivateUniqueRecordsAndLinkedWorktreeMetadata(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
worktree := filepath.Join(t.TempDir(), "linked")
|
||||||
|
recordGit(t, repo, "worktree", "add", "--detach", worktree)
|
||||||
|
evidence := Evidence{Tag: "v1.2.3", Endpoint: "https://user:secret@example.invalid/repo.git"}
|
||||||
|
first, err := SaveEvidence(t.Context(), worktree, "", evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := SaveEvidence(t.Context(), worktree, "", evidence)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first == second {
|
||||||
|
t.Fatalf("record paths collided: %q", first)
|
||||||
|
}
|
||||||
|
common := recordGit(t, repo, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||||
|
wantDir := filepath.Join(strings.TrimSpace(common), "releasekit", "publication-attempts")
|
||||||
|
if filepath.Dir(first) != wantDir {
|
||||||
|
t.Fatalf("record dir=%q want %q", filepath.Dir(first), wantDir)
|
||||||
|
}
|
||||||
|
dirInfo, err := os.Stat(wantDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dirInfo.Mode().Perm() != 0700 {
|
||||||
|
t.Fatalf("record directory permissions=%o", dirInfo.Mode().Perm())
|
||||||
|
}
|
||||||
|
info, err := os.Stat(first)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatalf("record permissions=%o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
loaded, err := LoadEvidence(first)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded.Endpoint != evidence.Endpoint || loaded.RecordedAt == "" {
|
||||||
|
t.Fatalf("loaded evidence=%#v", loaded)
|
||||||
|
}
|
||||||
|
var envelope map[string]json.RawMessage
|
||||||
|
data, err := os.ReadFile(first)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "user:secret@example.invalid") {
|
||||||
|
t.Fatal("private record did not retain the exact endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveEvidenceResolvesRelativeAndIgnoredOverrides(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved-records/\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := SaveEvidence(t.Context(), repo, "saved-records", Evidence{Tag: "v1.2.3"}); err != nil {
|
||||||
|
t.Fatalf("ignored override: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
external := filepath.Join(t.TempDir(), "external-store")
|
||||||
|
path, err := SaveEvidence(t.Context(), repo, external, Evidence{Tag: "v2.0.0"})
|
||||||
|
if err != nil || !strings.HasPrefix(path, external+string(filepath.Separator)) {
|
||||||
|
t.Fatalf("absolute external override path=%q error=%v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveEvidenceAllowsExplicitGitMetadataStores(t *testing.T) {
|
||||||
|
for _, layout := range []string{"ordinary", "linked", "separate"} {
|
||||||
|
t.Run(layout, func(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
switch layout {
|
||||||
|
case "linked":
|
||||||
|
linked := filepath.Join(t.TempDir(), "linked")
|
||||||
|
recordGit(t, repo, "worktree", "add", "--detach", linked)
|
||||||
|
repo = linked
|
||||||
|
case "separate":
|
||||||
|
recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata"))
|
||||||
|
}
|
||||||
|
defaultPath, err := SaveEvidence(t.Context(), repo, "", Evidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
privateDir := strings.TrimSuffix(recordGit(t, repo, "rev-parse", "--absolute-git-dir"), "\n")
|
||||||
|
for _, directory := range []string{filepath.Dir(defaultPath), filepath.Join(privateDir, "other-private-records")} {
|
||||||
|
path, err := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.2.3"})
|
||||||
|
if err != nil || filepath.Dir(path) != directory {
|
||||||
|
t.Fatalf("explicit metadata store %q = %q, %v", directory, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("metadata records appeared in candidate: %s", status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordStoragePreservesWhitespaceInRepositoryAndMetadataPaths(t *testing.T) {
|
||||||
|
for _, separate := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("separate=%t", separate), func(t *testing.T) {
|
||||||
|
original := recordRepo(t)
|
||||||
|
repo := original + " "
|
||||||
|
if err := os.Rename(original, repo); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// recordRepo's temporary-directory cleanup owns the old spelling.
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(repo) })
|
||||||
|
if separate {
|
||||||
|
recordGit(t, repo, "init", "--separate-git-dir", filepath.Join(t.TempDir(), "metadata "))
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved/\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recordGit(t, repo, "add", ".gitignore")
|
||||||
|
recordGit(t, repo, "commit", "-qm", "Ignore saved records")
|
||||||
|
for _, override := range []string{"", "saved"} {
|
||||||
|
path, err := SaveEvidence(t.Context(), repo, override, Evidence{RepositoryDir: repo, Tag: "v1.2.3"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
listed, err := ListAttempts(t.Context(), repo, override)
|
||||||
|
if err != nil || len(listed.Attempts) != 1 || listed.Attempts[0].RecordPath != path || listed.Attempts[0].Evidence.RepositoryDir != repo {
|
||||||
|
t.Fatalf("read/write disagreement for %q: %#v, %v", override, listed, err)
|
||||||
|
}
|
||||||
|
if override != "" && filepath.Dir(path) != filepath.Join(repo, override) {
|
||||||
|
t.Fatalf("changed repository path: %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := SaveEvidence(t.Context(), repo, filepath.Join(repo, "unignored"), Evidence{}); err == nil {
|
||||||
|
t.Fatal("absolute unignored store was misclassified as external")
|
||||||
|
}
|
||||||
|
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("recording changed candidate: %s", status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
_, err := SaveEvidence(t.Context(), repo, "records", Evidence{Tag: "v1.2.3"})
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "record_write" {
|
||||||
|
t.Fatalf("SaveEvidence() error=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(repo, "records")); err != nil {
|
||||||
|
t.Fatalf("expected parent directory to exist without record: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(repo, "records", "attempt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("unexpected record: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveEvidenceChecksEachActualFilename(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("selective/attempt-[0-7]*.json\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recordGit(t, repo, "add", ".gitignore")
|
||||||
|
recordGit(t, repo, "commit", "-qm", "Selective ignore")
|
||||||
|
dir := filepath.Join(repo, "selective")
|
||||||
|
next := func(name string) func(string) (string, error) {
|
||||||
|
return func(directory string) (string, error) { return filepath.Join(directory, name), nil }
|
||||||
|
}
|
||||||
|
path, err := saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v1.0.0"}, next("attempt-0.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recordGit(t, repo, "check-ignore", "--", path)
|
||||||
|
// A collision must validate the replacement name as well.
|
||||||
|
calls := 0
|
||||||
|
_, err = saveEvidenceNamed(t.Context(), repo, repo, dir, dir, Evidence{Tag: "v2.0.0"}, func(directory string) (string, error) {
|
||||||
|
calls++
|
||||||
|
name := "attempt-0.json"
|
||||||
|
if calls > 1 {
|
||||||
|
name = "attempt-c.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(directory, name), nil
|
||||||
|
})
|
||||||
|
if err == nil || calls != 2 {
|
||||||
|
t.Fatalf("unignored replacement: calls=%d error=%v", calls, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "attempt-c.json")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("unignored file was created: %v", err)
|
||||||
|
}
|
||||||
|
if status := recordGit(t, repo, "status", "--porcelain"); status != "" {
|
||||||
|
t.Fatalf("recording dirtied candidate: %s", status)
|
||||||
|
}
|
||||||
|
loaded, err := LoadEvidence(path)
|
||||||
|
if err != nil || loaded.Tag != "v1.0.0" {
|
||||||
|
t.Fatalf("collision changed original record: %#v, %v", loaded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAttemptsReturnsReadableRecordsAndDiagnosticsInPathOrder(t *testing.T) {
|
||||||
|
repo := recordRepo(t)
|
||||||
|
directory := filepath.Join(t.TempDir(), "attempts")
|
||||||
|
if err := os.Mkdir(directory, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v2.0.0"})
|
||||||
|
first, _ := SaveEvidence(t.Context(), repo, directory, Evidence{Tag: "v1.0.0"})
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "broken.json"), []byte("{"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
listed, err := ListAttempts(t.Context(), repo, directory)
|
||||||
|
if err != nil || len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 {
|
||||||
|
t.Fatalf("list=%#v error=%v", listed, err)
|
||||||
|
}
|
||||||
|
wantPaths := []string{first, second}
|
||||||
|
sort.Strings(wantPaths)
|
||||||
|
if listed.Attempts[0].RecordPath != wantPaths[0] || listed.Attempts[1].RecordPath != wantPaths[1] {
|
||||||
|
t.Fatalf("attempt order=%v", listed.Attempts)
|
||||||
|
}
|
||||||
|
if listed.Diagnostics[0].Kind != "record_format" || listed.Diagnostics[0].RecordPath == "" {
|
||||||
|
t.Fatalf("diagnostic=%#v", listed.Diagnostics[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAttemptsFiltersMixedDirectoryAcrossBatches(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
for i := range 400 {
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, fmt.Sprintf("unrelated-%04d.txt", i)), []byte("not a record"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(directory, "directory.json"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
names := []string{"z.json", "b.json", "a.json"}
|
||||||
|
for _, name := range names {
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, name), []byte(`{"format_version":1,"evidence":{"tag":"v1.2.3"}}`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listed, err := ListAttempts(t.Context(), "", directory)
|
||||||
|
if err != nil || len(listed.Diagnostics) != 0 || len(listed.Attempts) != len(names) {
|
||||||
|
t.Fatalf("filtered result = %#v, %v", listed, err)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for i, name := range names {
|
||||||
|
if listed.Attempts[i].RecordPath != filepath.Join(directory, name) {
|
||||||
|
t.Fatalf("incorrect path order: %#v", listed.Attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAttemptsRetainsEntriesWithEnumerationFailure(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"z.json": `{"format_version":1,"evidence":{"tag":"v2.0.0"}}`,
|
||||||
|
"a.json": `{"format_version":1,"evidence":{"tag":"v1.0.0"}}`,
|
||||||
|
"broken.json": "{", "unrelated.txt": "ignored",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, name), []byte(contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failure := errors.New("controlled directory read failure")
|
||||||
|
for _, empty := range []bool{false, true} {
|
||||||
|
calls := 0
|
||||||
|
listed, err := listAttempts(t.Context(), "", directory, func(ctx context.Context, dir string) ([]string, error) {
|
||||||
|
return readRecordPaths(ctx, dir, func(int) ([]os.DirEntry, error) {
|
||||||
|
calls++
|
||||||
|
if empty {
|
||||||
|
return nil, failure
|
||||||
|
}
|
||||||
|
return entries, failure
|
||||||
|
})
|
||||||
|
})
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" || !errors.Is(err, failure) || calls != 1 {
|
||||||
|
t.Fatalf("enumeration failure lost: %#v, %v; reads=%d", listed, err, calls)
|
||||||
|
}
|
||||||
|
if empty {
|
||||||
|
if len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 {
|
||||||
|
t.Fatalf("empty read: %#v", listed)
|
||||||
|
}
|
||||||
|
} else if len(listed.Attempts) != 2 || len(listed.Diagnostics) != 1 || filepath.Base(listed.Attempts[0].RecordPath) != "a.json" || filepath.Base(listed.Attempts[1].RecordPath) != "z.json" || listed.Diagnostics[0].Kind != "record_format" {
|
||||||
|
t.Fatalf("partial read dropped/reordered records: %#v", listed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAttemptsDistinguishesEmptyMissingAndCanceledStorage(t *testing.T) {
|
||||||
|
empty := t.TempDir()
|
||||||
|
listed, err := ListAttempts(t.Context(), "", empty)
|
||||||
|
if err != nil || len(listed.Attempts) != 0 || len(listed.Diagnostics) != 0 {
|
||||||
|
t.Fatalf("empty list=%#v error=%v", listed, err)
|
||||||
|
}
|
||||||
|
_, err = ListAttempts(t.Context(), "", filepath.Join(t.TempDir(), "missing"))
|
||||||
|
var recovery *RecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "storage_enumeration" {
|
||||||
|
t.Fatalf("missing storage error=%v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err = ListAttempts(ctx, "", empty)
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
publish.go
28
publish.go
@@ -6,18 +6,23 @@ import (
|
|||||||
"go.maximumdirect.net/releasekit/internal/publish"
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PublicationState describes one publication attempt or a later observation.
|
||||||
type PublicationState string
|
type PublicationState string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// PublicationNotAttempted means no tag push was attempted.
|
// PublicationNotAttempted means no tag push was attempted.
|
||||||
PublicationNotAttempted PublicationState = PublicationState(publish.NotAttempted)
|
PublicationNotAttempted PublicationState = PublicationState(publish.NotAttempted)
|
||||||
// PublicationNotPublished means this invocation knows the tag was not published.
|
// PublicationNotPublished means the operation established that its tag was
|
||||||
|
// not published or was absent at observation time. It does not establish that
|
||||||
|
// the version was never published.
|
||||||
PublicationNotPublished PublicationState = PublicationState(publish.NotPublished)
|
PublicationNotPublished PublicationState = PublicationState(publish.NotPublished)
|
||||||
// PublicationPublished means Git reported a successful push of the intended tag object.
|
// PublicationPublished means the intended tag object was pushed successfully
|
||||||
|
// or observed remotely.
|
||||||
PublicationPublished PublicationState = PublicationState(publish.Published)
|
PublicationPublished PublicationState = PublicationState(publish.Published)
|
||||||
// PublicationConflict means an authoritative branch or tag guard failed.
|
// PublicationConflict means an authoritative guard or later observation found
|
||||||
|
// conflicting state.
|
||||||
PublicationConflict PublicationState = PublicationState(publish.Conflict)
|
PublicationConflict PublicationState = PublicationState(publish.Conflict)
|
||||||
// PublicationUnknown means a tag push failed and its remote outcome is unresolved.
|
// PublicationUnknown means publication could not be determined from the available evidence.
|
||||||
PublicationUnknown PublicationState = PublicationState(publish.Unknown)
|
PublicationUnknown PublicationState = PublicationState(publish.Unknown)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,7 +37,7 @@ const (
|
|||||||
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
|
PublicationTagCreation PublicationPhase = PublicationPhase(publish.TagCreation)
|
||||||
// PublicationPush identifies the remote tag push attempt.
|
// PublicationPush identifies the remote tag push attempt.
|
||||||
PublicationPush PublicationPhase = PublicationPhase(publish.Push)
|
PublicationPush PublicationPhase = PublicationPhase(publish.Push)
|
||||||
// PublicationReconciliation identifies the single authoritative tag query after a push error.
|
// PublicationReconciliation identifies immediate observation after a push error.
|
||||||
PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation)
|
PublicationReconciliation PublicationPhase = PublicationPhase(publish.Reconciliation)
|
||||||
// PublicationComplete identifies a successful tag push.
|
// PublicationComplete identifies a successful tag push.
|
||||||
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
|
PublicationComplete PublicationPhase = PublicationPhase(publish.Complete)
|
||||||
@@ -50,6 +55,9 @@ type PublicationResult struct {
|
|||||||
Phase PublicationPhase // last operation phase reached
|
Phase PublicationPhase // last operation phase reached
|
||||||
TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty
|
TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty
|
||||||
State PublicationState // publication outcome for this invocation
|
State PublicationState // publication outcome for this invocation
|
||||||
|
Evidence PublicationEvidence // typed evidence for later reconciliation; Endpoint may contain credentials
|
||||||
|
RecordPath string // absolute private record path when recording succeeds
|
||||||
|
RecordError error // recording failure, separate from publication outcome and returned error
|
||||||
}
|
}
|
||||||
|
|
||||||
// After publication preflight succeeds, Publish runs one offline Go test suite
|
// After publication preflight succeeds, Publish runs one offline Go test suite
|
||||||
@@ -74,7 +82,11 @@ type PublicationResult struct {
|
|||||||
// tag query: an exact object match confirms publication, absence is
|
// tag query: an exact object match confirms publication, absence is
|
||||||
// not_published, a different object is conflict, and an unreadable result is
|
// not_published, a different object is conflict, and an unreadable result is
|
||||||
// unknown. A nil error means publication is confirmed by the successful push
|
// unknown. A nil error means publication is confirmed by the successful push
|
||||||
// or exact remote object match.
|
// or an exact remote object match. Publish records available attempt evidence
|
||||||
|
// before the tag push when local storage is available; recording failure is
|
||||||
|
// reported separately in PublicationResult.RecordError and does not change the
|
||||||
|
// publication outcome. PublicationResult.Evidence can be passed directly to
|
||||||
|
// ReconcilePublication even when no record was saved.
|
||||||
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
|
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
|
||||||
candidateInput := candidateRequest(request)
|
candidateInput := candidateRequest(request)
|
||||||
result, err := publish.Publish(ctx, publish.Request{
|
result, err := publish.Publish(ctx, publish.Request{
|
||||||
@@ -84,6 +96,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
|
|||||||
Version: candidateInput.Version,
|
Version: candidateInput.Version,
|
||||||
Remote: request.Remote,
|
Remote: request.Remote,
|
||||||
ExpectedPushURL: request.ExpectedPushURL,
|
ExpectedPushURL: request.ExpectedPushURL,
|
||||||
|
RecordDir: request.PublicationRecordDir,
|
||||||
Validation: candidateInput.Validation,
|
Validation: candidateInput.Validation,
|
||||||
AdditionalChecks: candidateInput.AdditionalChecks,
|
AdditionalChecks: candidateInput.AdditionalChecks,
|
||||||
AfterValidate: candidateInput.AfterValidate,
|
AfterValidate: candidateInput.AfterValidate,
|
||||||
@@ -100,5 +113,8 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
|
|||||||
Phase: PublicationPhase(result.Phase),
|
Phase: PublicationPhase(result.Phase),
|
||||||
TagObject: result.TagObject,
|
TagObject: result.TagObject,
|
||||||
State: PublicationState(result.State),
|
State: PublicationState(result.State),
|
||||||
|
Evidence: publicEvidence(result.Evidence),
|
||||||
|
RecordPath: result.RecordPath,
|
||||||
|
RecordError: publicRecoveryError(result.RecordError, ""),
|
||||||
}, convertValidationError(err)
|
}, convertValidationError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,67 @@ import (
|
|||||||
|
|
||||||
const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n"
|
const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n"
|
||||||
|
|
||||||
|
func TestPublishStopsOnReleaseNoteCaptureFailure(t *testing.T) {
|
||||||
|
for _, missing := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("missing=%t", missing), func(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
if missing {
|
||||||
|
gitRun(t, repo.work, "rm", "docs/releases/v1.2.3.md")
|
||||||
|
} else {
|
||||||
|
writeFile(t, repo.work, "docs/releases/v1.2.3.md", "# Wrong Product v1.2.3\n")
|
||||||
|
gitRun(t, repo.work, "add", ".")
|
||||||
|
}
|
||||||
|
gitRun(t, repo.work, "commit", "-m", "Invalid note")
|
||||||
|
called := false
|
||||||
|
result, err := releasekit.Publish(t.Context(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
|
||||||
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error { called = true; return nil },
|
||||||
|
})
|
||||||
|
if err == nil || called || result.State != releasekit.PublicationNotAttempted {
|
||||||
|
t.Fatalf("rejected candidate = %#v, %v; callback=%t", result, err, called)
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("missing note lost Git cause: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.RecordError != nil || result.RecordPath == "" || result.Evidence.CandidateCommit == "" {
|
||||||
|
t.Fatalf("partial evidence was not saved: %#v", result)
|
||||||
|
}
|
||||||
|
if tags := gitText(t, repo.work, "tag", "--list", "v1.2.3"); tags != "" {
|
||||||
|
t.Fatalf("created tag for rejected candidate: %s", tags)
|
||||||
|
}
|
||||||
|
if tags := gitText(t, repo.work, "ls-remote", "origin", "refs/tags/v1.2.3"); tags != "" {
|
||||||
|
t.Fatalf("pushed tag for rejected candidate: %s", tags)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishStorageResolutionFailureIsNonfatal(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
realGit := mustLookPath(t, "git")
|
||||||
|
bin := t.TempDir()
|
||||||
|
wrapper := "#!/bin/sh\nfor arg in \"$@\"; do\n [ \"$arg\" = --git-common-dir ] && exit 71\ndone\nexec " + shellQuote(realGit) + " \"$@\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
called := false
|
||||||
|
result, err := releasekit.Publish(t.Context(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
|
||||||
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error { called = true; return nil },
|
||||||
|
})
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if err != nil || !called || result.State != releasekit.PublicationPublished || result.RecordPath != "" || !errors.As(result.RecordError, &exitErr) || exitErr.ExitCode() != 71 {
|
||||||
|
t.Fatalf("publication with failed storage lookup = %#v, %v; callback=%t", result, err, called)
|
||||||
|
}
|
||||||
|
if got := gitText(t, repo.work, "ls-remote", "origin", "refs/tags/v1.2.3"); !strings.Contains(got, result.TagObject) {
|
||||||
|
t.Fatalf("published object missing: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishPreservesHookDirectoryEnvironment(t *testing.T) {
|
func TestPublishPreservesHookDirectoryEnvironment(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("directory-derived PWD is a POSIX behavior")
|
t.Skip("directory-derived PWD is a POSIX behavior")
|
||||||
@@ -174,6 +235,13 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
|||||||
if result.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != "example.com/releasekit-publish-fixture" || result.Tag != "v1.2.3" || result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" {
|
if result.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != "example.com/releasekit-publish-fixture" || result.Tag != "v1.2.3" || result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" {
|
||||||
t.Fatalf("publication identity = %#v", result)
|
t.Fatalf("publication identity = %#v", result)
|
||||||
}
|
}
|
||||||
|
if result.RecordPath == "" || result.RecordError != nil {
|
||||||
|
t.Fatalf("publication record path=%q error=%v", result.RecordPath, result.RecordError)
|
||||||
|
}
|
||||||
|
recorded, err := releasekit.LoadPublicationEvidence(result.RecordPath)
|
||||||
|
if err != nil || recorded.Endpoint != repo.bare || recorded.Tag != result.Tag || recorded.CandidateCommit != commit || recorded.TagObject != result.TagObject || recorded.RecordedAt == "" {
|
||||||
|
t.Fatalf("saved publication evidence=%#v, error=%v", recorded, err)
|
||||||
|
}
|
||||||
|
|
||||||
refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
refsAfter := gitText(t, repo.bare, "show-ref", "--heads", "--tags")
|
||||||
for _, ref := range strings.Split(refsBefore, "\n") {
|
for _, ref := range strings.Split(refsBefore, "\n") {
|
||||||
@@ -207,6 +275,21 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPublishKeepsSuccessWhenAttemptRecordCannotBeSaved(t *testing.T) {
|
||||||
|
repo := newPublishFixture(t)
|
||||||
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||||
|
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3", PublicationRecordDir: "unignored-records",
|
||||||
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil },
|
||||||
|
})
|
||||||
|
var recovery *releasekit.PublicationRecoveryError
|
||||||
|
if err != nil || result.State != releasekit.PublicationPublished {
|
||||||
|
t.Fatalf("publication=%#v error=%v", result, err)
|
||||||
|
}
|
||||||
|
if result.RecordPath != "" || !errors.As(result.RecordError, &recovery) || recovery.Kind != "record_write" {
|
||||||
|
t.Fatalf("record path=%q error=%v", result.RecordPath, result.RecordError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublishRejectsReplacementDerivedCheckout(t *testing.T) {
|
func TestPublishRejectsReplacementDerivedCheckout(t *testing.T) {
|
||||||
repo := newPublishFixture(t)
|
repo := newPublishFixture(t)
|
||||||
writeFile(t, repo.work, "broken.go", "package fixture\nfunc broken(\n")
|
writeFile(t, repo.work, "broken.go", "package fixture\nfunc broken(\n")
|
||||||
@@ -431,6 +514,10 @@ exec %s "$@"
|
|||||||
if !strings.Contains(err.Error(), "refs/tags/v1.2.3") || !strings.Contains(err.Error(), "may remain") {
|
if !strings.Contains(err.Error(), "refs/tags/v1.2.3") || !strings.Contains(err.Error(), "may remain") {
|
||||||
t.Fatalf("missing local ref evidence: %v", err)
|
t.Fatalf("missing local ref evidence: %v", err)
|
||||||
}
|
}
|
||||||
|
evidence, loadErr := releasekit.LoadPublicationEvidence(result.RecordPath)
|
||||||
|
if loadErr != nil || evidence.Tag != result.Tag || evidence.CandidateCommit != result.CandidateCommit || evidence.TagObject != result.TagObject {
|
||||||
|
t.Fatalf("tag-inspection record=%#v error=%v", evidence, loadErr)
|
||||||
|
}
|
||||||
actual, readErr := exec.Command(realGit, "-C", repo.work, "rev-parse", "--verify", "refs/tags/v1.2.3").Output()
|
actual, readErr := exec.Command(realGit, "-C", repo.work, "rev-parse", "--verify", "refs/tags/v1.2.3").Output()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
t.Fatal(readErr)
|
t.Fatal(readErr)
|
||||||
@@ -737,6 +824,9 @@ func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) {
|
|||||||
if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" {
|
if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" {
|
||||||
t.Fatalf("publication result = %#v", result)
|
t.Fatalf("publication result = %#v", result)
|
||||||
}
|
}
|
||||||
|
if evidence, loadErr := releasekit.LoadPublicationEvidence(result.RecordPath); loadErr != nil || evidence.TagObject != result.TagObject || evidence.CandidateCommit != result.CandidateCommit {
|
||||||
|
t.Fatalf("rejected-push record = %#v, %v", evidence, loadErr)
|
||||||
|
}
|
||||||
if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") {
|
if !strings.Contains(err.Error(), "failed to push some refs") || !strings.Contains(err.Error(), "authoritative tag is absent") {
|
||||||
t.Fatalf("push error was not preserved with absence evidence: %v", err)
|
t.Fatalf("push error was not preserved with absence evidence: %v", err)
|
||||||
}
|
}
|
||||||
@@ -814,6 +904,9 @@ func TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown(t *testing.T
|
|||||||
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
|
if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation {
|
||||||
t.Fatalf("result = %#v, error = %v", result, err)
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
}
|
}
|
||||||
|
if evidence, loadErr := releasekit.LoadPublicationEvidence(result.RecordPath); loadErr != nil || evidence.TagObject != result.TagObject {
|
||||||
|
t.Fatalf("unknown-push record = %#v, %v", evidence, loadErr)
|
||||||
|
}
|
||||||
if !strings.Contains(err.Error(), "scoped URL resolves") || !strings.Contains(err.Error(), "lost acknowledgment") {
|
if !strings.Contains(err.Error(), "scoped URL resolves") || !strings.Contains(err.Error(), "lost acknowledgment") {
|
||||||
t.Fatalf("error does not preserve endpoint verification and push failures: %v", err)
|
t.Fatalf("error does not preserve endpoint verification and push failures: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
153
recovery.go
Normal file
153
recovery.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package releasekit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/git"
|
||||||
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicationEvidence identifies one attempted publication independently of
|
||||||
|
// current repository state. Reconciliation uses RepositoryDir as Git's working
|
||||||
|
// directory, Tag and Endpoint as the observed ref and destination, and the
|
||||||
|
// captured object IDs to classify the observation. Endpoint may contain
|
||||||
|
// credentials; treat this value and saved records as sensitive.
|
||||||
|
type PublicationEvidence struct {
|
||||||
|
RepositoryDir string // Git working-directory context for relative endpoints; empty uses the caller's current directory.
|
||||||
|
ModuleDir string // physical module directory captured for this attempt.
|
||||||
|
ModulePath string // declared module path captured for this attempt.
|
||||||
|
Version string // semantic version selected for this attempt.
|
||||||
|
Tag string // complete Git tag ref selected for this attempt.
|
||||||
|
CandidateCommit string // candidate commit captured before publication.
|
||||||
|
TagObject string // annotated tag object ID captured before pushing, when available.
|
||||||
|
Endpoint string // exact effective push endpoint captured for this attempt; may contain credentials.
|
||||||
|
Remote string // remote name supplying supported transport configuration during later observation.
|
||||||
|
RecordedAt string // UTC RFC3339Nano time when this attempt's evidence was initialized, when available.
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicationReconciliationResult combines saved evidence with one later
|
||||||
|
// observation. It does not update local or remote state or modify the record.
|
||||||
|
type PublicationReconciliationResult struct {
|
||||||
|
Evidence PublicationEvidence // original selected evidence.
|
||||||
|
State PublicationState // published, not_published, conflict, or unknown.
|
||||||
|
ObservedTagObject string // observed remote tag-object ID, when available.
|
||||||
|
ObservedCommit string // observed peeled commit ID, when available.
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicationRecoveryError describes an unresolved recovery operation or a
|
||||||
|
// record/storage failure. Its Error text is credential-safe; Unwrap retains
|
||||||
|
// the underlying cause for errors.Is and errors.As.
|
||||||
|
// Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed,
|
||||||
|
// transport_configuration, remote_observation, inconsistent_observation,
|
||||||
|
// record_format, record_read, record_write, and storage_enumeration.
|
||||||
|
// endpoint_changed identifies a confirmed URL-resolution mismatch;
|
||||||
|
// transport_configuration identifies configuration-read or resolution failures,
|
||||||
|
// including when the original remote name is absent.
|
||||||
|
type PublicationRecoveryError struct {
|
||||||
|
Kind string
|
||||||
|
RecordPath string // selected or affected record path, when applicable.
|
||||||
|
MissingFields []string // private-record field names required for the attempted classification.
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns the credential-safe recovery diagnostic.
|
||||||
|
func (e *PublicationRecoveryError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
message := fmt.Sprintf("publication recovery %s", e.Kind)
|
||||||
|
if e.RecordPath != "" {
|
||||||
|
message += fmt.Sprintf(" for %q", git.DisplayDiagnostic(e.RecordPath))
|
||||||
|
}
|
||||||
|
if e.cause != nil {
|
||||||
|
message += ": " + git.DisplayError(e.cause).Error()
|
||||||
|
}
|
||||||
|
return git.DisplayDiagnostic(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying recovery or storage cause.
|
||||||
|
func (e *PublicationRecoveryError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReconcilePublication observes the recorded endpoint without running candidate
|
||||||
|
// validation or changing local or remote state.
|
||||||
|
func ReconcilePublication(ctx context.Context, evidence PublicationEvidence) (PublicationReconciliationResult, error) {
|
||||||
|
observation, err := publish.Reconcile(ctx, internalEvidence(evidence))
|
||||||
|
return publicObservation(observation), publicRecoveryError(err, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPublicationEvidence loads one explicit private attempt record. Relative
|
||||||
|
// paths are interpreted from the caller's current directory; no Git lookup is
|
||||||
|
// performed.
|
||||||
|
func LoadPublicationEvidence(path string) (PublicationEvidence, error) {
|
||||||
|
evidence, err := publish.LoadEvidence(path)
|
||||||
|
return publicEvidence(evidence), publicRecoveryError(err, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicationAttemptsRequest selects local publication-attempt storage.
|
||||||
|
type PublicationAttemptsRequest struct {
|
||||||
|
RepositoryDir string // repository used to resolve an empty or relative RecordDir; empty means the current directory.
|
||||||
|
RecordDir string // optional storage directory; relative paths use the repository root, and absolute paths can be listed outside a checkout.
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicationAttempt pairs one readable record path with the evidence it stores.
|
||||||
|
type PublicationAttempt struct {
|
||||||
|
RecordPath string // absolute path of the selected record.
|
||||||
|
Evidence PublicationEvidence // saved identity, including any partial fields.
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicationAttemptsResult contains readable attempts and per-record
|
||||||
|
// diagnostics from one local directory enumeration.
|
||||||
|
type PublicationAttemptsResult struct {
|
||||||
|
RecordDir string // resolved absolute directory selected for enumeration, when available.
|
||||||
|
Attempts []PublicationAttempt // readable records in deterministic path order.
|
||||||
|
Diagnostics []*PublicationRecoveryError // unreadable or unsupported records; other entries remain available.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicationAttempts discovers saved local records without querying a
|
||||||
|
// remote or inferring which attempt should be reconciled.
|
||||||
|
func ListPublicationAttempts(ctx context.Context, request PublicationAttemptsRequest) (PublicationAttemptsResult, error) {
|
||||||
|
listed, err := publish.ListAttempts(ctx, request.RepositoryDir, request.RecordDir)
|
||||||
|
return publicAttemptsResult(listed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicAttemptsResult(listed publish.AttemptsResult, err error) (PublicationAttemptsResult, error) {
|
||||||
|
result := PublicationAttemptsResult{RecordDir: listed.RecordDir, Attempts: make([]PublicationAttempt, 0, len(listed.Attempts)), Diagnostics: make([]*PublicationRecoveryError, 0, len(listed.Diagnostics))}
|
||||||
|
for _, attempt := range listed.Attempts {
|
||||||
|
result.Attempts = append(result.Attempts, PublicationAttempt{RecordPath: attempt.RecordPath, Evidence: publicEvidence(attempt.Evidence)})
|
||||||
|
}
|
||||||
|
for _, diagnostic := range listed.Diagnostics {
|
||||||
|
converted := publicRecoveryError(diagnostic, diagnostic.RecordPath)
|
||||||
|
if recovery, ok := converted.(*PublicationRecoveryError); ok {
|
||||||
|
result.Diagnostics = append(result.Diagnostics, recovery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, publicRecoveryError(err, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalEvidence(e PublicationEvidence) publish.Evidence { return publish.Evidence(e) }
|
||||||
|
func publicEvidence(e publish.Evidence) PublicationEvidence { return PublicationEvidence(e) }
|
||||||
|
func publicObservation(o publish.Observation) PublicationReconciliationResult {
|
||||||
|
return PublicationReconciliationResult{Evidence: publicEvidence(o.Evidence), State: PublicationState(o.State), ObservedTagObject: o.ObservedTagObject, ObservedCommit: o.ObservedCommit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicRecoveryError(err error, path string) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var recovery *publish.RecoveryError
|
||||||
|
if errors.As(err, &recovery) {
|
||||||
|
recordPath := path
|
||||||
|
if recordPath == "" {
|
||||||
|
recordPath = recovery.RecordPath
|
||||||
|
}
|
||||||
|
return &PublicationRecoveryError{Kind: recovery.Kind, RecordPath: recordPath, MissingFields: append([]string(nil), recovery.MissingFields...), cause: recovery}
|
||||||
|
}
|
||||||
|
return &PublicationRecoveryError{Kind: "remote_observation", RecordPath: path, cause: err}
|
||||||
|
}
|
||||||
21
recovery_conversion_test.go
Normal file
21
recovery_conversion_test.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package releasekit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/releasekit/internal/publish"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicAttemptsRetainsPartialResultAndFailure(t *testing.T) {
|
||||||
|
failure := &publish.RecoveryError{Kind: "storage_enumeration"}
|
||||||
|
diagnostic := &publish.RecoveryError{Kind: "record_format", RecordPath: "broken.json"}
|
||||||
|
result, err := publicAttemptsResult(publish.AttemptsResult{
|
||||||
|
RecordDir: "records", Attempts: []publish.Attempt{{RecordPath: "good.json", Evidence: publish.Evidence{Tag: "v1.2.3"}}},
|
||||||
|
Diagnostics: []*publish.RecoveryError{diagnostic},
|
||||||
|
}, failure)
|
||||||
|
var typed *PublicationRecoveryError
|
||||||
|
if !errors.As(err, &typed) || typed.Kind != "storage_enumeration" || !errors.Is(err, failure) || len(result.Attempts) != 1 || result.Attempts[0].RecordPath != "good.json" || result.Attempts[0].Evidence.Tag != "v1.2.3" || len(result.Diagnostics) != 1 || !errors.Is(result.Diagnostics[0], diagnostic) {
|
||||||
|
t.Fatalf("facade lost partial results or cause: %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
recovery_test.go
Normal file
72
recovery_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package releasekit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
releasekit "go.maximumdirect.net/releasekit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoveryFacadeRedactsSelectedPathAndRetainsRawCause(t *testing.T) {
|
||||||
|
path := "https://audit-user:audit-secret@example.invalid/attempt.json"
|
||||||
|
_, err := releasekit.LoadPublicationEvidence(path)
|
||||||
|
var recovery *releasekit.PublicationRecoveryError
|
||||||
|
var pathErr *os.PathError
|
||||||
|
if !errors.As(err, &recovery) || recovery.RecordPath != path || !errors.As(err, &pathErr) || pathErr.Path != path {
|
||||||
|
t.Fatalf("raw diagnostic context lost: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "audit-secret") || strings.Contains(err.Error(), "audit-user") {
|
||||||
|
t.Fatalf("credentials in displayed error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryFacadeLoadsPartialEvidenceAndPreservesCause(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "attempt.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
evidence, err := releasekit.LoadPublicationEvidence(path)
|
||||||
|
if err != nil || evidence != (releasekit.PublicationEvidence{}) {
|
||||||
|
t.Fatalf("LoadPublicationEvidence()=%#v, %v", evidence, err)
|
||||||
|
}
|
||||||
|
result, err := releasekit.ReconcilePublication(context.Background(), evidence)
|
||||||
|
var recovery *releasekit.PublicationRecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "missing_evidence" || result.State != releasekit.PublicationUnknown {
|
||||||
|
t.Fatalf("ReconcilePublication()=%#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if len(recovery.MissingFields) != 2 || recovery.MissingFields[0] != "endpoint" || recovery.MissingFields[1] != "tag" {
|
||||||
|
t.Fatalf("missing fields=%v", recovery.MissingFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoveryFacadeErrorUnwrapsCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err := releasekit.ReconcilePublication(ctx, releasekit.PublicationEvidence{Endpoint: "file:///unused", Tag: "v1.2.3"})
|
||||||
|
var recovery *releasekit.PublicationRecoveryError
|
||||||
|
if !errors.As(err, &recovery) || recovery.Kind != "canceled" || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPublicationAttemptsReadsExplicitExternalStore(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
good := filepath.Join(directory, "attempt-a.json")
|
||||||
|
if err := os.WriteFile(good, []byte(`{"format_version":1,"evidence":{"tag":"v1.2.3"}}`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(directory, "attempt-b.json"), []byte("{"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
listed, err := releasekit.ListPublicationAttempts(context.Background(), releasekit.PublicationAttemptsRequest{RecordDir: directory})
|
||||||
|
if err != nil || listed.RecordDir != directory || len(listed.Attempts) != 1 || len(listed.Diagnostics) != 1 {
|
||||||
|
t.Fatalf("listing=%#v error=%v", listed, err)
|
||||||
|
}
|
||||||
|
if listed.Attempts[0].RecordPath != good || listed.Attempts[0].Evidence.Tag != "v1.2.3" || listed.Diagnostics[0].Kind != "record_format" {
|
||||||
|
t.Fatalf("listing=%#v", listed)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user