Files
ReleaseKit/internal/build/build.go

260 lines
8.1 KiB
Go

// Package build compiles versioned application binaries for explicit targets.
package build
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
type Target = artifact.Target
type Spec struct {
MainPackage string
BinaryName string
Targets []Target
}
type Request struct {
RepositoryDir string
Version string
Spec Spec
OutputDir string
Output io.Writer
}
type Artifact struct {
Path string
Filename string
Target Target
SHA256 string
}
type Result struct {
RepositoryDir string
CandidateCommit string
SourceVerified bool
Version string
MainPackage string
BinaryName string
OutputDir string
Targets []Target
Artifacts []Artifact
Compiler string
}
func Build(ctx context.Context, request Request) (Result, error) {
result := Result{
Version: request.Version,
MainPackage: request.Spec.MainPackage,
BinaryName: request.Spec.BinaryName,
Targets: artifact.NormalizeTargets(request.Spec.Targets),
}
if !versioncontract.ValidStableVersion(request.Version) {
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version)
}
if request.RepositoryDir == "" {
return result, errors.New("build requires a repository directory")
}
if len(result.Targets) != 0 {
if request.Spec.MainPackage == "" {
return result, errors.New("build requires a main package when targets are selected")
}
if !artifact.ValidComponent(request.Spec.BinaryName) {
return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName)
}
for _, target := range result.Targets {
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch)
}
}
}
if err := ctx.Err(); err != nil {
return result, fmt.Errorf("build canceled: %w", err)
}
repositoryDir, err := filepath.Abs(request.RepositoryDir)
if err != nil {
return result, fmt.Errorf("resolve repository directory: %w", err)
}
result.RepositoryDir = repositoryDir
commit, err := gitCommit(ctx, repositoryDir)
if err != nil {
return result, err
}
result.CandidateCommit = commit
cleanBefore, err := cleanSource(ctx, repositoryDir)
if err != nil {
return result, err
}
if len(result.Targets) == 0 {
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
return result, nil
}
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
declarations := artifact.ExpectedBuild(request.Spec.BinaryName, request.Version, result.Targets)
filenames := make([]string, len(declarations))
for i, declaration := range declarations {
filenames[i] = declaration.Filename
}
if err := os.MkdirAll(result.OutputDir, 0o755); err != nil {
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err)
}
goTool, err := gotool.New(ctx, repositoryDir)
if err != nil {
return result, fmt.Errorf("prepare Go toolchain: %w", err)
}
compiler, err := goTool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version")
if err != nil {
return result, fmt.Errorf("identify Go compiler: %w", err)
}
result.Compiler = strings.TrimSpace(string(compiler))
goHost := goTool.HostTarget()
host := Target{OS: goHost.OS, Arch: goHost.Arch}
for index, target := range result.Targets {
if err := ctx.Err(); err != nil {
return result, fmt.Errorf("build canceled: %w", err)
}
filename := filenames[index]
path := filepath.Join(result.OutputDir, filename)
if request.Output != nil {
fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename)
}
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, path, request.Version, target); err != nil {
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
}
artifact := Artifact{Path: path, Filename: filename, Target: target}
result.Artifacts = append(result.Artifacts, artifact)
if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil {
return result, fmt.Errorf("hash build output %q: %w", path, err)
}
}
var smokePath string
for _, artifact := range result.Artifacts {
if artifact.Target == host {
smokePath = artifact.Path
break
}
}
if smokePath == "" {
temporaryDir, err := os.MkdirTemp("", "releasekit-native-smoke-")
if err != nil {
return result, fmt.Errorf("create native smoke directory: %w", err)
}
defer os.RemoveAll(temporaryDir)
smokePath = filepath.Join(temporaryDir, request.Spec.BinaryName)
if host.OS == "windows" {
smokePath += ".exe"
}
if request.Output != nil {
fmt.Fprintf(request.Output, "building temporary version check for %s/%s\n", host.OS, host.Arch)
}
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, smokePath, request.Version, host); err != nil {
return result, fmt.Errorf("build native version check: %w", err)
}
}
if request.Output != nil {
fmt.Fprintf(request.Output, "checking %s version output\n", request.Spec.BinaryName)
}
if err := checkVersion(ctx, smokePath, request.Spec.BinaryName, request.Version); err != nil {
return result, err
}
commitAfter, err := gitCommit(ctx, repositoryDir)
if err != nil {
return result, err
}
cleanAfter, err := cleanSource(ctx, repositoryDir)
if err != nil {
return result, err
}
result.SourceVerified = cleanBefore && cleanAfter && commitAfter == commit
return result, nil
}
func resolvedOutputDir(repositoryDir, outputDir string) string {
if outputDir == "" {
outputDir = filepath.Join(repositoryDir, "dist")
} else if !filepath.IsAbs(outputDir) {
outputDir = filepath.Join(repositoryDir, outputDir)
}
resolved, err := filepath.Abs(outputDir)
if err != nil {
return filepath.Clean(outputDir)
}
return resolved
}
func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
output, err := command.Output()
if err != nil {
if ctx.Err() != nil {
return "", fmt.Errorf("read source commit: %w", ctx.Err())
}
return "", fmt.Errorf("read source commit: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error {
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
_, err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
return err
}
func hashArtifact(artifact *Artifact) error {
file, err := os.Open(artifact.Path)
if err != nil {
return err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return err
}
artifact.SHA256 = hex.EncodeToString(hash.Sum(nil))
return nil
}
func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error {
command := exec.CommandContext(ctx, binaryPath, "version")
output, err := command.CombinedOutput()
if err != nil {
if ctx.Err() != nil {
return fmt.Errorf("run native version check: %w", ctx.Err())
}
return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output)))
}
want := binaryName + " " + version + "\n"
if string(output) != want {
return fmt.Errorf("native version output = %q, want %q", output, want)
}
return nil
}
func cleanSource(ctx context.Context, repositoryDir string) (bool, error) {
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "status", "--porcelain", "--untracked-files=all")
output, err := command.Output()
if err != nil {
return false, fmt.Errorf("inspect build source: %w", err)
}
return len(output) == 0, nil
}