Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name> }}`, 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
Expand Down
89 changes: 89 additions & 0 deletions internal/config/schema_v1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
24 changes: 22 additions & 2 deletions internal/config/validate_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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
Expand Down Expand Up @@ -293,20 +302,31 @@ 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.<name> }}), 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
case DispatchInputTypeChoice:
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))
}
Expand Down