Integrate selected builds with candidate workflows

This commit is contained in:
2026-10-05 19:38:50 +00:00
parent 5d6a6d10bd
commit e98ee0f7b9
21 changed files with 848 additions and 31 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"
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"
type Command string
@@ -24,6 +24,7 @@ const (
VersionCommand Command = "version"
CheckAction Command = "check"
PublishAction Command = "publish"
BuildAction Command = "build"
)
// Invocation contains parsed command settings owned by the CLI adapter.
@@ -31,6 +32,7 @@ type Invocation struct {
Command Command
Repository string
ConfigPath string
OutputDir string
Version string
}
@@ -45,19 +47,23 @@ func Parse(args []string) (Invocation, error) {
return Invocation{}, errors.New("version takes no arguments")
}
return Invocation{Command: VersionCommand}, nil
case string(CheckAction), string(PublishAction):
case string(CheckAction), string(PublishAction), string(BuildAction):
action := Command(args[0])
flags := flag.NewFlagSet(string(action), flag.ContinueOnError)
flags.SetOutput(io.Discard)
repository := flags.String("repo", ".", "repository directory")
configPath := flags.String("config", "", "profile path")
var outputDir string
if action == BuildAction {
flags.StringVar(&outputDir, "output", "", "build output 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, Version: flags.Arg(0)}, nil
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, Version: flags.Arg(0)}, nil
default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0])
}
@@ -75,6 +81,14 @@ type Settings struct {
Product string `json:"product"`
Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,omitempty"`
}
// BuildSettings selects the Go application package, artifact name, and targets.
type BuildSettings struct {
MainPackage string `json:"main_package,omitempty"`
BinaryName string `json:"binary_name,omitempty"`
Targets []string `json:"targets,omitempty"`
}
// LoadSettings reads a profile path relative to repoDir unless it is absolute.
@@ -122,6 +136,31 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
return Settings{}, fmt.Errorf("profile %q: checks[%d].args must include a command", configPath, i)
}
}
if settings.Build != nil {
if len(settings.Build.Targets) != 0 {
if strings.TrimSpace(settings.Build.MainPackage) == "" {
return Settings{}, fmt.Errorf("profile %q: build.main_package is required when targets are selected", configPath)
}
if strings.TrimSpace(settings.Build.BinaryName) == "" {
return Settings{}, fmt.Errorf("profile %q: build.binary_name is required when targets are selected", configPath)
}
if strings.ContainsAny(settings.Build.BinaryName, "/\\\r\n") {
return Settings{}, fmt.Errorf("profile %q: build.binary_name must be a filename component", configPath)
}
}
for i, target := range settings.Build.Targets {
if strings.Count(target, "/") != 1 {
return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i)
}
goos, goarch, _ := strings.Cut(target, "/")
if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" {
return Settings{}, fmt.Errorf("profile %q: build.targets[%d] must be GOOS/GOARCH", configPath, i)
}
if strings.ContainsAny(goos+goarch, "\\\r\n") {
return Settings{}, fmt.Errorf("profile %q: build.targets[%d] contains an unsafe filename component", configPath, i)
}
}
}
return settings, nil
}

View File

