Move release page recovery into shared delivery
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Implementation plan: neutral forge delivery and credential preflight
|
# 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.
|
are pending.
|
||||||
|
|
||||||
## Purpose and execution
|
## 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
|
## Stage 6: Move hosted page policy and recovery
|
||||||
|
|
||||||
Status: pending.
|
Status: complete.
|
||||||
|
|
||||||
1. Add shared page coordination in `internal/delivery` using `forge.Backend`.
|
1. Add shared page coordination in `internal/delivery` using `forge.Backend`.
|
||||||
After credential preflight and local source/build verification, obtain the
|
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