Record publication evidence before tag pushes

This commit is contained in:
2026-11-02 19:08:07 +00:00
parent b11c6d1788
commit 2a04938a96
5 changed files with 87 additions and 10 deletions

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}