@@ -33,6 +33,13 @@ func TestParse(t *testing.T) {
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
t.Fatalf("Parse(publish) = %#v", publish)
}
build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"})
if err != nil {
t.Fatal(err)
}
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)
}
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version")
}
@@ -121,6 +128,32 @@ func TestExampleProfileLoads(t *testing.T) {
}
}
func TestBuildProfileLoads(t *testing.T) {
repoDir := t.TempDir()
path := filepath.Join(repoDir, "profile.json")
if err := os.WriteFile(path, []byte(`{"product":"Example","build":{"main_package":"./cmd/app","binary_name":"example","targets":["linux/amd64","darwin/arm64"]}}`), 0o600); err != nil {
t.Fatal(err)
}
settings, err := LoadSettings(repoDir, path)
if err != nil {
t.Fatal(err)
}
if settings.Build == nil || settings.Build.MainPackage != "./cmd/app" || settings.Build.BinaryName != "example" || len(settings.Build.Targets) != 2 {
t.Fatalf("build settings = %#v", settings.Build)
}
repoDir, err = filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil {
t.Fatal(err)
}
example, err := LoadSettings(repoDir, filepath.Join("examples", "releasekit-binary.json"))
if err != nil {
t.Fatal(err)
}
if example.Build == nil || len(example.Build.Targets) != 3 {
t.Fatalf("binary example settings = %#v", example.Build)
}
}
func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
tests := []struct {
name string
@@ -129,7 +162,9 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
}{
{name: "missing product", content: `{}`, want: "product is required"},
{name: "unknown top-level field", content: `{"product":"Example","delivery":{}}`, want: "unknown field"},
{name: "unsupported build field", content: `{"product":"Example","build":{}}`, 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"},
{name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"},
{name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"},
{name: "multiple JSON values", content: `{"product":"Example"}{"product":"Other"}`, want: "multiple JSON values"},

View File

@@ -0,0 +1,267 @@
package cli
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
const BuildReceiptName = ".releasekit-build.json"
type ReceiptTarget struct {
OS string `json:"os"`
Arch string `json:"arch"`
}
type ReceiptArtifact struct {
Path string `json:"path"`
Filename string `json:"filename"`
Target ReceiptTarget `json:"target"`
SHA256 string `json:"sha256"`
}
// BuildReceipt is private local coordination data for generated binaries.
type BuildReceipt struct {
RepositoryDir string `json:"repository_dir"`
CandidateCommit string `json:"candidate_commit"`
Version string `json:"version"`
MainPackage string `json:"main_package"`
BinaryName string `json:"binary_name"`
OutputDir string `json:"output_dir"`
Targets []ReceiptTarget `json:"targets"`
Compiler string `json:"compiler"`
Artifacts []ReceiptArtifact `json:"artifacts"`
}
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
resolvedDir, err := filepath.Abs(outputDir)
if err != nil {
return "", fmt.Errorf("resolve build receipt directory: %w", err)
}
receipt.OutputDir = resolvedDir
if err := validateBuildReceipt(receipt, true); err != nil {
return "", err
}
contents, err := json.MarshalIndent(receipt, "", " ")
if err != nil {
return "", fmt.Errorf("encode build receipt: %w", err)
}
contents = append(contents, '\n')
temporary, err := os.CreateTemp(resolvedDir, ".releasekit-build-receipt-*")
if err != nil {
return "", fmt.Errorf("create temporary build receipt: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, err := temporary.Write(contents); err != nil {
_ = temporary.Close()
return "", fmt.Errorf("write build receipt: %w", err)
}
if err := temporary.Close(); err != nil {
return "", fmt.Errorf("close build receipt: %w", err)
}
path := filepath.Join(resolvedDir, BuildReceiptName)
if err := os.Rename(temporaryPath, path); err != nil {
if _, statErr := os.Lstat(path); statErr != nil {
return "", fmt.Errorf("install build receipt: %w", err)
}
if removeErr := os.Remove(path); removeErr != nil {
return "", fmt.Errorf("replace prior build receipt: %w", removeErr)
}
if err := os.Rename(temporaryPath, path); err != nil {
return "", fmt.Errorf("install build receipt: %w", err)
}
}
return path, nil
}
func LoadBuildReceipt(path string) (BuildReceipt, error) {
resolvedPath, err := filepath.Abs(path)
if err != nil {
return BuildReceipt{}, fmt.Errorf("resolve build receipt path: %w", err)
}
if filepath.Base(resolvedPath) != BuildReceiptName {
return BuildReceipt{}, fmt.Errorf("build receipt filename must be %s", BuildReceiptName)
}
file, err := os.Open(resolvedPath)
if err != nil {
return BuildReceipt{}, fmt.Errorf("open build receipt: %w", err)
}
defer file.Close()
var receipt BuildReceipt
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&receipt); err != nil {
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return BuildReceipt{}, errors.New("decode build receipt: multiple JSON values")
}
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
}
if err := validateBuildReceipt(receipt, true); err != nil {
return BuildReceipt{}, err
}
if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) {
return BuildReceipt{}, errors.New("build receipt output directory does not match its location")
}
return receipt, nil
}
func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool {
resolvedRepo, err := filepath.Abs(repositoryDir)
if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) {
return false
}
if receipt.CandidateCommit != commit || receipt.Version != version || receipt.MainPackage != settings.MainPackage || receipt.BinaryName != settings.BinaryName {
return false
}
want := normalizeReceiptTargets(settings.Targets)
if len(want) != len(receipt.Targets) {
return false
}
for i, target := range want {
if target != receipt.Targets[i] {
return false
}
}
return true
}
func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !versioncontract.ValidStableVersion(receipt.Version) {
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
}
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) {
return errors.New("build receipt repository and output directories must be absolute")
}
if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit")
}
if receipt.MainPackage == "" || !safeReceiptComponent(receipt.BinaryName) || receipt.Compiler == "" {
return errors.New("build receipt is missing build identity")
}
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
return errors.New("build receipt target and artifact counts do not match")
}
seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets))
seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts))
for _, target := range receipt.Targets {
if !safeReceiptComponent(target.OS) || !safeReceiptComponent(target.Arch) {
return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch)
}
if seenTargets[target] {
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
}
seenTargets[target] = true
}
for _, artifact := range receipt.Artifacts {
if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] {
return fmt.Errorf("build receipt has an unexpected artifact target %s/%s", artifact.Target.OS, artifact.Target.Arch)
}
seenArtifacts[artifact.Target] = true
wantName := receiptArtifactFilename(receipt.BinaryName, receipt.Version, artifact.Target)
if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename {
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
}
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)
if !filepath.IsAbs(artifact.Path) || filepath.Clean(artifact.Path) != filepath.Clean(wantPath) {
return fmt.Errorf("build receipt artifact path does not match filename %q", artifact.Filename)
}
if !validSHA256(artifact.SHA256) {
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename)
}
if verifyFiles {
if err := verifyReceiptArtifact(artifact); err != nil {
return err
}
}
}
return nil
}
func verifyReceiptArtifact(artifact ReceiptArtifact) error {
info, err := os.Lstat(artifact.Path)
if err != nil {
return fmt.Errorf("inspect build artifact %q: %w", artifact.Path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path)
}
file, err := os.Open(artifact.Path)
if err != nil {
return fmt.Errorf("open build artifact %q: %w", artifact.Path, err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("hash build artifact %q: %w", artifact.Path, err)
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != artifact.SHA256 {
return fmt.Errorf("build artifact %q hash changed: got %s, expected %s", artifact.Path, actual, artifact.SHA256)
}
return nil
}
func normalizeReceiptTargets(targets []string) []ReceiptTarget {
seen := make(map[ReceiptTarget]bool, len(targets))
result := make([]ReceiptTarget, 0, len(targets))
for _, value := range targets {
goos, goarch, ok := strings.Cut(value, "/")
if !ok {
continue
}
target := ReceiptTarget{OS: goos, Arch: goarch}
if !seen[target] {
seen[target] = true
result = append(result, target)
}
}
return result
}
func receiptArtifactFilename(binaryName, version string, target ReceiptTarget) string {
filename := fmt.Sprintf("%s-%s-%s-%s", binaryName, version, target.OS, target.Arch)
if target.OS == "windows" {
filename += ".exe"
}
return filename
}
func safeReceiptComponent(value string) bool {
return value != "" && !strings.ContainsAny(value, "/\\\r\n")
}
func validCommitID(value string) bool {
if len(value) != 40 && len(value) != 64 {
return false
}
for _, char := range value {
if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
return false
}
}
return true
}
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
for _, char := range value {
if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
return false
}
}
return true
}

