From 5c72dd2a979ba6bb0958ac120d97b14c35fbce1a Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 2 May 2026 09:56:02 +1000 Subject: [PATCH] Add config loader and validator Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/config/config.go | 150 ++++++++++++++++++++++++++++++++ internal/config/config_test.go | 153 +++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b0f0dac --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,150 @@ +package config + +import ( + "fmt" + "strings" +) + +// Config is the complete set of inputs to the action, parsed from env. +type Config struct { + WorkingDirectory string + AppName string + Version string + WailsVersion string + ExtraBuildFlags string + + DeveloperIDCertBase64 string + DeveloperIDCertPassword string + + NotarizationMethod string // "api-key" | "apple-id" | "auto" + NotarizationAPIKeyBase64 string + NotarizationAPIKeyID string + NotarizationAPIIssuerID string + NotarizationAppleID string + NotarizationApplePassword string + NotarizationTeamID string + + S3Bucket string + S3Key string + S3EndpointURL string + S3Region string +} + +// Load reads the action's INPUT_* environment variables. +func Load(get func(string) string) *Config { + c := &Config{ + WorkingDirectory: getOr(get, "INPUT_WORKING_DIRECTORY", "."), + AppName: get("INPUT_APP_NAME"), + Version: get("INPUT_VERSION"), + WailsVersion: get("INPUT_WAILS_VERSION"), + ExtraBuildFlags: get("INPUT_EXTRA_BUILD_FLAGS"), + DeveloperIDCertBase64: get("INPUT_DEVELOPER_ID_CERT_BASE64"), + DeveloperIDCertPassword: get("INPUT_DEVELOPER_ID_CERT_PASSWORD"), + NotarizationMethod: getOr(get, "INPUT_NOTARIZATION_METHOD", "auto"), + NotarizationAPIKeyBase64: get("INPUT_NOTARIZATION_API_KEY_BASE64"), + NotarizationAPIKeyID: get("INPUT_NOTARIZATION_API_KEY_ID"), + NotarizationAPIIssuerID: get("INPUT_NOTARIZATION_API_ISSUER_ID"), + NotarizationAppleID: get("INPUT_NOTARIZATION_APPLE_ID"), + NotarizationApplePassword: get("INPUT_NOTARIZATION_APPLE_PASSWORD"), + NotarizationTeamID: get("INPUT_NOTARIZATION_TEAM_ID"), + S3Bucket: get("INPUT_S3_BUCKET"), + S3Key: get("INPUT_S3_KEY"), + S3EndpointURL: get("INPUT_S3_ENDPOINT_URL"), + S3Region: getOr(get, "INPUT_S3_REGION", "us-east-1"), + } + return c +} + +func getOr(get func(string) string, key, def string) string { + if v := get(key); v != "" { + return v + } + return def +} + +// Validate checks structural rules. It does NOT touch the filesystem. +func (c *Config) Validate() error { + var missing []string + if c.DeveloperIDCertBase64 == "" { + missing = append(missing, "developer-id-cert-base64") + } + if c.DeveloperIDCertPassword == "" { + missing = append(missing, "developer-id-cert-password") + } + if len(missing) > 0 { + return fmt.Errorf("missing required input(s): %s", strings.Join(missing, ", ")) + } + + apiFields := []struct{ name, value string }{ + {"notarization-api-key-base64", c.NotarizationAPIKeyBase64}, + {"notarization-api-key-id", c.NotarizationAPIKeyID}, + {"notarization-api-issuer-id", c.NotarizationAPIIssuerID}, + } + appleFields := []struct{ name, value string }{ + {"notarization-apple-id", c.NotarizationAppleID}, + {"notarization-apple-password", c.NotarizationApplePassword}, + {"notarization-team-id", c.NotarizationTeamID}, + } + + apiFilled, apiMissing := groupStatus(apiFields) + appleFilled, appleMissing := groupStatus(appleFields) + + switch c.NotarizationMethod { + case "api-key": + if !apiFilled { + return fmt.Errorf("notarization-method=api-key requires: %s (missing: %s)", + joinNames(apiFields), strings.Join(apiMissing, ", ")) + } + case "apple-id": + if !appleFilled { + return fmt.Errorf("notarization-method=apple-id requires: %s (missing: %s)", + joinNames(appleFields), strings.Join(appleMissing, ", ")) + } + case "auto", "": + switch { + case apiFilled && appleFilled: + return fmt.Errorf("notarization credentials are ambiguous: both api-key and apple-id groups are populated; set notarization-method explicitly") + case !apiFilled && !appleFilled: + return fmt.Errorf("no notarization credentials supplied: populate either the api-key group (%s) or the apple-id group (%s)", + joinNames(apiFields), joinNames(appleFields)) + } + default: + return fmt.Errorf("notarization-method must be one of api-key, apple-id, auto (got %q)", c.NotarizationMethod) + } + + if c.S3Bucket != "" && c.S3Key == "" { + return fmt.Errorf("s3-bucket is set but s3-key is empty") + } + return nil +} + +// ResolvedNotarizationMethod returns "api-key" or "apple-id" once Validate has succeeded. +func (c *Config) ResolvedNotarizationMethod() string { + if c.NotarizationMethod == "api-key" || c.NotarizationMethod == "apple-id" { + return c.NotarizationMethod + } + if c.NotarizationAPIKeyID != "" { + return "api-key" + } + return "apple-id" +} + +func groupStatus(fields []struct{ name, value string }) (filled bool, missing []string) { + any := false + for _, f := range fields { + if f.value != "" { + any = true + } else { + missing = append(missing, f.name) + } + } + return any && len(missing) == 0, missing +} + +func joinNames(fields []struct{ name, value string }) string { + names := make([]string, len(fields)) + for i, f := range fields { + names[i] = f.name + } + return strings.Join(names, ", ") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9ff7684 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,153 @@ +package config_test + +import ( + "strings" + "testing" + + "github.com/leonmika/wails-release/internal/config" +) + +func TestLoad_PopulatesAllFieldsFromEnv(t *testing.T) { + env := map[string]string{ + "INPUT_WORKING_DIRECTORY": "apps/desktop", + "INPUT_APP_NAME": "MyApp", + "INPUT_VERSION": "1.2.3", + "INPUT_WAILS_VERSION": "v2.11.0", + "INPUT_EXTRA_BUILD_FLAGS": "-tags release", + "INPUT_DEVELOPER_ID_CERT_BASE64": "Y2VydA==", + "INPUT_DEVELOPER_ID_CERT_PASSWORD": "pw", + "INPUT_NOTARIZATION_METHOD": "api-key", + "INPUT_NOTARIZATION_API_KEY_BASE64": "a2V5", + "INPUT_NOTARIZATION_API_KEY_ID": "ABCD1234", + "INPUT_NOTARIZATION_API_ISSUER_ID": "12345678-aaaa-bbbb-cccc-111111111111", + "INPUT_S3_BUCKET": "my-bucket", + "INPUT_S3_KEY": "releases/{version}/{filename}", + "INPUT_S3_REGION": "us-east-1", + } + c := config.Load(envGetter(env)) + if c.WorkingDirectory != "apps/desktop" || c.AppName != "MyApp" || c.Version != "1.2.3" { + t.Fatalf("basic fields wrong: %+v", c) + } + if c.NotarizationMethod != "api-key" || c.NotarizationAPIKeyID != "ABCD1234" { + t.Fatalf("notarization fields wrong: %+v", c) + } + if c.S3Bucket != "my-bucket" || c.S3Key != "releases/{version}/{filename}" { + t.Fatalf("s3 fields wrong: %+v", c) + } +} + +func TestLoad_AppliesDefaults(t *testing.T) { + c := config.Load(envGetter(nil)) + if c.WorkingDirectory != "." { + t.Fatalf("expected default working-directory '.', got %q", c.WorkingDirectory) + } + if c.S3Region != "us-east-1" { + t.Fatalf("expected default s3-region us-east-1, got %q", c.S3Region) + } + if c.NotarizationMethod != "auto" { + t.Fatalf("expected default notarization-method auto, got %q", c.NotarizationMethod) + } +} + +func TestValidate(t *testing.T) { + base := func() *config.Config { + return &config.Config{ + WorkingDirectory: ".", + DeveloperIDCertBase64: "x", + DeveloperIDCertPassword: "x", + NotarizationMethod: "auto", + S3Region: "us-east-1", + } + } + + cases := []struct { + name string + mutate func(*config.Config) + errMsg string // substring expected in error; empty means must succeed + }{ + { + name: "valid with api-key group", + mutate: func(c *config.Config) { fillAPIKey(c) }, + }, + { + name: "valid with apple-id group", + mutate: func(c *config.Config) { fillAppleID(c) }, + }, + { + name: "missing cert base64", + mutate: func(c *config.Config) { fillAPIKey(c); c.DeveloperIDCertBase64 = "" }, + errMsg: "developer-id-cert-base64", + }, + { + name: "missing cert password", + mutate: func(c *config.Config) { fillAPIKey(c); c.DeveloperIDCertPassword = "" }, + errMsg: "developer-id-cert-password", + }, + { + name: "method=api-key with missing field", + mutate: func(c *config.Config) { fillAPIKey(c); c.NotarizationMethod = "api-key"; c.NotarizationAPIKeyID = "" }, + errMsg: "notarization-api-key-id", + }, + { + name: "method=apple-id with missing field", + mutate: func(c *config.Config) { fillAppleID(c); c.NotarizationMethod = "apple-id"; c.NotarizationTeamID = "" }, + errMsg: "notarization-team-id", + }, + { + name: "auto with both groups populated is ambiguous", + mutate: func(c *config.Config) { fillAPIKey(c); fillAppleID(c) }, + errMsg: "ambiguous", + }, + { + name: "auto with no group populated", + mutate: func(c *config.Config) {}, + errMsg: "no notarization credentials", + }, + { + name: "s3 bucket without key", + mutate: func(c *config.Config) { fillAPIKey(c); c.S3Bucket = "b" }, + errMsg: "s3-key", + }, + { + name: "unknown notarization method", + mutate: func(c *config.Config) { fillAPIKey(c); c.NotarizationMethod = "smoke-signals" }, + errMsg: "notarization-method", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := base() + tc.mutate(c) + err := c.Validate() + if tc.errMsg == "" { + if err != nil { + t.Fatalf("expected ok, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.errMsg) + } + if !strings.Contains(err.Error(), tc.errMsg) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.errMsg) + } + }) + } +} + +func envGetter(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func fillAPIKey(c *config.Config) { + c.NotarizationAPIKeyBase64 = "k" + c.NotarizationAPIKeyID = "id" + c.NotarizationAPIIssuerID = "issuer" +} + +func fillAppleID(c *config.Config) { + c.NotarizationAppleID = "apple" + c.NotarizationApplePassword = "pw" + c.NotarizationTeamID = "team" +}