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
4 changes: 1 addition & 3 deletions .github/workflows/orchestrate.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY
# Regenerate with: cascade generate-workflow --config /Users/joshua.temple/go/src/github.com/stablekernel/cascade/.worktrees/activate/.github/manifest.yaml
# Regenerate with: cascade generate-workflow --config .github/manifest.yaml

name: Orchestrate CI/CD

Expand Down Expand Up @@ -59,7 +59,6 @@ jobs:
name: Validate (validate)
needs: [setup]
uses: ./.github/workflows/validate.yaml
secrets: inherit

build-cli:
name: Build (cli)
Expand All @@ -70,7 +69,6 @@ jobs:
uses: ./.github/workflows/build-cli.yaml
with:
sha: ${{ needs.setup.outputs.head_sha }}
secrets: inherit

finalize:
name: Finalize
Expand Down
11 changes: 11 additions & 0 deletions e2e/harness/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ type BuildConfig struct {
RunsOn any `yaml:"runs_on,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty"`
Concurrency *ConcurrencySpec `yaml:"concurrency,omitempty"`
// Secrets carries the per-callback secrets union (scalar "inherit", the
// mapping {inherit: true}, or a per-secret map) through to the generated
// manifest untouched. A generic value keeps the harness decoupled from the
// generator's SecretsConfig shape while preserving every accepted form across
// the marshal round-trip. Omitted entirely when unset so the generator sees no
// secrets field (the opt-in default emits no secrets block).
Secrets any `yaml:"secrets,omitempty"`
}

// DeployConfig defines a deploy component
Expand All @@ -104,6 +111,10 @@ type DeployConfig struct {
RunsOn any `yaml:"runs_on,omitempty"`
Permissions map[string]string `yaml:"permissions,omitempty"`
Concurrency *ConcurrencySpec `yaml:"concurrency,omitempty"`
// Secrets carries the per-callback secrets union through to the generated
// manifest untouched. See BuildConfig.Secrets for the accepted forms and the
// rationale for the generic value type.
Secrets any `yaml:"secrets,omitempty"`
}

// ConcurrencySpec defines the per-callback concurrency block written to trunk-config.yaml.
Expand Down
40 changes: 40 additions & 0 deletions e2e/scenarios/orchestrate/secrets-default-none.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: "Secrets Default None"
description: |
Verifies the secrets default: a reusable-workflow callback with no secrets
field emits no secrets: block at all in the generated orchestrate.yaml.
secrets: inherit is opt-in, never the default, so the called workflow receives
only the secrets it explicitly declares (least privilege).

A single callback with no secrets field lets the whole-file not_contains check
prove the absence of any secrets block unambiguously.

Generator-output verification scenario; assertion runs on the staged repo after
StageRepoFromConfig generates workflows but before any orchestrate runs.

config:
trunk_branch: main
environments: []
builds:
- name: app
workflow: build-app.yaml
triggers: ["src/**"]
deploys: []

steps:
- name: "Initial commit; assert no secrets block is emitted by default"
action: commit
commit:
message: "feat: add a callback with no secrets field"
files:
src/main.go: |
package main
func main() {}
expect:
workflow_files:
- path: ".github/workflows/orchestrate.yaml"
contains:
- "build-app:"
- "uses: ./.github/workflows/build-app.yaml"
not_contains:
- "secrets:"
- "secrets: inherit"
61 changes: 61 additions & 0 deletions e2e/scenarios/orchestrate/secrets-opt-in.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: "Secrets Opt-In Forms"
description: |
Verifies the explicit opt-in forms of the per-callback secrets union are
emitted in the generated orchestrate.yaml: the scalar "inherit", the mapping
{inherit: true}, and a per-secret map that maps a called-workflow secret name
to a caller secret name (least privilege). The companion no-secrets scenario
pins the default (no secrets field -> no secrets block).

Three reusable-workflow callbacks exercise every opt-in form so the generated
output is pinned end to end through the generator:
- api : secrets: inherit -> secrets: inherit
- web : {inherit: true} -> secrets: inherit
- infra: per-secret map -> secrets: block with ${{ secrets.CALLER }}

Generator-output verification scenario; assertion runs on the staged repo after
StageRepoFromConfig generates workflows but before any orchestrate runs.

config:
trunk_branch: main
environments: []
builds:
- name: api
workflow: build-api.yaml
triggers: ["src/**"]
secrets: inherit
- name: web
workflow: build-web.yaml
triggers: ["src/**"]
secrets:
inherit: true
- name: infra
workflow: build-infra.yaml
triggers: ["src/**"]
secrets:
DEPLOY_TOKEN: MY_DEPLOY_TOKEN
deploys: []

