go-build/pkg/build/config.go

388 lines
11 KiB
Go

// Package build provides project type detection and cross-compilation for the Core build system.
// This file handles configuration loading from .core/build.yaml files.
package build
import (
"iter"
"dappco.re/go/core"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/build/pkg/build/signing"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// ConfigFileName is the name of the build configuration file.
//
// configPath := ax.Join(projectDir, build.ConfigDir, build.ConfigFileName)
const ConfigFileName = "build.yaml"
// ConfigDir is the directory where build configuration is stored.
//
// configPath := ax.Join(projectDir, build.ConfigDir, build.ConfigFileName)
const ConfigDir = ".core"
// BuildConfig holds the complete build configuration loaded from .core/build.yaml.
// This is distinct from Config which holds runtime build parameters.
//
// cfg, err := build.LoadConfig(io.Local, ".")
type BuildConfig struct {
// Version is the config file format version.
Version int `yaml:"version"`
// Project contains project metadata.
Project Project `yaml:"project"`
// Build contains build settings.
Build Build `yaml:"build"`
// Targets defines the build targets.
Targets []TargetConfig `yaml:"targets"`
// Sign contains code signing configuration.
Sign signing.SignConfig `yaml:"sign,omitempty"`
}
// Project holds project metadata.
//
// cfg.Project.Binary = "core-build"
type Project struct {
// Name is the project name.
Name string `yaml:"name"`
// Description is a brief description of the project.
Description string `yaml:"description"`
// Main is the path to the main package (e.g., ./cmd/core).
Main string `yaml:"main"`
// Binary is the output binary name.
Binary string `yaml:"binary"`
}
// Build holds build-time settings.
//
// cfg.Build.LDFlags = []string{"-s", "-w", "-X main.version=" + version}
type Build struct {
// Type overrides project type auto-detection (e.g., "go", "wails", "docker").
Type string `yaml:"type"`
// CGO enables CGO for the build.
CGO bool `yaml:"cgo"`
// Obfuscate uses garble instead of go build for binary obfuscation.
Obfuscate bool `yaml:"obfuscate"`
// NSIS enables Windows NSIS installer generation (Wails projects only).
NSIS bool `yaml:"nsis"`
// WebView2 sets the WebView2 delivery method: download|embed|browser|error.
WebView2 string `yaml:"webview2,omitempty"`
// Flags are additional build flags (e.g., ["-trimpath"]).
Flags []string `yaml:"flags"`
// LDFlags are linker flags (e.g., ["-s", "-w"]).
LDFlags []string `yaml:"ldflags"`
// BuildTags are Go build tags passed through to `go build`.
BuildTags []string `yaml:"build_tags,omitempty"`
// ArchiveFormat selects the archive compression format for build outputs.
// Supported values are "gz", "xz", and "zip"; empty uses gzip.
ArchiveFormat string `yaml:"archive_format,omitempty"`
// Env are additional environment variables.
Env []string `yaml:"env"`
// Cache controls build cache setup.
Cache CacheConfig `yaml:"cache,omitempty"`
// Dockerfile is the path to the Dockerfile used by Docker builds.
Dockerfile string `yaml:"dockerfile,omitempty"`
// Registry is the container registry used for Docker image references.
Registry string `yaml:"registry,omitempty"`
// Image is the image name used for Docker builds.
Image string `yaml:"image,omitempty"`
// Tags are Docker image tags to apply.
Tags []string `yaml:"tags,omitempty"`
// BuildArgs are Docker build arguments.
BuildArgs map[string]string `yaml:"build_args,omitempty"`
// Push enables pushing Docker images after build.
Push bool `yaml:"push,omitempty"`
// Load loads a single-platform Docker image into the local daemon after build.
Load bool `yaml:"load,omitempty"`
// LinuxKitConfig is the path to the LinuxKit config file.
LinuxKitConfig string `yaml:"linuxkit_config,omitempty"`
// Formats is the list of LinuxKit output formats.
// Supported values include iso, raw, qcow2, vmdk, vhd, gcp, aws, docker, tar, and kernel+initrd.
Formats []string `yaml:"formats,omitempty"`
}
// TargetConfig defines a build target in the config file.
// This is separate from Target to allow for additional config-specific fields.
//
// cfg.Targets = []build.TargetConfig{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
type TargetConfig struct {
// OS is the target operating system (e.g., "linux", "darwin", "windows").
OS string `yaml:"os"`
// Arch is the target architecture (e.g., "amd64", "arm64").
Arch string `yaml:"arch"`
}
// LoadConfig loads build configuration from the .core/build.yaml file in the given directory.
// If the config file does not exist, it returns DefaultConfig().
// Returns an error if the file exists but cannot be parsed.
//
// cfg, err := build.LoadConfig(io.Local, ".")
func LoadConfig(fs io.Medium, dir string) (*BuildConfig, error) {
return LoadConfigAtPath(fs, ax.Join(dir, ConfigDir, ConfigFileName))
}
// LoadConfigAtPath loads build configuration from an explicit file path.
// If the file does not exist, it returns DefaultConfig().
// Returns an error if the file exists but cannot be parsed.
//
// cfg, err := build.LoadConfigAtPath(io.Local, "/tmp/project/build.yaml")
func LoadConfigAtPath(fs io.Medium, configPath string) (*BuildConfig, error) {
content, err := fs.Read(configPath)
if err != nil {
if !fs.Exists(configPath) {
return DefaultConfig(), nil
}
return nil, coreerr.E("build.LoadConfigAtPath", "failed to read config file", err)
}
cfg := DefaultConfig()
data := []byte(content)
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, coreerr.E("build.LoadConfigAtPath", "failed to parse config file", err)
}
// Apply defaults for any missing fields
applyDefaults(cfg)
// Expand environment variables after defaults so overrides can still be
// expressed declaratively in config files.
cfg.ExpandEnv()
return cfg, nil
}
// DefaultConfig returns sensible defaults for Go projects.
//
// cfg := build.DefaultConfig()
func DefaultConfig() *BuildConfig {
return &BuildConfig{
Version: 1,
Project: Project{
Name: "",
Main: ".",
Binary: "",
},
Build: Build{
CGO: false,
Flags: []string{"-trimpath"},
LDFlags: []string{"-s", "-w"},
Env: []string{},
},
Targets: []TargetConfig{
{OS: "linux", Arch: "amd64"},
{OS: "linux", Arch: "arm64"},
{OS: "darwin", Arch: "arm64"},
{OS: "windows", Arch: "amd64"},
},
Sign: signing.DefaultSignConfig(),
}
}
// applyDefaults fills in default values for any empty fields in the config.
func applyDefaults(cfg *BuildConfig) {
defaults := DefaultConfig()
if cfg.Version == 0 {
cfg.Version = defaults.Version
}
if cfg.Project.Main == "" {
cfg.Project.Main = defaults.Project.Main
}
if cfg.Build.Flags == nil {
cfg.Build.Flags = defaults.Build.Flags
}
if cfg.Build.LDFlags == nil {
cfg.Build.LDFlags = defaults.Build.LDFlags
}
if cfg.Build.Env == nil {
cfg.Build.Env = defaults.Build.Env
}
if cfg.Targets == nil {
cfg.Targets = defaults.Targets
}
}
// ExpandEnv expands environment variables across the build config.
//
// cfg.ExpandEnv() // expands $APP_NAME, $IMAGE_TAG, $GPG_KEY_ID, etc.
func (cfg *BuildConfig) ExpandEnv() {
if cfg == nil {
return
}
cfg.Project.Name = expandEnv(cfg.Project.Name)
cfg.Project.Description = expandEnv(cfg.Project.Description)
cfg.Project.Main = expandEnv(cfg.Project.Main)
cfg.Project.Binary = expandEnv(cfg.Project.Binary)
cfg.Build.Type = expandEnv(cfg.Build.Type)
cfg.Build.WebView2 = expandEnv(cfg.Build.WebView2)
cfg.Build.ArchiveFormat = expandEnv(cfg.Build.ArchiveFormat)
cfg.Build.Dockerfile = expandEnv(cfg.Build.Dockerfile)
cfg.Build.Registry = expandEnv(cfg.Build.Registry)
cfg.Build.Image = expandEnv(cfg.Build.Image)
cfg.Build.LinuxKitConfig = expandEnv(cfg.Build.LinuxKitConfig)
cfg.Build.Flags = expandEnvSlice(cfg.Build.Flags)
cfg.Build.LDFlags = expandEnvSlice(cfg.Build.LDFlags)
cfg.Build.BuildTags = expandEnvSlice(cfg.Build.BuildTags)
cfg.Build.Env = expandEnvSlice(cfg.Build.Env)
cfg.Build.Tags = expandEnvSlice(cfg.Build.Tags)
cfg.Build.Formats = expandEnvSlice(cfg.Build.Formats)
cfg.Build.Cache.Directory = expandEnv(cfg.Build.Cache.Directory)
cfg.Build.Cache.KeyPrefix = expandEnv(cfg.Build.Cache.KeyPrefix)
cfg.Build.Cache.Paths = expandEnvSlice(cfg.Build.Cache.Paths)
cfg.Build.Cache.RestoreKeys = expandEnvSlice(cfg.Build.Cache.RestoreKeys)
cfg.Build.BuildArgs = expandEnvMap(cfg.Build.BuildArgs)
cfg.Targets = expandTargetConfigs(cfg.Targets)
cfg.Sign.ExpandEnv()
}
func expandEnvSlice(values []string) []string {
if len(values) == 0 {
return values
}
result := make([]string, len(values))
for i, value := range values {
result[i] = expandEnv(value)
}
return result
}
func expandEnvMap(values map[string]string) map[string]string {
if len(values) == 0 {
return values
}
result := make(map[string]string, len(values))
for key, value := range values {
result[key] = expandEnv(value)
}
return result
}
func expandTargetConfigs(values []TargetConfig) []TargetConfig {
if len(values) == 0 {
return values
}
result := make([]TargetConfig, len(values))
for i, value := range values {
result[i] = TargetConfig{
OS: expandEnv(value.OS),
Arch: expandEnv(value.Arch),
}
}
return result
}
// CloneStringMap returns a shallow copy of a string map.
//
// clone := build.CloneStringMap(map[string]string{"VERSION": "v1.2.3"})
func CloneStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return values
}
result := make(map[string]string, len(values))
for key, value := range values {
result[key] = value
}
return result
}
// expandEnv expands $VAR or ${VAR} using the current process environment.
func expandEnv(s string) string {
if !core.Contains(s, "$") {
return s
}
buf := core.NewBuilder()
for i := 0; i < len(s); {
if s[i] != '$' {
buf.WriteByte(s[i])
i++
continue
}
if i+1 < len(s) && s[i+1] == '{' {
j := i + 2
for j < len(s) && s[j] != '}' {
j++
}
if j < len(s) {
buf.WriteString(core.Env(s[i+2 : j]))
i = j + 1
continue
}
}
j := i + 1
for j < len(s) {
c := s[j]
if c != '_' && (c < '0' || c > '9') && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
break
}
j++
}
if j > i+1 {
buf.WriteString(core.Env(s[i+1 : j]))
i = j
continue
}
buf.WriteByte(s[i])
i++
}
return buf.String()
}
// ConfigPath returns the path to the build config file for a given directory.
//
// path := build.ConfigPath("/home/user/my-project") // → "/home/user/my-project/.core/build.yaml"
func ConfigPath(dir string) string {
return ax.Join(dir, ConfigDir, ConfigFileName)
}
// ConfigExists checks if a build config file exists in the given directory.
//
// if build.ConfigExists(io.Local, ".") { ... }
func ConfigExists(fs io.Medium, dir string) bool {
return fileExists(fs, ConfigPath(dir))
}
// TargetsIter returns an iterator for the build targets.
//
// for t := range cfg.TargetsIter() { fmt.Println(t.OS, t.Arch) }
func (cfg *BuildConfig) TargetsIter() iter.Seq[TargetConfig] {
return func(yield func(TargetConfig) bool) {
for _, t := range cfg.Targets {
if !yield(t) {
return
}
}
}
}
// ToTargets converts TargetConfig slice to Target slice for use with builders.
//
// targets := cfg.ToTargets()
func (cfg *BuildConfig) ToTargets() []Target {
targets := make([]Target, len(cfg.Targets))
for i, t := range cfg.Targets {
targets[i] = Target{OS: t.OS, Arch: t.Arch}
}
return targets
}