From 65535ac29643ef42dfb1dba2a994e6610c650b30 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Fri, 26 Jun 2026 11:58:40 -0400 Subject: [PATCH] test: cover drift-comment permissions and promote selective/force emission Closes the literal coverage gaps in #317, #326, and the unasserted promote emissions in #325. Adds an e2e assertion that the generated drift-comment workflow carries permissions {} (least privilege), and a generator unit test asserting promote threads the deploys filter and the force flag through to the workflow. The CLI deploys filter and the #325 runtime side-effects were already covered at unit or act-plus-gitea; live remains the documented ceiling. Signed-off-by: Joshua Temple --- e2e/scenarios/28-drift-check.yaml | 22 ++++++++++++++++ internal/generate/promote_test.go | 43 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/e2e/scenarios/28-drift-check.yaml b/e2e/scenarios/28-drift-check.yaml index f4e2bd5..027636a 100644 --- a/e2e/scenarios/28-drift-check.yaml +++ b/e2e/scenarios/28-drift-check.yaml @@ -36,6 +36,28 @@ steps: package main func main() {} + expect: + workflow_files: + # The fork-safe comment companion runs on workflow_run with secrets in + # scope, so its top-level token must default to nothing and each job opts + # into only the scope it needs. Pinning the empty top-level block proves + # the least-privilege posture the security model depends on. + - path: ".github/workflows/cascade-drift-comment.yaml" + contains: + - "permissions: {}" + # The single comment job opts into only what it needs: write to PRs + # and read the source run; no broader scope leaks in. + - " permissions:\n pull-requests: write\n actions: read\n" + not_contains: + # The companion must never grant itself write to repo contents. + - "contents: write" + # The pull_request check lane runs on fork-controlled input, so its + # top-level token is read-only and re-stated read-only at the job. + - path: ".github/workflows/cascade-drift-check.yaml" + contains: + - "permissions:\n contents: read\n" + not_contains: + - "\npermissions:\n contents: write" - name: "Verify clean against pristine generated output" action: verify diff --git a/internal/generate/promote_test.go b/internal/generate/promote_test.go index 6cb7321..7a981b8 100644 --- a/internal/generate/promote_test.go +++ b/internal/generate/promote_test.go @@ -1830,3 +1830,46 @@ func TestPromoteGenerator_ConcurrencyOverride(t *testing.T) { assert.Contains(t, content, "group: my-custom-promote", "custom group must propagate to promote") assert.Contains(t, content, "cancel-in-progress: true", "custom cancel_in_progress must propagate to promote") } + +// TestPromoteGenerator_SelectiveDeploysAndForcePassthrough pins the generator +// emission that the selective-deploy filter and the force flag actually reach +// the preflight CLI. The runtime effect of --deploys (run only the named +// deploys) is unit-asserted in internal/promote (TestPreflight_DeploysFilter), +// and the force-continue semantics in TestPreflight_ForceFlag; this guards the +// workflow plumbing that carries both inputs from the dispatch form into the +// CLI invocation, which was previously unasserted at generation time. +func TestPromoteGenerator_SelectiveDeploysAndForcePassthrough(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Deploys: []config.DeployConfig{ + {Name: "infra", Workflow: "deploy-infra.yaml"}, + {Name: "app", Workflow: "deploy-app.yaml"}, + }, + } + + gen := NewPromoteGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + // The dispatch form exposes both the deploys filter and the force toggle. + assert.Contains(t, content, " deploys:") + assert.Contains(t, content, " force:") + + // Both inputs are threaded into the preflight environment... + assert.Contains(t, content, " DEPLOYS: ${{ github.event.inputs.deploys }}") + assert.Contains(t, content, " PROMOTION_FORCE: ${{ github.event.inputs.force }}") + + // ...and forwarded to the preflight CLI flags that perform the filtering and + // the force-continue behavior. Defaulting deploys to "all" preserves the + // promote-everything path when the input is left blank. + assert.Contains(t, content, `--deploys="${DEPLOYS:-all}"`) + assert.Contains(t, content, `--force="${PROMOTION_FORCE:-false}"`) + + // Each selected deploy renders as its own reusable-workflow job gated on the + // resolved deploys_to_run set, so an unnamed deploy is skipped at runtime + // while finalize still commits via its always() guard, the plumbing that lets + // promote continue past a failed deploy. + assert.Contains(t, content, "contains(fromJSON(needs.preflight.outputs.deploys_to_run), 'infra')") + assert.Contains(t, content, "contains(fromJSON(needs.preflight.outputs.deploys_to_run), 'app')") +}