diff --git a/docs/src/content/docs/callback-contract.md b/docs/src/content/docs/callback-contract.md index ba82371..df362a8 100644 --- a/docs/src/content/docs/callback-contract.md +++ b/docs/src/content/docs/callback-contract.md @@ -5,6 +5,14 @@ description: Defines the inputs, outputs, and structural requirements for valida The framework calls your workflows (callbacks) during CI/CD execution. This document defines the contract your workflows must follow. +Every callback (validate, build, deploy, publish) is a reusable workflow that you declare with `workflow:` in the manifest. The framework invokes it with `workflow_call`. + +## Migrating from inline `run:`/`shell:` callbacks + +Inline `run:`/`shell:` callbacks were removed. A callback can no longer carry a `run:` script or a `shell:` setting in the manifest; it must point at a reusable workflow via `workflow:`. The manifest still parses these keys, but validation now rejects them. + +To migrate, move the script into a reusable workflow under `.github/workflows/`, expose it with `on: workflow_call` (declaring the standard `environment`, `sha`, and `dry_run` inputs), and replace the callback's `run:`/`shell:` with `workflow: .github/workflows/.yaml`. The script text becomes a `run:` step inside that workflow's job. The sections below show the required structure for each callback type. + ## Overview Adopting repositories provide callback workflows that the framework invokes: @@ -471,17 +479,14 @@ ci: ## Environment Protection -Use GitHub Environment protection for approval gates. Where you declare the -`environment:` key depends on whether the deploy is an external reusable -workflow or an inline `run:` callback, because GitHub Actions only allows a -job-level `environment:` key on a steps job, never on a job that calls a -reusable workflow with `uses:`. +Use GitHub Environment protection for approval gates. Because every deploy is a +reusable workflow, declare the `environment:` key on the job **inside your +reusable workflow**. GitHub Actions only allows a job-level `environment:` key on +a steps job, never on a job that calls a reusable workflow with `uses:`, so the +caller job cascade generates cannot carry it. -### External reusable-workflow deploys (`workflow:`) - -For a deploy backed by an external reusable workflow, declare `environment:` on -the job **inside your reusable workflow**. cascade passes the target environment -name to that workflow as the `environment` input, so wire it through: +cascade passes the target environment name to your workflow as the `environment` +input, so wire it through: ```yaml # your reusable deploy workflow @@ -497,15 +502,8 @@ cascade cannot set `environment:` on the caller job it generates: GitHub Actions rejects a workflow that puts `environment:` on a `uses:` job. cascade therefore emits only the `with: environment:` input on the caller and relies on your reusable workflow to apply the protection rules. cascade prints a generate-time -note when `gha_environment` is configured for an environment whose deploys are -external reusable workflows, reminding you to declare `environment:` inside the -reusable workflow. - -### Inline `run:` deploys - -For an inline `run:` deploy, cascade owns the job and emits the job-level -`environment:` key directly (resolved from `gha_environment` when configured), -so GitHub Environment protection applies without any extra wiring. +note when `gha_environment` is configured for an environment, reminding you to +declare `environment:` inside the reusable workflow. Configure protection in GitHub: **Settings -> Environments -> Add required reviewers**. diff --git a/docs/src/content/docs/security/hardening.md b/docs/src/content/docs/security/hardening.md index a487788..eef0a11 100644 --- a/docs/src/content/docs/security/hardening.md +++ b/docs/src/content/docs/security/hardening.md @@ -41,14 +41,11 @@ These properties hold for generated output today: - **Local reusable workflows are commit-pinned.** Workflows referenced as `./.github/...` are pinned to the calling commit, so your own callbacks resolve from a fixed commit rather than a moving branch. -- **Untrusted inputs are routed through `env:`.** Workflow inputs are exposed to - shell steps as environment variables rather than interpolated directly into - `run:` script text, which keeps input values as data instead of as part of the - script. -- **Inline deploys are environment-gated.** A deploy expressed as an inline - `run:` job receives a real job-level `environment:`, so the environment's - protection rules (required reviewers, wait timers, branch policies) apply to - it. +- **Every callback is a reusable workflow.** Validate, build, and deploy + callbacks run as reusable workflows referenced by `workflow:`. cascade does not + emit inline scripts on your behalf, so the script your pipeline runs is code you + author and review in a workflow file rather than text generated from the + manifest. - **The reusable-deploy gate boundary is surfaced at generate time.** GitHub does not allow a job-level `environment:` on a job that calls a reusable workflow. When you wire a reusable deploy, cascade warns you at generation time that the @@ -111,10 +108,10 @@ moves from the cross-repo trust boundary outward to the surrounding controls. 2. **Protect trunk and tags, and add CODEOWNERS.** Require review on trunk and on `.github/workflows/**`, and protect release and version tags from being moved or deleted. -3. **Gate every production deploy with a GitHub Environment.** For inline deploys, - confirm the job-level `environment:` is set. For reusable deploys, add the - `environment:` gate, required reviewers, and deployment branch or tag policy - inside the called workflow, since the calling job cannot carry the gate. +3. **Gate every production deploy with a GitHub Environment.** Deploys run as + reusable workflows, so add the `environment:` gate, required reviewers, and + deployment branch or tag policy inside the called workflow, since the calling + job cannot carry the gate. 4. **Scope secrets to the job that needs them.** Replace blanket secret inheritance with an explicit list, and place production secrets behind an environment so only the gated production job can read them. diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index ebac402..f3ac631 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -355,11 +355,10 @@ permissions: packages: write # Optional: only if your callbacks publish to GHCR ``` -For environment protection on an external reusable-workflow deploy, set the -`environment:` key on the job inside your callback. cascade passes the target -environment name as the `environment` input and cannot set `environment:` on the -caller job it generates, because GitHub Actions disallows that key on a `uses:` -job: +Every deploy is a reusable workflow, so set the `environment:` key on the job +inside your callback. cascade passes the target environment name as the +`environment` input and cannot set `environment:` on the caller job it generates, +because GitHub Actions disallows that key on a `uses:` job: ```yaml jobs: @@ -368,8 +367,8 @@ jobs: environment: ${{ inputs.environment }} # GitHub enforces approvals ``` -For an inline `run:` deploy, cascade owns the job and emits the job-level -`environment:` key for you when `gha_environment` is configured. +cascade prints a generate-time note when `gha_environment` is configured, +reminding you to declare `environment:` inside the reusable workflow. ## Concurrency Control diff --git a/e2e/scenarios/06-callback-timeout.yaml b/e2e/scenarios/06-callback-timeout.yaml deleted file mode 100644 index e985559..0000000 --- a/e2e/scenarios/06-callback-timeout.yaml +++ /dev/null @@ -1,37 +0,0 @@ -name: "Callback Timeout" -description: | - Verifies the timeout_minutes field on an inline run: callback propagates into - the generated orchestrate.yaml as a job-level timeout-minutes (#97). - - timeout-minutes is only valid on a steps job, so the callback uses an inline - run:. GitHub rejects timeout-minutes on a reusable-workflow (uses:) caller job, - so for those callbacks the timeout must live inside the called workflow. - - This is a generator-output verification scenario. Assertion runs on the staged - repo after StageRepoFromConfig generates workflows but before any orchestrate - runs. Real timeout-firing belongs to real-GHA PoC validation. - -config: - trunk_branch: main - environments: [] - builds: - - name: app - run: "make build" - triggers: ["src/**"] - timeout_minutes: 30 - deploys: [] - -steps: - - name: "Initial commit triggers workflow generation; assert timeout-minutes" - action: commit - commit: - message: "feat: add app" - files: - src/app.go: | - package main - func main() {} - expect: - workflow_files: - - path: ".github/workflows/orchestrate.yaml" - contains: - - "timeout-minutes: 30" diff --git a/e2e/scenarios/10-inline-job-attributes.yaml b/e2e/scenarios/10-inline-job-attributes.yaml deleted file mode 100644 index 257b5b6..0000000 --- a/e2e/scenarios/10-inline-job-attributes.yaml +++ /dev/null @@ -1,56 +0,0 @@ -name: "Inline Job Attributes" -description: | - Verifies that per-callback runs_on, permissions (including id-token: write for - OIDC), and concurrency are emitted on a cascade-owned inline run: job in the - generated orchestrate.yaml (#12, #35, #15, #17). - - A sibling reusable-workflow callback (workflow:) must not carry those - attributes; GHA forbids runs-on/concurrency on jobs..uses calls. - - 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: sign - run: | - cosign sign --yes "$IMAGE_DIGEST" - shell: bash - triggers: ["src/**"] - runs_on: self-hosted - permissions: - id-token: write - contents: read - concurrency: - group: sign-${{ github.ref }} - cancel_in_progress: true - - name: app - workflow: build.yaml - triggers: ["src/**"] - deploys: [] - -steps: - - name: "Initial commit; assert inline job carries runs-on, permissions, and concurrency" - action: commit - commit: - message: "feat: add sign and app callbacks" - files: - src/main.go: | - package main - func main() {} - expect: - workflow_files: - - path: ".github/workflows/orchestrate.yaml" - contains: - - "build-sign:" - - "runs-on: self-hosted" - - "permissions:" - - "id-token: write" - - "contents: read" - - "concurrency:" - - "group: sign-${{ github.ref }}" - - "cancel-in-progress: true" - - "build-app:" - - "uses: ./.github/workflows/build.yaml" diff --git a/e2e/scenarios/12-inline-run-callback.yaml b/e2e/scenarios/12-inline-run-callback.yaml deleted file mode 100644 index 7596a74..0000000 --- a/e2e/scenarios/12-inline-run-callback.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: "Inline Run Callback" -description: | - Verifies a callback declaring run: is emitted as a cascade-owned job with an - inline run: step in the generated orchestrate.yaml, while a sibling callback - declaring workflow: still emits a jobs..uses reusable-workflow call (#36). - - This is a 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.yaml - triggers: ["src/**"] - - name: smoke - run: | - echo "smoke check" - shell: bash - triggers: ["src/**"] - deploys: [] - -steps: - - name: "Initial commit triggers workflow generation; assert inline run step + reusable uses" - action: commit - commit: - message: "feat: add app and inline smoke" - files: - src/app.go: | - package main - func main() {} - expect: - workflow_files: - - path: ".github/workflows/orchestrate.yaml" - contains: - - "build-app:" - - "uses: ./.github/workflows/build.yaml" - - "build-smoke:" - - "shell: bash" - - 'echo "smoke check"' diff --git a/e2e/scenarios/13-inline-run-deploy-no-rollback.yaml b/e2e/scenarios/13-inline-run-deploy-no-rollback.yaml deleted file mode 100644 index 862369d..0000000 --- a/e2e/scenarios/13-inline-run-deploy-no-rollback.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: "Inline Run Deploy Skips Rollback Job" -description: | - Verifies that a deploy declaring run: (an inline-run callback with no reusable - workflow) does not produce a rollback- job in the generated promote.yaml. - A rollback job is a reusable-workflow call, so an inline-run deploy has nothing - to call: emitting one would write an empty `uses:` value and invalid workflow - YAML. The deploy- job itself must still be emitted. - - This is a generator-output verification scenario. Assertion runs on the staged - repo after StageRepoFromConfig generates workflows, before any run. - -config: - trunk_branch: main - environments: [test, staging, prod] - deploys: - - name: app - run: | - echo "deploy $ENVIRONMENT" - shell: bash - triggers: ["src/**"] - -steps: - - name: "Initial commit generates workflows; inline-run deploy has no rollback job and no empty uses" - action: commit - commit: - message: "feat: add inline-run deploy" - files: - src/app.go: | - package main - func main() {} - expect: - workflow_files: - - path: ".github/workflows/promote.yaml" - contains: - - "deploy-app:" - - "deploy-app-prod:" - not_contains: - - "rollback-app:" - - "uses: \n" diff --git a/e2e/scenarios/17-validate-callback.yaml b/e2e/scenarios/17-validate-callback.yaml index 85ca070..c922b08 100644 --- a/e2e/scenarios/17-validate-callback.yaml +++ b/e2e/scenarios/17-validate-callback.yaml @@ -18,7 +18,7 @@ config: workflow: deploy.yaml triggers: ["src/**"] validate: - run: "echo validate" + workflow: validate.yaml steps: - name: "Initial commit; assert validate gate in orchestrate.yaml" @@ -29,6 +29,25 @@ steps: src/app.go: | package main func main() {} + # Reusable validate callback the generated orchestrate.yaml invokes as a + # uses: job. Its inner job echoes "validate" so the gate has a real job to + # wait on; the orchestrate build jobs gate on needs.validate.result. + .github/workflows/validate.yaml: | + name: validate + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + runvalidate: + runs-on: ubuntu-latest + steps: + - run: echo "validate" expect: workflow_files: - path: ".github/workflows/orchestrate.yaml" diff --git a/e2e/scenarios/errors/build-failure-stops-promotion.yaml b/e2e/scenarios/errors/build-failure-stops-promotion.yaml index 347a4dd..ea8a536 100644 --- a/e2e/scenarios/errors/build-failure-stops-promotion.yaml +++ b/e2e/scenarios/errors/build-failure-stops-promotion.yaml @@ -1,18 +1,17 @@ name: "Build failure stops orchestrate" description: | - Drives a real build failure under act: the app build runs an inline - "exit 1" step, so the generated build-app job fails and the orchestrate run - concludes in failure. expect_failure on the orchestrate step makes that - failure the success path, proving the harness observes a genuinely failing - workflow rather than a simulated outcome. + Drives a real build failure under act: the app build is a reusable workflow + whose inner job runs "exit 1", so the generated build-app job fails and the + orchestrate run concludes in failure. expect_failure on the orchestrate step + makes that failure the success path, proving the harness observes a genuinely + failing workflow rather than a simulated outcome. config: trunk_branch: main environments: [dev, prod] builds: - name: app - run: "exit 1" - shell: bash + workflow: build.yaml triggers: ["src/**"] deploys: [] @@ -25,6 +24,25 @@ steps: src/app.go: | package main func main() {} + # Reusable build callback whose inner job exits non-zero on purpose, so + # the generated build-app job fails and the orchestrate run concludes in + # failure. Inner job id is failbuild so act keys it distinctly. + .github/workflows/build.yaml: | + name: build + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + failbuild: + runs-on: ubuntu-latest + steps: + - run: exit 1 - name: "Orchestrate fails because the build exits non-zero" action: orchestrate diff --git a/e2e/scenarios/orchestrate/env-gates.yaml b/e2e/scenarios/orchestrate/env-gates.yaml deleted file mode 100644 index a900f75..0000000 --- a/e2e/scenarios/orchestrate/env-gates.yaml +++ /dev/null @@ -1,55 +0,0 @@ -name: "GHA Environment Gates" -description: | - Verifies that when environment_config.gha_environment is set for an env, - the generated orchestrate.yaml deploy job carries a job-level environment: - key pointing at the configured GitHub Environment name (#11). - - The job-level environment: key is only valid on a cascade-owned steps job, so - the gated deploy here is an inline run: deploy. A reusable-workflow (uses:) - deploy correctly omits the job-level key, since GitHub Actions forbids it on a - uses: caller job (#137/#138); the environment name is threaded as a with: - input instead in that case. - - This is a generator-output verification scenario. The GitHub Environment's - actual protection rules (required reviewers, wait timers, branch policy, - scoped secrets) live in the repository's GitHub settings and are not - exercised here. - -config: - trunk_branch: main - environments: [dev, prod] - builds: - - name: app - workflow: build.yaml - triggers: ["src/**"] - deploys: - - name: cdk - run: | - echo "deploying cdk" - shell: bash - triggers: ["cdk/**"] - environment_config: - prod: - gha_environment: production - -steps: - - name: "Initial commit; assert deploy job carries job-level environment: key" - action: commit - commit: - message: "feat: add app and cdk" - files: - src/app.go: | - package main - func main() {} - cdk/stack.go: | - package cdk - expect: - workflow_files: - - path: ".github/workflows/orchestrate.yaml" - contains: - - "deploy-cdk:" - # Newline-anchored so it matches ONLY the 4-space job-level key and - # never the 6-space reusable-workflow with: input (which also ends - # in "environment: ..."). Without the leading \n, " environment:" - # is a substring of the 6-space " environment:" line. - - "\n environment: ${{ github.event.inputs.environment || 'dev' }}" diff --git a/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml index 66092b3..5c2f618 100644 --- a/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml +++ b/e2e/scenarios/rollback/rollback-deployable-scoped-leaves-env.yaml @@ -16,9 +16,11 @@ description: | sha stays at commit2, its divergence ref stays cleared, api lands on commit1, and web stays on commit2. - Both deploys are inline run: jobs (no actions/checkout, which act cannot - resolve for a reusable callback against the per-scenario gitea), so each deploy - job runs observably under act; only deploy-api runs for a scoped dispatch. + Both deploys are reusable workflows whose inner jobs echo the resolved env/sha + (no actions/checkout, which act cannot resolve for a reusable callback against + the per-scenario gitea), so each deploy job runs observably under act. act keys + them by their inner job ids apideploy / webdeploy; only apideploy runs for a + scoped dispatch. config: trunk_branch: main @@ -29,12 +31,10 @@ config: triggers: ["src/**"] deploys: - name: api - run: | - echo "deployed env=${ENVIRONMENT} sha=${SHA}" + workflow: .github/workflows/deploy-api.yaml triggers: ["**"] - name: web - run: | - echo "deployed env=${ENVIRONMENT} sha=${SHA}" + workflow: .github/workflows/deploy-web.yaml triggers: ["**"] steps: @@ -46,6 +46,41 @@ steps: src/app.go: | package main func main() {} + # api deploy: unique inner job id apideploy so act keys it distinctly from + # the web deploy and the build-app job. + .github/workflows/deploy-api.yaml: | + name: deploy-api + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + apideploy: + runs-on: ubuntu-latest + steps: + - run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" + # web deploy: unique inner job id webdeploy. + .github/workflows/deploy-web.yaml: | + name: deploy-web + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + webdeploy: + runs-on: ubuntu-latest + steps: + - run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" - name: "Orchestrate the first commit into dev" action: orchestrate @@ -122,5 +157,5 @@ steps: sha: commit2 jobs: preflight: success - deploy-api: success + apideploy: success finalize: success diff --git a/e2e/scenarios/rollback/rollback-deploys-prior.yaml b/e2e/scenarios/rollback/rollback-deploys-prior.yaml index 6f2ecfd..7b7e120 100644 --- a/e2e/scenarios/rollback/rollback-deploys-prior.yaml +++ b/e2e/scenarios/rollback/rollback-deploys-prior.yaml @@ -7,9 +7,10 @@ description: | job re-runs at that target SHA, and prod state lands back on the first commit's version. - The deploy is an inline run: job (no actions/checkout, which act cannot resolve - for a reusable callback against the per-scenario gitea), so the deploy job runs - observably under act and is asserted as deploy-app: success. + The deploy is a reusable workflow whose inner job echoes the resolved env/sha + (no actions/checkout, which act cannot resolve for a reusable callback against + the per-scenario gitea), so the deploy job runs observably under act. act keys + it by the inner job id appdeploy, which the assertion targets. config: trunk_branch: main @@ -19,12 +20,12 @@ config: workflow: build.yaml triggers: ["src/**"] deploys: - # Inline run: deploy. It echoes the resolved env/sha the rollback workflow - # threads in, so the deploy job runs under act without a checkout step. Its - # caller job id is deploy-app, which the harness's findJob matches directly. + # Reusable deploy. Its inner job echoes the resolved env/sha the rollback + # workflow threads in via the with: environment/sha inputs, so the deploy job + # runs under act without a checkout step. act keys it by the inner job id + # appdeploy, which the harness's findJob matches. - name: app - run: | - echo "deployed env=${ENVIRONMENT} sha=${SHA}" + workflow: .github/workflows/deploy-app.yaml triggers: ["**"] steps: @@ -36,6 +37,22 @@ steps: src/app.go: | package main func main() {} + .github/workflows/deploy-app.yaml: | + name: deploy-app + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + appdeploy: + runs-on: ubuntu-latest + steps: + - run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" - name: "Orchestrate the first commit into dev" action: orchestrate @@ -94,5 +111,5 @@ steps: sha: commit1 jobs: preflight: success - deploy-app: success + appdeploy: success finalize: success diff --git a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml index 12d094f..6b5ee97 100644 --- a/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml +++ b/e2e/scenarios/rollback/rollback-failed-deploy-no-state-change.yaml @@ -15,14 +15,15 @@ description: | no rollback ref. This is the safety property that distinguishes a real re-deploy from a blind state rewrite. - The deploy is an inline run: job (no actions/checkout, which act cannot resolve - for a reusable callback against the per-scenario gitea), so the deploy job runs - observably under act and is asserted as deploy-app: failure. The deploy keys - its conditional failure on $GITHUB_WORKFLOW (the invoking workflow's name, - which the harness suffixes with [scenario-...], so the deploy matches the - Rollback prefix): the Promote workflow succeeds, only the Rollback re-deploy - fails, exercising the gateOnDeployResults guard rather than aborting during - setup. + The deploy is a reusable workflow whose inner job runs under act (no actions/ + checkout, which act cannot resolve for a reusable callback against the + per-scenario gitea), and is asserted as appdeploy: failure. The deploy keys its + conditional failure on $GITHUB_WORKFLOW, which inside a reusable callback is the + invoking (caller) workflow's name: the generated promote workflow sets + name: Promote and the rollback workflow sets name: Rollback, and the harness + suffixes each with [scenario-...], so the deploy matches the Rollback prefix. + The Promote workflow succeeds, only the Rollback re-deploy fails, exercising the + gateOnDeployResults guard rather than aborting during setup. config: trunk_branch: main @@ -32,29 +33,18 @@ config: workflow: build.yaml triggers: ["src/**"] deploys: - # Inline run: deploy that succeeds under the Promote workflow but fails under - # the Rollback workflow's re-deploy. Both workflows surface the same env vars - # (ENVIRONMENT, SHA) to this inline step, and the rollback target SHA equals - # an earlier promote's SHA, so the SHA alone cannot tell the two apart. The - # invoking workflow name ($GITHUB_WORKFLOW) does: it is "Promote" during the - # two setup promotions and "Rollback" during the re-deploy under test. Failing - # only on Rollback forces deploy-app to fail there, so finalize sees - # DEPLOY_RESULT_APP=failure and aborts the state write, while setup lands prod - # on commit1 then commit2 cleanly. Its caller job id is deploy-app, which the - # harness's findJob matches directly. + # Reusable deploy that succeeds under the Promote workflow but fails under the + # Rollback workflow's re-deploy. Both workflows thread the same with: inputs + # (environment, sha) into this callback, and the rollback target SHA equals an + # earlier promote's SHA, so the SHA alone cannot tell the two apart. The + # invoking workflow name ($GITHUB_WORKFLOW, the caller's name inside a reusable + # callback) does: it is "Promote" during the two setup promotions and + # "Rollback" during the re-deploy under test. Failing only on Rollback forces + # appdeploy to fail there, so finalize sees DEPLOY_RESULT_APP=failure and + # aborts the state write, while setup lands prod on commit1 then commit2 + # cleanly. act keys it by the inner job id appdeploy. - name: app - run: | - echo "deploy of env=${ENVIRONMENT} sha=${SHA} via workflow=${GITHUB_WORKFLOW}" - # The harness suffixes each workflow name with [scenario-], so the - # Rollback workflow surfaces GITHUB_WORKFLOW as "Rollback [scenario-...]". - # Match the Rollback prefix rather than an exact string. - case "${GITHUB_WORKFLOW}" in - Rollback*) - echo "failing the rollback re-deploy on purpose" - exit 1 - ;; - esac - echo "promote deploy succeeded" + workflow: .github/workflows/deploy-app.yaml triggers: ["**"] steps: @@ -66,6 +56,37 @@ steps: src/app.go: | package main func main() {} + .github/workflows/deploy-app.yaml: | + name: deploy-app + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + appdeploy: + runs-on: ubuntu-latest + steps: + - env: + DEPLOY_ENV: ${{ inputs.environment }} + DEPLOY_SHA: ${{ inputs.sha }} + run: | + echo "deploy of env=$DEPLOY_ENV sha=$DEPLOY_SHA via workflow=$GITHUB_WORKFLOW" + # GITHUB_WORKFLOW inside a reusable callback is the caller + # workflow's name. The harness suffixes each with + # [scenario-], so the Rollback workflow surfaces it as + # "Rollback [scenario-...]". Match the Rollback prefix. + case "$GITHUB_WORKFLOW" in + Rollback*) + echo "failing the rollback re-deploy on purpose" + exit 1 + ;; + esac + echo "promote deploy succeeded" - name: "Orchestrate the first commit into dev" action: orchestrate @@ -126,4 +147,4 @@ steps: unchanged: true jobs: preflight: success - deploy-app: failure + appdeploy: failure diff --git a/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml index 11338f7..59f7e8d 100644 --- a/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml +++ b/e2e/scenarios/rollback/rollback-marks-diverged-blocks-promote.yaml @@ -17,9 +17,11 @@ config: workflow: build.yaml triggers: ["src/**"] deploys: + # Reusable deploy whose inner job echoes the resolved env/sha. No actions/ + # checkout: act cannot resolve one for a reusable callback against the + # per-scenario gitea. act keys the job by the inner id appdeploy. - name: app - run: | - echo "deployed env=${ENVIRONMENT} sha=${SHA}" + workflow: .github/workflows/deploy-app.yaml triggers: ["**"] steps: @@ -31,6 +33,22 @@ steps: src/app.go: | package main func main() {} + .github/workflows/deploy-app.yaml: | + name: deploy-app + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + appdeploy: + runs-on: ubuntu-latest + steps: + - run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" - name: "Orchestrate the first commit into dev" action: orchestrate diff --git a/e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml b/e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml index 1406228..4c3bffa 100644 --- a/e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml +++ b/e2e/scenarios/rollback/rollforward-rejoins-rolledback.yaml @@ -19,9 +19,11 @@ config: workflow: build.yaml triggers: ["src/**"] deploys: + # Reusable deploy whose inner job echoes the resolved env/sha. No actions/ + # checkout: act cannot resolve one for a reusable callback against the + # per-scenario gitea. act keys the job by the inner id appdeploy. - name: app - run: | - echo "deployed env=${ENVIRONMENT} sha=${SHA}" + workflow: .github/workflows/deploy-app.yaml triggers: ["**"] steps: @@ -33,6 +35,22 @@ steps: src/app.go: | package main func main() {} + .github/workflows/deploy-app.yaml: | + name: deploy-app + on: + workflow_call: + inputs: + environment: + required: false + type: string + sha: + required: false + type: string + jobs: + appdeploy: + runs-on: ubuntu-latest + steps: + - run: echo "deployed env=${{ inputs.environment }} sha=${{ inputs.sha }}" - name: "Orchestrate the first commit into dev" action: orchestrate diff --git a/internal/config/schema_v1_e2e_test.go b/internal/config/schema_v1_e2e_test.go index 721c312..a7b5b1c 100644 --- a/internal/config/schema_v1_e2e_test.go +++ b/internal/config/schema_v1_e2e_test.go @@ -73,12 +73,7 @@ const fullSurfaceManifest = `ci: - name: linux-amd64 path: dist/*.tar.gz - name: smoke - run: go test ./... - shell: bash - runs_on: [self-hosted, linux] - concurrency: - group: smoke-${{ github.ref }} - cancel_in_progress: true + workflow: .github/workflows/smoke.yaml triggers: ["**/*_test.go"] deploys: - name: app @@ -151,8 +146,8 @@ func TestFullSurfaceManifestE2E(t *testing.T) { if cfg.Builds[0].Matrix == nil || cfg.Builds[0].Permissions["id-token"] != "write" { t.Fatalf("build reserved fields not parsed: %#v", cfg.Builds[0]) } - if cfg.Builds[1].Run == "" || cfg.Builds[1].RunsOn == nil { - t.Fatalf("inline run build not parsed: %#v", cfg.Builds[1]) + if cfg.Builds[1].Workflow == "" { + t.Fatalf("reusable build not parsed: %#v", cfg.Builds[1]) } if cfg.Deploys[0].Rollout.GetType() != "canary" || cfg.Deploys[0].DeployTarget.GetMode() != "gitops" { t.Fatalf("deploy reserved fields not parsed: %#v", cfg.Deploys[0]) diff --git a/internal/config/schema_v1_test.go b/internal/config/schema_v1_test.go index c57fc34..9edf8f2 100644 --- a/internal/config/schema_v1_test.go +++ b/internal/config/schema_v1_test.go @@ -306,37 +306,82 @@ state: // --- Structural validation rejections ---------------------------------------- func TestValidateWorkflowRunXOR(t *testing.T) { - t.Run("both set rejected", func(t *testing.T) { - cfg := parseInline(t, ` + tests := []struct { + name string + manifest string + wantErrs []string + denyErrs []string + wantClean bool + }{ + { + name: "run set, no workflow", + manifest: ` +builds: + - name: app + run: go build ./... +`, + wantErrs: []string{ + "inline run: callbacks are no longer supported", + "workflow is required", + }, + }, + { + name: "workflow and run both set", + manifest: ` builds: - name: app workflow: b.yaml run: go build ./... -`) - if errs := Validate(cfg); !hasErrContaining(errs, "mutually exclusive") { - t.Fatalf("expected XOR rejection, got %v", errs) - } - }) - t.Run("neither set rejected", func(t *testing.T) { - cfg := parseInline(t, ` +`, + wantErrs: []string{"inline run: callbacks are no longer supported"}, + }, + { + name: "shell set, no workflow, no run", + manifest: ` builds: - name: app -`) - if errs := Validate(cfg); !hasErrContaining(errs, "one of workflow or run is required") { - t.Fatalf("expected missing-callback rejection, got %v", errs) - } - }) - t.Run("shell without run rejected", func(t *testing.T) { - cfg := parseInline(t, ` + shell: bash +`, + wantErrs: []string{ + "shell: is no longer supported", + "workflow is required", + }, + }, + { + name: "neither run nor workflow", + manifest: ` +builds: + - name: app +`, + wantErrs: []string{"workflow is required"}, + }, + { + name: "workflow only is clean", + manifest: ` builds: - name: app workflow: b.yaml - shell: bash -`) - if errs := Validate(cfg); !hasErrContaining(errs, "shell is only valid alongside run") { - t.Fatalf("expected shell rejection, got %v", errs) - } - }) +`, + denyErrs: []string{"inline run: callbacks are no longer supported", "shell: is no longer supported", "workflow is required"}, + wantClean: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := Validate(parseInline(t, tt.manifest)) + for _, want := range tt.wantErrs { + if !hasErrContaining(errs, want) { + t.Fatalf("expected error containing %q, got %v", want, errs) + } + } + for _, deny := range tt.denyErrs { + if hasErrContaining(errs, deny) { + t.Fatalf("did not expect error containing %q, got %v", deny, errs) + } + } + }) + } } func TestValidateRunsOnRejectedOnReusableWorkflow(t *testing.T) { @@ -351,18 +396,42 @@ builds: } } -func TestValidateRunsOnAllowedOnInlineRun(t *testing.T) { - cfg := parseInline(t, ` +// TestValidateRunRejected asserts a run-only callback is rejected with the +// inline-removed message, and a shell-only callback is rejected with the +// shell-removed message, while a workflow-only callback validates clean. +func TestValidateRunRejected(t *testing.T) { + t.Run("run-only rejected", func(t *testing.T) { + cfg := parseInline(t, ` builds: - name: app run: go build ./... - runs_on: ubuntu-latest `) - for _, e := range Validate(cfg) { - if strings.Contains(e, "runs_on") { - t.Fatalf("runs_on should be allowed on inline run, got %v", e) + if errs := Validate(cfg); !hasErrContaining(errs, "inline run: callbacks are no longer supported") { + t.Fatalf("expected inline run rejection, got %v", errs) } - } + }) + t.Run("shell-only rejected", func(t *testing.T) { + cfg := parseInline(t, ` +builds: + - name: app + shell: bash +`) + if errs := Validate(cfg); !hasErrContaining(errs, "shell: is no longer supported") { + t.Fatalf("expected shell rejection, got %v", errs) + } + }) + t.Run("workflow-only clean", func(t *testing.T) { + cfg := parseInline(t, ` +builds: + - name: app + workflow: b.yaml +`) + for _, e := range Validate(cfg) { + if strings.Contains(e, "no longer supported") || strings.Contains(e, "workflow is required") { + t.Fatalf("workflow-only callback must validate clean, got %v", e) + } + } + }) } func TestValidateConcurrencyRejectedOnReusableWorkflow(t *testing.T) { @@ -526,8 +595,8 @@ validate: workflow: v.yaml run: go vet ./... `) - if errs := Validate(cfg); !hasErrContaining(errs, "mutually exclusive") { - t.Fatalf("expected XOR rejection, got %v", errs) + if errs := Validate(cfg); !hasErrContaining(errs, "inline run: callbacks are no longer supported") { + t.Fatalf("expected inline run rejection, got %v", errs) } }) t.Run("runs_on rejected on reusable workflow", func(t *testing.T) { @@ -552,16 +621,13 @@ validate: t.Fatalf("expected concurrency rejection, got %v", errs) } }) - t.Run("runs_on allowed on inline run validate", func(t *testing.T) { + t.Run("run-only validate rejected", func(t *testing.T) { cfg := parseInline(t, ` validate: run: go vet ./... - runs_on: ubuntu-latest `) - for _, e := range Validate(cfg) { - if strings.Contains(e, "runs_on") { - t.Fatalf("runs_on should be allowed on inline run validate, got %v", e) - } + if errs := Validate(cfg); !hasErrContaining(errs, "inline run: callbacks are no longer supported") { + t.Fatalf("expected inline run rejection on validate, got %v", errs) } }) } diff --git a/internal/config/types.go b/internal/config/types.go index 5b48ed9..cc5b7a2 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -367,8 +367,8 @@ func (c *TrunkConfig) HasGPGSigning() bool { // ValidateConfig defines a validation workflow type ValidateConfig struct { Workflow string `yaml:"workflow,omitempty" json:"workflow,omitempty"` - Run string `yaml:"run,omitempty" json:"run,omitempty"` // Inline command, XOR with workflow (reserved-shape) - Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // Shell for inline run (default bash; only valid with run) + Run string `yaml:"run,omitempty" json:"run,omitempty"` // rejected: inline run is no longer supported; use workflow: + Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // rejected: inline shell is no longer supported; use workflow: Triggers []string `yaml:"triggers,omitempty" json:"triggers,omitempty"` // File patterns that should trigger validation SupportsDryRun bool `yaml:"supports_dry_run,omitempty" json:"supports_dry_run,omitempty"` Inputs map[string]interface{} `yaml:"inputs,omitempty" json:"inputs,omitempty"` @@ -391,8 +391,8 @@ type ValidateConfig struct { type BuildConfig struct { Name string `yaml:"name" json:"name"` Workflow string `yaml:"workflow,omitempty" json:"workflow,omitempty"` - Run string `yaml:"run,omitempty" json:"run,omitempty"` // Inline command, XOR with workflow (reserved-shape) - Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // Shell for inline run (default bash; only valid with run) + Run string `yaml:"run,omitempty" json:"run,omitempty"` // rejected: inline run is no longer supported; use workflow: + Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // rejected: inline shell is no longer supported; use workflow: Triggers []string `yaml:"triggers" json:"triggers"` DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"` StateTags []string `yaml:"state_tags,omitempty" json:"state_tags,omitempty"` @@ -451,8 +451,8 @@ type PassthroughArtifact struct { type DeployConfig struct { Name string `yaml:"name" json:"name"` Workflow string `yaml:"workflow,omitempty" json:"workflow,omitempty"` - Run string `yaml:"run,omitempty" json:"run,omitempty"` // Inline command, XOR with workflow (reserved-shape) - Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // Shell for inline run (default bash; only valid with run) + Run string `yaml:"run,omitempty" json:"run,omitempty"` // rejected: inline run is no longer supported; use workflow: + Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // rejected: inline shell is no longer supported; use workflow: Triggers []string `yaml:"triggers" json:"triggers"` DependsOn []string `yaml:"depends_on,omitempty" json:"depends_on,omitempty"` StateTags []string `yaml:"state_tags,omitempty" json:"state_tags,omitempty"` @@ -509,8 +509,8 @@ type ExternalRepoConfig struct { type ExternalDeployConfig struct { Name string `yaml:"name" json:"name"` // Deploy identifier (e.g., "cdk") Workflow string `yaml:"workflow,omitempty" json:"workflow,omitempty"` // Workflow path - local (.github/...) or external (org/repo/.github/...@ref) - Run string `yaml:"run,omitempty" json:"run,omitempty"` // Inline command, XOR with workflow (reserved-shape) - Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // Shell for inline run (default bash; only valid with run) + Run string `yaml:"run,omitempty" json:"run,omitempty"` // rejected: inline run is no longer supported; use workflow: + Shell string `yaml:"shell,omitempty" json:"shell,omitempty"` // rejected: inline shell is no longer supported; use workflow: Triggers []string `yaml:"triggers,omitempty" json:"triggers,omitempty"` // File patterns for change detection // v1 reserved-shape per-callback fields (parse + structural validation only). diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index fbaeba2..8ffc73e 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -71,26 +71,27 @@ func validateJobIDSafeName(prefix, name string) []string { "%s %q must contain only letters, digits, hyphens, and underscores", prefix, name)} } -// validateWorkflowRunXOR enforces that exactly one of workflow:/run: is set, and -// that shell: is only present alongside run:. +// validateWorkflowRunXOR enforces that callbacks are reusable-workflow only. +// Inline run: and shell: are no longer supported, so each is rejected with an +// actionable error, and workflow: is required. func validateWorkflowRunXOR(prefix, workflow, run, shell string) []string { var errs []string - switch { - case workflow == "" && run == "": - errs = append(errs, fmt.Sprintf("%s: one of workflow or run is required", prefix)) - case workflow != "" && run != "": - errs = append(errs, fmt.Sprintf("%s: workflow and run are mutually exclusive; set exactly one", prefix)) + if run != "" { + errs = append(errs, fmt.Sprintf("%s: inline run: callbacks are no longer supported; provide a reusable workflow via workflow: (see docs/security/hardening or the callback contract)", prefix)) + } + if shell != "" { + errs = append(errs, fmt.Sprintf("%s: shell: is no longer supported; inline run callbacks were removed, provide a reusable workflow via workflow:", prefix)) } - if shell != "" && run == "" { - errs = append(errs, fmt.Sprintf("%s: shell is only valid alongside run", prefix)) + if workflow == "" { + errs = append(errs, fmt.Sprintf("%s: workflow is required", prefix)) } return errs } // validateExternalDeployWorkflowOnly enforces that external deploys are -// reusable-workflow only. Inline run: callbacks are emitted as cascade-owned -// jobs in the primary repo; an external deploy resolves to a workflow in the +// reusable-workflow only. An external deploy resolves to a workflow in the // external repo, so it must declare workflow: and cannot use run:/shell:. +// Inline run: and shell: are no longer supported anywhere and are rejected here. func validateExternalDeployWorkflowOnly(prefix, workflow, run, shell string) []string { var errs []string if run != "" { @@ -99,15 +100,15 @@ func validateExternalDeployWorkflowOnly(prefix, workflow, run, shell string) []s if shell != "" { errs = append(errs, fmt.Sprintf("%s: external deploys are reusable-workflow only; shell is not supported", prefix)) } - if run == "" && workflow == "" { + if workflow == "" { errs = append(errs, fmt.Sprintf("%s: workflow is required", prefix)) } return errs } // validateJobControlFields rejects job-control fields that GHA does not accept on -// a reusable-workflow (jobs..uses) callback. runs_on and concurrency apply -// cleanly only to inline run: callbacks and cascade-owned jobs. +// a reusable-workflow (jobs..uses) callback. runs_on and concurrency must be +// set inside the callback workflow itself, not on the calling job. func validateJobControlFields(prefix string, isReusableWorkflow bool, runsOn *RunsOn, concurrency *ConcurrencyConfig) []string { if !isReusableWorkflow { return nil diff --git a/internal/generate/env_gate_reusable_test.go b/internal/generate/env_gate_reusable_test.go index dd79b2c..2f31ecd 100644 --- a/internal/generate/env_gate_reusable_test.go +++ b/internal/generate/env_gate_reusable_test.go @@ -115,36 +115,6 @@ func TestEnvGate_Orchestrate_ExternalDeploy_NoJobLevelEnvironment(t *testing.T) "external (uses:) deploy job must NOT carry a job-level environment: key; block:\n%s", block) } -// TestEnvGate_Orchestrate_InlineDeploy_KeepsJobLevelEnvironment asserts that an -// inline run: deploy (a cascade-owned steps job) still carries a job-level -// environment: key, which is valid on a steps job and provides GitHub -// Environment protection. -func TestEnvGate_Orchestrate_InlineDeploy_KeepsJobLevelEnvironment(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"staging", "prod"}, - Deploys: []config.DeployConfig{ - {Name: "app", Run: "echo deploying", Triggers: []string{"src/**"}}, - }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - block := jobBlock(t, result, "deploy-app") - require.NotEmpty(t, block, "deploy-app job not found") - - assert.NotContains(t, block, "uses:", "inline deploy must be a steps job, not a uses: caller") - assert.True(t, hasJobLevelEnvironment(block), - "inline (run:) deploy job must carry a job-level environment: key; block:\n%s", block) -} - // TestEnvGate_Promote_ExternalSingleDeploy_NoJobLevelEnvironment guards #137 for // the promote single-deploy path: an external deploy caller job must not carry // a job-level environment: key. @@ -205,33 +175,6 @@ func TestEnvGate_Promote_ExternalProdDeploy_NoJobLevelEnvironment(t *testing.T) "promote external prod deploy must still pass environment as a with: input") } -// TestEnvGate_Promote_InlineProdDeploy_KeepsJobLevelEnvironment asserts that an -// inline prod deploy still carries a job-level environment: key (valid on a -// steps job). -func TestEnvGate_Promote_InlineProdDeploy_KeepsJobLevelEnvironment(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev", "prod"}, - Deploys: []config.DeployConfig{ - {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, - }, - EnvironmentConfig: map[string]config.EnvironmentConfig{ - "prod": {GHAEnvironment: "production"}, - }, - } - - gen := NewPromoteGenerator(cfg, "") - result, err := gen.Generate() - require.NoError(t, err) - - block := jobBlock(t, result, "deploy-svc-prod") - require.NotEmpty(t, block, "deploy-svc-prod job not found") - - assert.NotContains(t, block, "uses:", "inline prod deploy must be a steps job") - assert.True(t, hasJobLevelEnvironment(block), - "inline (run:) prod deploy job must carry a job-level environment: key; block:\n%s", block) -} - // TestEnvGate_Warning_ExternalDeployWithGHAEnvironment asserts that a // generate-time warning fires when gha_environment is configured for an // environment whose deploys are external reusable workflows, since cascade diff --git a/internal/generate/env_gates_test.go b/internal/generate/env_gates_test.go index 6cf65a9..2bd595c 100644 --- a/internal/generate/env_gates_test.go +++ b/internal/generate/env_gates_test.go @@ -14,18 +14,19 @@ import ( // ---- orchestrate generator tests ---- // TestEnvGates_Orchestrate_DeployJob_WithGHAEnvironment asserts that when -// environment_config carries a gha_environment for an env and the deploy is an -// INLINE run: job (a cascade-owned steps job), the generated orchestrate deploy -// job carries a job-level environment: key. The job-level key is only valid on a -// steps job; the external (uses:) variant is covered in env_gate_reusable_test.go. +// environment_config carries a gha_environment for an env, the orchestrate +// deploy job (always a reusable-workflow uses: caller) threads the environment +// name via the with: environment: input using the dynamic input expression, and +// never carries a job-level environment: key (GHA forbids it on a uses: job). func TestEnvGates_Orchestrate_DeployJob_WithGHAEnvironment(t *testing.T) { tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, Deploys: []config.DeployConfig{ - {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ "prod": {GHAEnvironment: "production"}, @@ -39,10 +40,17 @@ func TestEnvGates_Orchestrate_DeployJob_WithGHAEnvironment(t *testing.T) { block := jobBlock(t, result, "deploy-svc") require.NotEmpty(t, block, "deploy-svc job not found in generated orchestrate workflow") - // Job-level environment: key must be present on an inline steps job. - assert.Contains(t, block, "environment:", "inline deploy job must carry job-level environment: key when gha_environment is configured") - // It should use the dynamic input expression (env is chosen at runtime). - assert.Contains(t, block, "github.event.inputs.environment", "orchestrate environment: key must use the workflow input expression") + // Reusable caller: environment is threaded via the with: input using the + // dynamic input expression (env is chosen at runtime). + assert.Contains(t, block, "uses:", "deploy job must call the reusable workflow via uses:") + assert.Contains(t, block, "github.event.inputs.environment", "orchestrate deploy must pass environment via the workflow input expression") + // No job-level environment: key on a uses: caller job. + lines := strings.Split(block, "\n") + for _, line := range lines { + if strings.HasPrefix(line, " environment:") && !strings.HasPrefix(line, " environment:") { + t.Errorf("reusable deploy caller must not carry a job-level environment: key; line: %q", line) + } + } } // TestEnvGates_Orchestrate_DeployJob_WithoutGHAEnvironment asserts that when @@ -126,31 +134,33 @@ func TestEnvGates_Orchestrate_BuildJob_NoEnvironmentKey(t *testing.T) { // ---- promote generator tests ---- // TestEnvGates_Promote_SingleDeployJob_WithGHAEnvironment asserts that the -// promote generator emits a job-level environment: on the single-mode INLINE -// run: deploy job when any environment has gha_environment configured. The -// job-level key is only valid on a steps job; the external (uses:) variant is -// covered in env_gate_reusable_test.go. +// promote single-mode deploy job (a reusable-workflow uses: caller) threads the +// environment via the with: environment: input referencing preflight's +// target_env output when any environment has gha_environment configured. func TestEnvGates_Promote_SingleDeployJob_WithGHAEnvironment(t *testing.T) { + tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") + cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, Deploys: []config.DeployConfig{ - {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ "prod": {GHAEnvironment: "production"}, }, } - gen := NewPromoteGenerator(cfg, "") + gen := NewPromoteGenerator(cfg, tmpDir) result, err := gen.Generate() require.NoError(t, err) block := jobBlock(t, result, "deploy-svc") require.NotEmpty(t, block, "deploy-svc job not found in generated promote workflow") - assert.Contains(t, block, "environment:", "inline promote deploy job must carry job-level environment: key when gha_environment is configured") - assert.Contains(t, block, "needs.preflight.outputs.target_env", "promote deploy environment: key must reference preflight's target_env output") + assert.Contains(t, block, "environment:", "promote deploy job must thread the environment via the with: input") + assert.Contains(t, block, "needs.preflight.outputs.target_env", "promote deploy environment input must reference preflight's target_env output") } // TestEnvGates_Promote_SingleDeployJob_WithoutGHAEnvironment asserts that the @@ -181,33 +191,43 @@ func TestEnvGates_Promote_SingleDeployJob_WithoutGHAEnvironment(t *testing.T) { } } -// TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment asserts that the -// promote generator emits a job-level environment: on the prod deploy job -// (deploy--prod) using the static gha_environment name when the final env -// has gha_environment configured AND the deploy is an INLINE run: job. The -// job-level key is only valid on a steps job; the external (uses:) variant is -// covered in env_gate_reusable_test.go. +// TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment asserts that the promote +// prod deploy job (deploy--prod, always a reusable-workflow uses: caller) +// threads the target environment via the with: environment: input statically +// (the prod env is known at generate time) and never carries a job-level +// environment: key, which GHA forbids on a uses: job. func TestEnvGates_Promote_ProdDeployJob_WithGHAEnvironment(t *testing.T) { + tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") + cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "prod"}, Deploys: []config.DeployConfig{ - {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ "prod": {GHAEnvironment: "production"}, }, } - gen := NewPromoteGenerator(cfg, "") + gen := NewPromoteGenerator(cfg, tmpDir) result, err := gen.Generate() require.NoError(t, err) block := jobBlock(t, result, "deploy-svc-prod") require.NotEmpty(t, block, "deploy-svc-prod job not found in generated promote workflow") - // The prod deploy job must carry the static resolved GitHub Environment name. - assert.Contains(t, block, "environment: production", "inline prod deploy job must carry the gha_environment name statically") + // Reusable caller: the target environment is threaded via the with: input. + assert.Contains(t, block, "uses:", "prod deploy job must call the reusable workflow via uses:") + assert.Contains(t, block, " environment: prod", "prod deploy must pass the target environment via the with: input") + // No job-level environment: key on a uses: caller job. + lines := strings.Split(block, "\n") + for _, line := range lines { + if strings.HasPrefix(line, " environment:") && !strings.HasPrefix(line, " environment:") { + t.Errorf("reusable prod deploy caller must not carry a job-level environment: key; line: %q", line) + } + } } // TestEnvGates_Promote_ProdDeployJob_WithoutGHAEnvironment asserts that the @@ -243,13 +263,14 @@ func TestEnvGates_Promote_ProdDeployJob_WithoutGHAEnvironment(t *testing.T) { // job does NOT receive a job-level environment: key (since we use a static // per-env lookup for the prod job). func TestEnvGates_Promote_ProdDeployJob_OnlyFinalEnvGated(t *testing.T) { + tmpDir := t.TempDir() + writeStubWorkflow(t, tmpDir, "deploy.yaml") + cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev", "staging", "prod"}, Deploys: []config.DeployConfig{ - // Inline run: deploy so the single job-level environment: key is valid; - // the gating-by-env behaviour under test is independent of deploy kind. - {Name: "svc", Run: "echo deploying", Triggers: []string{"src/**"}}, + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", Triggers: []string{"src/**"}}, }, EnvironmentConfig: map[string]config.EnvironmentConfig{ // Only the intermediate env has gha_environment; prod does not. @@ -257,7 +278,7 @@ func TestEnvGates_Promote_ProdDeployJob_OnlyFinalEnvGated(t *testing.T) { }, } - gen := NewPromoteGenerator(cfg, "") + gen := NewPromoteGenerator(cfg, tmpDir) result, err := gen.Generate() require.NoError(t, err) @@ -271,7 +292,7 @@ func TestEnvGates_Promote_ProdDeployJob_OnlyFinalEnvGated(t *testing.T) { } } - // The single deploy job (non-prod) SHOULD carry environment: because staging has gha_environment. + // The single deploy job (non-prod) SHOULD thread environment: because staging has gha_environment. singleBlock := jobBlock(t, result, "deploy-svc") // deploy-svc-prod starts with "deploy-svc-prod:", so we need to stop at that // boundary. jobBlock already handles that by stopping at the next " :" line. diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 53e4747..b1086b7 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -11,8 +11,8 @@ import ( ) // DefaultJobTimeoutMinutes is the timeout-minutes applied to cascade-owned jobs -// (setup, finalize, inline run: callbacks, retry shims, and passthrough -// artifact helper jobs) when config.job_timeout_minutes is not set. GitHub +// (setup, finalize, retry shims, and passthrough artifact helper jobs) when +// config.job_timeout_minutes is not set. GitHub // Actions defaults jobs to 360 minutes (6 hours); cascade's orchestration jobs // are meant to be fast, so a hung git push, CLI download, or API call should not // hold a runner for six hours. Override per manifest via config.job_timeout_minutes. @@ -338,9 +338,7 @@ func (g *Generator) externalDeployEnvironmentWarnings() []string { externalDeploys := make([]string, 0, len(g.config.Deploys)) for _, d := range g.config.Deploys { - if d.Run == "" { - externalDeploys = append(externalDeploys, d.Name) - } + externalDeploys = append(externalDeploys, d.Name) } if len(externalDeploys) == 0 { return nil @@ -452,7 +450,6 @@ func (g *Generator) discoverOutputsAndInputs() error { jobID string name string workflow string - run string inputs map[string]interface{} }{} @@ -461,9 +458,8 @@ func (g *Generator) discoverOutputsAndInputs() error { jobID string name string workflow string - run string inputs map[string]interface{} - }{"validate", "validate", g.config.Validate.Workflow, g.config.Validate.Run, g.config.Validate.Inputs}) + }{"validate", "validate", g.config.Validate.Workflow, g.config.Validate.Inputs}) } for _, b := range g.config.Builds { jobID := config.JobID(config.CallbackTypeBuild, b.Name) @@ -471,9 +467,8 @@ func (g *Generator) discoverOutputsAndInputs() error { jobID string name string workflow string - run string inputs map[string]interface{} - }{jobID, b.Name, b.Workflow, b.Run, b.Inputs}) + }{jobID, b.Name, b.Workflow, b.Inputs}) } for _, d := range g.config.Deploys { jobID := config.JobID(config.CallbackTypeDeploy, d.Name) @@ -481,21 +476,11 @@ func (g *Generator) discoverOutputsAndInputs() error { jobID string name string workflow string - run string inputs map[string]interface{} - }{jobID, d.Name, d.Workflow, d.Run, d.Inputs}) + }{jobID, d.Name, d.Workflow, d.Inputs}) } for _, cb := range allCallbacks { - // Inline run: callbacks have no reusable-workflow file. Their inputs come - // from the manifest (declared inputs keys); they emit no outputs. - if cb.run != "" { - g.outputs[cb.jobID] = nil - g.inputs[cb.jobID] = inputKeys(cb.inputs) - g.requiredInputs[cb.jobID] = nil - continue - } - // Read the stub from the normalized location so a bare filename // (build.yaml) resolves to .github/workflows/build.yaml, which is where // GitHub requires local reusable workflows to live and where the emitted @@ -825,7 +810,7 @@ func (g *Generator) writeCallbackJob(sb *strings.Builder, info CallbackInfo, wor // runs. The pre-job depends on the same upstream jobs as the callback itself so // it can start as soon as the producers finish. downloadPreJobID := "" - if info.Run == "" && info.PassthroughArtifact != nil && len(info.PassthroughArtifact.Downloads) > 0 { + if info.PassthroughArtifact != nil && len(info.PassthroughArtifact.Downloads) > 0 { downloadPreJobID = fmt.Sprintf("%s-download", info.JobID) g.writePassthroughDownloadJob(sb, info, downloadPreJobID) } @@ -852,48 +837,23 @@ func (g *Generator) writeCallbackJob(sb *strings.Builder, info CallbackInfo, wor // passed here; they sequence the job without gating it. g.writeIfCondition(sb, info, needs) - switch { - case info.TimeoutMinutes > 0 && info.Run != "": - // Explicit per-callback timeout wins, but only on an inline run: callback. - // timeout-minutes is forbidden on a reusable-workflow caller job - // (jobs..uses): GitHub rejects the workflow at parse time. For uses: - // callbacks the timeout must live inside the called workflow. This mirrors - // the info.Run gate on environment: below. - fmt.Fprintf(sb, " timeout-minutes: %d\n", info.TimeoutMinutes) - case info.Run != "": - // Inline run: callbacks are cascade-owned jobs, so they inherit the - // cascade-owned-job timeout default (#37). Reusable-workflow callbacks - // (jobs..uses) own their own timeout and get nothing here. - g.writeOwnedTimeout(sb, " ") - } + // timeout-minutes is forbidden on a reusable-workflow caller job + // (jobs..uses): GitHub rejects the workflow at parse time. Every callback + // is now a reusable-workflow call, so the timeout must live inside the called + // workflow and nothing is emitted here. // strategy: emitted only for build callbacks that declare matrix: if info.Matrix != nil && len(info.Matrix.Dimensions) > 0 { g.writeStrategyBlock(sb, info.Matrix) } - // environment: emitted on deploy jobs when the config declares a - // gha_environment for at least one environment. The job-level environment: - // key wires the job to a GitHub Environment so that the environment's - // protection rules (required reviewers, wait timers, deployment branch - // policy, scoped secrets) apply at runtime. Actual protection configuration - // lives in GitHub's Environment settings, not in the manifest. - // - // For orchestrate, the target environment is chosen at run time via the - // workflow_dispatch input, so we emit an expression that resolves to the - // cascade environment name. When gha_environment differs from the cascade - // env name, users should align their GitHub Environment names accordingly. - // - // The job-level environment: key is only valid on a steps job (an inline - // run: deploy). GitHub Actions forbids it on a reusable-workflow caller job - // (one that uses jobs..uses), so it is gated on info.Run being set. For - // external (uses:) deploys the environment name is threaded via the with: - // environment input instead, and GitHub Environment protection must be + // environment: GitHub Actions forbids a job-level environment: key on a + // reusable-workflow caller job (one that uses jobs..uses). Every deploy is + // now a reusable-workflow call, so cascade does not emit a job-level + // environment: here; the environment name is threaded via the with: + // environment input instead, and GitHub Environment protection (required + // reviewers, wait timers, deployment branch policy, scoped secrets) must be // declared inside the reusable workflow's own job. - if info.Type == config.CallbackTypeDeploy && info.Run != "" && len(g.config.Environments) > 0 && anyEnvHasGHAConfig(g.config) { - defaultEnv := g.config.Environments[0] - fmt.Fprintf(sb, " environment: ${{ github.event.inputs.environment || '%s' }}\n", defaultEnv) - } // continue-on-error: a callback with on_failure: continue is one the operator // has explicitly marked as tolerable. Emitting continue-on-error keeps the @@ -903,19 +863,13 @@ func (g *Generator) writeCallbackJob(sb *strings.Builder, info CallbackInfo, wor sb.WriteString(" continue-on-error: true\n") } - // Inline run: callback. Emit a cascade-owned job with an inline run: step - // instead of a jobs..uses reusable-workflow call. Standard inputs reach - // the step as env: variables rather than reusable-workflow with: inputs. - if info.Run != "" { - g.writeInlineRunBody(sb, info) - } else { - fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(workflow)) + // Every callback is emitted as a jobs..uses reusable-workflow call. + fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(workflow)) - // with: pass outputs from dependencies - g.writeWithInputs(sb, info) + // with: pass outputs from dependencies + g.writeWithInputs(sb, info) - writeSecretsBlock(sb, info.Secrets) - } + writeSecretsBlock(sb, info.Secrets) // Generate retry jobs if retries > 0 for i := 1; i <= info.Retries; i++ { @@ -924,119 +878,12 @@ func (g *Generator) writeCallbackJob(sb *strings.Builder, info CallbackInfo, wor // For reusable-workflow callbacks that declare a passthrough artifact upload, // emit a cascade-owned post-job that uploads the artifact after the callback - // completes. Inline-run callbacks handle upload inline (see writeInlineRunBody). - if info.Run == "" && info.PassthroughArtifact != nil && info.PassthroughArtifact.Upload != "" { + // completes. + if info.PassthroughArtifact != nil && info.PassthroughArtifact.Upload != "" { g.writePassthroughUploadJob(sb, info) } } -// writeInlineRunBody emits the runs-on / steps body of a cascade-owned inline -// run: callback job. The standard inputs that a reusable-workflow callback would -// receive via with: are surfaced to the inline step as env: variables (uppercased, -// e.g. ENVIRONMENT, SHA, and any dependency outputs the callback declares). -// When info.PassthroughArtifact is set, download steps are injected before the -// run step and an upload step is appended after it. -func (g *Generator) writeInlineRunBody(sb *strings.Builder, info CallbackInfo) { - // Per-callback job attributes (inline-run jobs only): runner selection (#12), - // permissions incl. id-token: write OIDC (#35/#15), and concurrency (#17). The - // config-level runs_on default applies when the callback sets no runner. - writeRunsOn(sb, " ", info.RunsOn, g.config.RunsOn) - writeJobPermissions(sb, " ", info.Permissions) - writeJobConcurrency(sb, " ", info.Concurrency) - sb.WriteString(" steps:\n") - - // Inject download-artifact steps before the run step so the artifacts are - // present in the workspace when the inline command executes. - g.writePassthroughDownloadSteps(sb, info) - - fmt.Fprintf(sb, " - name: %s\n", info.DisplayName) - - envVars := g.inlineEnvInputs(info) - if len(envVars) > 0 { - sb.WriteString(" env:\n") - for _, ev := range envVars { - sb.WriteString(ev + "\n") - } - } - - shell := info.Shell - if shell == "" { - shell = "bash" - } - fmt.Fprintf(sb, " shell: %s\n", shell) - - sb.WriteString(" run: |\n") - for _, line := range strings.Split(strings.TrimRight(info.Run, "\n"), "\n") { - fmt.Fprintf(sb, " %s\n", line) - } - sb.WriteString("\n") - - // Inject upload-artifact step after the run step. - g.writePassthroughUploadStep(sb, info) -} - -// inlineEnvInputs returns the env: lines that surface the standard callback -// inputs (environment, sha, and declared dependency outputs) to an inline run: -// step. It mirrors writeWithInputs but renders to env: rather than with:. -func (g *Generator) inlineEnvInputs(info CallbackInfo) []string { - deps := g.graph.GetDirectDependencies(info.JobID) - - var envVars []string - // Track emitted env-var names so a dependency output named "sha" doesn't - // emit a second SHA: alongside the standard one (GHA rejects duplicate keys). - seen := map[string]bool{} - emit := func(name, value string) { - if seen[name] { - return - } - seen[name] = true - envVars = append(envVars, fmt.Sprintf(" %s: %s", name, value)) - } - - // Only pass environment if there are environments configured - if len(g.config.Environments) > 0 { - emit("ENVIRONMENT", fmt.Sprintf("${{ github.event.inputs.environment || '%s' }}", g.config.Environments[0])) - } - - // Optional standard inputs - only passed if callback declares them - if g.jobHasInput(info.JobID, "sha") { - emit("SHA", "${{ needs.setup.outputs.head_sha }}") - } - - // For build callbacks with a matrix, surface each dimension's current value. - if info.Matrix != nil && len(info.Matrix.Dimensions) > 0 { - keys := make([]string, 0, len(info.Matrix.Dimensions)) - for k := range info.Matrix.Dimensions { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - if g.jobHasInput(info.JobID, k) { - emit(envVarName(k), fmt.Sprintf("${{ matrix.%s }}", k)) - } - } - } - - // Pass outputs from dependencies the callback declares as inputs. - for _, depJobID := range deps { - depInfo := g.graph.Nodes[depJobID] - for _, out := range g.outputs[depInfo.JobID] { - if g.jobHasInput(info.JobID, out) { - emit(envVarName(out), fmt.Sprintf("${{ needs.%s.outputs.%s }}", depJobID, out)) - } - } - } - - return envVars -} - -// envVarName converts an input key to a shell-safe env-var name: uppercased with -// hyphens translated to underscores (e.g. "image-tag" -> "IMAGE_TAG", reachable -// in the run step as $IMAGE_TAG). -func envVarName(key string) string { - return strings.ToUpper(strings.ReplaceAll(key, "-", "_")) -} - // writeStrategyBlock emits the GHA strategy: block for a build matrix. // max-parallel is omitted when 0 (GHA default). fail-fast is emitted only // when explicitly set (non-nil pointer). @@ -1151,21 +998,6 @@ func (g *Generator) writeIfCondition(sb *strings.Builder, info CallbackInfo, nee } } -// inputKeys returns the sorted keys of a manifest inputs map. Used to seed the -// declared-input set for inline run: callbacks, which have no reusable-workflow -// file to parse inputs from. -func inputKeys(m map[string]interface{}) []string { - if len(m) == 0 { - return nil - } - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - // jobHasInput checks if a job declares a specific input func (g *Generator) jobHasInput(jobID, inputName string) bool { for _, input := range g.inputs[jobID] { @@ -1349,12 +1181,11 @@ func (g *Generator) writeRetryJob(sb *strings.Builder, info CallbackInfo, workfl fmt.Fprintf(sb, " name: %s - Retry %d\n", info.DisplayName, retryNum) fmt.Fprintf(sb, " needs: [setup, %s]\n", prevJobName) fmt.Fprintf(sb, " if: needs.%s.result == 'failure'\n", prevJobName) - switch { - case info.TimeoutMinutes > 0: + // An explicit per-callback timeout-minutes is valid only on a steps job, not + // on a reusable-workflow caller job. A retry shim re-invokes the reusable + // workflow via uses:, so the timeout must live inside the called workflow. + if info.TimeoutMinutes > 0 { fmt.Fprintf(sb, " timeout-minutes: %d\n", info.TimeoutMinutes) - case info.Run != "": - // Inline run: retry shims are cascade-owned (#37). - g.writeOwnedTimeout(sb, " ") } // Propagate the matrix strategy to the retry job so that ${{ matrix.* }} @@ -1365,10 +1196,6 @@ func (g *Generator) writeRetryJob(sb *strings.Builder, info CallbackInfo, workfl g.writeStrategyBlock(sb, info.Matrix) } - if info.Run != "" { - g.writeInlineRunBody(sb, info) - return - } fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(workflow)) g.writeWithInputs(sb, info) writeSecretsBlock(sb, info.Secrets) @@ -1930,43 +1757,6 @@ func passthroughArtifactName(buildName string) string { return fmt.Sprintf("build-%s", buildName) } -// writePassthroughDownloadSteps emits one download-artifact step per entry in -// info.PassthroughArtifact.Downloads. Each step downloads the artifact produced -// by the named upstream build job and places it in a directory named after the -// artifact so multiple downloads do not collide. -func (g *Generator) writePassthroughDownloadSteps(sb *strings.Builder, info CallbackInfo) { - if info.PassthroughArtifact == nil || len(info.PassthroughArtifact.Downloads) == 0 { - return - } - for _, src := range info.PassthroughArtifact.Downloads { - name := passthroughArtifactName(src) - fmt.Fprintf(sb, " - name: Download artifact from %s\n", src) - writeActionUses(sb, g.config, " ", actionDownloadArtifact) - sb.WriteString(" with:\n") - fmt.Fprintf(sb, " name: %s\n", name) - fmt.Fprintf(sb, " path: %s\n", name) - sb.WriteString("\n") - } -} - -// writePassthroughUploadStep emits an upload-artifact step for -// info.PassthroughArtifact.Upload when set. The artifact is named -// "build-{job-name}" so downstream jobs can reference it by name. -// Used only for inline-run callbacks where the step is injected inside the -// cascade-owned job's steps list. -func (g *Generator) writePassthroughUploadStep(sb *strings.Builder, info CallbackInfo) { - if info.PassthroughArtifact == nil || info.PassthroughArtifact.Upload == "" { - return - } - name := passthroughArtifactName(info.Name) - fmt.Fprintf(sb, " - name: Upload artifact %s\n", name) - writeActionUses(sb, g.config, " ", actionUploadArtifact) - sb.WriteString(" with:\n") - fmt.Fprintf(sb, " name: %s\n", name) - fmt.Fprintf(sb, " path: %s\n", info.PassthroughArtifact.Upload) - sb.WriteString("\n") -} - // writePassthroughDownloadJob emits a cascade-owned job (jobID) that runs // actions/download-artifact for each entry in info.PassthroughArtifact.Downloads. // Used for reusable-workflow callbacks where steps cannot be injected into the diff --git a/internal/generate/generator_test.go b/internal/generate/generator_test.go index 7d9765d..e695bce 100644 --- a/internal/generate/generator_test.go +++ b/internal/generate/generator_test.go @@ -156,32 +156,30 @@ on: assert.Contains(t, result, "finalize:") } -// TestGenerator_CallbackTimeoutMinutes asserts the per-callback -// timeout_minutes field renders into the generated workflow as a job-level -// `timeout-minutes:` setting (#97) for inline run: callbacks. Without this, -// inline callbacks default to GHA's 360min timeout, which is too lenient for -// tight feedback loops and too strict for longer integration jobs. -// -// timeout-minutes is only valid on a steps job, so the callbacks here use inline -// run:. Reusable-workflow (uses:) callbacks must NOT receive it (GitHub rejects -// the workflow); that gate is covered by -// TestGenerator_ExplicitTimeoutNotOnReusableCallback. +// TestGenerator_CallbackTimeoutMinutes asserts that a per-callback +// timeout_minutes (#97) is NOT emitted as a job-level timeout-minutes on a +// reusable-workflow (uses:) callback. GitHub forbids timeout-minutes on a job +// that calls a reusable workflow, so the timeout must live inside the called +// workflow. Callbacks are reusable-workflow only, so the field is never emitted +// on the caller job for validate, build, or deploy. func TestGenerator_CallbackTimeoutMinutes(t *testing.T) { tmpDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + writeStubWorkflow(t, tmpDir, "validate.yaml") + writeStubWorkflow(t, tmpDir, "build.yaml") + writeStubWorkflow(t, tmpDir, "deploy.yaml") cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: []string{"dev"}, Validate: &config.ValidateConfig{ - Run: "make validate", + Workflow: ".github/workflows/validate.yaml", TimeoutMinutes: 5, }, Builds: []config.BuildConfig{ - {Name: "app", Run: "make build", Triggers: []string{"src/**"}, TimeoutMinutes: 30}, + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}, TimeoutMinutes: 30}, }, Deploys: []config.DeployConfig{ - {Name: "svc", Run: "make deploy", DependsOn: []string{"app"}, TimeoutMinutes: 15}, + {Name: "svc", Workflow: ".github/workflows/deploy.yaml", DependsOn: []string{"app"}, TimeoutMinutes: 15}, }, } @@ -189,31 +187,18 @@ func TestGenerator_CallbackTimeoutMinutes(t *testing.T) { result, err := gen.Generate() require.NoError(t, err) - // Find each job by its ID and assert timeout-minutes appears in its block. - requireJobHas := func(t *testing.T, content, jobID, want string) { + requireJobLacksTimeout := func(t *testing.T, content, jobID string) { t.Helper() - idx := strings.Index(content, " "+jobID+":") - require.GreaterOrEqual(t, idx, 0, "job %q not found", jobID) - // scope to this job (until next two-space indented job, or EOF) - block := content[idx:] - if next := strings.Index(block[1:], "\n ") + 1; next > 0 && next < len(block) { - // Find the next top-level " :" line after the current one - lines := strings.SplitAfter(block, "\n") - endIdx := 0 - for i := 1; i < len(lines); i++ { - if strings.HasPrefix(lines[i], " ") && !strings.HasPrefix(lines[i], " ") && strings.Contains(lines[i], ":") { - break - } - endIdx += len(lines[i]) - } - block = block[:len(lines[0])+endIdx] - } - assert.Contains(t, block, want, "job %q must contain %q", jobID, want) + block := jobBlock(t, content, jobID) + require.NotEmpty(t, block, "job %q not found", jobID) + assert.Contains(t, block, "uses:", "job %q must be a reusable-workflow caller", jobID) + assert.NotContains(t, block, "timeout-minutes:", + "timeout-minutes must not be emitted on reusable-workflow callback %q", jobID) } - requireJobHas(t, result, "validate", "timeout-minutes: 5") - requireJobHas(t, result, "build-app", "timeout-minutes: 30") - requireJobHas(t, result, "deploy-svc", "timeout-minutes: 15") + requireJobLacksTimeout(t, result, "validate") + requireJobLacksTimeout(t, result, "build-app") + requireJobLacksTimeout(t, result, "deploy-svc") } // TestGenerator_CallbackTimeoutOmittedWhenZero asserts no timeout-minutes is @@ -1829,91 +1814,6 @@ func TestGenerator_BuildMatrix_NoMatrixNoStrategy(t *testing.T) { assert.NotContains(t, result, "strategy:", "no matrix → no strategy block") } -// TestGenerator_PassthroughArtifact_InlineUpload asserts that an inline-run -// build declaring artifact.upload gets an upload-artifact step injected after -// the run step in the same cascade-owned job. -func TestGenerator_PassthroughArtifact_InlineUpload(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "compile", - Run: "make build", - Triggers: []string{"src/**"}, - PassthroughArtifact: &config.PassthroughArtifact{ - Upload: "dist/", - }, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - // The job must contain the upload-artifact step. - assert.Contains(t, result, "uses: actions/upload-artifact@v4", - "inline build with artifact.upload must emit upload-artifact step") - // The artifact name must be "build-compile". - assert.Contains(t, result, "name: build-compile", - "uploaded artifact must be named build-{build-name}") - // The path must match the declared upload path. - assert.Contains(t, result, "path: dist/", - "upload step must set path to artifact.upload value") - // No download step; this build has no downloads configured. - assert.NotContains(t, result, "uses: actions/download-artifact@v4", - "build without artifact.downloads must not emit download-artifact step") -} - -// TestGenerator_PassthroughArtifact_InlineDownload asserts that an inline-run -// build declaring artifact.downloads gets one download-artifact step per declared -// source injected before the run step. -func TestGenerator_PassthroughArtifact_InlineDownload(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "compile", - Run: "make build", - Triggers: []string{"src/**"}, - PassthroughArtifact: &config.PassthroughArtifact{ - Upload: "dist/", - }, - }, - { - Name: "sign", - Run: "make sign", - DependsOn: []string{"compile"}, - Triggers: []string{"src/**"}, - PassthroughArtifact: &config.PassthroughArtifact{ - Downloads: []string{"compile"}, - Upload: "dist-signed/", - }, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - // The sign job must contain a download-artifact step referencing the - // compile job's artifact. - assert.Contains(t, result, "uses: actions/download-artifact@v4", - "build with artifact.downloads must emit download-artifact step") - assert.Contains(t, result, "name: build-compile", - "download step must reference the producer's artifact name") - // And it still uploads its own artifact. - assert.Contains(t, result, "name: build-sign", - "sign job must upload its own artifact named build-sign") -} - // TestGenerator_PassthroughArtifact_NoArtifactNoSteps asserts that a build // without artifact: produces neither upload-artifact nor download-artifact steps // (non-breaking for existing configs). @@ -2039,84 +1939,6 @@ func TestGenerator_PassthroughArtifact_ReusableWorkflowDownloadJob(t *testing.T) "compile build with artifact.upload must emit its post-upload job") } -// TestGenerator_PassthroughArtifact_E2E_OrchestrateYAML exercises the full -// parse → generate pipeline with a manifest that declares a three-stage inline -// build pipeline: compile → sign → package. It asserts that the generated -// orchestrate.yaml carries the correct upload-artifact and download-artifact -// steps so the artifact flows from compile through sign to package without any -// external registry workaround. -func TestGenerator_PassthroughArtifact_E2E_OrchestrateYAML(t *testing.T) { - tmpDir := t.TempDir() - - // Write a manifest file that exercises the full artifact-passing path. - manifest := `ci: - config: - trunk_branch: main - environments: - - dev - builds: - - name: compile - run: make build - triggers: - - "src/**" - artifact: - upload: dist/ - - name: sign - run: make sign - depends_on: [compile] - triggers: - - "src/**" - artifact: - downloads: [compile] - upload: dist-signed/ - - name: package - run: make package - depends_on: [sign] - triggers: - - "src/**" - artifact: - downloads: [sign] -` - manifestPath := filepath.Join(tmpDir, "manifest.yaml") - require.NoError(t, os.WriteFile(manifestPath, []byte(manifest), 0644)) - - cfg, err := config.Parse(manifestPath) - require.NoError(t, err) - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - // compile: uploads dist/ - assert.Contains(t, result, "name: build-compile", - "compile job must upload artifact named build-compile") - assert.Contains(t, result, "path: dist/", - "compile upload step must reference dist/") - - // sign: downloads build-compile and uploads dist-signed/ - assert.Contains(t, result, "name: build-compile", - "sign job must download the compile artifact") - assert.Contains(t, result, "name: build-sign", - "sign job must upload artifact named build-sign") - assert.Contains(t, result, "path: dist-signed/", - "sign upload step must reference dist-signed/") - - // package: downloads build-sign - assert.Contains(t, result, "name: build-sign", - "package job must download the sign artifact") - - // The generated workflow must be valid YAML (no stray tabs or bad indentation - // introduced by the artifact step injection). Count action references as a - // proxy: 3 upload steps (compile, sign) + package has no upload so 2 uploads, - // and 2 download steps (sign downloads compile, package downloads sign). - uploadCount := strings.Count(result, "uses: actions/upload-artifact@v4") - downloadCount := strings.Count(result, "uses: actions/download-artifact@v4") - assert.Equal(t, 2, uploadCount, - "expected 2 upload-artifact steps (compile and sign)") - assert.Equal(t, 2, downloadCount, - "expected 2 download-artifact steps (sign and package)") -} - // TestGenerator_DispatchInputs_StringType asserts that a string dispatch_input // is emitted correctly in the workflow_dispatch.inputs block. func TestGenerator_DispatchInputs_StringType(t *testing.T) { diff --git a/internal/generate/graph.go b/internal/generate/graph.go index 8af0685..7531735 100644 --- a/internal/generate/graph.go +++ b/internal/generate/graph.go @@ -26,8 +26,6 @@ type CallbackInfo struct { DisplayName string // Display name (e.g., "Build (app)") Type string // "build" or "deploy" or "validate" Workflow string - Run string // Inline command; when set the callback is emitted as a cascade-owned inline-step job instead of a reusable-workflow call - Shell string // Shell for the inline run step (default bash; only meaningful with Run) RunPolicy string OnFailure string Retries int @@ -35,10 +33,11 @@ type CallbackInfo struct { Matrix *config.MatrixConfig // Build fan-out; nil for deploys and validate SupportsDryRun bool // When true, dry-run promotes invoke the callback with dry_run: true instead of skipping it - // Per-callback job attributes for cascade-owned inline run: jobs. These are - // emitted only on inline-run jobs (never on reusable-workflow uses: callbacks, - // where GHA forbids runs-on/concurrency); schema validation already rejects - // runs_on/concurrency on reusable callbacks. + // Per-callback job attributes carried from config. GHA forbids + // runs-on/permissions/concurrency on a reusable-workflow uses: callback, and + // schema validation rejects runs_on/permissions/concurrency on reusable + // callbacks. Callbacks are reusable-workflow only, so these fields are + // populated from config but never emitted as job-level keys. RunsOn *config.RunsOn // Per-callback runner selection (#12) Permissions map[string]string // Per-callback job permissions, incl. id-token: write OIDC (#35, #15) Concurrency *config.ConcurrencyConfig // Per-callback concurrency override (#17) @@ -74,8 +73,6 @@ func BuildDependencyGraph(cfg *config.TrunkConfig) *DependencyGraph { DisplayName: "Validate (validate)", Type: config.CallbackTypeValidate, Workflow: cfg.Validate.Workflow, - Run: cfg.Validate.Run, - Shell: cfg.Validate.Shell, RunPolicy: defaultString(cfg.Validate.RunPolicy, config.RunPolicyDefault), OnFailure: defaultString(cfg.Validate.OnFailure, config.OnFailureAbort), Retries: cfg.Validate.Retries, @@ -98,8 +95,6 @@ func BuildDependencyGraph(cfg *config.TrunkConfig) *DependencyGraph { DisplayName: config.DisplayName(config.CallbackTypeBuild, b.Name), Type: config.CallbackTypeBuild, Workflow: b.Workflow, - Run: b.Run, - Shell: b.Shell, RunPolicy: defaultString(b.RunPolicy, config.RunPolicyDefault), OnFailure: defaultString(b.OnFailure, config.OnFailureAbort), Retries: b.Retries, @@ -144,8 +139,6 @@ func BuildDependencyGraph(cfg *config.TrunkConfig) *DependencyGraph { DisplayName: config.DisplayName(config.CallbackTypeDeploy, d.Name), Type: config.CallbackTypeDeploy, Workflow: d.Workflow, - Run: d.Run, - Shell: d.Shell, RunPolicy: defaultString(d.RunPolicy, config.RunPolicyDefault), OnFailure: defaultString(d.OnFailure, config.OnFailureAbort), Retries: d.Retries, diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 81dbbe0..3b5d280 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -424,7 +424,8 @@ func (g *HotfixGenerator) writeBuildJobs(sb *strings.Builder) { writeSecretsBlock(sb, b.Secrets) continue } - // Inline build fallback: mirror the run-based callback shape. + // Hotfix build placeholder step: a steps-based job that echoes the build + // name. Operators replace this scaffold with the real build commands. sb.WriteString(" runs-on: ubuntu-latest\n") sb.WriteString(" steps:\n") writeActionStep(sb, g.config, " ", actionCheckout) @@ -432,11 +433,7 @@ func (g *HotfixGenerator) writeBuildJobs(sb *strings.Builder) { sb.WriteString(" ref: ${{ github.event.pull_request.merge_commit_sha }}\n") fmt.Fprintf(sb, " - name: Run build %s\n", b.Name) sb.WriteString(" run: |\n") - if b.Run != "" { - fmt.Fprintf(sb, " %s\n", b.Run) - } else { - fmt.Fprintf(sb, " echo \"build %s\"\n", b.Name) - } + fmt.Fprintf(sb, " echo \"build %s\"\n", b.Name) } } @@ -470,7 +467,7 @@ func (g *HotfixGenerator) writeDeployJobs(sb *strings.Builder) { // Decision 7: bind to the target GitHub Environment so org protection // rules (manual approval on prod, etc.) apply to the hotfix deploy. The // environment: key is invalid on a reusable-workflow (uses:) job, so the - // hotfix deploy is an inline job that carries the gate and invokes the + // hotfix deploy is a steps-based job that carries the gate and invokes the // deploy via the CLI; the configured deploy workflow path is recorded for // the operator in the step. sb.WriteString(" environment: ${{ needs.context.outputs.target_env }}\n") @@ -481,12 +478,9 @@ func (g *HotfixGenerator) writeDeployJobs(sb *strings.Builder) { sb.WriteString(" DEPLOY_ENV: ${{ needs.context.outputs.target_env }}\n") sb.WriteString(" DEPLOY_SHA: ${{ github.event.pull_request.merge_commit_sha }}\n") sb.WriteString(" run: |\n") - switch { - case d.Workflow != "": + if d.Workflow != "" { fmt.Fprintf(sb, " echo \"deploy %s via %s to $DEPLOY_ENV at $DEPLOY_SHA\"\n", d.Name, normalizeWorkflowPath(d.Workflow)) - case d.Run != "": - fmt.Fprintf(sb, " %s\n", d.Run) - default: + } else { fmt.Fprintf(sb, " echo \"deploy %s to $DEPLOY_ENV at $DEPLOY_SHA\"\n", d.Name) } diff --git a/internal/generate/inline_run_test.go b/internal/generate/inline_run_test.go deleted file mode 100644 index a4c13fa..0000000 --- a/internal/generate/inline_run_test.go +++ /dev/null @@ -1,315 +0,0 @@ -package generate - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stablekernel/cascade/internal/config" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestGenerator_InlineRunBuildEmitsStep asserts that a build callback declaring -// run: is emitted as a cascade-owned job with an inline run: step rather than a -// jobs..uses reusable-workflow call. -func TestGenerator_InlineRunBuildEmitsStep(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "go test ./...", - Triggers: []string{"src/**"}, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - assert.Contains(t, result, "build-smoke:") - // inline body - assert.Contains(t, result, "runs-on: ubuntu-latest") - assert.Contains(t, result, "shell: bash") - assert.Contains(t, result, "go test ./...") - // must NOT be emitted as a reusable-workflow call or carry secrets: inherit - buildJob := jobSection(result, "build-smoke:") - assert.NotContains(t, buildJob, "uses:") - assert.NotContains(t, buildJob, "secrets: inherit") -} - -// TestGenerator_InlineRunShellHonored asserts shell: overrides the bash default. -func TestGenerator_InlineRunShellHonored(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "py", - Run: "print('hi')", - Shell: "python", - Triggers: []string{"src/**"}, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-py:") - assert.Contains(t, job, "shell: python") - assert.NotContains(t, job, "shell: bash") -} - -// TestGenerator_WorkflowCallbackStillUsesReusable asserts a workflow: callback -// 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)) - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "app", - Workflow: ".github/workflows/build.yaml", - Triggers: []string{"src/**"}, - Secrets: &config.SecretsConfig{Inherit: true}, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-app:") - assert.Contains(t, job, "uses: ./.github/workflows/build.yaml") - assert.Contains(t, job, "secrets: inherit") - assert.NotContains(t, job, "shell: bash") -} - -// TestGenerator_InlineRunStandardInputsReachStep asserts that the standard -// inputs a reusable callback would receive via with: (environment, sha) reach -// an inline run: step as env: variables when the callback declares them. -func TestGenerator_InlineRunStandardInputsReachStep(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "curl -sf https://$ENVIRONMENT.example.com/healthz", - Triggers: []string{"src/**"}, - Inputs: map[string]interface{}{"sha": nil}, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - assert.Contains(t, job, "env:") - assert.Contains(t, job, "ENVIRONMENT: ${{ github.event.inputs.environment || 'dev' }}") - assert.Contains(t, job, "SHA: ${{ needs.setup.outputs.head_sha }}") -} - -// TestGenerator_InlineAndWorkflowMixEmitsBoth covers the e2e scenario: a manifest -// with one workflow: callback and one run: callback produces a workflow where -// the workflow: callback is a jobs..uses job and the run: callback is a -// run: step. -func TestGenerator_InlineAndWorkflowMixEmitsBoth(t *testing.T) { - tmpDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte("on:\n workflow_call:\n"), 0644)) - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "app", - Workflow: ".github/workflows/build.yaml", - Triggers: []string{"src/**"}, - }, - { - Name: "smoke", - Run: "echo smoke", - Triggers: []string{"src/**"}, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - appJob := jobSection(result, "build-app:") - assert.Contains(t, appJob, "uses: ./.github/workflows/build.yaml") - - smokeJob := jobSection(result, "build-smoke:") - assert.Contains(t, smokeJob, "run: |") - assert.Contains(t, smokeJob, "echo smoke") - assert.NotContains(t, smokeJob, "uses:") -} - -// TestPromoteGenerator_InlineRunDeployEmitsStep asserts that a deploy callback -// declaring run: is emitted as a cascade-owned job with an inline run: step in -// the promote workflow (both the trigger-gated and prod deploy jobs), with the -// standard environment/sha inputs reaching the step as env: variables. -func TestPromoteGenerator_InlineRunDeployEmitsStep(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev", "prod"}, - Deploys: []config.DeployConfig{ - { - Name: "notify", - Run: "echo deploying to $ENVIRONMENT @ $SHA", - Triggers: []string{"deploy/**"}, - }, - }, - } - - gen := NewPromoteGenerator(cfg, "") - content, err := gen.Generate() - require.NoError(t, err) - - deployJob := jobSection(content, "deploy-notify:") - require.NotEmpty(t, deployJob) - assert.Contains(t, deployJob, "runs-on: ubuntu-latest") - assert.Contains(t, deployJob, "shell: bash") - assert.Contains(t, deployJob, "echo deploying to $ENVIRONMENT @ $SHA") - assert.Contains(t, deployJob, "ENVIRONMENT: ${{ needs.preflight.outputs.target_env }}") - assert.Contains(t, deployJob, "SHA: ${{ needs.preflight.outputs.source_sha }}") - assert.NotContains(t, deployJob, "uses:") - assert.NotContains(t, deployJob, "secrets: inherit") - - prodJob := jobSection(content, "deploy-notify-prod:") - require.NotEmpty(t, prodJob) - assert.Contains(t, prodJob, "run: |") - assert.Contains(t, prodJob, "ENVIRONMENT: prod") - assert.NotContains(t, prodJob, "uses:") -} - -// TestPromoteGenerator_InlineRunDeployImageTag asserts an inline deploy that -// declares an image_tag input gets IMAGE_TAG in its env (the input-seeding fix -// for inline deploys, which have no reusable-workflow file to discover from). -func TestPromoteGenerator_InlineRunDeployImageTag(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev", "prod"}, - Deploys: []config.DeployConfig{ - { - Name: "notify", - Run: "echo $IMAGE_TAG", - Triggers: []string{"deploy/**"}, - Inputs: map[string]interface{}{"image_tag": nil}, - }, - }, - } - - gen := NewPromoteGenerator(cfg, "") - content, err := gen.Generate() - require.NoError(t, err) - - deployJob := jobSection(content, "deploy-notify:") - require.NotEmpty(t, deployJob) - assert.Contains(t, deployJob, "IMAGE_TAG: ${{ needs.preflight.outputs.source_image_tag }}") - - prodJob := jobSection(content, "deploy-notify-prod:") - require.NotEmpty(t, prodJob) - assert.Contains(t, prodJob, "IMAGE_TAG: ${{ needs.preflight.outputs.prod_version }}") -} - -// TestGenerator_InlineRunDepOutputEnvNameMangled asserts a hyphenated dependency -// output (e.g. image-tag) is surfaced as a shell-safe env var IMAGE_TAG, and a -// dep output named sha does not duplicate the standard SHA: env entry. -func TestGenerator_InlineRunDepOutputEnvNameMangled(t *testing.T) { - tmpDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) - // Build workflow that emits image-tag and sha outputs. - buildWorkflow := ` -on: - workflow_call: - outputs: - image-tag: - value: x - sha: - value: y -` - require.NoError(t, os.WriteFile(filepath.Join(tmpDir, ".github/workflows/build.yaml"), []byte(buildWorkflow), 0644)) - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "app", - Workflow: ".github/workflows/build.yaml", - Triggers: []string{"src/**"}, - }, - { - Name: "smoke", - Run: "echo $IMAGE_TAG $SHA", - Triggers: []string{"src/**"}, - DependsOn: []string{"app"}, - Inputs: map[string]interface{}{"image-tag": nil, "sha": nil}, - }, - }, - } - - gen := NewGenerator(cfg, tmpDir) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - require.NotEmpty(t, job) - assert.Contains(t, job, "IMAGE_TAG: ${{ needs.build-app.outputs.image-tag }}") - // SHA must appear exactly once (standard input), not duplicated by the dep output. - assert.Equal(t, 1, strings.Count(job, "SHA:")) -} - -// jobSection returns the text of the job whose header line (e.g. "build-app:") -// starts the section, up to the next top-level (two-space-indented) job header. -func jobSection(yaml, header string) string { - lines := strings.Split(yaml, "\n") - start := -1 - for i, l := range lines { - if strings.TrimSpace(l) == header && strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " ") { - start = i - break - } - } - if start < 0 { - return "" - } - end := len(lines) - for i := start + 1; i < len(lines); i++ { - l := lines[i] - if strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " ") && strings.HasSuffix(strings.TrimSpace(l), ":") && len(strings.TrimSpace(l)) > 1 { - // next job header at the same two-space indent - trimmed := strings.TrimLeft(l, " ") - if len(l)-len(trimmed) == 2 { - end = i - break - } - } - } - return strings.Join(lines[start:end], "\n") -} diff --git a/internal/generate/job_attributes.go b/internal/generate/job_attributes.go deleted file mode 100644 index 117830b..0000000 --- a/internal/generate/job_attributes.go +++ /dev/null @@ -1,105 +0,0 @@ -package generate - -import ( - "fmt" - "sort" - "strings" - - "github.com/stablekernel/cascade/internal/config" -) - -// This file holds the emit helpers for the per-callback job attributes that -// apply to cascade-owned inline run: jobs: runs-on (#12), permissions (#35, -// including the id-token: write OIDC use case #15), and concurrency (#17). -// -// These attributes are valid ONLY on cascade-owned jobs (inline run: callbacks -// and cascade-owned setup/finalize jobs), never on reusable-workflow -// jobs..uses callbacks (GHA forbids runs-on/concurrency there). Schema -// validation already rejects runs_on/concurrency on reusable callbacks, so the -// generator only ever calls these helpers from the inline-run emit paths. - -// defaultRunner is the runner used for cascade-owned jobs when neither a -// per-callback runs_on nor a config-level runs_on default is set. -const defaultRunner = "ubuntu-latest" - -// resolveRunsOn returns the runs-on value (as a YAML scalar/flow string) for a -// cascade-owned job, applying the precedence: per-callback runs_on > config-level -// runs_on default > "ubuntu-latest". It is rendered inline after "runs-on: ". -func resolveRunsOn(callback *config.RunsOn, configDefault *config.RunsOn) string { - if v := renderRunsOnValue(callback); v != "" { - return v - } - if v := renderRunsOnValue(configDefault); v != "" { - return v - } - return defaultRunner -} - -// renderRunsOnValue renders a *config.RunsOn into the value that follows -// "runs-on: " on a single line. It handles the three union forms: -// -// - scalar label -> ubuntu-latest -// - list of labels -> [self-hosted, linux, arm64] -// - {group[, labels]} -> {group: my-group, labels: [self-hosted]} -// -// Returns "" when r is nil or carries no usable value, so callers can fall back -// to the next precedence level. -func renderRunsOnValue(r *config.RunsOn) string { - if r == nil { - return "" - } - switch { - case r.Group != "": - if len(r.Labels) > 0 { - return fmt.Sprintf("{group: %s, labels: [%s]}", r.Group, strings.Join(r.Labels, ", ")) - } - return fmt.Sprintf("{group: %s}", r.Group) - case len(r.Labels) > 0: - return fmt.Sprintf("[%s]", strings.Join(r.Labels, ", ")) - case r.Label != "": - return r.Label - default: - return "" - } -} - -// writeRunsOn emits the "runs-on:" line for a cascade-owned job at the given -// indent (e.g. " " for a job-level key). The value is resolved via -// resolveRunsOn so the config-level default and "ubuntu-latest" fallback apply. -func writeRunsOn(sb *strings.Builder, indent string, callback, configDefault *config.RunsOn) { - fmt.Fprintf(sb, "%sruns-on: %s\n", indent, resolveRunsOn(callback, configDefault)) -} - -// writeJobPermissions emits a "permissions:" map on a cascade-owned job from the -// callback's permissions. When perms is empty nothing is emitted, so the -// workflow-level permissions continue to apply (backward-compatible). The -// id-token: write OIDC use case is just one entry in this map (#15) and needs no -// dedicated field. Keys are emitted in deterministic (sorted) order. -func writeJobPermissions(sb *strings.Builder, indent string, perms map[string]string) { - if len(perms) == 0 { - return - } - fmt.Fprintf(sb, "%spermissions:\n", indent) - scopes := make([]string, 0, len(perms)) - for scope := range perms { - scopes = append(scopes, scope) - } - sort.Strings(scopes) - for _, scope := range scopes { - fmt.Fprintf(sb, "%s %s: %s\n", indent, scope, perms[scope]) - } -} - -// writeJobConcurrency emits a job-level "concurrency:" block on a cascade-owned -// job from the callback's concurrency config. When c is nil nothing is emitted. -// A bare group renders as a scalar; a group plus cancel_in_progress renders as -// the object form. cancel_in_progress is always emitted (the zero value false is -// meaningful: queue rather than cancel). -func writeJobConcurrency(sb *strings.Builder, indent string, c *config.ConcurrencyConfig) { - if c == nil { - return - } - fmt.Fprintf(sb, "%sconcurrency:\n", indent) - fmt.Fprintf(sb, "%s group: %s\n", indent, c.Group) - fmt.Fprintf(sb, "%s cancel-in-progress: %t\n", indent, c.CancelInProgress) -} diff --git a/internal/generate/job_attributes_test.go b/internal/generate/job_attributes_test.go index 9b2523c..1f40b9f 100644 --- a/internal/generate/job_attributes_test.go +++ b/internal/generate/job_attributes_test.go @@ -11,257 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -// TestInlineRunRunsOnScalar asserts a per-callback scalar runs_on is emitted on -// an inline-run job's runs-on line, overriding the ubuntu-latest default. -func TestInlineRunRunsOnScalar(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "go test ./...", - Triggers: []string{"src/**"}, - RunsOn: &config.RunsOn{Label: "self-hosted"}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - require.NotEmpty(t, job) - assert.Contains(t, job, "runs-on: self-hosted") - assert.NotContains(t, job, "runs-on: ubuntu-latest") -} - -// TestInlineRunRunsOnList asserts the list (label-set) union form renders as a -// flow sequence on the runs-on line. -func TestInlineRunRunsOnList(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "arm", - Run: "make build", - Triggers: []string{"src/**"}, - RunsOn: &config.RunsOn{Labels: []string{"self-hosted", "linux", "arm64"}}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-arm:") - assert.Contains(t, job, "runs-on: [self-hosted, linux, arm64]") -} - -// TestInlineRunRunsOnGroup asserts the {group, labels} union form renders as a -// flow mapping on the runs-on line. -func TestInlineRunRunsOnGroup(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "gpu", - Run: "make train", - Triggers: []string{"src/**"}, - RunsOn: &config.RunsOn{Group: "ml-runners", Labels: []string{"gpu"}}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-gpu:") - assert.Contains(t, job, "runs-on: {group: ml-runners, labels: [gpu]}") -} - -// TestInlineRunRunsOnConfigDefault asserts the config-level runs_on default is -// applied to a cascade-owned inline job that sets no per-callback runner. -func TestInlineRunRunsOnConfigDefault(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - RunsOn: &config.RunsOn{Label: "self-hosted"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "go test ./...", - Triggers: []string{"src/**"}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - assert.Contains(t, job, "runs-on: self-hosted") -} - -// TestInlineRunRunsOnPerCallbackOverridesConfigDefault asserts the per-callback -// runs_on wins over the config-level default. -func TestInlineRunRunsOnPerCallbackOverridesConfigDefault(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - RunsOn: &config.RunsOn{Label: "default-runner"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "go test ./...", - Triggers: []string{"src/**"}, - RunsOn: &config.RunsOn{Label: "override-runner"}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - assert.Contains(t, job, "runs-on: override-runner") - assert.NotContains(t, job, "default-runner") -} - -// TestInlineRunRunsOnFallbackDefault asserts ubuntu-latest is still emitted when -// nothing sets a runner (unchanged behavior, non-breaking). -func TestInlineRunRunsOnFallbackDefault(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "go test ./...", - Triggers: []string{"src/**"}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - assert.Contains(t, job, "runs-on: ubuntu-latest") -} - -// TestInlineRunPermissionsEmit asserts a per-callback permissions map is emitted -// on the inline job, with keys in deterministic (sorted) order. -func TestInlineRunPermissionsEmit(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "image", - Run: "docker build .", - Triggers: []string{"src/**"}, - Permissions: map[string]string{"packages": "write", "contents": "read"}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-image:") - assert.Contains(t, job, "permissions:") - assert.Contains(t, job, "contents: read") - assert.Contains(t, job, "packages: write") - // deterministic order: contents (sorted) before packages - assert.Less(t, strings.Index(job, "contents: read"), strings.Index(job, "packages: write")) -} - -// TestInlineRunOIDCIdTokenPermission asserts the OIDC use case (#15) is satisfied -// by the general permissions map: id-token: write is emitted as a plain entry, -// no dedicated field required. -func TestInlineRunOIDCIdTokenPermission(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "deploy", - Run: "aws sts get-caller-identity", - Triggers: []string{"src/**"}, - Permissions: map[string]string{"id-token": "write", "contents": "read"}, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-deploy:") - assert.Contains(t, job, "id-token: write") -} - -// TestInlineRunConcurrencyEmit asserts a per-callback concurrency block is -// emitted on the inline job, including cancel-in-progress: false. -func TestInlineRunConcurrencyEmit(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "go test ./...", - Triggers: []string{"src/**"}, - Concurrency: &config.ConcurrencyConfig{ - Group: "smoke-${{ github.ref }}", - CancelInProgress: false, - }, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - assert.Contains(t, job, "concurrency:") - assert.Contains(t, job, "group: smoke-${{ github.ref }}") - assert.Contains(t, job, "cancel-in-progress: false") -} - -// TestInlineRunOmittedAttributesUnchanged asserts that omitting all three fields -// changes nothing: no job-level permissions/concurrency, runs-on defaults to -// ubuntu-latest (non-breaking). -func TestInlineRunOmittedAttributesUnchanged(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - {Name: "smoke", Run: "go test ./...", Triggers: []string{"src/**"}}, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(result, "build-smoke:") - assert.Contains(t, job, "runs-on: ubuntu-latest") - assert.NotContains(t, job, "permissions:") - assert.NotContains(t, job, "concurrency:") -} - // TestReusableWorkflowCallbackHasNoJobAttributes asserts that a reusable // workflow: callback (jobs..uses) never carries runs-on / permissions / // concurrency on the job. GHA forbids them there and schema validation rejects @@ -319,46 +68,6 @@ func TestValidationRejectsJobAttributesOnReusableCallback(t *testing.T) { "expected concurrency rejection on reusable callback") } -// TestPromoteInlineDeployCarriesAllJobAttributes is the e2e scenario: an inline -// run: deploy carrying runs_on + permissions (incl. OIDC id-token) + concurrency -// emits all three on the cascade-owned deploy job in the promote workflow. -func TestPromoteInlineDeployCarriesAllJobAttributes(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev", "prod"}, - Deploys: []config.DeployConfig{ - { - Name: "k8s", - Run: "kubectl apply -f .", - Triggers: []string{"deploy/**"}, - RunsOn: &config.RunsOn{Labels: []string{"self-hosted", "cdk"}}, - Permissions: map[string]string{"id-token": "write", "contents": "read"}, - Concurrency: &config.ConcurrencyConfig{Group: "k8s-${{ inputs.target_env }}", CancelInProgress: false}, - }, - }, - } - - gen := NewPromoteGenerator(cfg, "") - content, err := gen.Generate() - require.NoError(t, err) - - job := jobSection(content, "deploy-k8s:") - require.NotEmpty(t, job) - assert.Contains(t, job, "runs-on: [self-hosted, cdk]") - assert.Contains(t, job, "permissions:") - assert.Contains(t, job, "id-token: write") - assert.Contains(t, job, "contents: read") - assert.Contains(t, job, "concurrency:") - assert.Contains(t, job, "group: k8s-${{ inputs.target_env }}") - assert.Contains(t, job, "cancel-in-progress: false") - - // The prod variant of the same inline deploy also carries the attributes. - prod := jobSection(content, "deploy-k8s-prod:") - require.NotEmpty(t, prod) - assert.Contains(t, prod, "runs-on: [self-hosted, cdk]") - assert.Contains(t, prod, "id-token: write") -} - // hasErr reports whether any error string contains substr. func hasErr(errs []string, substr string) bool { for _, e := range errs { @@ -368,3 +77,32 @@ func hasErr(errs []string, substr string) bool { } return false } + +// jobSection returns the text of the job whose header line (e.g. "build-app:") +// starts the section, up to the next top-level (two-space-indented) job header. +func jobSection(yaml, header string) string { + lines := strings.Split(yaml, "\n") + start := -1 + for i, l := range lines { + if strings.TrimSpace(l) == header && strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " ") { + start = i + break + } + } + if start < 0 { + return "" + } + end := len(lines) + for i := start + 1; i < len(lines); i++ { + l := lines[i] + if strings.HasPrefix(l, " ") && !strings.HasPrefix(l, " ") && strings.HasSuffix(strings.TrimSpace(l), ":") && len(strings.TrimSpace(l)) > 1 { + // next job header at the same two-space indent + trimmed := strings.TrimLeft(l, " ") + if len(l)-len(trimmed) == 2 { + end = i + break + } + } + } + return strings.Join(lines[start:end], "\n") +} diff --git a/internal/generate/job_control_test.go b/internal/generate/job_control_test.go index 2e2ebb8..8599375 100644 --- a/internal/generate/job_control_test.go +++ b/internal/generate/job_control_test.go @@ -90,8 +90,7 @@ func TestGenerator_OwnedJobTimeoutConfigurable(t *testing.T) { // TestGenerator_TimeoutNotOnReusableCallback asserts a reusable-workflow // callback (jobs..uses) does NOT receive the owned-job timeout. The called -// workflow owns its own timeout. Inline run: callbacks, which are cascade-owned, -// DO get it. +// workflow owns its own timeout. func TestGenerator_TimeoutNotOnReusableCallback(t *testing.T) { tmpDir := t.TempDir() writeStubWorkflow(t, tmpDir, "build.yaml") @@ -102,8 +101,6 @@ func TestGenerator_TimeoutNotOnReusableCallback(t *testing.T) { Builds: []config.BuildConfig{ // Reusable-workflow callback (uses:): no timeout-minutes. {Name: "reusable", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, - // Inline run: callback: cascade-owned, gets the default. - {Name: "inline", Run: "go test ./...", Triggers: []string{"src/**"}}, }, } @@ -113,31 +110,6 @@ func TestGenerator_TimeoutNotOnReusableCallback(t *testing.T) { reusable := jobBlock(t, result, "build-reusable") assert.Contains(t, reusable, "uses:", "sanity: reusable callback is a uses: caller") assert.NotContains(t, reusable, "timeout-minutes:", "reusable-workflow caller must not get a cascade timeout") - - inline := jobBlock(t, result, "build-inline") - assert.Contains(t, inline, "timeout-minutes: 30", "inline run: callback is cascade-owned and gets the default") -} - -// TestGenerator_PerCallbackTimeoutWinsOnInline asserts an explicit per-callback -// timeout_minutes takes precedence over the owned-job default on an inline run: -// callback. -func TestGenerator_PerCallbackTimeoutWinsOnInline(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - {Name: "inline", Run: "go test ./...", Triggers: []string{"src/**"}, TimeoutMinutes: 7}, - }, - } - - result, err := NewGenerator(cfg, tmpDir).Generate() - require.NoError(t, err) - - inline := jobBlock(t, result, "build-inline") - assert.Contains(t, inline, "timeout-minutes: 7") - assert.NotContains(t, inline, "timeout-minutes: 30") } // --- #18: optional_depends_on ---------------------------------------------- @@ -254,8 +226,7 @@ func TestGenerator_BothFieldsUnsetNonBreaking(t *testing.T) { // a reusable-workflow (uses:) callback. GitHub forbids timeout-minutes on a job // that calls a reusable workflow (allowed caller keys: name, uses, with, // secrets, needs, if, permissions, strategy, concurrency); the timeout must live -// inside the called workflow. An inline run: callback with the same setting DOES -// carry it, since inline jobs are cascade-owned steps jobs. +// inside the called workflow. func TestGenerator_ExplicitTimeoutNotOnReusableCallback(t *testing.T) { tmpDir := t.TempDir() writeStubWorkflow(t, tmpDir, "build.yaml") @@ -267,8 +238,6 @@ func TestGenerator_ExplicitTimeoutNotOnReusableCallback(t *testing.T) { Builds: []config.BuildConfig{ // Reusable-workflow callback with an explicit timeout: must NOT emit it. {Name: "reusable", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}, TimeoutMinutes: 15}, - // Inline run: callback with an explicit timeout: DOES emit it. - {Name: "inline", Run: "go test ./...", Triggers: []string{"src/**"}, TimeoutMinutes: 15}, }, Deploys: []config.DeployConfig{ // Reusable-workflow deploy callback with an explicit timeout: no emit. @@ -288,8 +257,4 @@ func TestGenerator_ExplicitTimeoutNotOnReusableCallback(t *testing.T) { assert.Contains(t, reusableDeploy, "uses:", "sanity: reusable deploy is a uses: caller") assert.NotContains(t, reusableDeploy, "timeout-minutes:", "explicit timeout_minutes must not be emitted on a reusable-workflow deploy caller job") - - inline := jobBlock(t, result, "build-inline") - assert.Contains(t, inline, "timeout-minutes: 15", - "explicit timeout_minutes on an inline run: callback is honored") } diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 6a4a02c..ee4aa92 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -117,12 +117,6 @@ func (g *PromoteGenerator) Generate() (string, error) { // discoverDeployInputs parses deploy workflow files to discover their inputs func (g *PromoteGenerator) discoverDeployInputs() error { for _, d := range g.config.Deploys { - // Inline run: deploys have no reusable-workflow file to parse inputs from; - // their declared-input set comes from the manifest inputs: keys instead. - if d.Run != "" { - g.inputs[d.Name] = inputKeys(d.Inputs) - continue - } workflowPath := filepath.Join(g.baseDir, d.Workflow) data, err := os.ReadFile(workflowPath) if err != nil { @@ -768,34 +762,6 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { fmt.Fprintf(sb, " deploy-%s:\n", d.Name) - if d.Run != "" { - // Inline run: deploy callback. This is a cascade-owned job with an inline run: - // step. Inline callbacks declare their inputs via the manifest inputs: - // keys (no reusable-workflow with: matrix); the standard environment/ - // sha/image_tag inputs reach the step as env: vars. - fmt.Fprintf(sb, " name: Deploy %s\n", d.Name) - sb.WriteString(" needs: [preflight, promote]\n") - if d.SupportsDryRun { - // Callback handles dry-run internally: run it regardless of dry_run, - // and let the inline body surface DRY_RUN so the script can emulate. - fmt.Fprintf(sb, " if: ${{ contains(fromJSON(needs.preflight.outputs.deploys_to_run), '%s') }}\n", d.Name) - } else { - fmt.Fprintf(sb, " if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.deploys_to_run), '%s') }}\n", d.Name) - } - // environment: wires the job to a GitHub Environment so that the - // environment's protection rules apply when gha_environment is configured - // for any env. The target env is resolved at runtime by preflight. - if anyEnvHasGHAConfig(g.config) { - sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") - } - g.writeInlineDeployBody(sb, d, - "${{ needs.preflight.outputs.target_env }}", - "${{ needs.preflight.outputs.source_sha }}", - "${{ needs.preflight.outputs.source_image_tag }}", - "${{ needs.preflight.outputs.source_image_digest }}") - continue - } - if hasInputs { // Matrix-based deploy job fmt.Fprintf(sb, " name: Deploy %s (${{ matrix.environment }})\n", d.Name) @@ -893,30 +859,12 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { } else { sb.WriteString(" if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.has_prod_deployment == 'true' }}\n") } - // environment: The prod deploy job always targets a single known env - // (the final environment in the pipeline), so we can resolve the GitHub - // Environment name statically from gha_environment when configured. - // - // The job-level environment: key is only valid on a steps job (an inline - // run: deploy). GitHub Actions forbids it on a reusable-workflow caller - // job, so it is gated on d.Run being set. For external (uses:) deploys - // the environment name is threaded via the with: environment input below, - // and GitHub Environment protection must be declared inside the reusable + // The prod deploy job always targets a single known env (the final + // environment in the pipeline). This is a reusable-workflow (uses:) caller + // job, on which GitHub Actions forbids a job-level environment: key, so the + // environment name is threaded via the with: environment input below and + // GitHub Environment protection must be declared inside the reusable // workflow's own job. - if d.Run != "" { - if ec, ok := g.config.EnvironmentConfig[finalEnv]; ok && ec.GHAEnvironment != "" { - fmt.Fprintf(sb, " environment: %s\n", ec.GHAEnvironment) - } - // The prod path uses prod_version as its tag and has no - // prod_image_digest preflight output, so digest pinning is not threaded - // here. Pass an empty digest to keep the prod deploy unchanged. - g.writeInlineDeployBody(sb, d, - finalEnv, - "${{ needs.preflight.outputs.prod_sha }}", - "${{ needs.preflight.outputs.prod_version }}", - "") - continue - } fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(d.Workflow)) sb.WriteString(" with:\n") fmt.Fprintf(sb, " environment: %s\n", finalEnv) @@ -940,53 +888,6 @@ func (g *PromoteGenerator) writeDeployJobs(sb *strings.Builder) { g.writeExternalDeployJobs(sb, finalEnv) } -// writeInlineDeployBody emits the runs-on / steps body of a cascade-owned inline -// run: deploy callback in a promote workflow. The standard inputs a reusable -// deploy callback would receive via with: (environment, sha, image_tag, and -// image_digest when the callback declares them) are surfaced to the inline step -// as env: variables. An empty imageDigest means no digest is available for this -// path (for example the prod path), so IMAGE_DIGEST is omitted. -func (g *PromoteGenerator) writeInlineDeployBody(sb *strings.Builder, d config.DeployConfig, environment, sha, imageTag, imageDigest string) { - // Per-callback job attributes (inline-run deploy jobs only): runner selection - // (#12), permissions incl. id-token: write OIDC (#35/#15), and concurrency - // (#17). The config-level runs_on default applies when the deploy sets no - // runner. - writeRunsOn(sb, " ", d.RunsOn, g.config.RunsOn) - writeJobPermissions(sb, " ", d.Permissions) - writeJobConcurrency(sb, " ", d.Concurrency) - sb.WriteString(" steps:\n") - fmt.Fprintf(sb, " - name: Deploy %s\n", d.Name) - - sb.WriteString(" env:\n") - fmt.Fprintf(sb, " ENVIRONMENT: %s\n", environment) - fmt.Fprintf(sb, " SHA: %s\n", sha) - if g.deployHasInput(d.Name, "image_tag") { - fmt.Fprintf(sb, " IMAGE_TAG: %s\n", imageTag) - } - // Additively surface IMAGE_DIGEST when the callback declares image_digest and - // a digest is available for this path (imageDigest non-empty). - if imageDigest != "" && g.deployHasInput(d.Name, "image_digest") { - fmt.Fprintf(sb, " IMAGE_DIGEST: %s\n", imageDigest) - } - // When the callback opts in to dry-run emulation, surface the dispatch input - // as DRY_RUN so the inline script can branch on it. - if d.SupportsDryRun { - sb.WriteString(" DRY_RUN: ${{ github.event.inputs.dry_run }}\n") - } - - shell := d.Shell - if shell == "" { - shell = "bash" - } - fmt.Fprintf(sb, " shell: %s\n", shell) - - sb.WriteString(" run: |\n") - for _, line := range strings.Split(strings.TrimRight(d.Run, "\n"), "\n") { - fmt.Fprintf(sb, " %s\n", line) - } - sb.WriteString("\n") -} - func (g *PromoteGenerator) writeExternalDeployJobs(sb *strings.Builder, finalEnv string) { // Skip if no external deploys configured if len(g.config.External) == 0 { @@ -1081,14 +982,6 @@ func (g *PromoteGenerator) writeRollbackJobs(sb *strings.Builder) { // Write rollback jobs for local deploys for _, d := range g.config.Deploys { - // A rollback job is, by contract, a reusable-workflow call that reverts a - // deploy. An inline run: deploy callback has no reusable workflow to call, - // so there is nothing to roll back through: skip it. Emitting a rollback - // job here would write an empty `uses:` value and invalid workflow YAML. - if d.Run != "" { - continue - } - jobName := fmt.Sprintf("deploy-%s", d.Name) fmt.Fprintf(sb, " rollback-%s:\n", d.Name) @@ -1114,13 +1007,6 @@ func (g *PromoteGenerator) writeRollbackJobs(sb *strings.Builder) { // Write rollback jobs for external deploys for _, ext := range g.config.External { for _, d := range ext.Deploys { - // Inline run: external deploys (Run set, Workflow empty) have no - // reusable workflow to call, so they cannot have a rollback job for - // the same reason as local inline-run deploys above. - if d.Run != "" { - continue - } - jobName := fmt.Sprintf("deploy-%s", d.Name) fmt.Fprintf(sb, " rollback-%s:\n", d.Name) diff --git a/internal/generate/promote_test.go b/internal/generate/promote_test.go index a2a97cc..6cb7321 100644 --- a/internal/generate/promote_test.go +++ b/internal/generate/promote_test.go @@ -1503,74 +1503,6 @@ func TestPromoteGenerator_NoRollbackWhenNoEnvironments(t *testing.T) { "emitted promote workflow must be valid YAML") } -func TestPromoteGenerator_RollbackJobs_InlineRunDeploy(t *testing.T) { - tests := []struct { - name string - deploys []config.DeployConfig - wantRollback []string // rollback job names that must be present - noRollback []string // rollback job names that must be absent - wantDeployJob []string // deploy job names that must still be present - }{ - { - name: "single inline-run deploy emits no rollback job", - deploys: []config.DeployConfig{ - {Name: "app", Run: "echo deploying $ENVIRONMENT", Shell: "bash"}, - }, - noRollback: []string{"rollback-app:"}, - wantDeployJob: []string{"deploy-app:", "deploy-app-prod:"}, - }, - { - name: "mixed inline-run and reusable-workflow deploys", - deploys: []config.DeployConfig{ - {Name: "app", Run: "echo deploying $ENVIRONMENT", Shell: "bash"}, - {Name: "infra", Workflow: ".github/workflows/deploy-infra.yaml"}, - }, - wantRollback: []string{"rollback-infra:"}, - noRollback: []string{"rollback-app:"}, - wantDeployJob: []string{"deploy-app:", "deploy-infra:"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"test", "staging", "prod"}, - Deploys: tt.deploys, - } - - gen := NewPromoteGenerator(cfg, "") - content, err := gen.Generate() - require.NoError(t, err) - - // An inline-run deploy has no reusable workflow to call, so the - // generator must never emit an empty `uses:` line. - for _, line := range strings.Split(content, "\n") { - assert.NotEqual(t, " uses:", strings.TrimRight(line, " "), - "generated promote workflow must not contain an empty uses: value") - } - - for _, want := range tt.wantRollback { - assert.Contains(t, content, want, - "reusable-workflow deploy should keep its rollback job") - } - for _, absent := range tt.noRollback { - assert.NotContains(t, content, absent, - "inline-run deploy must not produce a rollback job") - } - for _, want := range tt.wantDeployJob { - assert.Contains(t, content, want, - "deploy job must still be emitted regardless of rollback skip") - } - - // The emitted workflow must remain structurally valid YAML. - var parsed map[string]any - require.NoError(t, yaml.Unmarshal([]byte(content), &parsed), - "emitted promote workflow must be valid YAML") - }) - } -} - func TestPromoteGenerator_RollbackOnFailureInput(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index 6c1b896..6ea4a63 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -14,9 +14,9 @@ import ( // on the resolved SHA, and a finalize job applies the state write back to trunk // (marking the environment diverged until a forward promotion rejoins it). // -// The deploy stage reuses the same deploy callbacks (reusable workflow, inline -// run, or matrix) the promote workflow drives; there is no separate rollback -// deploy path. The generator is gated on the configured environment count: it +// The deploy stage reuses the same deploy callbacks (reusable-workflow, or +// matrix) the promote workflow drives; there is no separate rollback deploy +// path. The generator is gated on the configured environment count: it // emits only when at least one environment is declared. type RollbackGenerator struct { config *config.TrunkConfig @@ -227,9 +227,9 @@ func rollbackDeployGuard(deployName string) string { // writeDeployJobs emits one deploy job per configured deploy, re-running the same // callback the promote workflow uses but sourced from the resolved rollback -// target SHA. Inline run: deploys carry the job-level environment gate when GHA -// environment protection is configured; reusable (uses:) deploys thread the env -// via the with: input. With no environments configured, no deploy jobs emit. +// target SHA. Each deploy is a reusable (uses:) workflow call that threads the +// resolved env and SHA via the with: input. With no environments configured, no +// deploy jobs emit. func (g *RollbackGenerator) writeDeployJobs(sb *strings.Builder) { if len(g.config.Environments) == 0 { return @@ -241,16 +241,6 @@ func (g *RollbackGenerator) writeDeployJobs(sb *strings.Builder) { sb.WriteString(" needs: [preflight]\n") fmt.Fprintf(sb, " if: %s\n", rollbackDeployGuard(d.Name)) - if d.Run != "" { - // Inline run: deploy callback. The environment gate is valid only on a - // steps job, so it is emitted here (not on a reusable caller job). - if anyEnvHasGHAConfig(g.config) { - sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") - } - g.writeInlineDeployBody(sb, d) - continue - } - // Reusable (uses:) deploy: thread the resolved env and SHA via with:. The // environment name is carried as an input; GitHub Environment protection // must be declared inside the reusable workflow's own job. @@ -262,30 +252,6 @@ func (g *RollbackGenerator) writeDeployJobs(sb *strings.Builder) { } } -// writeInlineDeployBody emits the runs-on / steps body of an inline run: deploy -// callback, surfacing the resolved environment and SHA as env: variables. -func (g *RollbackGenerator) writeInlineDeployBody(sb *strings.Builder, d config.DeployConfig) { - writeRunsOn(sb, " ", d.RunsOn, g.config.RunsOn) - writeJobPermissions(sb, " ", d.Permissions) - writeJobConcurrency(sb, " ", d.Concurrency) - sb.WriteString(" steps:\n") - fmt.Fprintf(sb, " - name: Deploy %s\n", d.Name) - sb.WriteString(" env:\n") - sb.WriteString(" ENVIRONMENT: ${{ needs.preflight.outputs.target_env }}\n") - sb.WriteString(" SHA: ${{ needs.preflight.outputs.target_sha }}\n") - - shell := d.Shell - if shell == "" { - shell = "bash" - } - fmt.Fprintf(sb, " shell: %s\n", shell) - sb.WriteString(" run: |\n") - for _, line := range strings.Split(strings.TrimRight(d.Run, "\n"), "\n") { - fmt.Fprintf(sb, " %s\n", line) - } - sb.WriteString("\n") -} - // deployJobNames returns the deploy job identifiers so finalize can declare // correct needs: references. func (g *RollbackGenerator) deployJobNames() []string { diff --git a/internal/generate/secrets_test.go b/internal/generate/secrets_test.go index c053286..9d42dd6 100644 --- a/internal/generate/secrets_test.go +++ b/internal/generate/secrets_test.go @@ -199,34 +199,6 @@ func TestOrchestrateDeployCallbackJob_ExplicitSecretsMap(t *testing.T) { assert.NotContains(t, result, " secrets: inherit\n") } -// TestOrchestrateCallbackJob_InlineRunUnaffected verifies that an inline-run -// callback (run: ...) does not emit any secrets: key at all; inline jobs are -// cascade-owned, not reusable-workflow calls. -func TestOrchestrateCallbackJob_InlineRunUnaffected(t *testing.T) { - cfg := &config.TrunkConfig{ - TrunkBranch: "main", - Environments: []string{"dev"}, - Builds: []config.BuildConfig{ - { - Name: "smoke", - Run: "go test ./...", - Triggers: []string{"**/*.go"}, - // Secrets field set on an inline-run callback; must be ignored. - Secrets: &config.SecretsConfig{ - Map: map[string]string{"SOME_TOKEN": "MY_TOKEN"}, - }, - }, - }, - } - - gen := NewGenerator(cfg, t.TempDir()) - result, err := gen.Generate() - require.NoError(t, err) - - // Inline run: jobs are cascade-owned steps; no secrets: key of any form. - assert.NotContains(t, result, "secrets:") -} - // TestPromoteDeployJob_ExplicitSecretsMap verifies that a promote-workflow // deploy callback with an explicit secrets map carries the per-entry block in // the generated promote workflow.