864 lines
32 KiB
Go
864 lines
32 KiB
Go
package releasekit_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
|
)
|
|
|
|
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
|
|
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
|
|
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: filepath.Join(t.TempDir(), "absent"), Version: "bad-version", Spec: spec,
|
|
})
|
|
if !errors.Is(err, releasekit.ErrInvalidVersion) || invalid.Version != "bad-version" || invalid.MainPackage != spec.MainPackage || invalid.BinaryName != spec.BinaryName || len(invalid.Targets) != 1 {
|
|
t.Fatalf("invalid version result = %#v, error = %v", invalid, err)
|
|
}
|
|
repo := newPublishFixture(t)
|
|
writeFile(t, repo.work, "nested/go.mod", "go 1.26.0\n")
|
|
failed, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: repo.work, ModuleDir: "nested", Version: "v1.2.3", Spec: spec,
|
|
})
|
|
if err == nil || failed.RepositoryDir != repo.work || failed.ModuleDir != filepath.Join(repo.work, "nested") || failed.Version != "v1.2.3" || failed.MainPackage != spec.MainPackage || failed.BinaryName != spec.BinaryName || len(failed.Targets) != 1 || failed.SourceVerified {
|
|
t.Fatalf("declaration failure = %#v, error = %v", failed, err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := releasekit.Build(ctx, releasekit.BuildRequest{RepositoryDir: repo.work, Version: "v1.2.3", Spec: spec}); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled build error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) {
|
|
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.Printf("fixture %s\n", buildinfo.Version())
|
|
}
|
|
}
|
|
`)
|
|
outputDir := filepath.Join(t.TempDir(), "artifacts")
|
|
t.Setenv("GOOS", "windows")
|
|
t.Setenv("GOARCH", "arm64")
|
|
t.Setenv("GOFLAGS", "-race")
|
|
var progress bytes.Buffer
|
|
result, 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: "linux", Arch: "amd64"},
|
|
{OS: "linux", Arch: "arm64"},
|
|
{OS: "darwin", Arch: "arm64"},
|
|
{OS: "linux", Arch: "amd64"},
|
|
},
|
|
},
|
|
OutputDir: outputDir,
|
|
Output: &progress,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Build() error = %v; partial result = %#v", err, result)
|
|
}
|
|
if result.CandidateCommit == "" || result.CandidateCommit != buildGit(t, repository, "rev-parse", "HEAD") {
|
|
t.Fatalf("candidate commit = %q", result.CandidateCommit)
|
|
}
|
|
if result.Compiler == "" || len(result.Targets) != 3 || len(result.Artifacts) != 3 {
|
|
t.Fatalf("build result = %#v", result)
|
|
}
|
|
wantNames := []string{
|
|
"fixture-v1.2.3-linux-amd64",
|
|
"fixture-v1.2.3-linux-arm64",
|
|
"fixture-v1.2.3-darwin-arm64",
|
|
}
|
|
for index, artifact := range result.Artifacts {
|
|
if artifact.Filename != wantNames[index] || artifact.Target != result.Targets[index] {
|
|
t.Fatalf("artifact %d = %#v", index, artifact)
|
|
}
|
|
contents, err := os.ReadFile(artifact.Path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256.Sum256(contents)
|
|
if artifact.SHA256 != hex.EncodeToString(digest[:]) {
|
|
t.Fatalf("artifact digest = %q, want %x", artifact.SHA256, digest)
|
|
}
|
|
}
|
|
if !strings.Contains(progress.String(), "checking fixture version output") {
|
|
t.Fatalf("build progress = %q", progress.String())
|
|
}
|
|
for _, artifact := range result.Artifacts {
|
|
if artifact.Target != (releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) {
|
|
continue
|
|
}
|
|
command := exec.Command(artifact.Path, "version")
|
|
output, err := command.CombinedOutput()
|
|
if err != nil || string(output) != "fixture v1.2.3\n" {
|
|
t.Fatalf("native version command output = %q, error = %v", output, err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func TestBuildUsesGoHostDespiteAmbientTargetEnvironment(t *testing.T) {
|
|
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.Printf("fixture %s\n", buildinfo.Version()) }
|
|
}
|
|
`)
|
|
t.Setenv("GOOS", "windows")
|
|
t.Setenv("GOARCH", "arm64")
|
|
result, 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: "windows", Arch: "arm64"}},
|
|
},
|
|
OutputDir: filepath.Join(t.TempDir(), "artifacts"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Build() error = %v; partial result = %#v", err, result)
|
|
}
|
|
if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v1.2.3-windows-arm64.exe" {
|
|
t.Fatalf("build artifacts = %#v", result.Artifacts)
|
|
}
|
|
}
|
|
|
|
func TestBuildIgnoresAmbientAndPersistentArchitectureTuning(t *testing.T) {
|
|
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.Printf("fixture %s\n", buildinfo.Version()) } }
|
|
`)
|
|
|
|
for _, source := range []string{"process environment", "Go environment file"} {
|
|
t.Run(source, func(t *testing.T) {
|
|
unsetBuildEnv(t, "GOCACHE")
|
|
unsetBuildEnv(t, "GOAMD64")
|
|
unsetBuildEnv(t, "GOEXPERIMENT")
|
|
unsetBuildEnv(t, "GOFLAGS")
|
|
cache := filepath.Join(t.TempDir(), "configured-cache")
|
|
goEnv := filepath.Join(t.TempDir(), "go-env")
|
|
if source == "Go environment file" {
|
|
contents := fmt.Sprintf("GOAMD64=v2\nGOEXPERIMENT=loopvar\nGOFLAGS=-run=^$\nGOCACHE=%s\n", cache)
|
|
if err := os.WriteFile(goEnv, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else {
|
|
if err := os.WriteFile(goEnv, nil, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("GOAMD64", "v2")
|
|
t.Setenv("GOEXPERIMENT", "loopvar")
|
|
t.Setenv("GOFLAGS", "-run=^$")
|
|
}
|
|
t.Setenv("GOENV", goEnv)
|
|
|
|
result, 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: "linux", Arch: "amd64"}},
|
|
},
|
|
OutputDir: filepath.Join(t.TempDir(), "artifacts"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Build() error = %v; partial result = %#v", err, result)
|
|
}
|
|
metadata := goBuildMetadata(t, result.Artifacts[0].Path)
|
|
if !strings.Contains(metadata, "GOAMD64=v1") || strings.Contains(metadata, "GOAMD64=v2") || strings.Contains(metadata, "GOEXPERIMENT=loopvar") {
|
|
t.Fatalf("build metadata includes ambient tuning:\n%s", metadata)
|
|
}
|
|
if source == "Go environment file" {
|
|
entries, err := os.ReadDir(cache)
|
|
if err != nil || len(entries) == 0 {
|
|
t.Fatalf("configured persistent cache was not used: entries=%d error=%v", len(entries), err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func goBuildMetadata(t *testing.T, binary string) string {
|
|
t.Helper()
|
|
t.Setenv("GOENV", "off")
|
|
t.Setenv("GOFLAGS", "")
|
|
t.Setenv("GOEXPERIMENT", "")
|
|
t.Setenv("GOAMD64", "")
|
|
t.Setenv("GOOS", "")
|
|
t.Setenv("GOARCH", "")
|
|
output, err := exec.Command("go", "version", "-m", binary).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go version -m: %v\n%s", err, output)
|
|
}
|
|
return string(output)
|
|
}
|
|
|
|
func unsetBuildEnv(t *testing.T, key string) {
|
|
t.Helper()
|
|
value, exists := os.LookupEnv(key)
|
|
if err := os.Unsetenv(key); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if exists {
|
|
_ = os.Setenv(key, value)
|
|
} else {
|
|
_ = os.Unsetenv(key)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildWithNoTargetsDoesNotInvokeCompiler(t *testing.T) {
|
|
repository := newBuildConsumer(t, `package main
|
|
func main() {}
|
|
`)
|
|
outputDir := filepath.Join(t.TempDir(), "must-not-be-created")
|
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: repository,
|
|
Version: "v1.2.3",
|
|
OutputDir: outputDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Build() error = %v", err)
|
|
}
|
|
if result.CandidateCommit == "" || result.Compiler != "" || len(result.Targets) != 0 || len(result.Artifacts) != 0 {
|
|
t.Fatalf("empty build result = %#v", result)
|
|
}
|
|
if _, err := os.Stat(outputDir); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("empty build created output directory: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput(t *testing.T) {
|
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
|
moduleDir := filepath.Join(repository, "integrations", "nested")
|
|
writeNestedBuildConsumer(t, repository, "integrations/nested", "example.com/nestedbuild", `package main
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
|
)
|
|
func main() {
|
|
if len(os.Args) == 2 && os.Args[1] == "version" {
|
|
if logPath := os.Getenv("RELEASEKIT_SMOKE_LOG"); logPath != "" {
|
|
if executable, err := os.Executable(); err == nil {
|
|
_ = os.WriteFile(logPath, []byte(executable), 0o600)
|
|
}
|
|
}
|
|
fmt.Printf("nested-app %s\n", buildinfo.Version())
|
|
}
|
|
}
|
|
`)
|
|
outputDirectory := "artifacts"
|
|
crossTarget := releasekit.Target{OS: "darwin", Arch: "arm64"}
|
|
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
|
|
crossTarget = releasekit.Target{OS: "linux", Arch: "amd64"}
|
|
}
|
|
nativeTarget := releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}
|
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: repository,
|
|
ModuleDir: "integrations/nested",
|
|
Version: "v1.2.3",
|
|
Spec: releasekit.BuildSpec{
|
|
MainPackage: "./cmd/app",
|
|
BinaryName: "nested-app",
|
|
Targets: []releasekit.Target{nativeTarget, crossTarget},
|
|
},
|
|
OutputDir: outputDirectory,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Build() error = %v; result = %#v", err, result)
|
|
}
|
|
if result.ModuleDir != moduleDir || result.ModulePath != "example.com/nestedbuild" || result.Tag != "integrations/nested/v1.2.3" {
|
|
t.Fatalf("nested build identity = %#v", result)
|
|
}
|
|
wantOutput := filepath.Join(moduleDir, outputDirectory)
|
|
if result.OutputDir != wantOutput || len(result.Artifacts) != 2 {
|
|
t.Fatalf("nested build output = %#v, want directory %q and two artifacts", result, wantOutput)
|
|
}
|
|
var native string
|
|
for _, artifact := range result.Artifacts {
|
|
if filepath.Dir(artifact.Path) != wantOutput || !strings.Contains(artifact.Filename, "nested-app-v1.2.3-") {
|
|
t.Fatalf("nested artifact = %#v", artifact)
|
|
}
|
|
if artifact.Target == nativeTarget {
|
|
native = artifact.Path
|
|
}
|
|
}
|
|
if native == "" {
|
|
t.Fatalf("native artifact missing from %#v", result.Artifacts)
|
|
}
|
|
if output, err := exec.Command(native, "version").CombinedOutput(); err != nil || string(output) != "nested-app v1.2.3\n" {
|
|
t.Fatalf("nested native version = %q, error = %v", output, err)
|
|
}
|
|
|
|
localTemporary := filepath.Join(repository, "source-temporary")
|
|
if err := os.Mkdir(localTemporary, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
crossOutput := filepath.Join(t.TempDir(), "cross-artifacts")
|
|
smokeLog := filepath.Join(t.TempDir(), "smoke-executable")
|
|
t.Setenv("RELEASEKIT_SMOKE_LOG", smokeLog)
|
|
t.Setenv("TMPDIR", localTemporary)
|
|
smokeResult, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: repository,
|
|
ModuleDir: "integrations/nested",
|
|
Version: "v1.2.3",
|
|
Spec: releasekit.BuildSpec{
|
|
MainPackage: "./cmd/app",
|
|
BinaryName: "nested-app",
|
|
Targets: []releasekit.Target{crossTarget},
|
|
},
|
|
OutputDir: crossOutput,
|
|
})
|
|
if err != nil || len(smokeResult.Artifacts) != 1 {
|
|
t.Fatalf("nested cross build with source-local TMPDIR = %#v, %v", smokeResult, err)
|
|
}
|
|
smokePathBytes, err := os.ReadFile(smokeLog)
|
|
if err != nil {
|
|
t.Fatalf("read native smoke executable path: %v", err)
|
|
}
|
|
smokePath := string(smokePathBytes)
|
|
relativeSmokePath, err := filepath.Rel(repository, smokePath)
|
|
if err != nil || relativeSmokePath == "." || (!filepath.IsAbs(relativeSmokePath) && relativeSmokePath != ".." && !strings.HasPrefix(relativeSmokePath, ".."+string(filepath.Separator))) {
|
|
t.Fatalf("native smoke output path %q is inside repository %q (relative %q, error %v)", smokePath, repository, relativeSmokePath, err)
|
|
}
|
|
if _, err := os.Stat(smokePath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("native smoke output %q was not removed: %v", smokePath, err)
|
|
}
|
|
remaining, err := os.ReadDir(localTemporary)
|
|
if err != nil || len(remaining) != 0 {
|
|
t.Fatalf("native smoke output remained in source-local TMPDIR: %v %v", remaining, err)
|
|
}
|
|
|
|
emptyResult, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: repository,
|
|
ModuleDir: "integrations/nested",
|
|
Version: "v1.2.3",
|
|
})
|
|
if err != nil || emptyResult.Compiler != "" || emptyResult.OutputDir != filepath.Join(moduleDir, "dist") {
|
|
t.Fatalf("nested empty build = %#v, %v", emptyResult, err)
|
|
}
|
|
if _, err := os.Stat(emptyResult.OutputDir); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("empty nested build created dist: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildResolvesDirtyWorkingTreeDeclaration(t *testing.T) {
|
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
|
declarationPath := filepath.Join(repository, "go.mod")
|
|
contents, err := os.ReadFile(declarationPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
updated := strings.Replace(string(contents), "module example.com/buildfixture", "module example.com/changedbuild", 1)
|
|
if updated == string(contents) {
|
|
t.Fatal("fixture module declaration was not replaced")
|
|
}
|
|
writeBuildFixture(t, repository, "go.mod", updated)
|
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: repository,
|
|
Version: "v1.2.3",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.ModulePath != "example.com/changedbuild" || result.Tag != "v1.2.3" {
|
|
t.Fatalf("dirty declaration identity = %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
|
|
const source = `package main
|
|
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
|
func main(){fmt.Println("fixture",buildinfo.Version())}
|
|
`
|
|
repository := newBuildConsumer(t, source)
|
|
outputDir := t.TempDir()
|
|
unrelated := filepath.Join(outputDir, "keep.txt")
|
|
if err := os.WriteFile(unrelated, []byte("keep this file"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := 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}
|
|
unsafe := request
|
|
unsafe.Spec.BinaryName = "../unsafe"
|
|
if _, err := releasekit.Build(context.Background(), unsafe); err == nil {
|
|
t.Fatal("unsafe binary name accepted")
|
|
}
|
|
first, err := releasekit.Build(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repeated, err := releasekit.Build(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repeated.Artifacts[0].SHA256 != first.Artifacts[0].SHA256 {
|
|
t.Fatal("identical build changed output")
|
|
}
|
|
writeBuildFixture(t, repository, "cmd/app/main.go", source+"\nfunc init(){if len([]byte(buildinfo.Version()))==0 {panic(\"changed build\")}}\n")
|
|
changed, err := releasekit.Build(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if changed.Artifacts[0].SHA256 == first.Artifacts[0].SHA256 {
|
|
t.Fatal("changed source did not regenerate binary")
|
|
}
|
|
content, err := os.ReadFile(unrelated)
|
|
if err != nil || string(content) != "keep this file" {
|
|
t.Fatalf("unrelated file=%q %v", content, err)
|
|
}
|
|
}
|
|
|
|
func TestBuildReturnsPartialArtifactsAfterTargetFailure(t *testing.T) {
|
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
|
result, 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},
|
|
{OS: "invalid-os", Arch: "invalid-arch"},
|
|
},
|
|
},
|
|
OutputDir: filepath.Join(t.TempDir(), "artifacts"),
|
|
})
|
|
if err == nil || len(result.Artifacts) != 1 {
|
|
t.Fatalf("Build() result = %#v, error = %v; want one retained artifact", result, err)
|
|
}
|
|
if _, err := os.Stat(result.Artifacts[0].Path); err != nil {
|
|
t.Fatalf("partial artifact was not retained: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildReportsMissingMainPackage(t *testing.T) {
|
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
|
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
|
RepositoryDir: repository,
|
|
Version: "v1.2.3",
|
|
Spec: releasekit.BuildSpec{
|
|
MainPackage: "./cmd/missing",
|
|
BinaryName: "fixture",
|
|
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
|
},
|
|
OutputDir: filepath.Join(t.TempDir(), "artifacts"),
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "cmd/missing") || len(result.Artifacts) != 0 {
|
|
t.Fatalf("Build() result = %#v, error = %v; want missing-package failure", result, err)
|
|
}
|
|
}
|
|
|
|
func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) {
|
|
repository := newBuildConsumer(t, `package main
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
)
|
|
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("wrong") } }
|
|
`)
|
|
outputDir := filepath.Join(t.TempDir(), "artifacts")
|
|
result, 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 || !strings.Contains(err.Error(), "native version output") || len(result.Artifacts) != 1 {
|
|
t.Fatalf("Build() result = %#v, error = %v; want retained-binary smoke failure", result, err)
|
|
}
|
|
if _, err := os.Stat(result.Artifacts[0].Path); err != nil {
|
|
t.Fatalf("built artifact was not retained after smoke failure: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) {
|
|
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
|
|
realGo, err := exec.LookPath("go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wrapperDir := t.TempDir()
|
|
wrapper := filepath.Join(wrapperDir, "go")
|
|
callCount := filepath.Join(wrapperDir, "build-count")
|
|
blocked := filepath.Join(wrapperDir, "second-build-started")
|
|
contents := "#!/bin/sh\n" +
|
|
"for argument do\n" +
|
|
" if [ \"$argument\" = build ]; then\n" +
|
|
" count=0\n" +
|
|
" if [ -f \"$RELEASEKIT_BUILD_COUNT\" ]; then count=$(cat \"$RELEASEKIT_BUILD_COUNT\"); fi\n" +
|
|
" count=$((count + 1))\n" +
|
|
" printf '%s\\n' \"$count\" > \"$RELEASEKIT_BUILD_COUNT\"\n" +
|
|
" if [ \"$count\" -eq 2 ]; then : > \"$RELEASEKIT_BUILD_BLOCKED\"; exec sleep 30; fi\n" +
|
|
" fi\n" +
|
|
"done\n" +
|
|
"exec \"$RELEASEKIT_BUILD_REAL_GO\" \"$@\"\n"
|
|
if err := os.WriteFile(wrapper, []byte(contents), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", wrapperDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
t.Setenv("RELEASEKIT_BUILD_COUNT", callCount)
|
|
t.Setenv("RELEASEKIT_BUILD_BLOCKED", blocked)
|
|
t.Setenv("RELEASEKIT_BUILD_REAL_GO", realGo)
|
|
secondTarget := releasekit.Target{OS: "darwin", Arch: "arm64"}
|
|
if secondTarget == (releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) {
|
|
secondTarget = releasekit.Target{OS: "linux", Arch: "amd64"}
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct {
|
|
result releasekit.BuildResult
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
result, err := releasekit.Build(ctx, 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}, secondTarget},
|
|
},
|
|
OutputDir: filepath.Join(t.TempDir(), "artifacts"),
|
|
})
|
|
done <- struct {
|
|
result releasekit.BuildResult
|
|
err error
|
|
}{result: result, err: err}
|
|
}()
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
deadline := time.NewTimer(30 * time.Second)
|
|
defer deadline.Stop()
|
|
for {
|
|
if _, err := os.Stat(blocked); err == nil {
|
|
cancel()
|
|
break
|
|
}
|
|
select {
|
|
case outcome := <-done:
|
|
cancel()
|
|
t.Fatalf("Build() ended before cancellation: result=%#v error=%v", outcome.result, outcome.err)
|
|
case <-deadline.C:
|
|
cancel()
|
|
outcome := <-done
|
|
t.Fatalf("second build did not start: result=%#v error=%v", outcome.result, outcome.err)
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
outcome := <-done
|
|
if !errors.Is(outcome.err, context.Canceled) || len(outcome.result.Artifacts) != 1 {
|
|
t.Fatalf("canceled build result=%#v error=%v; want one retained artifact", outcome.result, outcome.err)
|
|
}
|
|
}
|
|
|
|
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
|
|
repository := newPublishFixture(t)
|
|
realGo, err := exec.LookPath("go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
goShimDir := t.TempDir()
|
|
goLog := filepath.Join(goShimDir, "go-commands")
|
|
goShim := filepath.Join(goShimDir, "go")
|
|
shimSource := "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$RELEASEKIT_REAL_GO_LOG\"\nexec \"$RELEASEKIT_REAL_GO\" \"$@\"\n"
|
|
if err := os.WriteFile(goShim, []byte(shimSource), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("RELEASEKIT_REAL_GO", realGo)
|
|
t.Setenv("RELEASEKIT_REAL_GO_LOG", goLog)
|
|
t.Setenv("GOENV", "off")
|
|
t.Setenv("PATH", goShimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
writeFile(t, repository.work, "guide.md", "# Guide\n[section](#guide)\n")
|
|
gitRun(t, repository.work, "add", "guide.md")
|
|
gitRun(t, repository.work, "commit", "-m", "add Markdown guide")
|
|
prepareCandidateBuild(t, repository.work, `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 validations int
|
|
candidate, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repository.work,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
|
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
|
|
validations++
|
|
return nil
|
|
},
|
|
Build: &releasekit.BuildSpec{
|
|
MainPackage: "./cmd/app",
|
|
BinaryName: "fixture",
|
|
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Check() error = %v", err)
|
|
}
|
|
if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") {
|
|
t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations)
|
|
}
|
|
goCommands, err := os.ReadFile(goLog)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var discoveries, suites, builds, versions int
|
|
for _, command := range strings.Split(strings.TrimSpace(string(goCommands)), "\n") {
|
|
switch {
|
|
case strings.HasPrefix(command, "env "):
|
|
discoveries++
|
|
case strings.HasPrefix(command, "test "):
|
|
suites++
|
|
case strings.HasPrefix(command, "build "):
|
|
builds++
|
|
case command == "version":
|
|
versions++
|
|
}
|
|
}
|
|
if discoveries != 2 || suites != 1 || builds != 1 || versions != 1 {
|
|
t.Fatalf("Go commands = %q; discovery=%d suite=%d build=%d version=%d, want 2, 1, 1, 1", goCommands, discoveries, suites, builds, versions)
|
|
}
|
|
if got := gitText(t, repository.work, "status", "--porcelain"); got != "" {
|
|
t.Fatalf("candidate build dirtied worktree: %s", got)
|
|
}
|
|
if paths, err := filepath.Glob(filepath.Join(filepath.Dir(repository.work), ".releasekit-candidate-build-*")); err != nil || len(paths) != 0 {
|
|
t.Fatalf("temporary candidate output remains: %v, error=%v", paths, err)
|
|
}
|
|
}
|
|
|
|
func TestPublishBuildFailurePreventsTagCreation(t *testing.T) {
|
|
repository := newPublishFixture(t)
|
|
prepareCandidateBuild(t, repository.work, "package main\nfunc main() {}\n")
|
|
var validations int
|
|
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
|
RepositoryDir: repository.work,
|
|
Product: "Sample App",
|
|
Version: "v1.2.3",
|
|
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
|
|
validations++
|
|
return nil
|
|
},
|
|
Build: &releasekit.BuildSpec{
|
|
MainPackage: "./cmd/missing",
|
|
BinaryName: "fixture",
|
|
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
|
|
},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "build candidate") || result.TagObject != "" {
|
|
t.Fatalf("Publish() result=%#v error=%v; want build failure before tag creation", result, err)
|
|
}
|
|
if validations != 1 {
|
|
t.Fatalf("validation calls = %d, want 1", validations)
|
|
}
|
|
if _, err := exec.Command("git", "--git-dir", repository.bare, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
|
|
t.Fatal("build failure published the release tag")
|
|
}
|
|
if _, err := exec.Command("git", "-C", repository.work, "show-ref", "--verify", "--quiet", "refs/tags/v1.2.3").CombinedOutput(); err == nil {
|
|
t.Fatal("build failure created a local release tag")
|
|
}
|
|
}
|
|
|
|
func newBuildConsumer(t *testing.T, mainSource string) string {
|
|
t.Helper()
|
|
repository, err := os.MkdirTemp("", "releasekit-build-consumer-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(repository) })
|
|
root, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
|
writeBuildFixture(t, repository, "go.mod", module)
|
|
writeBuildFixture(t, repository, "cmd/app/main.go", mainSource)
|
|
writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n")
|
|
buildGit(t, repository, "init", "--initial-branch=main")
|
|
buildGit(t, repository, "config", "user.name", "ReleaseKit Build Test")
|
|
buildGit(t, repository, "config", "user.email", "releasekit-build@example.test")
|
|
buildGit(t, repository, "add", "--all")
|
|
buildGit(t, repository, "commit", "-m", "build fixture")
|
|
return repository
|
|
}
|
|
|
|
func writeNestedBuildConsumer(t *testing.T, repository, moduleDir, modulePath, mainSource string) string {
|
|
t.Helper()
|
|
root, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
module := fmt.Sprintf("module %s\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", modulePath, filepath.ToSlash(root))
|
|
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "go.mod")), module)
|
|
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource)
|
|
return filepath.Join(repository, filepath.FromSlash(moduleDir))
|
|
}
|
|
|
|
func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
|
|
t.Helper()
|
|
root, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
|
|
writeFile(t, repository, "go.mod", module)
|
|
writeFile(t, repository, ".gitignore", "dist/\n")
|
|
writeFile(t, repository, "cmd/app/main.go", mainSource)
|
|
writeFile(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n")
|
|
gitRun(t, repository, "add", "--all")
|
|
gitRun(t, repository, "commit", "-m", "add buildable candidate")
|
|
gitRun(t, repository, "config", "remote.origin.mirror", "false")
|
|
gitRun(t, repository, "push", "origin", "HEAD:refs/heads/main")
|
|
}
|
|
|
|
func writeBuildFixture(t *testing.T, directory, name, contents string) {
|
|
t.Helper()
|
|
path := filepath.Join(directory, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func buildGit(t *testing.T, directory string, args ...string) string {
|
|
t.Helper()
|
|
command := exec.Command("git", append([]string{"-C", directory}, args...)...)
|
|
output, err := command.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, output)
|
|
}
|
|
return strings.TrimSpace(string(output))
|
|
}
|
|
|
|
func TestBuildSourceEligibilitySurvivesLaterCheckoutCleanup(t *testing.T) {
|
|
const source = `package main
|
|
import("fmt";"os";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
|
func main(){ if len(os.Args)>1 && os.Args[1]=="version" {fmt.Println("fixture",buildinfo.Version())} }
|
|
`
|
|
for _, mode := range []string{"clean", "dirty before build", "changed during build", "commit during build"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
repository := newBuildConsumer(t, source)
|
|
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n")
|
|
buildGit(t, repository, "add", "--all")
|
|
buildGit(t, repository, "commit", "-m", "release note")
|
|
buildGit(t, repository, "tag", "v1.2.3")
|
|
mutate := func() { writeBuildFixture(t, repository, "cmd/app/main.go", source+"\n// local change\n") }
|
|
if mode == "dirty before build" {
|
|
mutate()
|
|
}
|
|
var writer *sourceChangeWriter
|
|
request := 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: t.TempDir()}
|
|
if mode == "changed during build" || mode == "commit during build" {
|
|
writer = &sourceChangeWriter{change: func() {
|
|
mutate()
|
|
if mode == "commit during build" {
|
|
buildGit(t, repository, "add", "--all")
|
|
buildGit(t, repository, "commit", "-m", "source advanced during compilation")
|
|
}
|
|
}}
|
|
request.Output = writer
|
|
}
|
|
result, err := releasekit.Build(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.SourceVerified != (mode == "clean") {
|
|
t.Fatalf("source eligibility=%t for %s", result.SourceVerified, mode)
|
|
}
|
|
if mode == "clean" {
|
|
return
|
|
}
|
|
buildGit(t, repository, "restore", "cmd/app/main.go")
|
|
buildGit(t, repository, "checkout", "--detach", "v1.2.3")
|
|
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: repository, Product: "Fixture", Version: "v1.2.3", Prebuilt: &result})
|
|
if err == nil || !strings.Contains(err.Error(), "build source") || delivered.State != releasekit.DeliveryNotAttempted {
|
|
t.Fatalf("dirty source delivered: %#v %v", delivered, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type sourceChangeWriter struct{ change func() }
|
|
|
|
func (w *sourceChangeWriter) Write(p []byte) (int, error) {
|
|
if w.change != nil {
|
|
w.change()
|
|
w.change = nil
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func TestCandidateBuildDoesNotRequireWritableRepositoryParent(t *testing.T) {
|
|
if runtime.GOOS == "windows" || os.Getuid() == 0 {
|
|
t.Skip("requires ordinary Unix permission checks")
|
|
}
|
|
repository := newBuildConsumer(t, `package main
|
|
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
|
func main(){fmt.Println("fixture",buildinfo.Version())}
|
|
`)
|
|
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n")
|
|
buildGit(t, repository, "add", "--all")
|
|
buildGit(t, repository, "commit", "-m", "note")
|
|
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) })
|
|
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{RepositoryDir: moved, Product: "Fixture", Version: "v1.2.3", AdditionalChecks: func(context.Context, releasekit.Candidate) error { return nil }, Build: &releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status := buildGit(t, moved, "status", "--porcelain"); status != "" {
|
|
t.Fatalf("candidate dirtied: %s", status)
|
|
}
|
|
}
|