Give delivery facade ownership of builds

This commit is contained in:
2026-10-06 18:23:55 +00:00
parent c9afc8dd12
commit a67a00a733
9 changed files with 605 additions and 38 deletions

View File

@@ -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, "restore", "cmd/app/main.go")
buildGit(t, repository, "checkout", "--detach", "v1.2.3") 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 { if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted {
t.Fatalf("dirty source delivered: %#v %v", delivered, err) t.Fatalf("dirty source delivered: %#v %v", delivered, err)
} }

View File

@@ -225,7 +225,7 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
Destination: releasekit.GiteaDestination{ Destination: releasekit.GiteaDestination{
BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token, BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token,
}, },
Build: build, Prebuilt: build,
}) })
if err != nil { 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) fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err)

View File

@@ -31,10 +31,10 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) {
} }
updatedSources := map[string][]byte{} updatedSources := map[string][]byte{}
for _, path := range []string{ 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", "build_test.go", "publish_test.go", "internal/candidate/check_test.go",
"internal/gitea/upload.go", "internal/gitea/upload_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", "cmd/releasekit/internal/cli/config_test.go", "examples/releasekit.json",
} { } {
contents, err := os.ReadFile(filepath.Join(root, path)) contents, err := os.ReadFile(filepath.Join(root, path))

View File

@@ -2,6 +2,7 @@ package releasekit
import ( import (
"context" "context"
"io"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
internaldelivery "gitea.maximumdirect.net/eric/ReleaseKit/internal/delivery" 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 // 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 { type DeliveryRequest struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Destination GiteaDestination 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 // 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 // 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) { func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
var build *internalbuild.Result
if request.Build != nil {
build = internalBuildResult(*request.Build)
}
destination := gitea.Destination{ destination := gitea.Destination{
BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner, BaseURL: request.Destination.BaseURL, Owner: request.Destination.Owner,
Repository: request.Destination.Repository, Token: request.Destination.Token, 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{ internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Destination: destination, Destination: destination,
Build: build, Build: internalBuildSpec(request.Build),
Prebuilt: prebuilt,
Output: request.Output,
}) })
result.CandidateCommit = internalResult.CandidateCommit result.CandidateCommit = internalResult.CandidateCommit
result.ReleaseURL = internalResult.ReleaseURL result.ReleaseURL = internalResult.ReleaseURL

490
delivery_test.go Normal file
View File

@@ -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
}

View File

@@ -62,13 +62,20 @@ The command does not run candidate checks or publish tags.
## Go API ## Go API
Pass the Gitea credential explicitly. A `BuildResult` may be supplied for a Pass the Gitea credential explicitly. Set `Build` to a `BuildSpec` to compile
binary-producing release; it must identify the same tagged repository commit selected targets as part of delivery. The facade verifies the local tag, clean
and selected version, its `SourceVerified` field must confirm the completed checkout, and committed release note before compiling. Managed output is stored
build's clean, unchanged source observations, and its local artifacts must still outside the checkout and removed when delivery returns. `Output` receives build
match their digests. Cleaning the checkout after a dirty build does not make progress. With no build, or with an empty target set, delivery is page-only.
that build eligible for delivery.
Leave `Build` nil for a source-only release. 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 ```go
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
@@ -81,6 +88,11 @@ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
Repository: "example", Repository: "example",
Token: token, Token: token,
}, },
Build: &releasekit.BuildSpec{
MainPackage: "./cmd/example",
BinaryName: "example",
Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}},
},
}) })
if err != nil { if err != nil {
// Keep the returned state and any completed asset identities for recovery. // Keep the returned state and any completed asset identities for recovery.

View File

@@ -38,7 +38,7 @@ these implemented packages:
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. | | `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/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/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/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/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). | | `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` | | 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` | | 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` | | 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` | | 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` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerDeliveryCommandSequences`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |

View File

@@ -1,6 +1,6 @@
# ReleaseKit audit implementation plan # 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, Baseline: `e4be7fa` (2026-10-06). The [audit](audit.md) records the evidence,
product goals, and selected direction. This document owns implementation order product goals, and selected direction. This document owns implementation order
and completion criteria for that work. 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 needed. Preserve prebuilt retry, digest conflict, note conflict, and source
eligibility coverage. Confirm the two facades' dependency boundaries. 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 ## Stage 7 — Route ordinary CLI delivery through the facade
**Covers:** the CLI portion of F5. **Primary owners:** **Covers:** the CLI portion of F5. **Primary owners:**

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -11,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
@@ -29,7 +32,9 @@ type Request struct {
Product string Product string
Version string Version string
Destination gitea.Destination Destination gitea.Destination
Build *internalbuild.Result Build *internalbuild.Spec
Prebuilt *internalbuild.Result
Output io.Writer
} }
type Result struct { type Result struct {
@@ -51,6 +56,9 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
if strings.TrimSpace(request.Product) == "" { if strings.TrimSpace(request.Product) == "" {
return result, errors.New("product is required") 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) repositoryDir, err := filepath.Abs(request.RepositoryDir)
if err != nil { if err != nil {
return result, fmt.Errorf("resolve repository directory: %w", err) 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 { 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) 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" notePath := "docs/releases/" + request.Version + ".md"
note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath) note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath)
if err != nil { 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) 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{ releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
Destination: request.Destination, Destination: request.Destination,
Version: request.Version, Version: request.Version,