View File

@@ -0,0 +1,84 @@
package cli
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
outputDir := t.TempDir()
repositoryDir := t.TempDir()
filename := "example-v1.2.3-linux-amd64"
artifactPath := filepath.Join(outputDir, filename)
contents := []byte("built application")
if err := os.WriteFile(artifactPath, contents, 0o755); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(contents)
receipt := BuildReceipt{
RepositoryDir: repositoryDir,
CandidateCommit: strings.Repeat("a", 40),
Version: "v1.2.3",
MainPackage: "./cmd/example",
BinaryName: "example",
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
Compiler: "go version go1.25.0 linux/amd64",
Artifacts: []ReceiptArtifact{{
Path: artifactPath,
Filename: filename,
Target: ReceiptTarget{OS: "linux", Arch: "amd64"},
SHA256: hex.EncodeToString(digest[:]),
}},
}
path, err := WriteBuildReceipt(outputDir, receipt)
if err != nil {
t.Fatal(err)
}
loaded, err := LoadBuildReceipt(path)
if err != nil {
t.Fatal(err)
}
settings := BuildSettings{
MainPackage: "./cmd/example",
BinaryName: "example",
Targets: []string{"linux/amd64", "linux/amd64"},
}
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
t.Fatalf("receipt does not match normalized build settings: %#v", loaded)
}
settings.BinaryName = "other"
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
t.Fatal("receipt matched changed build settings")
}
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "hash changed") {
t.Fatalf("LoadBuildReceipt() error = %v, want changed-file hash error", err)
}
}
func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
for _, test := range []struct {
name string
body string
want string
}{
{name: "unknown field", body: `{"unexpected":true}`, want: "unknown field"},
{name: "multiple values", body: `{} {}`, want: "multiple JSON values"},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), BuildReceiptName)
if err := os.WriteFile(path, []byte(test.body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("LoadBuildReceipt() error = %v, want %q", err, test.want)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"io"
"os"
"os/signal"
"strings"
"syscall"
"gitea.maximumdirect.net/eric/ReleaseKit"
@@ -43,6 +44,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Build: configuredBuildSpec(settings.Build),
Output: stderr,
}
if len(settings.Checks) != 0 {
@@ -80,6 +82,100 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
fmt.Fprintf(stdout, "local candidate %s at %s is valid\n", candidate.Version, candidate.Commit)
return 0
case cli.BuildAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
if settings.Build == nil {
fmt.Fprintln(stderr, "build requires build settings in the profile")
return 2
}
result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir,
Output: stderr,
})
if err != nil {
fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
}
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
return 1
}
if len(result.Targets) == 0 {
fmt.Fprintf(stdout, "no build targets selected for %s at %s\n", result.Version, result.CandidateCommit)
return 0
}
receipt := receiptFromBuild(result)
receiptPath, err := cli.WriteBuildReceipt(result.OutputDir, receipt)
if err != nil {
fmt.Fprintf(stderr, "write build receipt: %v\n", err)
return 1
}
stored, err := cli.LoadBuildReceipt(receiptPath)
if err != nil {
fmt.Fprintf(stderr, "verify build receipt: %v\n", err)
return 1
}
if !stored.MatchesBuild(result.RepositoryDir, result.CandidateCommit, result.Version, *settings.Build) {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1
}
fmt.Fprintf(stdout, "built %d artifacts for %s at %s with %s\n", len(result.Artifacts), result.Version, result.CandidateCommit, result.Compiler)
for _, artifact := range result.Artifacts {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
}
fmt.Fprintf(stdout, "build receipt: %s\n", receiptPath)
return 0
}
return 2
}
func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
if settings == nil {
return nil
}
spec := &releasekit.BuildSpec{
MainPackage: settings.MainPackage,
BinaryName: settings.BinaryName,
Targets: make([]releasekit.Target, 0, len(settings.Targets)),
}
for _, value := range settings.Targets {
goos, goarch, _ := strings.Cut(value, "/")
spec.Targets = append(spec.Targets, releasekit.Target{OS: goos, Arch: goarch})
}
return spec
}
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
receipt := cli.BuildReceipt{
RepositoryDir: result.RepositoryDir,
CandidateCommit: result.CandidateCommit,
Version: result.Version,
MainPackage: result.MainPackage,
BinaryName: result.BinaryName,
OutputDir: result.OutputDir,
Compiler: result.Compiler,
Targets: make([]cli.ReceiptTarget, 0, len(result.Targets)),
Artifacts: make([]cli.ReceiptArtifact, 0, len(result.Artifacts)),
}
for _, target := range result.Targets {
receipt.Targets = append(receipt.Targets, cli.ReceiptTarget{OS: target.OS, Arch: target.Arch})
}
for _, artifact := range result.Artifacts {
receipt.Artifacts = append(receipt.Artifacts, cli.ReceiptArtifact{
Path: artifact.Path,
Filename: artifact.Filename,
Target: cli.ReceiptTarget{OS: artifact.Target.OS, Arch: artifact.Target.Arch},
SHA256: artifact.SHA256,
})
}
return receipt
}

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -128,6 +129,75 @@ func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
}
}
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
@@ -164,6 +234,33 @@ func newCLIRepository(t *testing.T) string {
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...)