diff --git a/check.go b/check.go index d7da521..a77abe2 100644 --- a/check.go +++ b/check.go @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5f13bfc..8ea7e13 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/publish/reconcile.go b/internal/publish/reconcile.go index 2fe400c..2020d72 100644 --- a/internal/publish/reconcile.go +++ b/internal/publish/reconcile.go @@ -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) +} diff --git a/publish.go b/publish.go index c5762d9..e6b0490 100644 --- a/publish.go +++ b/publish.go @@ -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) } diff --git a/recovery.go b/recovery.go new file mode 100644 index 0000000..9681e25 --- /dev/null +++ b/recovery.go @@ -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} +} diff --git a/recovery_test.go b/recovery_test.go new file mode 100644 index 0000000..b52d8b3 --- /dev/null +++ b/recovery_test.go @@ -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) + } +}