Expose publication recovery facade

This commit is contained in:
2026-11-02 19:03:24 +00:00
parent 1ddeb2c6dc
commit b11c6d1788
6 changed files with 164 additions and 10 deletions

View File

@@ -46,6 +46,9 @@ type ReleaseRequest struct {
// this string exactly before validation or tag creation. Check ignores this
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
ExpectedPushURL string
// PublicationRecordDir selects Publish's private attempt-record directory.
// Check ignores this publication-only setting.
PublicationRecordDir string
// Validation selects optional checks run against the captured candidate.
Validation ValidationSpec
// AdditionalChecks optionally runs product checks after shared validation.

View File

@@ -184,6 +184,8 @@ that private records retain synthetic credentials while diagnostics redact them.
## Stage 3: Add the facade-owned recovery interfaces
**Completed:** 2026-11-02
**Outcome:** Go callers can pass publication evidence directly to recovery or
load it through ReleaseKit.

View File

@@ -136,3 +136,8 @@ func observe(ctx context.Context, evidence Evidence) (Observation, error) {
}
return result, err
}
// Reconcile observes the recorded endpoint and classifies its exact tag.
func Reconcile(ctx context.Context, evidence Evidence) (Observation, error) {
return observe(ctx, evidence)
}

View File

@@ -40,16 +40,19 @@ const (
// PublicationResult describes the publication state reached by this call.
type PublicationResult struct {
RepositoryDir string // absolute source repository path.
ModuleDir string // absolute selected module directory.
ModulePath string // selected module's declared Go path.
Tag string // complete selected Git tag.
Version string // selected stable version
CandidateCommit string // captured main commit
Endpoint string // captured effective push URL, which may contain credentials
Phase PublicationPhase // last operation phase reached
TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty
State PublicationState // publication outcome for this invocation
RepositoryDir string // absolute source repository path.
ModuleDir string // absolute selected module directory.
ModulePath string // selected module's declared Go path.
Tag string // complete selected Git tag.
Version string // selected stable version
CandidateCommit string // captured main commit
Endpoint string // captured effective push URL, which may contain credentials
Phase PublicationPhase // last operation phase reached
TagObject string // local annotated tag object ID, when established; a local ref may remain even when empty
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 is available
RecordError error // recording failure, separate from publication outcome
}
// After publication preflight succeeds, Publish runs one offline Go test suite
@@ -77,6 +80,10 @@ 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,
@@ -100,5 +107,6 @@ 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},
}, convertValidationError(err)
}

96
recovery.go Normal file
View File

@@ -0,0 +1,96 @@
package releasekit
import (
"context"
"errors"
"fmt"
"go.maximumdirect.net/releasekit/internal/git"
"go.maximumdirect.net/releasekit/internal/publish"
)
// PublicationEvidence identifies one attempted publication. Endpoint may
// contain credentials; treat this value and saved records as sensitive.
type PublicationEvidence struct {
RepositoryDir string
ModuleDir string
ModulePath string
Version string
Tag string
CandidateCommit string
TagObject string
Endpoint string
Remote string
RecordedAt string
}
// PublicationReconciliationResult combines evidence with current remote state.
type PublicationReconciliationResult struct {
Evidence PublicationEvidence
State PublicationState
ObservedTagObject string
ObservedCommit string
}
// PublicationRecoveryError describes an unresolved recovery operation.
// Kind includes missing_evidence, invalid_evidence, canceled, endpoint_changed,
// transport_configuration, remote_observation, inconsistent_observation,
// record_format, record_read, record_write, and storage_enumeration.
type PublicationRecoveryError struct {
Kind string
RecordPath string
MissingFields []string
cause error
}
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", e.RecordPath)
}
if e.cause != nil {
message += ": " + git.DisplayError(e.cause).Error()
}
return message
}
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)
}
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) {
return &PublicationRecoveryError{Kind: recovery.Kind, RecordPath: path, MissingFields: append([]string(nil), recovery.MissingFields...), cause: recovery}
}
return &PublicationRecoveryError{Kind: "remote_observation", RecordPath: path, cause: err}
}

40
recovery_test.go Normal file
View File

@@ -0,0 +1,40 @@
package releasekit_test
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
releasekit "go.maximumdirect.net/releasekit"
)
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)
}
}