From 479612fdf758b01fae196ef76de6db6187bf7b4c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 19:32:07 +0000 Subject: [PATCH] feat(build): expand env vars in build config --- pkg/build/config.go | 114 ++++++++++++++++++++++++++++++++++++++- pkg/build/config_test.go | 76 ++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) diff --git a/pkg/build/config.go b/pkg/build/config.go index bf8686a..15bd207 100644 --- a/pkg/build/config.go +++ b/pkg/build/config.go @@ -5,6 +5,7 @@ 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" @@ -144,6 +145,10 @@ func LoadConfigAtPath(fs io.Medium, configPath string) (*BuildConfig, error) { // 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 } @@ -202,10 +207,117 @@ func applyDefaults(cfg *BuildConfig) { cfg.Targets = defaults.Targets } - // Expand environment variables in sign config +} + +// 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.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 +} + +// 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" diff --git a/pkg/build/config_test.go b/pkg/build/config_test.go index 235256a..3914cef 100644 --- a/pkg/build/config_test.go +++ b/pkg/build/config_test.go @@ -84,6 +84,82 @@ targets: assert.Equal(t, "arm64", cfg.Targets[1].Arch) }) + t.Run("expands environment variables in build and signing config", func(t *testing.T) { + t.Setenv("APP_NAME", "demo-app") + t.Setenv("APP_ROOT", "./cmd/demo") + t.Setenv("APP_BINARY", "demo-bin") + t.Setenv("BUILD_TYPE", "wails") + t.Setenv("WEBVIEW2", "embed") + t.Setenv("ARCHIVE_FORMAT", "xz") + t.Setenv("APP_VERSION", "v1.2.3") + t.Setenv("APP_TAG", "integration") + t.Setenv("CACHE_DIR", ".core/cache/demo-app") + t.Setenv("DOCKERFILE", "Dockerfile.release") + t.Setenv("IMAGE_NAME", "owner/demo-app") + t.Setenv("GPG_KEY_ID", "ABCD1234") + + content := ` +version: 1 +project: + name: ${APP_NAME} + main: ${APP_ROOT} + binary: ${APP_BINARY} +build: + type: ${BUILD_TYPE} + webview2: ${WEBVIEW2} + archive_format: ${ARCHIVE_FORMAT} + flags: + - -trimpath + - -X + - main.version=${APP_VERSION} + ldflags: + - -s + - -w + build_tags: + - ${APP_TAG} + env: + - VERSION=${APP_VERSION} + cache: + enabled: true + dir: ${CACHE_DIR} + paths: + - ${CACHE_DIR}/go-build + dockerfile: ${DOCKERFILE} + image: ${IMAGE_NAME} + tags: + - latest + - ${APP_VERSION} + build_args: + VERSION: ${APP_VERSION} +sign: + gpg: + key: ${GPG_KEY_ID} +` + dir := setupConfigTestDir(t, content) + + cfg, err := LoadConfig(fs, dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, "demo-app", cfg.Project.Name) + assert.Equal(t, "./cmd/demo", cfg.Project.Main) + assert.Equal(t, "demo-bin", cfg.Project.Binary) + assert.Equal(t, "wails", cfg.Build.Type) + assert.Equal(t, "embed", cfg.Build.WebView2) + assert.Equal(t, "xz", cfg.Build.ArchiveFormat) + assert.Equal(t, []string{"-trimpath", "-X", "main.version=v1.2.3"}, cfg.Build.Flags) + assert.Equal(t, []string{"-s", "-w"}, cfg.Build.LDFlags) + assert.Equal(t, []string{"integration"}, cfg.Build.BuildTags) + assert.Equal(t, []string{"VERSION=v1.2.3"}, cfg.Build.Env) + assert.Equal(t, ".core/cache/demo-app", cfg.Build.Cache.Directory) + assert.Equal(t, []string{".core/cache/demo-app/go-build"}, cfg.Build.Cache.Paths) + assert.Equal(t, "Dockerfile.release", cfg.Build.Dockerfile) + assert.Equal(t, "owner/demo-app", cfg.Build.Image) + assert.Equal(t, []string{"latest", "v1.2.3"}, cfg.Build.Tags) + assert.Equal(t, map[string]string{"VERSION": "v1.2.3"}, cfg.Build.BuildArgs) + assert.Equal(t, "ABCD1234", cfg.Sign.GPG.Key) + }) + t.Run("returns defaults when config file missing", func(t *testing.T) { dir := t.TempDir()