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