277 lines
8.8 KiB
Go
277 lines
8.8 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/releaseidentity"
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
|
)
|
|
|
|
type Target = artifact.Target
|
|
type Spec = artifact.Settings
|
|
|
|
type Request struct {
|
|
Identity releaseidentity.Identity
|
|
Spec Spec
|
|
OutputDir string
|
|
Output io.Writer
|
|
ToolOwner *gotool.Owner
|
|
}
|
|
|
|
type Artifact struct {
|
|
Path string
|
|
Filename string
|
|
Target Target
|
|
SHA256 string
|
|
}
|
|
|
|
type Result struct {
|
|
RepositoryDir string
|
|
ModuleDir string
|
|
ModulePath string
|
|
Tag string
|
|
CandidateCommit string
|
|
SourceVerified bool
|
|
Settings artifact.Settings
|
|
Version string
|
|
MainPackage string
|
|
BinaryName string
|
|
OutputDir string
|
|
Targets []Target
|
|
Artifacts []Artifact
|
|
Compiler string
|
|
}
|
|
|
|
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
|
identity := request.Identity
|
|
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
|
result := Result{
|
|
RepositoryDir: identity.RepositoryDir,
|
|
ModuleDir: identity.ModuleDir,
|
|
ModulePath: identity.ModulePath,
|
|
Tag: identity.Tag,
|
|
Settings: settings,
|
|
Version: identity.Version,
|
|
MainPackage: settings.MainPackage,
|
|
BinaryName: settings.BinaryName,
|
|
Targets: settings.Targets,
|
|
}
|
|
if !versioncontract.ValidStableVersion(identity.Version) {
|
|
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
|
}
|
|
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
|
return result, errors.New("build requires a resolved module identity")
|
|
}
|
|
if len(result.Targets) != 0 {
|
|
if settings.MainPackage == "" {
|
|
return result, errors.New("build requires a main package when targets are selected")
|
|
}
|
|
if !artifact.ValidComponent(settings.BinaryName) {
|
|
return result, fmt.Errorf("unsafe binary name %q", settings.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(identity.RepositoryDir)
|
|
if err != nil {
|
|
return result, fmt.Errorf("resolve repository directory: %w", err)
|
|
}
|
|
result.RepositoryDir = repositoryDir
|
|
moduleDir, err := filepath.Abs(identity.ModuleDir)
|
|
if err != nil {
|
|
return result, fmt.Errorf("resolve module directory: %w", err)
|
|
}
|
|
result.ModuleDir = moduleDir
|
|
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(moduleDir, request.OutputDir)
|
|
return result, nil
|
|
}
|
|
|
|
result.OutputDir = resolvedOutputDir(moduleDir, request.OutputDir)
|
|
declarations := artifact.ExpectedBuild(settings.BinaryName, identity.Version, settings.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)
|
|
}
|
|
|
|
owner := request.ToolOwner
|
|
if owner == nil {
|
|
owner = gotool.NewOwner(moduleDir)
|
|
}
|
|
goTool, err := owner.Acquire(ctx)
|
|
if err != nil {
|
|
return result, fmt.Errorf("prepare Go toolchain: %w", err)
|
|
}
|
|
compiler, err := goTool.Output(ctx, moduleDir, 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, moduleDir, settings.MainPackage, path, identity.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 := tempdir.Outside(repositoryDir, "releasekit-native-smoke-")
|
|
if err != nil {
|
|
return result, fmt.Errorf("create native smoke directory: %w", err)
|
|
}
|
|
defer os.RemoveAll(temporaryDir)
|
|
smokePath = filepath.Join(temporaryDir, settings.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, moduleDir, settings.MainPackage, smokePath, identity.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", settings.BinaryName)
|
|
}
|
|
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.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(moduleDir, outputDir string) string {
|
|
if outputDir == "" {
|
|
outputDir = filepath.Join(moduleDir, "dist")
|
|
} else if !filepath.IsAbs(outputDir) {
|
|
outputDir = filepath.Join(moduleDir, 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, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
|
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
|
err := goTool.Run(ctx, moduleDir, 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
|
|
}
|