Move release page recovery into shared delivery
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Implementation plan: neutral forge delivery and credential preflight
|
||||
|
||||
Status: implementation in progress; Stages 1–5 are complete and Stages 6–10
|
||||
Status: implementation in progress; Stages 1–6 are complete and Stages 7–10
|
||||
are pending.
|
||||
|
||||
## Purpose and execution
|
||||
@@ -357,7 +357,7 @@ orchestration as its callers move rather than retaining a parallel policy path.
|
||||
|
||||
## Stage 6: Move hosted page policy and recovery
|
||||
|
||||
Status: pending.
|
||||
Status: complete.
|
||||
|
||||
1. Add shared page coordination in `internal/delivery` using `forge.Backend`.
|
||||
After credential preflight and local source/build verification, obtain the
|
||||
|
||||
91
internal/delivery/page.go
Normal file
91
internal/delivery/page.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func coordinateRelease(ctx context.Context, backend forge.Backend, tag, commit, note string) (forge.Release, forge.WriteEvidence, error) {
|
||||
observedCommit, err := backend.TagCommit(ctx, tag)
|
||||
if err != nil {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
if observedCommit != commit {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureConflict,
|
||||
Err: fmt.Errorf("hosted tag %q targets %q, want %q", tag, observedCommit, commit),
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := backend.FindRelease(ctx, tag)
|
||||
if err == nil {
|
||||
if !matchesExistingRelease(existing, tag, note) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{
|
||||
Kind: forge.FailureConflict,
|
||||
Err: errors.New("existing release does not match the committed note and stable tag"),
|
||||
}
|
||||
}
|
||||
return existing, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, nil
|
||||
}
|
||||
if !isForgeFailure(err, forge.FailureNotFound) {
|
||||
return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err
|
||||
}
|
||||
|
||||
request := forge.CreateReleaseRequest{
|
||||
Tag: tag, Commit: commit, Title: releaseNoteTitle(note), Body: note,
|
||||
}
|
||||
created, evidence, createErr := backend.CreateRelease(ctx, request)
|
||||
if createErr == nil && matchesCreatedRelease(created, request) {
|
||||
return created, evidence, nil
|
||||
}
|
||||
if createErr == nil {
|
||||
createErr = &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("created release does not match the requested stable release")}
|
||||
}
|
||||
if evidence.Disposition != forge.WriteConflict && evidence.Disposition != forge.WriteUncertain && !isForgeFailure(createErr, forge.FailureMalformed) {
|
||||
return forge.Release{}, evidence, createErr
|
||||
}
|
||||
|
||||
return reconcileReleaseCreate(ctx, backend, tag, note, evidence, createErr)
|
||||
}
|
||||
|
||||
func reconcileReleaseCreate(ctx context.Context, backend forge.Backend, tag, note string, evidence forge.WriteEvidence, createErr error) (forge.Release, forge.WriteEvidence, error) {
|
||||
existing, lookupErr := backend.FindRelease(ctx, tag)
|
||||
if lookupErr == nil {
|
||||
if matchesExistingRelease(existing, tag, note) {
|
||||
return existing, evidence, nil
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: errors.Join(createErr, errors.New("reconciled release does not match the committed note and stable tag"))}
|
||||
}
|
||||
if isForgeFailure(lookupErr, forge.FailureNotFound) {
|
||||
if evidence.Disposition == forge.WriteConflict {
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureConflict, Err: createErr}
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: createErr}
|
||||
}
|
||||
return forge.Release{}, evidence, &forge.Failure{Kind: forge.FailureUnknown, Err: errors.Join(createErr, lookupErr)}
|
||||
}
|
||||
|
||||
func matchesExistingRelease(release forge.Release, tag, note string) bool {
|
||||
return release.Tag == tag && release.Body == note && !release.Draft && !release.Prerelease
|
||||
}
|
||||
|
||||
func matchesCreatedRelease(release forge.Release, request forge.CreateReleaseRequest) bool {
|
||||
return release.Tag == request.Tag && release.Body == request.Body && release.Title == request.Title && !release.Draft && !release.Prerelease
|
||||
}
|
||||
|
||||
func releaseNoteTitle(note string) string {
|
||||
first := strings.SplitN(note, "\n", 2)[0]
|
||||
if !strings.HasPrefix(first, "# ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||
}
|
||||
|
||||
func isForgeFailure(err error, kind forge.FailureKind) bool {
|
||||
var failure *forge.Failure
|
||||
return errors.As(err, &failure) && failure.Kind == kind
|
||||
}
|
||||
204
internal/delivery/page_test.go
Normal file
204
internal/delivery/page_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/releasekit/internal/forge"
|
||||
)
|
||||
|
||||
func TestCoordinateReleaseAcceptsExistingPageAndRetainsItsTitle(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{release: forge.Release{
|
||||
Tag: "v1.2.3", Title: "Maintained title", Body: "# Release\n\nNotes", Handle: "release",
|
||||
}}},
|
||||
}
|
||||
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
|
||||
if err != nil || release.Title != "Maintained title" || evidence.Attempted {
|
||||
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
|
||||
}
|
||||
if backend.createCalls != 0 || backend.findCalls != 1 {
|
||||
t.Fatalf("calls = create %d, find %d", backend.createCalls, backend.findCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseRejectsWrongHostedTagAndExistingPage(t *testing.T) {
|
||||
t.Run("wrong hosted tag", func(t *testing.T) {
|
||||
backend := &pageBackend{commit: "other"}
|
||||
_, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureConflict) || evidence.Attempted || backend.findCalls != 0 || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease() evidence %#v, error %v, calls find=%d create=%d", evidence, err, backend.findCalls, backend.createCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing hosted tag", func(t *testing.T) {
|
||||
backend := &pageBackend{tagErr: &forge.Failure{Kind: forge.FailureNotFound, Err: errors.New("tag missing")}}
|
||||
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureNotFound) || backend.findCalls != 0 || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease() error = %v, calls find=%d create=%d", err, backend.findCalls, backend.createCalls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, release := range []forge.Release{
|
||||
{Tag: "v9.9.9", Body: "# Release", Title: "Release"},
|
||||
{Tag: "v1.2.3", Body: "different", Title: "Release"},
|
||||
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Draft: true},
|
||||
{Tag: "v1.2.3", Body: "# Release", Title: "Release", Prerelease: true},
|
||||
} {
|
||||
backend := &pageBackend{commit: "commit", findResults: []pageFindResult{{release: release}}}
|
||||
_, _, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release")
|
||||
if !isForgeFailure(err, forge.FailureConflict) || backend.createCalls != 0 {
|
||||
t.Fatalf("coordinateRelease(%#v) error = %v, create calls = %d", release, err, backend.createCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseCreatesWithFullNoteAndVerifiesRequestedTitle(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{err: &forge.Failure{Kind: forge.FailureNotFound}}},
|
||||
createResult: pageCreateResult{
|
||||
release: forge.Release{Tag: "v1.2.3", Title: "Release", Body: "# Release\n\nFull note", Handle: "release"},
|
||||
evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted},
|
||||
},
|
||||
}
|
||||
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nFull note")
|
||||
if err != nil || release.Handle != "release" || evidence.Disposition != forge.WriteAccepted || !evidence.Attempted {
|
||||
t.Fatalf("coordinateRelease() = %#v, evidence %#v, error %v", release, evidence, err)
|
||||
}
|
||||
if got := backend.createRequest; got.Tag != "v1.2.3" || got.Commit != "commit" || got.Title != "Release" || got.Body != "# Release\n\nFull note" || got.Draft || got.Prerelease {
|
||||
t.Fatalf("CreateRelease request = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateReleaseReconcilesCreateOutcomes(t *testing.T) {
|
||||
createErr := &forge.Failure{Kind: forge.FailureTransport, Err: errors.New("connection lost")}
|
||||
lookupErr := errors.New("lookup unavailable")
|
||||
matching := forge.Release{Tag: "v1.2.3", Title: "Original", Body: "# Release\n\nNotes", Handle: "release"}
|
||||
wrong := forge.Release{Tag: "v1.2.3", Body: "different", Handle: "other"}
|
||||
notFound := &forge.Failure{Kind: forge.FailureNotFound}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
create pageCreateResult
|
||||
followup pageFindResult
|
||||
wantKind forge.FailureKind
|
||||
wantRecovered bool
|
||||
wantCause error
|
||||
}{
|
||||
{
|
||||
name: "matching page after conflict",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
|
||||
followup: pageFindResult{release: matching}, wantRecovered: true,
|
||||
},
|
||||
{
|
||||
name: "different page after uncertain write",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{release: wrong}, wantKind: forge.FailureConflict,
|
||||
},
|
||||
{
|
||||
name: "missing after conflict",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteConflict}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureConflict,
|
||||
},
|
||||
{
|
||||
name: "missing after uncertain write",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown, wantCause: createErr,
|
||||
},
|
||||
{
|
||||
name: "unavailable follow-up",
|
||||
create: pageCreateResult{err: createErr, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}},
|
||||
followup: pageFindResult{err: lookupErr}, wantKind: forge.FailureUnknown, wantCause: lookupErr,
|
||||
},
|
||||
{
|
||||
name: "inconsistent accepted response reconciled",
|
||||
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
followup: pageFindResult{release: matching}, wantRecovered: true,
|
||||
},
|
||||
{
|
||||
name: "inconsistent accepted response missing",
|
||||
create: pageCreateResult{release: wrong, evidence: forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}},
|
||||
followup: pageFindResult{err: notFound}, wantKind: forge.FailureUnknown,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
backend := &pageBackend{
|
||||
commit: "commit",
|
||||
findResults: []pageFindResult{{err: notFound}, test.followup},
|
||||
createResult: test.create,
|
||||
}
|
||||
release, evidence, err := coordinateRelease(context.Background(), backend, "v1.2.3", "commit", "# Release\n\nNotes")
|
||||
if test.wantRecovered {
|
||||
if err != nil || release.Handle != "release" || !evidence.Attempted || backend.findCalls != 2 {
|
||||
t.Fatalf("recovered result = %#v, evidence %#v, error %v, find calls %d", release, evidence, err, backend.findCalls)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !isForgeFailure(err, test.wantKind) || !evidence.Attempted {
|
||||
t.Fatalf("result evidence %#v, error %v; want kind %s", evidence, err, test.wantKind)
|
||||
}
|
||||
if test.wantCause != nil && !errors.Is(err, test.wantCause) {
|
||||
t.Fatalf("error %v does not retain cause %v", err, test.wantCause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type pageFindResult struct {
|
||||
release forge.Release
|
||||
err error
|
||||
}
|
||||
|
||||
type pageCreateResult struct {
|
||||
release forge.Release
|
||||
evidence forge.WriteEvidence
|
||||
err error
|
||||
}
|
||||
|
||||
type pageBackend struct {
|
||||
commit string
|
||||
tagErr error
|
||||
findResults []pageFindResult
|
||||
findCalls int
|
||||
createResult pageCreateResult
|
||||
createCalls int
|
||||
createRequest forge.CreateReleaseRequest
|
||||
}
|
||||
|
||||
func (backend *pageBackend) Preflight(context.Context) error { return nil }
|
||||
|
||||
func (backend *pageBackend) TagCommit(context.Context, string) (string, error) {
|
||||
return backend.commit, backend.tagErr
|
||||
}
|
||||
|
||||
func (backend *pageBackend) FindRelease(context.Context, string) (forge.Release, error) {
|
||||
index := backend.findCalls
|
||||
backend.findCalls++
|
||||
if index >= len(backend.findResults) {
|
||||
return forge.Release{}, &forge.Failure{Kind: forge.FailureNotFound}
|
||||
}
|
||||
result := backend.findResults[index]
|
||||
return result.release, result.err
|
||||
}
|
||||
|
||||
func (backend *pageBackend) CreateRelease(_ context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) {
|
||||
backend.createCalls++
|
||||
backend.createRequest = request
|
||||
return backend.createResult.release, backend.createResult.evidence, backend.createResult.err
|
||||
}
|
||||
|
||||
func (*pageBackend) ListAssets(context.Context, any) ([]forge.Asset, error) { return nil, nil }
|
||||
|
||||
func (*pageBackend) OpenAsset(context.Context, any) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("")), nil
|
||||
}
|
||||
|
||||
func (*pageBackend) UploadAsset(context.Context, any, string, io.Reader) (forge.WriteEvidence, error) {
|
||||
return forge.WriteEvidence{}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user