go-build/pkg/release/config.go
Virgil c648e981b4 feat(build): expand env vars in target config
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:16:57 +00:00

541 lines
16 KiB
Go

// Package release provides release automation with changelog generation and publishing.
package release
import (
"iter"
"dappco.re/go/core"
"dappco.re/go/core/build/internal/ax"
coreerr "dappco.re/go/core/log"
"gopkg.in/yaml.v3"
)
// ConfigFileName is the name of the release configuration file.
//
// configPath := ax.Join(projectDir, release.ConfigDir, release.ConfigFileName)
const ConfigFileName = "release.yaml"
// ConfigDir is the directory where release configuration is stored.
//
// configPath := ax.Join(projectDir, release.ConfigDir, release.ConfigFileName)
const ConfigDir = ".core"
// Config holds the complete release configuration loaded from .core/release.yaml.
//
// cfg, err := release.LoadConfig(".")
type Config struct {
// Version is the config file format version.
Version int `yaml:"version"`
// Project contains project metadata.
Project ProjectConfig `yaml:"project"`
// Build contains build settings for the release.
Build BuildConfig `yaml:"build"`
// Publishers defines where to publish the release.
Publishers []PublisherConfig `yaml:"publishers"`
// Changelog configures changelog generation.
Changelog ChangelogConfig `yaml:"changelog"`
// SDK configures SDK generation.
SDK *SDKConfig `yaml:"sdk,omitempty"`
// Internal fields (not serialized)
projectDir string // Set by LoadConfig
version string // Set by CLI flag
}
// ProjectConfig holds project metadata for releases.
//
// cfg.Project = release.ProjectConfig{Name: "core-build", Repository: "host-uk/core-build"}
type ProjectConfig struct {
// Name is the project name.
Name string `yaml:"name"`
// Repository is the GitHub repository in owner/repo format.
Repository string `yaml:"repository"`
}
// BuildConfig holds build settings for releases.
//
// cfg.Build.Targets = []release.TargetConfig{{OS: "linux", Arch: "amd64"}}
type BuildConfig struct {
// Targets defines the build targets.
Targets []TargetConfig `yaml:"targets"`
// 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"`
}
// TargetConfig defines a build target.
//
// t := release.TargetConfig{OS: "linux", 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"`
}
// PublisherConfig holds configuration for a publisher.
//
// cfg.Publishers = []release.PublisherConfig{{Type: "github", Draft: false}}
type PublisherConfig struct {
// Type is the publisher type (e.g., "github", "linuxkit", "docker").
Type string `yaml:"type"`
// Prerelease marks the release as a prerelease.
Prerelease bool `yaml:"prerelease"`
// Draft creates the release as a draft.
Draft bool `yaml:"draft"`
// LinuxKit-specific configuration
// Config is the path to the LinuxKit YAML configuration file.
Config string `yaml:"config,omitempty"`
// Formats are the output formats to build (iso, raw, qcow2, vmdk).
Formats []string `yaml:"formats,omitempty"`
// Platforms are the target platforms (linux/amd64, linux/arm64).
Platforms []string `yaml:"platforms,omitempty"`
// Docker-specific configuration
// Registry is the container registry (default: ghcr.io).
Registry string `yaml:"registry,omitempty"`
// Image is the image name in owner/repo format.
Image string `yaml:"image,omitempty"`
// Dockerfile is the path to the Dockerfile (default: Dockerfile).
Dockerfile string `yaml:"dockerfile,omitempty"`
// Tags are the image tags to apply.
Tags []string `yaml:"tags,omitempty"`
// BuildArgs are additional Docker build arguments.
BuildArgs map[string]string `yaml:"build_args,omitempty"`
// npm-specific configuration
// Package is the npm package name (e.g., "@host-uk/core").
Package string `yaml:"package,omitempty"`
// Access is the npm access level: "public" or "restricted".
Access string `yaml:"access,omitempty"`
// Homebrew-specific configuration
// Tap is the Homebrew tap repository (e.g., "host-uk/homebrew-tap").
Tap string `yaml:"tap,omitempty"`
// Formula is the formula name (defaults to project name).
Formula string `yaml:"formula,omitempty"`
// Scoop-specific configuration
// Bucket is the Scoop bucket repository (e.g., "host-uk/scoop-bucket").
Bucket string `yaml:"bucket,omitempty"`
// AUR-specific configuration
// Maintainer is the AUR package maintainer (e.g., "Name <email>").
Maintainer string `yaml:"maintainer,omitempty"`
// Chocolatey-specific configuration
// Push determines whether to push to Chocolatey (false = generate only).
Push bool `yaml:"push,omitempty"`
// Official repo configuration (for Homebrew, Scoop)
// When enabled, generates files for PR to official repos.
Official *OfficialConfig `yaml:"official,omitempty"`
}
// OfficialConfig holds configuration for generating files for official repo PRs.
//
// pub.Official = &release.OfficialConfig{Enabled: true, Output: "dist/homebrew"}
type OfficialConfig struct {
// Enabled determines whether to generate files for official repos.
Enabled bool `yaml:"enabled"`
// Output is the directory to write generated files.
Output string `yaml:"output,omitempty"`
}
// SDKConfig holds SDK generation configuration.
//
// cfg.SDK = &release.SDKConfig{Spec: "docs/openapi.yaml", Languages: []string{"typescript", "go"}}
type SDKConfig struct {
// Spec is the path to the OpenAPI spec file.
Spec string `yaml:"spec,omitempty"`
// Languages to generate.
Languages []string `yaml:"languages,omitempty"`
// Output directory (default: sdk/).
Output string `yaml:"output,omitempty"`
// Package naming.
Package SDKPackageConfig `yaml:"package,omitempty"`
// Diff configuration.
Diff SDKDiffConfig `yaml:"diff,omitempty"`
// Publish configuration.
Publish SDKPublishConfig `yaml:"publish,omitempty"`
}
// SDKPackageConfig holds package naming configuration.
//
// cfg.SDK.Package = release.SDKPackageConfig{Name: "@host-uk/api-client", Version: "1.0.0"}
type SDKPackageConfig struct {
Name string `yaml:"name,omitempty"`
Version string `yaml:"version,omitempty"`
}
// SDKDiffConfig holds diff configuration.
//
// cfg.SDK.Diff = release.SDKDiffConfig{Enabled: true, FailOnBreaking: true}
type SDKDiffConfig struct {
Enabled bool `yaml:"enabled,omitempty"`
FailOnBreaking bool `yaml:"fail_on_breaking,omitempty"`
}
// SDKPublishConfig holds monorepo publish configuration.
//
// cfg.SDK.Publish = release.SDKPublishConfig{Repo: "host-uk/ts", Path: "packages/api-client"}
type SDKPublishConfig struct {
Repo string `yaml:"repo,omitempty"`
Path string `yaml:"path,omitempty"`
}
// ChangelogConfig holds changelog generation settings.
//
// cfg.Changelog = release.ChangelogConfig{Include: []string{"feat", "fix"}, Exclude: []string{"chore"}}
type ChangelogConfig struct {
// Include specifies commit types to include in the changelog.
Include []string `yaml:"include"`
// Exclude specifies commit types to exclude from the changelog.
Exclude []string `yaml:"exclude"`
}
// PublishersIter returns an iterator for the publishers.
//
// for p := range cfg.PublishersIter() { fmt.Println(p.Type) }
func (c *Config) PublishersIter() iter.Seq[PublisherConfig] {
return func(yield func(PublisherConfig) bool) {
for _, p := range c.Publishers {
if !yield(p) {
return
}
}
}
}
// LoadConfig loads release configuration from the .core/release.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 := release.LoadConfig(".")
func LoadConfig(dir string) (*Config, error) {
configPath := ax.Join(dir, ConfigDir, ConfigFileName)
// Resolve path with AX-aware helpers.
absPath, err := ax.Abs(configPath)
if err != nil {
return nil, coreerr.E("release.LoadConfig", "failed to resolve path", err)
}
content, err := ax.ReadFile(absPath)
if err != nil {
if !ax.IsFile(absPath) {
cfg := DefaultConfig()
cfg.projectDir = dir
return cfg, nil
}
return nil, coreerr.E("release.LoadConfig", "failed to read config file", err)
}
var cfg Config
if err := yaml.Unmarshal([]byte(content), &cfg); err != nil {
return nil, coreerr.E("release.LoadConfig", "failed to parse config file", err)
}
// Apply defaults for any missing fields
applyDefaults(&cfg)
cfg.ExpandEnv()
cfg.projectDir = dir
return &cfg, nil
}
// DefaultConfig returns sensible defaults for release configuration.
//
// cfg := release.DefaultConfig()
func DefaultConfig() *Config {
return &Config{
Version: 1,
Project: ProjectConfig{
Name: "",
Repository: "",
},
Build: BuildConfig{
Targets: []TargetConfig{
{OS: "linux", Arch: "amd64"},
{OS: "linux", Arch: "arm64"},
{OS: "darwin", Arch: "arm64"},
{OS: "windows", Arch: "amd64"},
},
},
Publishers: []PublisherConfig{
{
Type: "github",
Prerelease: false,
Draft: false,
},
},
Changelog: ChangelogConfig{
Include: []string{"feat", "fix", "perf", "refactor"},
Exclude: []string{"chore", "docs", "style", "test", "ci"},
},
}
}
// ScaffoldConfig returns the config shape written by `core ci init`.
//
// cfg := release.ScaffoldConfig()
func ScaffoldConfig() *Config {
cfg := DefaultConfig()
cfg.SDK = &SDKConfig{
Spec: "api/openapi.yaml",
Languages: []string{"typescript", "python", "go", "php"},
Output: "sdk",
Diff: SDKDiffConfig{
Enabled: true,
FailOnBreaking: false,
},
}
return cfg
}
// applyDefaults fills in default values for any empty fields in the config.
func applyDefaults(cfg *Config) {
defaults := DefaultConfig()
if cfg.Version == 0 {
cfg.Version = defaults.Version
}
if len(cfg.Build.Targets) == 0 {
cfg.Build.Targets = defaults.Build.Targets
}
if len(cfg.Publishers) == 0 {
cfg.Publishers = defaults.Publishers
}
if len(cfg.Changelog.Include) == 0 && len(cfg.Changelog.Exclude) == 0 {
cfg.Changelog.Include = defaults.Changelog.Include
cfg.Changelog.Exclude = defaults.Changelog.Exclude
}
}
// ExpandEnv expands environment variables across the release config.
//
// cfg.ExpandEnv() // expands $REPO, $PACKAGE_NAME, $SDK_SPEC, etc.
func (c *Config) ExpandEnv() {
if c == nil {
return
}
c.Project.Name = expandEnv(c.Project.Name)
c.Project.Repository = expandEnv(c.Project.Repository)
c.Build.ArchiveFormat = expandEnv(c.Build.ArchiveFormat)
c.Build.Targets = expandTargetConfigs(c.Build.Targets)
c.Publishers = expandPublisherConfigs(c.Publishers)
c.Changelog.Include = expandEnvSlice(c.Changelog.Include)
c.Changelog.Exclude = expandEnvSlice(c.Changelog.Exclude)
if c.SDK != nil {
c.SDK.Spec = expandEnv(c.SDK.Spec)
c.SDK.Output = expandEnv(c.SDK.Output)
c.SDK.Package.Name = expandEnv(c.SDK.Package.Name)
c.SDK.Package.Version = expandEnv(c.SDK.Package.Version)
c.SDK.Publish.Repo = expandEnv(c.SDK.Publish.Repo)
c.SDK.Publish.Path = expandEnv(c.SDK.Publish.Path)
}
}
// SetProjectDir sets the project directory on the config.
//
// cfg.SetProjectDir("/home/user/my-project")
func (c *Config) SetProjectDir(dir string) {
c.projectDir = dir
}
// SetVersion sets the version override on the config.
//
// cfg.SetVersion("v1.2.3")
func (c *Config) SetVersion(version string) {
c.version = version
}
func expandPublisherConfigs(publishers []PublisherConfig) []PublisherConfig {
if len(publishers) == 0 {
return publishers
}
result := make([]PublisherConfig, len(publishers))
copy(result, publishers)
for i := range result {
result[i].Type = expandEnv(result[i].Type)
result[i].Config = expandEnv(result[i].Config)
result[i].Formats = expandEnvSlice(result[i].Formats)
result[i].Platforms = expandEnvSlice(result[i].Platforms)
result[i].Registry = expandEnv(result[i].Registry)
result[i].Image = expandEnv(result[i].Image)
result[i].Dockerfile = expandEnv(result[i].Dockerfile)
result[i].Tags = expandEnvSlice(result[i].Tags)
result[i].BuildArgs = expandEnvMap(result[i].BuildArgs)
result[i].Package = expandEnv(result[i].Package)
result[i].Access = expandEnv(result[i].Access)
result[i].Tap = expandEnv(result[i].Tap)
result[i].Formula = expandEnv(result[i].Formula)
result[i].Bucket = expandEnv(result[i].Bucket)
result[i].Maintainer = expandEnv(result[i].Maintainer)
if result[i].Official != nil {
result[i].Official.Output = expandEnv(result[i].Official.Output)
}
}
return result
}
// ConfigPath returns the path to the release config file for a given directory.
//
// path := release.ConfigPath("/home/user/my-project") // → "/home/user/my-project/.core/release.yaml"
func ConfigPath(dir string) string {
return ax.Join(dir, ConfigDir, ConfigFileName)
}
// ConfigExists checks if a release config file exists in the given directory.
//
// if release.ConfigExists(".") { ... }
func ConfigExists(dir string) bool {
configPath := ConfigPath(dir)
absPath, err := ax.Abs(configPath)
if err != nil {
return false
}
return ax.IsFile(absPath)
}
// GetRepository returns the repository from the config.
//
// repo := cfg.GetRepository() // → "host-uk/core-build"
func (c *Config) GetRepository() string {
return c.Project.Repository
}
// GetProjectName returns the project name from the config.
//
// name := cfg.GetProjectName() // → "core-build"
func (c *Config) GetProjectName() string {
return c.Project.Name
}
// WriteConfig writes the config to the .core/release.yaml file.
//
// err := release.WriteConfig(cfg, ".")
func WriteConfig(cfg *Config, dir string) error {
configPath := ConfigPath(dir)
// Resolve path with AX-aware helpers.
absPath, err := ax.Abs(configPath)
if err != nil {
return coreerr.E("release.WriteConfig", "failed to resolve path", err)
}
// Ensure directory exists
configDir := ax.Dir(absPath)
if err := ax.MkdirAll(configDir, 0o755); err != nil {
return coreerr.E("release.WriteConfig", "failed to create directory", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return coreerr.E("release.WriteConfig", "failed to marshal config", err)
}
if err := ax.WriteString(absPath, string(data), 0o644); err != nil {
return coreerr.E("release.WriteConfig", "failed to write config file", err)
}
return nil
}
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
}
// 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()
}