From 88a79c3561cefb2c6bd7e7f23d663940650c3d48 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 1 Nov 2026 18:04:30 +0000 Subject: [PATCH] Move release page recovery into shared delivery --- docs/roadmap/implementation.md | 4 +- internal/delivery/page.go | 91 +++++++++++++++ internal/delivery/page_test.go | 204 +++++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 internal/delivery/page.go create mode 100644 internal/delivery/page_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 50e81f9..179d820 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/delivery/page.go b/internal/delivery/page.go new file mode 100644 index 0000000..5dcbbcf --- /dev/null +++ b/internal/delivery/page.go @@ -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 +} diff --git a/internal/delivery/page_test.go b/internal/delivery/page_test.go new file mode 100644 index 0000000..17226d2 --- /dev/null +++ b/internal/delivery/page_test.go @@ -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 +}