From 2a04938a96f8a59026465dfee0109d5dc302bf3c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 19:08:07 +0000 Subject: [PATCH] Record publication evidence before tag pushes --- docs/roadmap/implementation.md | 2 ++ internal/publish/publish.go | 50 ++++++++++++++++++++++++++++++---- internal/publish/record.go | 4 +++ publish.go | 9 +++--- publish_test.go | 32 ++++++++++++++++++++++ 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 8ea7e13..67cef89 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -253,6 +253,8 @@ current external-type allowances. ## Stage 4: Record attempts inside shared publication +**Completed:** 2026-11-02 + **Outcome:** API and CLI publication retain the same evidence before pushing, with recording failures separate from the publication outcome. diff --git a/internal/publish/publish.go b/internal/publish/publish.go index cdc10ad..512fb01 100644 --- a/internal/publish/publish.go +++ b/internal/publish/publish.go @@ -48,6 +48,7 @@ type Request struct { Version string Remote string ExpectedPushURL string + RecordDir string Validation validation.Settings AdditionalChecks func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate, *gotool.Owner) error @@ -65,6 +66,9 @@ type Result struct { Phase Phase TagObject string State State + Evidence Evidence + RecordPath string + RecordError error } // 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 // object equality confirms publication; absence and conflict are classified, // while an unreadable result remains Unknown and preserves the push error. -func Publish(ctx context.Context, request Request) (Result, error) { - result := Result{Version: request.Version, Phase: Preflight, State: NotAttempted} +func Publish(ctx context.Context, request Request) (result Result, resultErr error) { + 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(result.RepositoryDir, repositoryRoot, request.RecordDir, recordDir, result.Evidence) + if err != nil { + result.RecordError = err + return + } + result.RecordPath = path + }() remoteName := request.Remote if remoteName == "" { remoteName = "origin" @@ -95,6 +113,17 @@ func Publish(ctx context.Context, request Request) (Result, error) { result.ModulePath = captured.ModulePath result.Tag = captured.Tag result.CandidateCommit = captured.Commit + result.Evidence = Evidence{RepositoryDir: captured.RepositoryDir, ModuleDir: captured.ModuleDir, ModulePath: captured.ModulePath, Version: captured.Version, Tag: captured.Tag, CandidateCommit: captured.Commit, Remote: remoteName} + 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} + recordDir = "" + recorded = true + } + } if err != nil { return result, fmt.Errorf("capture release candidate: %w", err) } @@ -104,21 +133,22 @@ func Publish(ctx context.Context, request Request) (Result, error) { return result, fmt.Errorf("resolve publication endpoint: %w", err) } result.Endpoint = endpoint.PushURL + result.Evidence.Endpoint = 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)) } if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { 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 isRemoteConflict(err) { result.State = Conflict } 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 checks.ToolOwner = gotool.NewOwner(captured.ModuleDir) @@ -148,6 +178,7 @@ func Publish(ctx context.Context, request Request) (Result, error) { result.State = NotPublished tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note)) result.TagObject = tagObject + result.Evidence.TagObject = tagObject if err != nil { return result, err } @@ -166,6 +197,15 @@ func Publish(ctx context.Context, request Request) (Result, error) { result.Phase = Push result.State = Unknown + if !recorded { + recorded = true + path, recordErr := saveEvidenceInDir(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 { return reconcilePush(endpoint, captured, tagObject, result, err) } diff --git a/internal/publish/record.go b/internal/publish/record.go index c3df3b2..86a9e80 100644 --- a/internal/publish/record.go +++ b/internal/publish/record.go @@ -55,6 +55,10 @@ func SaveEvidence(repositoryDir, override string, evidence Evidence) (string, er if err != nil { return "", &RecoveryError{Kind: "record_write", cause: err} } + return saveEvidenceInDir(repositoryDir, root, override, recordDir, evidence) +} + +func saveEvidenceInDir(repositoryDir, root, override, recordDir string, evidence Evidence) (string, error) { if evidence.RecordedAt == "" { evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano) } diff --git a/publish.go b/publish.go index e6b0490..36c5096 100644 --- a/publish.go +++ b/publish.go @@ -80,10 +80,6 @@ type PublicationResult struct { // or exact remote object match. func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) { candidateInput := candidateRequest(request) - remote := request.Remote - if remote == "" { - remote = "origin" - } result, err := publish.Publish(ctx, publish.Request{ RepositoryDir: candidateInput.RepositoryDir, ModuleDir: candidateInput.ModuleDir, @@ -91,6 +87,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er Version: candidateInput.Version, Remote: request.Remote, ExpectedPushURL: request.ExpectedPushURL, + RecordDir: request.PublicationRecordDir, Validation: candidateInput.Validation, AdditionalChecks: candidateInput.AdditionalChecks, AfterValidate: candidateInput.AfterValidate, @@ -107,6 +104,8 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er Phase: PublicationPhase(result.Phase), TagObject: result.TagObject, State: PublicationState(result.State), - Evidence: PublicationEvidence{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, Version: result.Version, CandidateCommit: result.CandidateCommit, TagObject: result.TagObject, Endpoint: result.Endpoint, Remote: remote}, + Evidence: publicEvidence(result.Evidence), + RecordPath: result.RecordPath, + RecordError: publicRecoveryError(result.RecordError, ""), }, convertValidationError(err) } diff --git a/publish_test.go b/publish_test.go index e6e539c..9b32500 100644 --- a/publish_test.go +++ b/publish_test.go @@ -174,6 +174,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 == "" { 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") for _, ref := range strings.Split(refsBefore, "\n") { @@ -207,6 +214,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) { repo := newPublishFixture(t) writeFile(t, repo.work, "broken.go", "package fixture\nfunc broken(\n") @@ -431,6 +453,10 @@ exec %s "$@" if !strings.Contains(err.Error(), "refs/tags/v1.2.3") || !strings.Contains(err.Error(), "may remain") { 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() if readErr != nil { t.Fatal(readErr) @@ -737,6 +763,9 @@ func TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished(t *testing.T) { if result.State != releasekit.PublicationNotPublished || result.Phase != releasekit.PublicationReconciliation || result.TagObject == "" { 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") { t.Fatalf("push error was not preserved with absence evidence: %v", err) } @@ -814,6 +843,9 @@ func TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown(t *testing.T if err == nil || result.State != releasekit.PublicationUnknown || result.Phase != releasekit.PublicationReconciliation { 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") { t.Fatalf("error does not preserve endpoint verification and push failures: %v", err) }