Expose tagged-source delivery through facade and CLI

This commit is contained in:
2026-10-05 20:39:33 +00:00
parent eb2cab78bb
commit bdf6a155b5
23 changed files with 1215 additions and 40 deletions

View File

@@ -16,7 +16,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION"
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION"
type Command string
@@ -25,6 +25,7 @@ const (
CheckAction Command = "check"
PublishAction Command = "publish"
BuildAction Command = "build"
DeliveryAction Command = "deliver"
)
// Invocation contains parsed command settings owned by the CLI adapter.
@@ -33,6 +34,7 @@ type Invocation struct {
Repository string
ConfigPath string
OutputDir string
AssetsDir string
Version string
}
@@ -47,7 +49,7 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("version takes no arguments")
}
return Invocation{Command: VersionCommand}, nil
case string(CheckAction), string(PublishAction), string(BuildAction):
case string(CheckAction), string(PublishAction), string(BuildAction), string(DeliveryAction):
action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard)
@@ -57,13 +59,17 @@ func Parse(args []string) (Invocation, error) {
if action == BuildAction {
flags.StringVar(&outputDir, "output", "", "build output directory")
}
var assetsDir string
if action == DeliveryAction {
flags.StringVar(&assetsDir, "assets", "", "verified build assets directory")
}
if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err
}
if flags.NArg() != 1 {
return Invocation{}, fmt.Errorf("%s requires one version argument", action)
}
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, Version: flags.Arg(0)}, nil
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Version: flags.Arg(0)}, nil
default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
}
@@ -78,10 +84,19 @@ type CheckCommand struct {
// Settings contains the product, remote, and check commands read from a
// releasekit.json profile.
type Settings struct {
Product string `json:"product"`
Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,omitempty"`
Product string `json:"product"`
Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,omitempty"`
Delivery *DeliverySettings `json:"delivery,omitempty"`
}
// DeliverySettings selects the hosted Gitea release destination.
type DeliverySettings struct {
BaseURL string `json:"base_url"`
Owner string `json:"owner"`
Repository string `json:"repository"`
TokenEnv string `json:"token_env,omitempty"`
}
// BuildSettings selects the Go application package, artifact name, and targets.

View File

@@ -40,6 +40,13 @@ func TestParse(t *testing.T) {
if build.Command != BuildAction || build.Repository != "candidate" || build.ConfigPath != "releasekit.json" || build.OutputDir != "output with spaces" || build.Version != "v1.2.5" {
t.Fatalf("Parse(build) = %#v", build)
}
deliver, err := Parse([]string{"deliver", "--repo", "candidate", "--config", "releasekit.json", "--assets", "assets with spaces", "v1.2.6"})
if err != nil {
t.Fatal(err)
}
if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" {
t.Fatalf("Parse(deliver) = %#v", deliver)
}
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version")
}
@@ -161,7 +168,7 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
want string
}{
{name: "missing product", content: `{}`, want: "product is required"},
{name: "unknown top-level field", content: `{"product":"Example","delivery":{}}`, want: "unknown field"},
{name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"},
{name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"},
{name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"},
{name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"},

View File

@@ -7,6 +7,7 @@ import (
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
@@ -134,10 +135,140 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0
case cli.DeliveryAction:
return runDelivery(ctx, invocation, stdout, stderr)
}
return 2
}
func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
delivery := settings.Delivery
if delivery == nil || strings.TrimSpace(delivery.BaseURL) == "" || strings.TrimSpace(delivery.Owner) == "" || strings.TrimSpace(delivery.Repository) == "" {
fmt.Fprintln(stderr, "deliver requires base_url, owner, and repository in the profile delivery settings")
return 2
}
tokenEnv := delivery.TokenEnv
if strings.TrimSpace(tokenEnv) == "" {
tokenEnv = "RELEASEKIT_GITEA_TOKEN"
}
token, ok := os.LookupEnv(tokenEnv)
if !ok || strings.TrimSpace(token) == "" {
fmt.Fprintf(stderr, "deliver requires a non-empty token in environment variable %s\n", tokenEnv)
return 2
}
hasTargets := settings.Build != nil && len(settings.Build.Targets) != 0
if invocation.AssetsDir != "" && !hasTargets {
fmt.Fprintln(stderr, "--assets requires build targets in the profile")
return 2
}
var build *releasekit.BuildResult
var temporaryDir string
if hasTargets {
if invocation.AssetsDir != "" {
assetsDir := invocation.AssetsDir
if !filepath.IsAbs(assetsDir) {
assetsDir = filepath.Join(invocation.Repository, assetsDir)
}
receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName))
if err != nil {
fmt.Fprintf(stderr, "load verified build receipt: %v\n", err)
return 1
}
if !receipt.MatchesBuild(invocation.Repository, receipt.CandidateCommit, invocation.Version, *settings.Build) {
fmt.Fprintln(stderr, "build receipt does not match the selected repository, version, or build settings")
return 1
}
build = buildResultFromReceipt(receipt)
} else {
repositoryDir, resolveErr := filepath.Abs(invocation.Repository)
if resolveErr != nil {
fmt.Fprintf(stderr, "resolve repository directory: %v\n", resolveErr)
return 1
}
temporaryDir, err = os.MkdirTemp(filepath.Dir(repositoryDir), ".releasekit-delivery-")
if err != nil {
fmt.Fprintf(stderr, "create temporary build directory: %v\n", err)
return 1
}
defer os.RemoveAll(temporaryDir)
result, buildErr := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: filepath.Join(temporaryDir, "assets"),
Output: stderr,
})
if buildErr != nil {
fmt.Fprintf(stderr, "build tagged-source assets: %v\n", buildErr)
return 1
}
build = &result
}
}
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Destination: releasekit.GiteaDestination{
BaseURL: delivery.BaseURL, Owner: delivery.Owner, Repository: delivery.Repository, Token: token,
},
Build: 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)
writeDeliveryAssets(stderr, result)
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
if result.State == releasekit.DeliveryUnknown {
return 3
}
return 1
}
fmt.Fprintf(stdout, "delivered %s at %s (state %s)\n", result.Version, result.CandidateCommit, result.State)
if result.ReleaseURL != "" {
fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL)
}
writeDeliveryAssets(stdout, result)
return 0
}
func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
for _, group := range []struct {
label string
assets []releasekit.DeliveryAsset
}{{"matching", result.Matching}, {"uploaded", result.Uploaded}, {"pending", result.Pending}, {"conflicting", result.Conflicting}, {"uncertain", result.Uncertain}} {
for _, asset := range group.assets {
fmt.Fprintf(output, "%s %s %s\n", group.label, asset.Filename, asset.SHA256)
}
}
}
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
result := &releasekit.BuildResult{
RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit,
Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName,
OutputDir: receipt.OutputDir, Compiler: receipt.Compiler,
}
for _, target := range receipt.Targets {
result.Targets = append(result.Targets, releasekit.Target{OS: target.OS, Arch: target.Arch})
}
for _, artifact := range receipt.Artifacts {
result.Artifacts = append(result.Artifacts, releasekit.BuildArtifact{
Path: artifact.Path, Filename: artifact.Filename,
Target: releasekit.Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, SHA256: artifact.SHA256,
})
}
return result
}
func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
if settings == nil {
return nil

View File

@@ -3,8 +3,13 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
@@ -234,6 +239,348 @@ func newCLIRepository(t *testing.T) string {
return repository
}
func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
settings := cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs.
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
for attempt := 0; attempt < 2; attempt++ {
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 0 {
t.Fatalf("delivery attempt %d status=%d stdout=%q stderr=%q", attempt+1, status, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "state complete") || !strings.Contains(stdout.String(), "release: "+server.URL+"/releases/10") {
t.Fatalf("delivery output = %q", stdout.String())
}
}
if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 {
t.Fatalf("page-only calls = creates:%d asset uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads)
}
if state.noteBody != "# Example Product v1.2.3\n\nRelease note.\n" {
t.Fatalf("hosted note body = %q", state.noteBody)
}
}
func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
repository := newDeliveryBuildRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
settings := cli.Settings{
Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}
writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
cliGitRun(t, repository, "checkout", "--detach", "v1.2.3")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
for attempt := 0; attempt < 2; attempt++ {
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 0 {
t.Fatalf("delivery attempt %d status=%d stdout=%q stderr=%q", attempt+1, status, stdout.String(), stderr.String())
}
}
if state.releaseCreates != 1 || state.assetUploads != 2 {
t.Fatalf("delivery calls = creates:%d asset uploads:%d", state.releaseCreates, state.assetUploads)
}
if _, ok := state.assets["helper-v1.2.3-linux-amd64"]; !ok {
t.Fatalf("binary was not uploaded: %#v", state.assets)
}
if _, ok := state.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not uploaded: %#v", state.assets)
}
}
func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{remoteCommit: strings.Repeat("0", 40)}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || state.releaseCreates != 0 || state.assetUploads != 0 {
t.Fatalf("wrong hosted tag status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String())
}
}
func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 2 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "RELEASEKIT_TEST_DELIVERY_TOKEN") {
t.Fatalf("missing credential status=%d requests=%d stdout=%q stderr=%q", status, state.apiRequests, stdout.String(), stderr.String())
}
}
func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "add", "later.txt")
cliGitRun(t, repository, "commit", "-m", "advance checkout")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 {
t.Fatalf("mismatched checkout status=%d requests=%d creates=%d stdout=%q stderr=%q", status, state.apiRequests, state.releaseCreates, stdout.String(), stderr.String())
}
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3"); got != state.commit {
t.Fatalf("delivery moved tag from %s to %s", state.commit, got)
}
}
func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testing.T) {
for _, mode := range []string{"valid receipt", "tampered", "stale settings"} {
t.Run(mode, func(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
settings := cli.Settings{Product: "Example Product", Build: &current, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
receiptSettings := current
if mode == "stale settings" {
receiptSettings.BinaryName = "previous"
}
assetsDir, artifactPath := writeDeliveryReceipt(t, repository, receiptSettings)
if mode == "tampered" {
if err := os.WriteFile(artifactPath, []byte("tampered artifact"), 0o600); err != nil {
t.Fatal(err)
}
}
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr)
if mode == "valid receipt" {
if status != 0 || state.releaseCreates != 1 || state.assetUploads != 2 {
t.Fatalf("valid receipt status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String())
}
return
}
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 {
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.apiRequests, stdout.String(), stderr.String())
}
})
}
}
func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSettings) (string, string) {
t.Helper()
assetsDir := t.TempDir()
filename := settings.BinaryName + "-v1.2.3-linux-amd64"
artifactPath := filepath.Join(assetsDir, filename)
content := []byte("fixture artifact")
if err := os.WriteFile(artifactPath, content, 0o600); err != nil {
t.Fatal(err)
}
resolvedRepository, err := filepath.Abs(repository)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(content)
target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"}
receipt := cli.BuildReceipt{
RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"),
Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName,
OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture",
Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}},
}
if _, err := cli.WriteBuildReceipt(assetsDir, receipt); err != nil {
t.Fatal(err)
}
return assetsDir, artifactPath
}
type deliveryServerState struct {
commit string
remoteCommit string
apiRequests int
pageExists bool
releaseCreates int
assetUploads int
assetListReads int
noteBody string
assets map[string][]byte
}
func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server {
t.Helper()
if state.assets == nil {
state.assets = make(map[string][]byte)
}
serverURL := ""
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
state.apiRequests++
if r.Header.Get("Authorization") != "token fixture-token" {
t.Errorf("authorization = %q", r.Header.Get("Authorization"))
}
}
switch r.URL.Path {
case "/api/v1/repos/owner/example/tags/v1.2.3":
if state.commit == "" {
t.Error("tag source commit was not initialized")
}
commit := state.remoteCommit
if commit == "" {
commit = state.commit
}
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit)
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
if !state.pageExists {
http.NotFound(w, r)
return
}
writeDeliveryRelease(w, serverURL, state)
case "/api/v1/repos/owner/example/releases":
if r.Method != http.MethodPost {
t.Errorf("release request method = %s", r.Method)
http.Error(w, "method", 405)
return
}
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 {
t.Errorf("decode create payload: %v", err)
}
if payload.Tag != "v1.2.3" || payload.Target != state.commit {
t.Errorf("release source payload = %#v", payload)
}
state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body
writeDeliveryRelease(w, serverURL, state)
case "/api/v1/repos/owner/example/releases/10/assets":
if r.Method == http.MethodGet {
state.assetListReads++
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": serverURL + "/files/" + name})
}
_ = json.NewEncoder(w).Encode(list)
return
}
if r.Method != http.MethodPost {
t.Errorf("asset request method = %s", r.Method)
http.Error(w, "method", 405)
return
}
name := r.URL.Query().Get("name")
file, _, err := r.FormFile("attachment")
if err != nil {
t.Errorf("read asset upload %q: %v", name, err)
http.Error(w, "multipart", 400)
return
}
content, err := io.ReadAll(file)
_ = file.Close()
if err != nil {
t.Errorf("read asset bytes: %v", err)
}
if _, exists := state.assets[name]; exists {
t.Errorf("asset %q uploaded more than once", name)
}
state.assets[name] = content
state.assetUploads++
_ = json.NewEncoder(w).Encode(map[string]any{"id": state.assetUploads, "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
}
t.Errorf("unexpected delivery request %s %s", r.Method, r.URL.String())
http.NotFound(w, r)
}
})
server := httptest.NewServer(handler)
serverURL = server.URL
return server
}
func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *deliveryServerState) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": "v1.2.3", "target_commitish": state.commit, "name": "Example Product v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false})
}
func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) {
t.Helper()
contents, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repository, "releasekit.json"), contents, 0o600); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure delivery")
}
func newDeliveryBuildRepository(t *testing.T) string {
t.Helper()
repository := filepath.Join(t.TempDir(), "tagged application")
if err := os.Mkdir(repository, 0o700); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "init", "--initial-branch=main")
cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test")
cliGitRun(t, repository, "config", "user.email", "releasekit@example.test")
files := map[string]string{
"go.mod": "module example.com/tagged-app\n\ngo 1.25.0\n",
"main.go": "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"helper v1.2.3\") }\n",
"docs/releases/v1.2.3.md": "# Example Product v1.2.3\n\nRelease note.\n",
}
for name, contents := range files {
path := filepath.Join(repository, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "tagged candidate")
return repository
}
func newCLIBuildRepository(t *testing.T, mainSource string) string {
t.Helper()
repository := newCLIRepository(t)