From 64070649246df12e455f5019590969c3869a9fa3 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 23:14:36 -0400 Subject: [PATCH] fix: make secrets inherit opt-in instead of the default Generate no secrets block when a manifest secrets field is unset, so a reusable or cross-repo deploy, rollback, callback, and hotfix build job receives only its own GITHUB_TOKEN by default. secrets: inherit is now an explicit opt-in, available via the scalar form or a {inherit: true} mapping, and the per-secret map form is unchanged. Signed-off-by: Joshua Temple --- .github/workflows/orchestrate.yaml | 4 +- e2e/harness/scenario.go | 11 ++ .../orchestrate/secrets-default-none.yaml | 40 +++++ e2e/scenarios/orchestrate/secrets-opt-in.yaml | 61 +++++++ internal/config/schema_v1.go | 36 ++++- internal/config/schema_v1_test.go | 40 +++++ internal/generate/generator.go | 27 ++-- internal/generate/graph.go | 9 +- internal/generate/hotfix.go | 5 +- internal/generate/inline_run_test.go | 4 +- internal/generate/secrets_test.go | 150 +++++++++++++++--- 11 files changed, 339 insertions(+), 48 deletions(-) create mode 100644 e2e/scenarios/orchestrate/secrets-default-none.yaml create mode 100644 e2e/scenarios/orchestrate/secrets-opt-in.yaml diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index f98d18fb..b6e7689c 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -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 @@ -59,7 +59,6 @@ jobs: name: Validate (validate) needs: [setup] uses: ./.github/workflows/validate.yaml - secrets: inherit build-cli: name: Build (cli) @@ -70,7 +69,6 @@ jobs: uses: ./.github/workflows/build-cli.yaml with: sha: ${{ needs.setup.outputs.head_sha }} - secrets: inherit finalize: name: Finalize diff --git a/e2e/harness/scenario.go b/e2e/harness/scenario.go index d354db0c..0d66e0b4 100644 --- a/e2e/harness/scenario.go +++ b/e2e/harness/scenario.go @@ -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 @@ -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. diff --git a/e2e/scenarios/orchestrate/secrets-default-none.yaml b/e2e/scenarios/orchestrate/secrets-default-none.yaml new file mode 100644 index 00000000..c171620b --- /dev/null +++ b/e2e/scenarios/orchestrate/secrets-default-none.yaml @@ -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" diff --git a/e2e/scenarios/orchestrate/secrets-opt-in.yaml b/e2e/scenarios/orchestrate/secrets-opt-in.yaml new file mode 100644 index 00000000..211e0258 --- /dev/null +++ b/e2e/scenarios/orchestrate/secrets-opt-in.yaml @@ -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" diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 3b091235..8984f768 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -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: }, 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" { @@ -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 diff --git a/internal/config/schema_v1_test.go b/internal/config/schema_v1_test.go index 652486a2..c57fc347 100644 --- a/internal/config/schema_v1_test.go +++ b/internal/config/schema_v1_test.go @@ -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(` diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 7d52ccae..53e47473 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -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 } diff --git a/internal/generate/graph.go b/internal/generate/graph.go index eb13d700..8af06850 100644 --- a/internal/generate/graph.go +++ b/internal/generate/graph.go @@ -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 } diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 5bca8b59..6b07d5a0 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -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. diff --git a/internal/generate/inline_run_test.go b/internal/generate/inline_run_test.go index 9237dace..a4c13fa8 100644 --- a/internal/generate/inline_run_test.go +++ b/internal/generate/inline_run_test.go @@ -71,7 +71,8 @@ func TestGenerator_InlineRunShellHonored(t *testing.T) { } // TestGenerator_WorkflowCallbackStillUsesReusable asserts a workflow: callback -// is unchanged: it still emits jobs..uses + secrets: inherit. +// is unchanged: it still emits jobs..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)) @@ -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}, }, }, } diff --git a/internal/generate/secrets_test.go b/internal/generate/secrets_test.go index 44950c6f..c053286e 100644 --- a/internal/generate/secrets_test.go +++ b/internal/generate/secrets_test.go @@ -59,24 +59,24 @@ func setupWorkflowDir(t *testing.T, relPath string) string { return tmpDir } -// TestWriteSecretsBlock_Inherit verifies that nil and inherit-flagged configs -// both emit the "secrets: inherit" scalar form. +// TestWriteSecretsBlock_NilNoOp verifies that an unset (nil) secrets config +// emits NO secrets block at all. Only a single blank line is written to +// preserve YAML job separation. secrets: inherit is now opt-in. +func TestWriteSecretsBlock_NilNoOp(t *testing.T) { + var sb strings.Builder + writeSecretsBlock(&sb, nil) + got := sb.String() + assert.Equal(t, "\n", got) + assert.NotContains(t, got, "secrets:") +} + +// TestWriteSecretsBlock_Inherit verifies that an explicit Inherit:true config +// still emits the "secrets: inherit" scalar form (opt-in). func TestWriteSecretsBlock_Inherit(t *testing.T) { - cases := []struct { - name string - s *config.SecretsConfig - }{ - {"nil (default)", nil}, - {"explicit inherit", &config.SecretsConfig{Inherit: true}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var sb strings.Builder - writeSecretsBlock(&sb, tc.s) - got := sb.String() - assert.Equal(t, " secrets: inherit\n\n", got) - }) - } + var sb strings.Builder + writeSecretsBlock(&sb, &config.SecretsConfig{Inherit: true}) + got := sb.String() + assert.Equal(t, " secrets: inherit\n\n", got) } // TestWriteSecretsBlock_ExplicitMap verifies that an explicit map emits the @@ -105,10 +105,24 @@ func TestWriteSecretsBlock_ExplicitMap(t *testing.T) { assert.Less(t, deployIdx, npmIdx, "keys must be sorted alphabetically") } +// TestOrchestrateCallbackJob_NoSecretsByDefault verifies that a reusable-workflow +// build with no explicit secrets config emits NO secrets block at all. secrets: +// inherit is opt-in, never the default. +func TestOrchestrateCallbackJob_NoSecretsByDefault(t *testing.T) { + cfg, wfPath := orchestrateCfgWithBuildSecrets(nil) + tmpDir := setupWorkflowDir(t, wfPath) + + gen := NewGenerator(cfg, tmpDir) + result, err := gen.Generate() + require.NoError(t, err) + + assert.NotContains(t, result, "secrets:") +} + // TestOrchestrateCallbackJob_InheritSecrets verifies that a reusable-workflow -// build with no explicit secrets config emits "secrets: inherit". +// build with explicit Inherit:true emits "secrets: inherit". func TestOrchestrateCallbackJob_InheritSecrets(t *testing.T) { - cfg, wfPath := orchestrateCfgWithBuildSecrets(nil) + cfg, wfPath := orchestrateCfgWithBuildSecrets(&config.SecretsConfig{Inherit: true}) tmpDir := setupWorkflowDir(t, wfPath) gen := NewGenerator(cfg, tmpDir) @@ -138,10 +152,24 @@ func TestOrchestrateCallbackJob_ExplicitSecretsMap(t *testing.T) { assert.NotContains(t, result, " secrets: inherit\n") } +// TestOrchestrateDeployCallbackJob_NoSecretsByDefault verifies that a +// reusable-workflow deploy with no explicit secrets config emits NO secrets +// block at all. +func TestOrchestrateDeployCallbackJob_NoSecretsByDefault(t *testing.T) { + cfg, wfPath := orchestrateCfgWithDeploySecrets(nil) + tmpDir := setupWorkflowDir(t, wfPath) + + gen := NewGenerator(cfg, tmpDir) + result, err := gen.Generate() + require.NoError(t, err) + + assert.NotContains(t, result, "secrets:") +} + // TestOrchestrateDeployCallbackJob_InheritSecrets verifies that a reusable-workflow -// deploy with no explicit secrets config emits "secrets: inherit". +// deploy with explicit Inherit:true emits "secrets: inherit". func TestOrchestrateDeployCallbackJob_InheritSecrets(t *testing.T) { - cfg, wfPath := orchestrateCfgWithDeploySecrets(nil) + cfg, wfPath := orchestrateCfgWithDeploySecrets(&config.SecretsConfig{Inherit: true}) tmpDir := setupWorkflowDir(t, wfPath) gen := NewGenerator(cfg, tmpDir) @@ -231,8 +259,30 @@ func TestPromoteDeployJob_ExplicitSecretsMap(t *testing.T) { "secrets: inherit must not appear when explicit map is configured") } -// TestPromoteDeployJob_InheritSecrets verifies that a promote deploy with no -// explicit secrets config (nil) still emits "secrets: inherit". +// TestPromoteDeployJob_NoSecretsByDefault verifies that a promote deploy with no +// explicit secrets config (nil) emits NO secrets block at all. +func TestPromoteDeployJob_NoSecretsByDefault(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + { + Name: "app", + Workflow: ".github/workflows/deploy.yaml", + Triggers: []string{"src/**"}, + }, + }, + } + + gen := NewPromoteGenerator(cfg, "") + result, err := gen.Generate() + require.NoError(t, err) + + assert.NotContains(t, result, "secrets:") +} + +// TestPromoteDeployJob_InheritSecrets verifies that a promote deploy with +// explicit Inherit:true emits "secrets: inherit". func TestPromoteDeployJob_InheritSecrets(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", @@ -242,6 +292,7 @@ func TestPromoteDeployJob_InheritSecrets(t *testing.T) { Name: "app", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}, + Secrets: &config.SecretsConfig{Inherit: true}, }, }, } @@ -253,3 +304,56 @@ func TestPromoteDeployJob_InheritSecrets(t *testing.T) { assert.Contains(t, result, " secrets: inherit\n") assert.NotContains(t, result, " secrets:\n ") } + +// hotfixCfgWithBuildSecrets returns a 2-environment hotfix-capable config whose +// single reusable-workflow build carries the given SecretsConfig. +func hotfixCfgWithBuildSecrets(secrets *config.SecretsConfig) *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Builds: []config.BuildConfig{ + { + Name: "app", + Workflow: ".github/workflows/build.yaml", + Secrets: secrets, + }, + }, + } +} + +// TestHotfixBuildJob_NoSecretsByDefault verifies the hotfix build job honors the +// opt-in model: a reusable-workflow build with no explicit secrets config emits +// NO secrets block, matching the orchestrate and promote callbacks. +func TestHotfixBuildJob_NoSecretsByDefault(t *testing.T) { + gen := NewHotfixGenerator(hotfixCfgWithBuildSecrets(nil), "") + result, err := gen.Generate() + require.NoError(t, err) + + // The hotfix build job is the only reusable-workflow (uses:) job in the + // hotfix workflow, so no secrets: block should appear anywhere. + assert.NotContains(t, result, "secrets:") +} + +// TestHotfixBuildJob_InheritSecrets verifies that an explicit Inherit:true on the +// hotfix build emits "secrets: inherit". +func TestHotfixBuildJob_InheritSecrets(t *testing.T) { + gen := NewHotfixGenerator(hotfixCfgWithBuildSecrets(&config.SecretsConfig{Inherit: true}), "") + result, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, result, " secrets: inherit\n") + assert.NotContains(t, result, " secrets:\n ") +} + +// TestHotfixBuildJob_ExplicitSecretsMap verifies that an explicit secrets map on +// the hotfix build emits the per-entry least-privilege form. +func TestHotfixBuildJob_ExplicitSecretsMap(t *testing.T) { + gen := NewHotfixGenerator(hotfixCfgWithBuildSecrets(&config.SecretsConfig{ + Map: map[string]string{"BUILD_TOKEN": "GH_PACKAGES_TOKEN"}, + }), "") + result, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, result, " secrets:\n BUILD_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}\n") + assert.NotContains(t, result, " secrets: inherit\n") +}