// 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 }