Expose publication recovery facade
This commit is contained in:
3
check.go
3
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
28
publish.go
28
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)
|
||||
}
|
||||
|
||||
96
recovery.go
Normal file
96
recovery.go
Normal 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
40
recovery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user