package main import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" ) func TestVersionCommand(t *testing.T) { var stdout, stderr bytes.Buffer if status := run(context.Background(), []string{"version"}, &stdout, &stderr); status != 0 { t.Fatalf("run() status = %d, want 0; stderr = %q", status, stderr.String()) } if want := fmt.Sprintf("releasekit %s\n", buildinfo.Version()); stdout.String() != want { t.Fatalf("version output = %q, want %q", stdout.String(), want) } if stderr.Len() != 0 { t.Fatalf("version command wrote to stderr: %q", stderr.String()) } } func TestUnknownCommand(t *testing.T) { var stdout, stderr bytes.Buffer if status := run(context.Background(), []string{"unknown"}, &stdout, &stderr); status != 2 { t.Fatalf("run() status = %d, want 2", status) } if stdout.Len() != 0 { t.Fatalf("unknown command wrote to stdout: %q", stdout.String()) } if want := "unknown command \"unknown\"\n" + cli.Usage + "\n"; stderr.String() != want { t.Fatalf("usage output = %q, want %q", stderr.String(), want) } } func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { repository := newCLIRepository(t) profilePath := filepath.Join(t.TempDir(), "profile with spaces.json") orderPath := filepath.Join(t.TempDir(), "check-order") t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath) executable, err := os.Executable() if err != nil { t.Fatal(err) } check := func(name string) cli.CheckCommand { return cli.CheckCommand{ Args: []string{executable, "-test.run=TestCandidateCheckHelper", "--", name}, Env: map[string]string{ "RELEASEKIT_CHECK_HELPER": "1", "RELEASEKIT_CHECK_NAME": name, "RELEASEKIT_VERSION": "profile version", "RELEASEKIT_CANDIDATE": "profile commit", }, } } settings := cli.Settings{ Product: "Example Product", Remote: "origin", Checks: []cli.CheckCommand{check("first"), check("second")}, } contents, err := json.Marshal(settings) if err != nil { t.Fatal(err) } if err := os.WriteFile(profilePath, contents, 0o600); err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer args := []string{"check", "--repo", repository, "--config", profilePath, "v1.2.3"} if status := run(context.Background(), args, &stdout, &stderr); status != 0 { t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) } commit := cliGitOutput(t, repository, "rev-parse", "HEAD") if got, err := os.ReadFile(orderPath); err != nil || string(got) != "first\nsecond\n" { t.Fatalf("configured check order = %q, error = %v", got, err) } if got, want := strings.Count(stderr.String(), "v1.2.3|"+commit), 2; got != want { t.Fatalf("configured checks received captured candidate %d times, want %d; stderr=%q", got, want, stderr.String()) } if want := fmt.Sprintf("local candidate v1.2.3 at %s is valid\n", commit); stdout.String() != want { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } } func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) { repository := newCLIRepository(t) settings := cli.Settings{ Product: "Example Product", Remote: "origin", } 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 source release") remote := filepath.Join(t.TempDir(), "origin.git") cliGitRun(t, repository, "init", "--bare", remote) cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") var stdout, stderr bytes.Buffer args := []string{"publish", "--repo", repository, "v1.2.3"} if status := run(context.Background(), args, &stdout, &stderr); status != 0 { t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) } commit := cliGitOutput(t, repository, "rev-parse", "HEAD") tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3") if want := fmt.Sprintf("published v1.2.3 at %s (tag object %s)\n", commit, tagObject); stdout.String() != want { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) { t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String()) } if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject { t.Fatalf("remote tag object = %s, want %s", got, tagObject) } } func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) { repository := newCLIBuildRepository(t, `package main import ( "fmt" "os" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" ) func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } } `) var stdout, stderr bytes.Buffer args := []string{"build", "--repo", repository, "v1.2.3"} if status := run(context.Background(), args, &stdout, &stderr); status != 0 { t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) } receiptPath := filepath.Join(repository, "dist", cli.BuildReceiptName) receipt, err := cli.LoadBuildReceipt(receiptPath) if err != nil { t.Fatal(err) } commit := cliGitOutput(t, repository, "rev-parse", "HEAD") settings := cli.BuildSettings{ MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}, } if !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 { t.Fatalf("build receipt = %#v", receipt) } if !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].Filename)) || !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].SHA256)) { t.Fatalf("stdout does not report receipt artifacts: %q", stdout.String()) } if got := cliGitOutput(t, repository, "status", "--porcelain"); got != "" { t.Fatalf("build output dirtied candidate: %s", got) } } func TestBuildCommandLeavesNoReceiptAfterSmokeFailure(t *testing.T) { repository := newCLIBuildRepository(t, `package main import ( "fmt" "os" ) func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("wrong") } } `) var stdout, stderr bytes.Buffer args := []string{"build", "--repo", repository, "v1.2.3"} if status := run(context.Background(), args, &stdout, &stderr); status != 1 { t.Fatalf("run() status = %d; stdout=%q stderr=%q, want 1", status, stdout.String(), stderr.String()) } if stdout.Len() != 0 || !bytes.Contains(stderr.Bytes(), []byte("native version output")) { t.Fatalf("stdout=%q stderr=%q; want smoke failure without final output", stdout.String(), stderr.String()) } if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !os.IsNotExist(err) { t.Fatalf("partial build wrote receipt: %v", err) } filename := fmt.Sprintf("fixture-v1.2.3-%s-%s", runtime.GOOS, runtime.GOARCH) if runtime.GOOS == "windows" { filename += ".exe" } if _, err := os.Stat(filepath.Join(repository, "dist", filename)); err != nil { t.Fatalf("completed artifact not reported or retained: %v; stderr=%q", err, stderr.String()) } } func TestCandidateCheckHelper(t *testing.T) { if os.Getenv("RELEASEKIT_CHECK_HELPER") != "1" { return } if orderPath := os.Getenv("RELEASEKIT_CHECK_ORDER_FILE"); orderPath != "" { file, err := os.OpenFile(orderPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(9) } if _, err := fmt.Fprintln(file, os.Getenv("RELEASEKIT_CHECK_NAME")); err != nil { _ = file.Close() fmt.Fprintln(os.Stderr, err) os.Exit(9) } if err := file.Close(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(9) } } fmt.Fprintf(os.Stdout, "%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE")) os.Exit(0) } func newCLIRepository(t *testing.T) string { t.Helper() repository := filepath.Join(t.TempDir(), "candidate repository") 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") cliGitRun(t, repository, "config", "commit.gpgsign", "false") write := func(name, value string) { t.Helper() 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(value), 0o600); err != nil { t.Fatal(err) } } write("go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n") write("candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n") write("docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n") cliGitRun(t, repository, "add", "--all") cliGitRun(t, repository, "commit", "-m", "candidate") 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 TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) { for _, test := range []struct { name string state string status int recovery []string assetObservation string missingObservation string }{ { name: "partial", status: 1, recovery: []string{"recovery: fix the reported cause", "retry this version"}, assetObservation: "pending helper-v1.2.3-linux-amd64", }, { name: "conflict", status: 1, recovery: []string{"investigate conflicting hosted content", "new version", "retain the original build artifacts"}, assetObservation: "conflicting helper-v1.2.3-linux-amd64", missingObservation: "pending SHA256SUMS", }, { name: "unknown", status: 3, recovery: []string{"resolve the remote state before retrying"}, assetObservation: "uncertain helper-v1.2.3-linux-amd64", missingObservation: "pending SHA256SUMS", }, } { t.Run(test.name, func(t *testing.T) { repository := newCLIRepository(t) state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} writeDeliveryProfile(t, repository, cli.Settings{ Product: "Example Product", Build: &build, 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") assetsDir, _ := writeDeliveryReceipt(t, repository, build) switch test.name { case "partial": state.failAssetName = "helper-v1.2.3-linux-amd64" case "conflict": state.assets["helper-v1.2.3-linux-amd64"] = []byte("different hosted bytes") case "unknown": state.ambiguousAssetName = "helper-v1.2.3-linux-amd64" } 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 status != test.status || !strings.Contains(stderr.String(), "state="+test.name) { t.Fatalf("status=%d output=%q want status=%d state=%s", status, stderr.String(), test.status, test.name) } for _, phrase := range test.recovery { if !strings.Contains(stderr.String(), phrase) { t.Errorf("delivery output does not include recovery guidance %q: %s", phrase, stderr.String()) } } if !strings.Contains(stderr.String(), test.assetObservation) { t.Errorf("delivery output does not include asset observation %q: %s", test.assetObservation, stderr.String()) } if test.missingObservation != "" && !strings.Contains(stderr.String(), test.missingObservation) { t.Errorf("delivery output does not include later missing asset %q: %s", test.missingObservation, stderr.String()) } if test.name == "unknown" && strings.Contains(stderr.String(), "pending helper-v1.2.3-linux-amd64") { t.Errorf("uncertain asset also appears pending: %s", stderr.String()) } }) } } 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: ¤t, 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"), SourceVerified: true, 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 { malformedCreate bool failAssetListing bool failAssetDownloads bool failAssetName string ambiguousAssetName string commit string product 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 if state.malformedCreate { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "tag_name": "v1.2.3", "name": "inconsistent response", "body": payload.Body}) return } writeDeliveryRelease(w, serverURL, state) case "/api/v1/repos/owner/example/releases/10/assets": if r.Method == http.MethodGet { state.assetListReads++ if state.failAssetListing { http.Error(w, "unavailable", 503) return } 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) } if name == state.failAssetName { http.Error(w, "upload failed", http.StatusBadRequest) return } if name == state.ambiguousAssetName { http.Error(w, "upload outcome ambiguous", http.StatusInternalServerError) return } 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/") { if state.failAssetDownloads { http.Error(w, "unavailable", 503) return } 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") product := state.product if product == "" { product = "Example Product" } _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": "v1.2.3", "target_commitish": state.commit, "name": 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) root, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatal(err) } module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) write := func(name, contents string) { t.Helper() 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) } } write("go.mod", module) write("cmd/app/main.go", mainSource) write("releasekit.json", fmt.Sprintf(`{"product":"Example Product","build":{"main_package":"./cmd/app","binary_name":"fixture","targets":[%q]}}`, fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH))) write(".gitignore", "dist/\n") cliGitRun(t, repository, "add", "--all") cliGitRun(t, repository, "commit", "-m", "configure build") return repository } func cliGitRun(t *testing.T, repository string, args ...string) { t.Helper() commandArgs := append([]string{"git"}, args...) command := exec.Command(commandArgs[0], commandArgs[1:]...) command.Dir = repository output, err := command.CombinedOutput() if err != nil { t.Fatalf("%v: %v\n%s", commandArgs, err, output) } } func cliGitOutput(t *testing.T, repository string, args ...string) string { t.Helper() commandArgs := append([]string{"git"}, args...) command := exec.Command(commandArgs[0], commandArgs[1:]...) command.Dir = repository output, err := command.Output() if err != nil { t.Fatalf("%v: %v", commandArgs, err) } return strings.TrimSpace(string(output)) } func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(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") mainPath := filepath.Join(repository, "main.go") original, err := os.ReadFile(mainPath) if err != nil { t.Fatal(err) } if err := os.WriteFile(mainPath, append(original, []byte("\n// uncommitted build\n")...), 0600); err != nil { t.Fatal(err) } assets := t.TempDir() var stdout, stderr bytes.Buffer if status := run(context.Background(), []string{"build", "--repo", repository, "--output", assets, "v1.2.3"}, &stdout, &stderr); status != 0 { t.Fatalf("local build status=%d %s", status, stderr.String()) } receipt, err := cli.LoadBuildReceipt(filepath.Join(assets, cli.BuildReceiptName)) if err != nil { t.Fatal(err) } if receipt.SourceVerified { t.Fatal("dirty build receipt claims verified source") } cliGitRun(t, repository, "restore", "main.go") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") stdout.Reset() stderr.Reset() status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr) if status != 1 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "build source") { t.Fatalf("status=%d requests=%d %s", status, state.apiRequests, stderr.String()) } } func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testing.T) { repository := newCLIRepository(t) state := &deliveryServerState{malformedCreate: true, failAssetListing: true} server := newDeliveryServer(t, state) defer server.Close() build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, 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") assets, _ := writeDeliveryReceipt(t, repository, build) t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") var stdout, stderr bytes.Buffer status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr) if status != 1 || !strings.Contains(stderr.String(), "state=partial") || state.releaseCreates != 1 || state.assetListReads != 1 || state.assetUploads != 0 { t.Fatalf("status=%d state=%#v %s", status, state, stderr.String()) } } func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) { for _, existingPage := range []bool{false, true} { t.Run(fmt.Sprintf("existing-page-%t", existingPage), func(t *testing.T) { repository := newCLIRepository(t) state := &deliveryServerState{pageExists: existingPage, noteBody: "# Example Product v1.2.3\n\nRelease note.\n", failAssetDownloads: true, assets: map[string][]byte{"helper-v1.2.3-linux-amd64": []byte("fixture artifact")}} server := newDeliveryServer(t, state) defer server.Close() build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, 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") assets, _ := writeDeliveryReceipt(t, repository, build) t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") var stdout, stderr bytes.Buffer status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr) expectedState := "partial" expectedCreates := 1 if existingPage { expectedState = "not_attempted" expectedCreates = 0 } if status != 1 || !strings.Contains(stderr.String(), "state="+expectedState) || state.assetUploads != 0 || state.releaseCreates != expectedCreates { t.Fatalf("status=%d creates=%d uploads=%d %s", status, state.releaseCreates, state.assetUploads, stderr.String()) } if existingPage && !strings.Contains(stderr.String(), "recovery: fix the local or preflight failure") { t.Fatalf("not-attempted recovery guidance missing: %s", stderr.String()) } }) } } func TestFailedRebuildInvalidatesPreviousReceipt(t *testing.T) { const source = `package main import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") func main(){fmt.Println("fixture",buildinfo.Version())} ` repository := newCLIBuildRepository(t, source) var stdout, stderr bytes.Buffer args := []string{"build", "--repo", repository, "v1.2.3"} for attempt := 0; attempt < 2; attempt++ { if status := run(context.Background(), args, &stdout, &stderr); status != 0 { t.Fatalf("build %d: status=%d %s", attempt, status, stderr.String()) } } path := filepath.Join(repository, "cmd/app/main.go") if err := os.WriteFile(path, []byte("package main\nfunc main(){}\n"), 0600); err != nil { t.Fatal(err) } if status := run(context.Background(), args, &stdout, &stderr); status != 1 { t.Fatalf("failed rebuild status=%d", status) } if _, err := os.Stat(filepath.Join(repository, "dist", cli.BuildReceiptName)); !os.IsNotExist(err) { t.Fatalf("stale receipt remains: %v", err) } } func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing.T) { if runtime.GOOS == "windows" || os.Getuid() == 0 { t.Skip("requires ordinary Unix permission checks") } repository := newDeliveryBuildRepository(t) state := &deliveryServerState{} server := newDeliveryServer(t, state) defer server.Close() writeDeliveryProfile(t, repository, 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"}}) state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") cliGitRun(t, repository, "tag", "v1.2.3") parent := t.TempDir() moved := filepath.Join(parent, "repo") if err := os.Rename(repository, moved); err != nil { t.Fatal(err) } if err := os.Chmod(parent, 0555); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(parent, 0700) }) t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") var stdout, stderr bytes.Buffer if status := run(context.Background(), []string{"deliver", "--repo", moved, "v1.2.3"}, &stdout, &stderr); status != 0 { t.Fatalf("status=%d %s", status, stderr.String()) } if status := cliGitOutput(t, moved, "status", "--porcelain"); status != "" { t.Fatalf("source dirtied: %s", status) } }