steps:
- name: "Initial commit; assert each opt-in secrets form is emitted"
action: commit
commit:
message: "feat: add callbacks exercising the secrets opt-in forms"
files:
src/main.go: |
package main
func main() {}
expect:
workflow_files:
- path: ".github/workflows/orchestrate.yaml"
contains:
# api opted in via the scalar inherit form.
- "build-api:"
- "secrets: inherit"
# web opted in via the {inherit: true} mapping (same emitted form).
- "build-web:"
# infra opted in via a per-secret map (least-privilege form).
- "build-infra:"
- "DEPLOY_TOKEN: ${{ secrets.MY_DEPLOY_TOKEN }}"
not_contains:
# The per-secret map must not collapse to the inherit shorthand.
- "DEPLOY_TOKEN: inherit"
36 changes: 29 additions & 7 deletions internal/config/schema_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@ import (
// and structural validation are implemented; emit/generation behavior is not.

// SecretsConfig models the per-callback secrets passing union. It is either the
// literal string "inherit" (form A, the default that preserves today's
// hardcoded behavior) or an explicit map of called-workflow secret name to
// caller secret name (form B, least-privilege).
// opt-in "inherit" form (the scalar "inherit", or the mapping {inherit: true})
// or an explicit map of called-workflow secret name to caller secret name (the
// least-privilege form). An unset secrets field (nil SecretsConfig) emits no
// secrets block at all.
type SecretsConfig struct {
// Inherit is true when the manifest specified the scalar "inherit".
// Inherit is true when the manifest opted in to inheriting all caller secrets,
// via the scalar "inherit" or the mapping {inherit: true}.
Inherit bool `json:"inherit,omitempty"`
// Map holds the explicit form-B mapping (called name -> caller name) when a
// mapping was provided. Nil when Inherit is true.
// Map holds the explicit mapping (called name -> caller name) when a mapping of
// secret names was provided. Nil when Inherit is true.
Map map[string]string `json:"map,omitempty"`
}

// UnmarshalYAML accepts either the scalar "inherit" or a mapping of secret names.
// UnmarshalYAML accepts the scalar "inherit", the mapping {inherit: <bool>}, or a
// mapping of secret names. inherit may not be mixed with explicit secret keys.
func (s *SecretsConfig) UnmarshalYAML(value *yaml.Node) error {
// A bare `secrets:` (null node) is treated as unset.
if value.Tag == "!!null" {
Expand All @@ -42,6 +45,25 @@ func (s *SecretsConfig) UnmarshalYAML(value *yaml.Node) error {
s.Inherit = true
return nil
case yaml.MappingNode:
// Reject mixing the inherit key with explicit secret mappings.
hasInherit := false
for i := 0; i+1 < len(value.Content); i += 2 {
if value.Content[i].Value == "inherit" {
hasInherit = true
break
}
}
if hasInherit {
if len(value.Content) != 2 {
return fmt.Errorf("secrets: cannot mix \"inherit\" with explicit secret mappings")
}
var b bool
if err := value.Content[1].Decode(&b); err != nil {
return fmt.Errorf("secrets: inherit value must be a boolean: %w", err)
}
s.Inherit = b
return nil
}
m := map[string]string{}
if err := value.Decode(&m); err != nil {
return err
Expand Down
40 changes: 40 additions & 0 deletions internal/config/schema_v1_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,46 @@ deploys:
t.Fatalf("expected map form, got %#v", s)
}
})
t.Run("inherit mapping", func(t *testing.T) {
cfg := parseInline(t, `
deploys:
- name: app
workflow: .github/workflows/deploy.yaml
secrets:
inherit: true
`)
s := cfg.Deploys[0].Secrets
if s == nil || !s.Inherit || s.Map != nil {
t.Fatalf("expected inherit form from mapping, got %#v", s)
}
})
t.Run("inherit false mapping treated as unset", func(t *testing.T) {
cfg := parseInline(t, `
deploys:
- name: app
workflow: .github/workflows/deploy.yaml
secrets:
inherit: false
`)
s := cfg.Deploys[0].Secrets
if s == nil || s.Inherit || s.Map != nil {
t.Fatalf("expected inherit:false to parse to Inherit=false, no map, got %#v", s)
}
})
t.Run("mixed inherit and secret keys rejected", func(t *testing.T) {
var cfg TrunkConfig
err := yaml.Unmarshal([]byte(`
deploys:
- name: app
workflow: w.yaml
secrets:
inherit: true
NPM_TOKEN: PUBLISH_NPM_TOKEN
`), &cfg)
if err == nil {
t.Fatal("expected error mixing inherit with explicit secret mappings")
}
})
t.Run("invalid scalar rejected", func(t *testing.T) {
var cfg TrunkConfig
err := yaml.Unmarshal([]byte(`
Expand Down
27 changes: 18 additions & 9 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -783,18 +783,27 @@ func (g *Generator) writeSetupJob(sb *strings.Builder) {
sb.WriteString("\n")
}

// writeSecretsBlock emits the secrets: line for a reusable-workflow job.
// When s is nil or s.Inherit is true the default "secrets: inherit" is emitted.
// When s carries an explicit Map each entry is emitted as
// writeSecretsBlock writes the secrets configuration for a reusable-workflow job
// call. This function always writes a trailing blank line so callers do not need
// to manage job-boundary spacing themselves; in the no-op (nil/unset) case a
// single blank line is still written to preserve correct YAML job separation.
//
// secrets:
// CALLED_NAME: ${{ secrets.CALLER_NAME }}
// secrets: inherit is opt-in, never the default: a callback with no secrets
// config emits no secrets block at all, so the called workflow receives only the
// secrets it explicitly declares (least privilege).
//
// The trailing newline that terminates the job block is always written by the
// caller, so this function writes a trailing "\n" after the last entry only in
// the map form (matching the blank-line separation written by the inherit path).
// Behavior by value of s:
// - nil (unset): emit no secrets block; write one blank line for job separation.
// - s.Inherit == true (explicit opt-in): emit " secrets: inherit\n\n".
// - len(s.Map) > 0: emit the per-entry mapping form
// " secrets:\n CALLED_NAME: ${{ secrets.CALLER_NAME }}\n...".
// - inherit:false with no map: treated as unset (no secrets block).
func writeSecretsBlock(sb *strings.Builder, s *config.SecretsConfig) {
if s == nil || s.Inherit || len(s.Map) == 0 {
if s == nil || (!s.Inherit && len(s.Map) == 0) {
sb.WriteString("\n")
return
}
if s.Inherit {
sb.WriteString(" secrets: inherit\n\n")
return
}
Expand Down
9 changes: 5 additions & 4 deletions internal/generate/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ type CallbackInfo struct {
// within a single orchestrate run (#16).
PassthroughArtifact *config.PassthroughArtifact

// Secrets holds the per-callback secrets passing config. Nil means inherit
// (the default). When non-nil and Inherit is true, secrets: inherit is emitted.
// When non-nil with an explicit Map, a secrets: block with per-entry
// ${{ secrets.CALLER_NAME }} expressions is emitted instead.
// Secrets holds the per-callback secrets passing config. Nil means no secrets
// block is emitted at all (the default; secrets: inherit is opt-in). When
// non-nil and Inherit is true, secrets: inherit is emitted. When non-nil with
// an explicit Map, a secrets: block with per-entry ${{ secrets.CALLER_NAME }}
// expressions is emitted instead.
Secrets *config.SecretsConfig
}

Expand Down
5 changes: 4 additions & 1 deletion internal/generate/hotfix.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,10 @@ func (g *HotfixGenerator) writeBuildJobs(sb *strings.Builder) {
sb.WriteString(" with:\n")
sb.WriteString(" sha: ${{ github.event.pull_request.merge_commit_sha }}\n")
sb.WriteString(" target_env: ${{ needs.context.outputs.target_env }}\n")
sb.WriteString(" secrets: inherit\n")
// Honor the same opt-in / least-privilege model as the orchestrate and
// promote callbacks: emit no secrets block unless the build opted in via
// secrets: inherit or an explicit per-secret map.
writeSecretsBlock(sb, b.Secrets)
continue
}
// Inline build fallback: mirror the run-based callback shape.
Expand Down
4 changes: 3 additions & 1 deletion internal/generate/inline_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ func TestGenerator_InlineRunShellHonored(t *testing.T) {
}

// TestGenerator_WorkflowCallbackStillUsesReusable asserts a workflow: callback
// is unchanged: it still emits jobs.<id>.uses + secrets: inherit.
// is unchanged: it still emits jobs.<id>.uses. With explicit secrets: inherit
// opted in, it carries the inherit form.
func TestGenerator_WorkflowCallbackStillUsesReusable(t *testing.T) {
tmpDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755))
Expand All @@ -85,6 +86,7 @@ func TestGenerator_WorkflowCallbackStillUsesReusable(t *testing.T) {
Name: "app",
Workflow: ".github/workflows/build.yaml",
Triggers: []string{"src/**"},
Secrets: &config.SecretsConfig{Inherit: true},
},
},
}
Expand Down
Loading
Loading