348 lines
10 KiB
Go
348 lines
10 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"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
|
)
|
|
|
|
type Target struct {
|
|
OS string
|
|
Arch string
|
|
}
|
|
|
|
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: 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 !safeComponent(request.Spec.BinaryName) {
|
|
return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName)
|
|
}
|
|
for _, target := range result.Targets {
|
|
if !safeComponent(target.OS) || !safeComponent(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)
|
|
filenames := make([]string, 0, len(result.Targets))
|
|
for _, target := range result.Targets {
|
|
filenames = append(filenames, artifactFilename(request.Spec.BinaryName, request.Version, target))
|
|
}
|
|
if err := os.MkdirAll(result.OutputDir, 0o755); err != nil {
|
|
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err)
|
|
}
|
|
|
|
compiler, err := runGo(ctx, repositoryDir, nil, "version")
|
|
if err != nil {
|
|
return result, fmt.Errorf("identify Go compiler: %w", err)
|
|
}
|
|
result.Compiler = strings.TrimSpace(string(compiler))
|
|
host, err := hostTarget(ctx, repositoryDir)
|
|
if err != nil {
|
|
return result, fmt.Errorf("identify Go host: %w", err)
|
|
}
|
|
|
|
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, 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, 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 normalizeTargets(targets []Target) []Target {
|
|
seen := make(map[Target]struct{}, len(targets))
|
|
result := make([]Target, 0, len(targets))
|
|
for _, target := range targets {
|
|
if _, ok := seen[target]; ok {
|
|
continue
|
|
}
|
|
seen[target] = struct{}{}
|
|
result = append(result, target)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func safeComponent(value string) bool {
|
|
return value != "" && !strings.ContainsAny(value, "/\\\r\n")
|
|
}
|
|
|
|
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 artifactFilename(binaryName, version string, target Target) string {
|
|
filename := fmt.Sprintf("%s-%s-%s-%s", binaryName, version, target.OS, target.Arch)
|
|
if target.OS == "windows" {
|
|
filename += ".exe"
|
|
}
|
|
return filename
|
|
}
|
|
|
|
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 hostTarget(ctx context.Context, repositoryDir string) (Target, error) {
|
|
output, err := runGo(ctx, repositoryDir, nil, "env", "GOHOSTOS", "GOHOSTARCH")
|
|
if err != nil {
|
|
return Target{}, err
|
|
}
|
|
parts := strings.Fields(string(output))
|
|
if len(parts) != 2 {
|
|
return Target{}, fmt.Errorf("unexpected Go host response %q", output)
|
|
}
|
|
return Target{OS: parts[0], Arch: parts[1]}, nil
|
|
}
|
|
|
|
func buildTarget(ctx context.Context, repositoryDir, mainPackage, outputPath, version string, target Target) error {
|
|
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
|
_, err := runGo(ctx, repositoryDir, map[string]string{"GOOS": target.OS, "GOARCH": target.Arch},
|
|
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
|
|
return err
|
|
}
|
|
|
|
func runGo(ctx context.Context, directory string, target map[string]string, args ...string) ([]byte, error) {
|
|
commandArgs := append([]string(nil), args...)
|
|
command := exec.CommandContext(ctx, "go", commandArgs...)
|
|
command.Dir = directory
|
|
command.Env = goEnvironment(target)
|
|
output, err := command.CombinedOutput()
|
|
if err == nil {
|
|
return output, nil
|
|
}
|
|
if ctx.Err() != nil {
|
|
err = fmt.Errorf("%w: %v", ctx.Err(), err)
|
|
}
|
|
if len(output) != 0 {
|
|
return output, fmt.Errorf("go %s: %w: %s", strings.Join(commandArgs, " "), err, strings.TrimSpace(string(output)))
|
|
}
|
|
return output, fmt.Errorf("go %s: %w", strings.Join(commandArgs, " "), err)
|
|
}
|
|
|
|
func goEnvironment(target map[string]string) []string {
|
|
controlled := map[string]bool{
|
|
"GOWORK": true, "GOPROXY": true, "GONOPROXY": true, "GOSUMDB": true,
|
|
"GOTOOLCHAIN": true, "GOFLAGS": true, "CGO_ENABLED": true, "GOOS": true, "GOARCH": true,
|
|
}
|
|
for key := range target {
|
|
controlled[key] = true
|
|
}
|
|
environment := make([]string, 0, len(os.Environ())+len(controlled))
|
|
for _, entry := range os.Environ() {
|
|
key, _, ok := strings.Cut(entry, "=")
|
|
if !ok || !controlled[key] {
|
|
environment = append(environment, entry)
|
|
}
|
|
}
|
|
values := map[string]string{
|
|
"GOWORK": "off", "GOPROXY": "off", "GONOPROXY": "none", "GOSUMDB": "off",
|
|
"GOTOOLCHAIN": "local", "GOFLAGS": "-mod=readonly", "CGO_ENABLED": "0",
|
|
}
|
|
for key, value := range target {
|
|
values[key] = value
|
|
}
|
|
keys := make([]string, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
environment = append(environment, key+"="+values[key])
|
|
}
|
|
return environment
|
|
}
|
|
|
|
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")
|
|
command.Env = goEnvironment(nil)
|
|
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
|
|
}
|