diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index edc48ea8..fa91a3dd 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -100,7 +100,7 @@ ci: :::note[Environment names are yours; roles are positional] The `environments` list is fully configurable. cascade attaches no meaning to specific labels: `dev`, `test`, `uat`, `staging`, and `prod` are illustrative examples used throughout these docs, not reserved names. Roles are decided by position in the list, not by name. The last environment is the release stage (prod), the second-to-last is the prerelease environment, and the publish boundary is the final crossing into the last environment. The count is structural too: zero environments is release-only, one environment generates a single-environment Release workflow, and two or more enable the full promote cascade. -**Naming.** Environment, build, and deploy names become GitHub Actions job IDs and output-variable keys, so keep them identifier-safe: use letters, digits, and underscores (hyphens are read as subtraction in GitHub Actions expressions). The reserved generator-owned names `environment` and `dry_run` cannot be used as `dispatch_inputs`. Any `gha_environment` value maps to a real GitHub Environment, so GitHub's own naming rules apply there. +**Naming.** Environment, build, and deploy names become GitHub Actions job IDs and output-variable keys, so keep them identifier-safe: use letters, digits, and underscores (hyphens are read as subtraction in GitHub Actions expressions). The reserved generator-owned names `environment` and `dry_run` cannot be used as `dispatch_inputs`. A `dispatch_inputs` name is emitted as a `workflow_dispatch` input key and referenced as `${{ inputs. }}`, so it is held to the same identifier-safe charset (letters, digits, hyphens, underscores); a choice input's `options` are emitted verbatim and must contain only letters, digits, dots, hyphens, and underscores (so version-like values such as `v1.2.3` are allowed, but spaces, colons, and `${{ }}` fragments are rejected). Any `gha_environment` value maps to a real GitHub Environment, so GitHub's own naming rules apply there. ::: ### cli_version diff --git a/internal/config/schema_v1_test.go b/internal/config/schema_v1_test.go index 03dcfe26..1d579bba 100644 --- a/internal/config/schema_v1_test.go +++ b/internal/config/schema_v1_test.go @@ -648,6 +648,95 @@ dispatch_inputs: t.Fatalf("expected choice options rejection, got %v", errs) } }) + t.Run("dispatch input name with space rejected", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + "bad name": + type: string +`) + if errs := Validate(cfg); !hasErrContaining(errs, `dispatch_inputs "bad name" must contain only`) { + t.Fatalf("expected dispatch input name rejection, got %v", errs) + } + }) + t.Run("dispatch input name with dot rejected", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + "region.primary": + type: string +`) + if errs := Validate(cfg); !hasErrContaining(errs, `dispatch_inputs "region.primary" must contain only`) { + t.Fatalf("expected dispatch input name rejection, got %v", errs) + } + }) + t.Run("dispatch input name with expression fragment rejected", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + "x${{ secrets.TOKEN }}": + type: string +`) + if errs := Validate(cfg); !hasErrContaining(errs, "must contain only") { + t.Fatalf("expected dispatch input name rejection, got %v", errs) + } + }) + t.Run("safe dispatch input name accepted", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + target_region: + type: string +`) + if errs := Validate(cfg); hasErrContaining(errs, "must contain only") { + t.Fatalf("expected safe dispatch input name to pass, got %v", errs) + } + }) + t.Run("choice option with space rejected", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + region: + type: choice + options: + - "us east 1" +`) + if errs := Validate(cfg); !hasErrContaining(errs, `dispatch_inputs.region option "us east 1" must contain only`) { + t.Fatalf("expected choice option rejection, got %v", errs) + } + }) + t.Run("choice option with expression fragment rejected", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + region: + type: choice + options: + - "${{ github.token }}" +`) + if errs := Validate(cfg); !hasErrContaining(errs, "dispatch_inputs.region option") { + t.Fatalf("expected choice option rejection, got %v", errs) + } + }) + t.Run("empty choice option rejected", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + region: + type: choice + options: + - "" +`) + if errs := Validate(cfg); !hasErrContaining(errs, "dispatch_inputs.region option") { + t.Fatalf("expected empty choice option rejection, got %v", errs) + } + }) + t.Run("dotted and hyphenated choice options accepted", func(t *testing.T) { + cfg := parseInline(t, ` +dispatch_inputs: + region: + type: choice + options: + - us-east-1 + - v1.2.3 +`) + if errs := Validate(cfg); hasErrContaining(errs, "option") { + t.Fatalf("expected dotted/hyphenated options to pass, got %v", errs) + } + }) t.Run("environment_config unknown env rejected", func(t *testing.T) { cfg := parseInline(t, ` environments: [dev] diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 0f407996..67633929 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -59,6 +59,15 @@ var jobIDSafeNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // commitSHARe matches a full 40-character lowercase-hex Git commit SHA. var commitSHARe = regexp.MustCompile(`^[0-9a-f]{40}$`) +// dispatchInputOptionRe matches a choice dispatch-input option that is safe to +// emit verbatim as a YAML block-sequence item under workflow_dispatch's +// inputs..options. cascade renders each option unquoted, so the accepted +// set is constrained to characters that need no escaping and cannot break the +// surrounding document: an empty option, a space, a colon, or a ${{ }} fragment +// would otherwise produce a workflow that fails actionlint or GitHub's parser. +// Dots are allowed so version-like options (v1.2.3) remain expressible. +var dispatchInputOptionRe = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + // validateJobIDSafeName rejects a name that would produce an invalid GitHub // Actions job ID or break the expression references derived from it. Names are // rejected (not sanitized) on purpose: sanitizing distinct names could collapse @@ -293,13 +302,18 @@ func validateConfigLevel(cfg *TrunkConfig) []string { errs = append(errs, "release_trigger must be one of: push, dispatch") } - // dispatch_inputs may not shadow generator-owned reserved names, and choice - // inputs need options. + // dispatch_inputs may not shadow generator-owned reserved names, must carry a + // name safe to emit as a workflow_dispatch input key (and referenced via + // ${{ inputs. }}), and choice inputs need options that are safe to emit + // verbatim. Every sibling identifier is charset-validated; the dispatch input + // name and its choice options reach raw YAML the same way, so they are guarded + // here rather than left to fail at actionlint or GitHub parse time. for _, name := range sortedKeys(toStringKeyed(cfg.DispatchInputs)) { di := cfg.DispatchInputs[name] if reservedDispatchInputNames[name] { errs = append(errs, fmt.Sprintf("dispatch_inputs.%s shadows a reserved dispatch input name", name)) } + errs = append(errs, validateJobIDSafeName("dispatch_inputs", name)...) switch di.Type { case "", DispatchInputTypeString, DispatchInputTypeBoolean, DispatchInputTypeEnvironment, DispatchInputTypeNumber: // ok @@ -307,6 +321,12 @@ func validateConfigLevel(cfg *TrunkConfig) []string { if len(di.Options) == 0 { errs = append(errs, fmt.Sprintf("dispatch_inputs.%s is a choice input but has no options", name)) } + for _, opt := range di.Options { + if !dispatchInputOptionRe.MatchString(opt) { + errs = append(errs, fmt.Sprintf( + "dispatch_inputs.%s option %q must contain only letters, digits, dots, hyphens, and underscores", name, opt)) + } + } default: errs = append(errs, fmt.Sprintf("dispatch_inputs.%s.type must be one of: string, boolean, choice, environment, number", name)) }