Files
ReleaseKit/delivery_test.go

491 lines
17 KiB
Go

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
}