diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 7951407..3a50c18 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -22,6 +22,10 @@ cover these failure combinations. ## R01: Preserve candidate failures and keep storage failures separate — P1 +**Completed:** Capture and storage errors now remain separate. Focused publication +regressions passed for invalid/missing notes, retained Git causes, saved partial +evidence, and successful publication after storage lookup failure. + **Relevant code:** `internal/publish/publish.go:110`, especially the assignment at line 120 and the error check at line 127. diff --git a/internal/publish/publish.go b/internal/publish/publish.go index 512fb01..116deae 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -107,7 +107,7 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err AdditionalChecks: request.AdditionalChecks, Output: request.Output, } - captured, err := candidate.Capture(ctx, checks) + captured, captureErr := candidate.Capture(ctx, checks) result.RepositoryDir = captured.RepositoryDir result.ModuleDir = captured.ModuleDir result.ModulePath = captured.ModulePath @@ -117,15 +117,16 @@ func Publish(ctx context.Context, request Request) (result Result, resultErr err if captured.RepositoryDir != "" { initialized = true result.Evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano) - recordDir, repositoryRoot, err = resolveRecordDir(captured.RepositoryDir, request.RecordDir) - if err != nil { - result.RecordError = &RecoveryError{Kind: "record_write", cause: err} + var storageErr error + recordDir, repositoryRoot, storageErr = resolveRecordDir(captured.RepositoryDir, request.RecordDir) + if storageErr != nil { + result.RecordError = &RecoveryError{Kind: "record_write", cause: storageErr} recordDir = "" recorded = true } } - if err != nil { - return result, fmt.Errorf("capture release candidate: %w", err) + if captureErr != nil { + return result, fmt.Errorf("capture release candidate: %w", captureErr) } endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName) diff --git a/publish_test.go b/publish_test.go index 9b32500..2fe32c0 100644 --- a/publish_test.go +++ b/publish_test.go @@ -19,6 +19,67 @@ import ( 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) { if runtime.GOOS == "windows" { t.Skip("directory-derived PWD is a POSIX behavior")