Record publication evidence before tag pushes
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user