From a67a00a733598798eb3c440a7b99c05315029b1d Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 6 Oct 2026 18:23:55 +0000 Subject: [PATCH] Give delivery facade ownership of builds --- build_test.go | 2 +- cmd/releasekit/main.go | 2 +- cmd/releasekit/release_wrapper_test.go | 4 +- delivery.go | 30 +- delivery_test.go | 490 +++++++++++++++++++++++++ docs/consumers/delivery.md | 26 +- docs/internal/overview.md | 4 +- docs/roadmap/implementation.md | 13 +- internal/delivery/delivery.go | 72 +++- 9 files changed, 605 insertions(+), 38 deletions(-) create mode 100644 delivery_test.go diff --git a/build_test.go b/build_test.go index 7f395a6..fee9f55 100644 --- a/build_test.go +++ b/build_test.go @@ -602,7 +602,7 @@ func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",b } buildGit(t, repository, "restore", "cmd/app/main.go") buildGit(t, repository, "checkout", "--detach", "v1.2.3") - delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Build: &result}) + delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Prebuilt: &result}) if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted { t.Fatalf("dirty source delivered: %#v %v", delivered, err) } diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index 50cf2eb..0fecfdf 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -225,7 +225,7 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr Destination: releasekit.GiteaDestination{ BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token, }, - Build: build, + Prebuilt: build, }) if err != nil { fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err) diff --git a/cmd/releasekit/release_wrapper_test.go b/cmd/releasekit/release_wrapper_test.go index 4bb986c..0cfaa56 100644 --- a/cmd/releasekit/release_wrapper_test.go +++ b/cmd/releasekit/release_wrapper_test.go @@ -31,10 +31,10 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) { } updatedSources := map[string][]byte{} for _, path := range []string{ - "check.go", "publish.go", "internal/candidate/check.go", "internal/publish/publish.go", + "check.go", "publish.go", "delivery.go", "internal/candidate/check.go", "internal/delivery/delivery.go", "internal/publish/publish.go", "build_test.go", "publish_test.go", "internal/candidate/check_test.go", "internal/gitea/upload.go", "internal/gitea/upload_test.go", - "cmd/releasekit/main_test.go", "cmd/releasekit/release_wrapper_test.go", + "delivery_test.go", "cmd/releasekit/main_test.go", "cmd/releasekit/release_wrapper_test.go", "cmd/releasekit/internal/cli/config_test.go", "examples/releasekit.json", } { contents, err := os.ReadFile(filepath.Join(root, path)) diff --git a/delivery.go b/delivery.go index 3b3e2f9..3a039c0 100644 --- a/delivery.go +++ b/delivery.go @@ -2,6 +2,7 @@ package releasekit import ( "context" + "io" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery" @@ -39,13 +40,21 @@ type DeliveryAsset struct { } // DeliveryRequest identifies a local tagged checkout and its explicit Gitea -// destination. Build is nil for page-only delivery. +// destination. Build selects a managed build; Prebuilt supplies caller-owned +// build output instead. They cannot both be set. Either may be nil for +// page-only delivery when both are nil. Build with no targets, or an empty +// Prebuilt result, also performs page-only delivery. type DeliveryRequest struct { RepositoryDir string Product string Version string Destination GiteaDestination - Build *BuildResult + // Build selects binaries to build in temporary storage outside the checkout. + Build *BuildSpec + // Prebuilt supplies an existing build whose files remain caller-owned. + Prebuilt *BuildResult + // Output receives progress from a managed build. + Output io.Writer } // DeliveryResult reports hosted content and the state reached by this @@ -70,23 +79,28 @@ type DeliveryResult struct { } // Deliver verifies the selected local tag and hosted source, then creates or -// finds its release page and adds any missing declared build assets. +// finds its release page and adds any missing declared build assets. Build +// requests are compiled into temporary storage that Deliver removes on return. +// Prebuilt artifacts remain caller-owned and are not removed. Both paths +// validate source identity and artifact contents before hosted writes. func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) { result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} - var build *internalbuild.Result - if request.Build != nil { - build = internalBuildResult(*request.Build) - } destination := gitea.Destination{ BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner, Repository: request.Destination.Repository, Token: request.Destination.Token, } + var prebuilt *internalbuild.Result + if request.Prebuilt != nil { + prebuilt = internalBuildResult(*request.Prebuilt) + } internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{ RepositoryDir: request.RepositoryDir, Product: request.Product, Version: request.Version, Destination: destination, - Build: build, + Build: internalBuildSpec(request.Build), + Prebuilt: prebuilt, + Output: request.Output, }) result.CandidateCommit = internalResult.CandidateCommit result.ReleaseURL = internalResult.ReleaseURL diff --git a/delivery_test.go b/delivery_test.go new file mode 100644 index 0000000..505f10f --- /dev/null +++ b/delivery_test.go @@ -0,0 +1,490 @@ +package releasekit_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + releasekit "gitea.maximumdirect.net/eric/ReleaseKit" +) + +func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + var progress strings.Builder + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + Output: &progress, + }) + if err != nil { + t.Fatalf("Deliver() error = %v; result = %#v", err, result) + } + if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit { + t.Fatalf("delivery result = %#v", result) + } + if len(result.Uploaded) != 2 || len(remote.assets) != 2 { + t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets)) + } + if !strings.Contains(progress.String(), "checking fixture version output") { + t.Fatalf("build progress = %q", progress.String()) + } + if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { + t.Fatalf("managed build output remains: entries=%v error=%v", entries, err) + } +} + +func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + outputDir := filepath.Join(t.TempDir(), "caller-owned-assets") + prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + OutputDir: outputDir, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + server, _ := newFacadeDeliveryServer(t, repository) + defer server.Close() + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Prebuilt: &prebuilt, + }) + if err != nil || result.State != releasekit.DeliveryComplete { + t.Fatalf("Deliver() = %#v, %v", result, err) + } + for _, artifact := range prebuilt.Artifacts { + if _, err := os.Stat(artifact.Path); err != nil { + t.Errorf("caller-owned artifact %q was removed: %v", artifact.Path, err) + } + } +} + +func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) { + for _, test := range []struct { + name string + build *releasekit.BuildSpec + prebuilt *releasekit.BuildResult + }{ + {name: "no build"}, + {name: "empty target set", build: &releasekit.BuildSpec{}}, + {name: "empty prebuilt result", prebuilt: &releasekit.BuildResult{}}, + } { + t.Run(test.name, func(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + var progress strings.Builder + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: test.build, + Prebuilt: test.prebuilt, + Output: &progress, + }) + if err != nil || result.State != releasekit.DeliveryComplete { + t.Fatalf("Deliver() = %#v, %v", result, err) + } + if len(remote.assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 { + t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.assets)) + } + if progress.Len() != 0 { + t.Fatalf("page-only delivery emitted build progress: %q", progress.String()) + } + if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { + t.Fatalf("page-only delivery allocated temporary output: entries=%v error=%v", entries, err) + } + }) + } +} + +func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + writeBuildFixture(t, repository, "local-change.txt", "dirty source") + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + var progress strings.Builder + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + Output: &progress, + }) + if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit { + t.Fatalf("Deliver() = %#v, %v; want preflight failure for known commit", result, err) + } + if progress.Len() != 0 { + t.Fatalf("build started before source eligibility passed: %q", progress.String()) + } + if remote.requests != 0 { + t.Fatalf("hosted requests = %d, want none", remote.requests) + } +} + +func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + writeBuildFixture(t, repository, "later-change.txt", "new commit") + buildGit(t, repository, "add", "--all") + buildGit(t, repository, "commit", "-m", "advance checkout after release tag") + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + var progress strings.Builder + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + Output: &progress, + }) + if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == remote.commit { + t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err) + } + if progress.Len() != 0 { + t.Fatalf("build started before tag/HEAD agreement: %q", progress.String()) + } + if remote.requests != 0 { + t.Fatalf("hosted requests = %d, want none", remote.requests) + } +} + +func TestDeliverRejectsInvalidCommittedNoteBeforeBuildOrHostedWrites(t *testing.T) { + repository := newTaggedDeliveryConsumerWithNote(t, "# Different Product v1.2.3\n\nWrong product.\n") + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + var progress strings.Builder + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + Output: &progress, + }) + if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit { + t.Fatalf("Deliver() = %#v, %v; want committed-note preflight failure", result, err) + } + if progress.Len() != 0 { + t.Fatalf("build started before committed-note validation: %q", progress.String()) + } + if remote.requests != 0 { + t.Fatalf("hosted requests = %d, want none", remote.requests) + } +} + +func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + writer := &sourceChangeWriter{change: func() { + source, err := os.ReadFile(filepath.Join(repository, "cmd/app/main.go")) + if err != nil { + t.Errorf("read application source: %v", err) + return + } + writeBuildFixture(t, repository, "cmd/app/main.go", string(source)+"\n// changed during delivery build\n") + }} + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + Output: writer, + }) + if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted { + t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err) + } + if remote.requests != 0 { + t.Fatalf("hosted requests = %d, want none", remote.requests) + } + if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { + t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err) + } +} + +func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/missing", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + }) + if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit { + t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err) + } + if remote.requests != 0 { + t.Fatalf("hosted requests = %d, want none", remote.requests) + } + if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { + t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err) + } +} + +func TestDeliverManagedBuildCancellationCleansTemporaryOutput(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + temporaryRoot := t.TempDir() + t.Setenv("TMPDIR", temporaryRoot) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + writer := deliveryCancelWriter{cancel: cancel} + + result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + Output: writer, + }) + if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted { + t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err) + } + if remote.requests != 0 { + t.Fatalf("hosted requests = %d, want none", remote.requests) + } + if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { + t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err) + } +} + +func TestDeliverRejectsBothBuildSourcesBeforeHostedWrites(t *testing.T) { + repository := newTaggedDeliveryConsumer(t) + server, remote := newFacadeDeliveryServer(t, repository) + defer server.Close() + + result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{ + RepositoryDir: repository, + Product: "Fixture", + Version: "v1.2.3", + Destination: facadeDeliveryDestination(server.URL), + Build: &releasekit.BuildSpec{}, + Prebuilt: &releasekit.BuildResult{}, + }) + if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "both") { + t.Fatalf("Deliver() = %#v, %v; want invalid build-source combination", result, err) + } + if remote.requests != 0 { + t.Fatalf("hosted requests = %d, want none", remote.requests) + } +} + +func newTaggedDeliveryConsumer(t *testing.T) string { + return newTaggedDeliveryConsumerWithNote(t, "# Fixture v1.2.3\n\nDelivery fixture.\n") +} + +func newTaggedDeliveryConsumerWithNote(t *testing.T, note string) string { + t.Helper() + repository := newBuildConsumer(t, `package main +import ( + "fmt" + "os" + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) +func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } } +`) + writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", note) + buildGit(t, repository, "add", "--all") + buildGit(t, repository, "commit", "-m", "add release note") + buildGit(t, repository, "tag", "v1.2.3") + return repository +} + +func facadeDeliveryDestination(baseURL string) releasekit.GiteaDestination { + return releasekit.GiteaDestination{BaseURL: baseURL, Owner: "owner", Repository: "example", Token: "fixture-token"} +} + +type deliveryCancelWriter struct{ cancel context.CancelFunc } + +func (writer deliveryCancelWriter) Write(content []byte) (int, error) { + writer.cancel() + return len(content), nil +} + +type facadeDeliveryServer struct { + t *testing.T + commit string + page bool + requests int + assets map[string][]byte + url string +} + +func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) { + t.Helper() + state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), assets: make(map[string][]byte)} + server := httptest.NewServer(http.HandlerFunc(state.serveHTTP)) + state.url = server.URL + return server, state +} + +func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + state.requests++ + if r.Header.Get("Authorization") != "token fixture-token" { + state.t.Errorf("authorization = %q", r.Header.Get("Authorization")) + } + } + switch r.URL.Path { + case "/api/v1/repos/owner/example/tags/v1.2.3": + _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit) + case "/api/v1/repos/owner/example/releases/tags/v1.2.3": + if !state.page { + http.NotFound(w, r) + return + } + state.writeRelease(w) + case "/api/v1/repos/owner/example/releases": + var payload struct { + Tag string `json:"tag_name"` + Target string `json:"target_commitish"` + Name string `json:"name"` + Body string `json:"body"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + state.t.Errorf("decode release payload: %v", err) + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if payload.Tag != "v1.2.3" || payload.Target != state.commit || payload.Name != "Fixture v1.2.3" { + state.t.Errorf("release payload = %#v", payload) + } + state.page = true + state.writeRelease(w) + case "/api/v1/repos/owner/example/releases/10/assets": + if r.Method == http.MethodGet { + list := make([]map[string]any, 0, len(state.assets)) + for name, content := range state.assets { + list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": state.url + "/files/" + name}) + } + _ = json.NewEncoder(w).Encode(list) + return + } + name := r.URL.Query().Get("name") + file, _, err := r.FormFile("attachment") + if err != nil { + state.t.Errorf("read upload %q: %v", name, err) + http.Error(w, "invalid multipart upload", http.StatusBadRequest) + return + } + content, err := io.ReadAll(file) + _ = file.Close() + if err != nil { + state.t.Errorf("read upload %q: %v", name, err) + } + state.assets[name] = content + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"id": len(state.assets), "name": name, "size": len(content)}) + default: + if strings.HasPrefix(r.URL.Path, "/files/") { + name := strings.TrimPrefix(r.URL.Path, "/files/") + content, ok := state.assets[name] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(content) + return + } + state.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL) + http.NotFound(w, r) + } +} + +func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 10, "html_url": state.url + "/releases/10", "tag_name": "v1.2.3", + "target_commitish": state.commit, "name": "Fixture v1.2.3", + "body": "# Fixture v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false, + }) +} + +func mapKeys(values map[string][]byte) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} diff --git a/docs/consumers/delivery.md b/docs/consumers/delivery.md index bffe7fe..6110b26 100644 --- a/docs/consumers/delivery.md +++ b/docs/consumers/delivery.md @@ -62,13 +62,20 @@ The command does not run candidate checks or publish tags. ## Go API -Pass the Gitea credential explicitly. A `BuildResult` may be supplied for a -binary-producing release; it must identify the same tagged repository commit -and selected version, its `SourceVerified` field must confirm the completed -build's clean, unchanged source observations, and its local artifacts must still -match their digests. Cleaning the checkout after a dirty build does not make -that build eligible for delivery. -Leave `Build` nil for a source-only release. +Pass the Gitea credential explicitly. Set `Build` to a `BuildSpec` to compile +selected targets as part of delivery. The facade verifies the local tag, clean +checkout, and committed release note before compiling. Managed output is stored +outside the checkout and removed when delivery returns. `Output` receives build +progress. With no build, or with an empty target set, delivery is page-only. + +To deliver binaries built separately, set `Prebuilt` to the `BuildResult`. +ReleaseKit checks that it identifies the selected repository, tagged commit, and +version, that its source was verified clean and unchanged during compilation, +and that the current files still match their recorded digests. The files remain +caller-owned, so callers can retain the original bytes for retries and are +responsible for cleanup. With neither artifact source, an empty managed target +set, or an empty prebuilt result, delivery is page-only. Do not set both `Build` +and `Prebuilt` in one request. ```go result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ @@ -81,6 +88,11 @@ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ Repository: "example", Token: token, }, + Build: &releasekit.BuildSpec{ + MainPackage: "./cmd/example", + BinaryName: "example", + Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}, + }, }) if err != nil { // Keep the returned state and any completed asset identities for recovery. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index b1f290e..d8f331f 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -38,7 +38,7 @@ these implemented packages: | `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. | | `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. | | `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. | -| `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. | +| `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, prebuilt artifact validation, manifest assembly, and hosted delivery coordination. | | `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations, including captured-endpoint verification during recovery. | | `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | | `internal/gotool` | Effective Go configuration discovery and shared native-validation and static-build environments; see [Go command environments](gotool.md). | @@ -69,7 +69,7 @@ not a separate release matrix: | Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | | Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` | -| Page-only and binary tagged-source delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlyCreatesAndReusesRelease`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | +| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` | | Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerDeliveryCommandSequences`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 103e06c..2c8809e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit audit implementation plan -Status: Stages 1–5 complete; Stage 6 next. +Status: Stages 1–6 complete; Stage 7 next. Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence, product goals, and selected direction. This document owns implementation order and completion criteria for that work. @@ -329,6 +329,17 @@ on success and failure. Extend existing mutation/cancellation coverage where needed. Preserve prebuilt retry, digest conflict, note conflict, and source eligibility coverage. Confirm the two facades' dependency boundaries. +**Completed:** `Deliver` now owns optional managed builds, page-only requests, +and explicit prebuilt artifact delivery behind the public facade. Managed output +uses temporary storage outside the checkout and is cleaned after success, +failure, and cancellation; source and note checks precede compilation, and +eligibility is rechecked before hosted writes. Prebuilt files remain +caller-owned. Consumer and internal package guides describe these paths. +Facade-level consumer and HTTP tests cover managed upload, page-only delivery, +preflight and build failures, checkout mutation, cleanup, cancellation, and +prebuilt ownership. The full offline suite, API boundary check, and +documentation whitespace checks pass. + ## Stage 7 — Route ordinary CLI delivery through the facade **Covers:** the CLI portion of F5. **Primary owners:** diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index a0bef49..75bb666 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "io" + "os" "path/filepath" "strings" @@ -11,6 +13,7 @@ import ( "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) @@ -29,7 +32,9 @@ type Request struct { Product string Version string Destination gitea.Destination - Build *internalbuild.Result + Build *internalbuild.Spec + Prebuilt *internalbuild.Result + Output io.Writer } type Result struct { @@ -51,6 +56,9 @@ func Deliver(ctx context.Context, request Request) (Result, error) { if strings.TrimSpace(request.Product) == "" { return result, errors.New("product is required") } + if request.Build != nil && request.Prebuilt != nil { + return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts") + } repositoryDir, err := filepath.Abs(request.RepositoryDir) if err != nil { return result, fmt.Errorf("resolve repository directory: %w", err) @@ -66,21 +74,6 @@ func Deliver(ctx context.Context, request Request) (Result, error) { if source.Head != source.TagCommit { return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit) } - var assets []gitea.ExpectedAsset - if request.Build != nil { - assets, err = declaredBuildAssets(*request.Build) - if err != nil { - return result, err - } - } - if err := validateBuild(repositoryDir, source.TagCommit, request.Version, request.Build, assets); err != nil { - return result, err - } - if len(assets) > 0 { - if err := gitea.VerifyLocalAssets(assets); err != nil { - return result, fmt.Errorf("verify build assets: %w", err) - } - } notePath := "docs/releases/" + request.Version + ".md" note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath) if err != nil { @@ -90,6 +83,53 @@ func Deliver(ctx context.Context, request Request) (Result, error) { return result, fmt.Errorf("release note %q: %w", notePath, err) } + build := request.Prebuilt + if request.Build != nil && len(request.Build.Targets) != 0 { + temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-delivery-build-*") + if err != nil { + return result, fmt.Errorf("create temporary delivery build directory: %w", err) + } + defer os.RemoveAll(temporaryDir) + built, err := internalbuild.Build(ctx, internalbuild.Request{ + RepositoryDir: repositoryDir, + Version: request.Version, + Spec: *request.Build, + OutputDir: filepath.Join(temporaryDir, "assets"), + Output: request.Output, + }) + if err != nil { + return result, fmt.Errorf("build delivery assets: %w", err) + } + build = &built + } + var assets []gitea.ExpectedAsset + if build != nil { + assets, err = declaredBuildAssets(*build) + if err != nil { + return result, err + } + } + if err := validateBuild(repositoryDir, source.TagCommit, request.Version, build, assets); err != nil { + return result, err + } + if len(assets) > 0 { + if err := gitea.VerifyLocalAssets(assets); err != nil { + return result, fmt.Errorf("verify build assets: %w", err) + } + } + if build != nil && len(build.Targets) != 0 { + current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version) + if err != nil { + return result, fmt.Errorf("recheck tagged source after build: %w", err) + } + if current.Status != "" { + return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status) + } + if current.Head != source.TagCommit || current.TagCommit != source.TagCommit { + return result, errors.New("tagged source changed during build") + } + } + releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ Destination: request.Destination, Version: request.Version,