diff --git a/errors/concurrency-timing/always-cleanup-5min-forced-kill.yml b/errors/concurrency-timing/always-cleanup-5min-forced-kill.yml new file mode 100644 index 0000000..2787fe4 --- /dev/null +++ b/errors/concurrency-timing/always-cleanup-5min-forced-kill.yml @@ -0,0 +1,140 @@ +id: concurrency-timing-011 +title: "always() Cleanup Jobs Forcibly Killed After 5-Minute Cancellation Timeout" +category: concurrency-timing +severity: warning +tags: + - always + - cancellation + - cleanup + - forced-termination + - notification + - timeout + - teardown +patterns: + - regex: "The runner has received a shutdown signal" + flags: "i" + - regex: "Job was cancelled" + flags: "i" + - regex: "The operation was canceled" + flags: "i" +error_messages: + - "The runner has received a shutdown signal. This can happen when the runner service is stopped, a new job is started, or the runner is in the process of shutting down." + - "Job was cancelled" +root_cause: | + When a workflow run is cancelled (manually or via `cancel-in-progress`), GitHub Actions + re-evaluates the `if:` condition for every currently running job. Jobs marked with + `if: always()` continue running — this is the intended mechanism for cleanup, notifications, + and teardown steps. + + However, GitHub enforces a **5-minute hard termination window** after cancellation is + initiated. Once 5 minutes have elapsed since the cancellation signal, ALL remaining jobs + are forcibly killed by the server, regardless of their `if:` conditions — including jobs + explicitly marked `if: always()`. + + This means: + - Cleanup jobs that take more than 5 minutes (Terraform destroy, test result uploads, + Slack notifications with retries, database teardown) will be killed mid-execution. + - The job may appear partially completed in the logs with no clear failure message — + it simply stops, often leaving infrastructure in a partial or inconsistent state. + - Developers are surprised that `always()` does not guarantee the job completes after + a workflow cancellation. + + Common failure scenarios: + - Artifact upload in an `if: always()` post-job step when the upload is slow + - Terraform `destroy` as a cleanup job when a long-running deployment is cancelled + - Notification jobs that retry on transient failures and consume more time than expected + - Integration test teardown (database resets, container removal) that exceeds 5 minutes + + Source: GitHub Docs — Canceling a workflow: "After the 5 minute cancellation timeout + period, the server will forcibly terminate all jobs that are still running." +fix: | + Design `always()` cleanup jobs to complete well within 5 minutes. Add a job-level + `timeout-minutes: 4` to any cleanup job that runs after cancellation so it fails + cleanly rather than being force-killed at an unpredictable point. + + For teardown that cannot be shortened, trigger cleanup from a separate workflow using + `workflow_run: [completed]` — it runs after the cancelled run fully settles and is + not subject to the 5-minute window. + + Use the `cancelled()` expression to detect cancellation and take a fast code path. +fix_code: + - language: yaml + label: "Guard cleanup job with timeout-minutes to fail fast before forced kill" + code: | + jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - run: ./deploy.sh + + cleanup: + needs: deploy + if: always() + runs-on: ubuntu-latest + timeout-minutes: 4 # Stay under the 5-min forced-kill window + steps: + - name: Teardown infrastructure + run: ./teardown.sh + timeout-minutes: 3 # Per-step guard too + + - language: yaml + label: "Use cancelled() to take a fast notification path on cancellation" + code: | + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: ./slow-build.sh + + notify: + needs: build + if: always() + runs-on: ubuntu-latest + steps: + - name: Quick notification (cancellation — must be fast) + if: cancelled() + run: | + curl -s -X POST "$SLACK_WEBHOOK" \ + -H 'Content-type: application/json' \ + -d '{"text":"⚠️ Workflow cancelled — cleanup may be incomplete"}' + + - name: Full notification (success or failure path — has time) + if: "!cancelled()" + run: ./full-notify.sh "${{ needs.build.result }}" + + - language: yaml + label: "Post-cancellation teardown via workflow_run — not subject to 5-min window" + code: | + # cleanup.yml — separate workflow triggered after any completion including cancellation + on: + workflow_run: + workflows: ["Deploy"] + types: [completed] + + jobs: + teardown: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Emergency cleanup when deploy was cancelled + if: github.event.workflow_run.conclusion == 'cancelled' + run: ./emergency-teardown.sh + + - name: Normal cleanup on success or failure + if: github.event.workflow_run.conclusion != 'cancelled' + run: ./standard-teardown.sh +prevention: + - "Keep `if: always()` cleanup jobs under 4 minutes — add `timeout-minutes: 4` as a safety guard." + - "Use `if: cancelled()` to detect cancellation and take a fast code path rather than the full teardown path." + - "For cleanup that takes longer than 5 minutes, use a separate `workflow_run: [completed]` workflow that runs outside the cancellation window." + - "Test cancellation behavior by manually cancelling a long-running workflow and verifying cleanup jobs complete before 5 minutes." +docs: + - url: "https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/canceling-a-workflow" + label: "GitHub Docs: Canceling a workflow (5-minute forced termination)" + - url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#status-check-functions" + label: "Status check functions: always(), cancelled()" + - url: "https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run" + label: "workflow_run event — trigger cleanup after completed workflows" diff --git a/errors/concurrency-timing/required-check-pending-path-filter-skip.yml b/errors/concurrency-timing/required-check-pending-path-filter-skip.yml new file mode 100644 index 0000000..c7a4a5d --- /dev/null +++ b/errors/concurrency-timing/required-check-pending-path-filter-skip.yml @@ -0,0 +1,160 @@ +id: concurrency-timing-013 +title: "Required Status Check Stuck in Pending When Workflow Skipped by Path or Branch Filter" +category: concurrency-timing +severity: warning +tags: + - required-status-check + - path-filter + - branch-filter + - pending + - pull-request + - branch-protection + - paths + - blocked-pr +patterns: + - regex: "Some checks haven't completed yet|Required status check.*pending" + flags: "i" + - regex: "Waiting for status:.*pending" + flags: "i" +error_messages: + - "Some checks haven't completed yet" + - "Required status check is pending" + - "Waiting for status: CI / test (pending)" +root_cause: | + GitHub Actions workflows that use `paths:`, `paths-ignore:`, `branches:`, or + `branches-ignore:` filters will NOT run — and will NOT report ANY status — for + commits that don't match the filter criteria. + + When a required status check is configured in a branch protection rule and the + workflow providing that check is skipped by a filter: + - The check is NEVER created for that commit — it remains in "Pending" state indefinitely + - The PR is blocked from merging with "Some checks haven't completed yet" + - The check CANNOT be manually re-triggered without pushing a commit that matches the filter + + Common scenario: + A repository has a `ci.yml` workflow with `paths: ['src/**', '*.ts']` and + `CI / test` configured as a required status check. A developer opens a PR that only + changes `README.md` or `.github/docs/`. The `CI / test` check never runs, shows as + "Pending" forever, and the PR is permanently blocked from merging without an admin + override or a dummy code commit to trigger the workflow. + + This is explicitly documented behavior but frequently misunderstood: + - The workflow appears to work correctly for code-change PRs (the common case) + - The bug only surfaces on documentation-only, config-only, or administrative PRs + - Developers and reviewers see a pending check with no way to trigger it + + Note: This is distinct from skipped-needs-cascade (job dependency skipping) — this + is specifically about the WORKFLOW TRIGGER filter preventing the run from ever starting, + so no job status is reported at all. + + Source: GitHub Docs — Troubleshooting required status checks: "If a workflow is skipped + due to path filtering, branch filtering or a commit message, then checks associated + with that workflow will remain in a 'Pending' state. A pull request that requires those + checks to be successful will be blocked from merging." +fix: | + Two main approaches: + + 1. **Always-succeeding bypass job** — remove path filters from the workflow trigger, + run the workflow for all PRs, use `dorny/paths-filter` or `tj-actions/changed-files` + to detect changes inside the workflow, and add a sentinel job that always produces a + status. Configure the required check to point at the sentinel job name. + + 2. **Split workflow** — keep the path-filtered workflow for actual CI work, and add a + separate always-running workflow that provides the required status check name + (succeeds immediately for non-code PRs, waits for CI for code PRs). +fix_code: + - language: yaml + label: "Fix: always-running workflow with internal path detection and sentinel job" + code: | + name: CI + # No path filter — workflow always runs for all PRs + on: + pull_request: + branches: [main] + + jobs: + changes: + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: + - 'src/**' + - '*.ts' + - 'package*.json' + + test: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm ci && npm test + + # Branch protection required check: "CI / ci-gate" (not "CI / test") + # Always produces a status — green for docs PRs, waits for test on code PRs + ci-gate: + needs: [changes, test] + if: always() + runs-on: ubuntu-latest + steps: + - name: Confirm CI passed or code was not changed + run: | + CODE_CHANGED="${{ needs.changes.outputs.code }}" + TEST_RESULT="${{ needs.test.result }}" + if [[ "$CODE_CHANGED" == "false" ]]; then + echo "✅ No code changes — CI gate passes automatically" + elif [[ "$TEST_RESULT" == "success" ]]; then + echo "✅ Tests passed" + else + echo "❌ Tests $TEST_RESULT" + exit 1 + fi + + - language: yaml + label: "Alternative: run all steps always but skip expensive ones via filter" + code: | + name: CI + on: + pull_request: + branches: [main] + # No workflow-level path filter — status always reported + + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: ['src/**', '*.ts', 'package*.json'] + + - name: Install dependencies + if: steps.filter.outputs.code == 'true' + run: npm ci + + - name: Run tests + if: steps.filter.outputs.code == 'true' + run: npm test + # Job always completes with success — check is always reported +prevention: + - "Never use workflow-level `paths:` or `branches:` filters as the sole trigger for a required status check." + - "Use `dorny/paths-filter` or `tj-actions/changed-files` INSIDE an always-running workflow instead of workflow-level path filters." + - "Name required status checks after jobs that always produce a status, even on non-code PRs." + - "Test branch protection rules by opening a documentation-only PR to verify all required checks complete." + - "Consider admin overrides as a last resort, not a workflow fix — the root cause will keep happening." +docs: + - url: "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks" + label: "Troubleshooting required status checks (path filter skip documented)" + - url: "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore" + label: "Workflow syntax: paths and paths-ignore filters" + - url: "https://github.com/dorny/paths-filter" + label: "dorny/paths-filter — detect changed files inside workflow" diff --git a/errors/concurrency-timing/wait-timer-cancel-in-progress-starvation.yml b/errors/concurrency-timing/wait-timer-cancel-in-progress-starvation.yml new file mode 100644 index 0000000..c9296bb --- /dev/null +++ b/errors/concurrency-timing/wait-timer-cancel-in-progress-starvation.yml @@ -0,0 +1,125 @@ +id: concurrency-timing-012 +title: "Deployment wait-timer + cancel-in-progress: true Creates Permanent Deployment Starvation Loop" +category: concurrency-timing +severity: warning +tags: + - wait-timer + - cancel-in-progress + - deployment + - environment + - starvation + - concurrency + - production +patterns: + - regex: "Run was cancelled|Canceling since a higher priority waiting request" + flags: "i" + - regex: "wait.?timer|waiting for environment.*approval" + flags: "i" +error_messages: + - "Run was cancelled" + - "Canceling since a higher priority waiting request for 'production' exists" +root_cause: | + When a deployment workflow combines `concurrency.cancel-in-progress: true` with a + deployment environment that has a `wait-timer` configured (a mandatory delay before + deployment proceeds), every new commit to the branch creates a starvation loop where + no deployment ever reaches the execution phase: + + 1. Run A starts → deployment job begins waiting out the environment wait-timer (e.g., 5 min) + 2. A new commit is pushed → Run B starts in the same concurrency group + 3. `cancel-in-progress: true` fires → Run A is cancelled while still in the wait-timer + 4. Run B now begins its own wait-timer countdown + 5. Another commit arrives → Run B is cancelled during its timer + 6. This repeats indefinitely — no deployment ever executes + + This loop is particularly insidious because: + - All cancellations appear expected and benign in the Actions UI (no failures shown) + - The repository looks healthy — CI passes, deployments start — but production is + silently never updated + - Active development repos where commits arrive faster than the wait-timer duration + are especially vulnerable + + Note: GitHub's concurrency model allows only ONE pending run per group. With + `cancel-in-progress: true`, a new run cancels the RUNNING run (not just queues) — + so even a very short wait-timer cannot escape this if commits arrive frequently. +fix: | + Do not combine `cancel-in-progress: true` with deployment environment `wait-timer` + on the same workflow. Use `cancel-in-progress: false` (the default) for deploy + workflows — this queues runs so each commit eventually deploys in order. + + If you want fast feedback for CI but reliable deployments for CD, split them into + separate workflow files with different concurrency strategies. +fix_code: + - language: yaml + label: "Fix: disable cancel-in-progress for deploy workflow with wait-timer" + code: | + name: Deploy to Production + on: + push: + branches: [main] + + concurrency: + group: deploy-production + cancel-in-progress: false # Queue — never starve a deployment with wait-timer + + jobs: + deploy: + runs-on: ubuntu-latest + environment: production # Has wait-timer: 5 configured + steps: + - uses: actions/checkout@v4 + - run: ./deploy.sh + + - language: yaml + label: "Split pipeline: CI cancels freely; deploy queues safely after CI" + code: | + # ci.yml — fast feedback, cancel stale runs is fine + name: CI + on: + push: + branches: [main] + concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true # OK: no side effects + + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm test + + --- + # deploy.yml — triggered after CI, environment has wait-timer + name: Deploy + on: + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + + concurrency: + group: deploy-production + cancel-in-progress: false # Queue; every successful CI run gets deployed + + jobs: + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + environment: production # wait-timer is safe — no cancel-in-progress racing it + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - run: ./deploy.sh +prevention: + - "Never combine `cancel-in-progress: true` with a deployment environment `wait-timer` in the same workflow." + - "Use `cancel-in-progress: false` for any workflow that deploys to environments with protection rules." + - "Decouple CI (cancel-ok, fast) from CD (queued, reliable) into separate workflow files." + - "Monitor the Actions tab for a pattern of all deployments showing as CANCELLED — this is a sign of starvation." +docs: + - url: "https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/using-concurrency" + label: "GitHub Docs: Using concurrency in GitHub Actions" + - url: "https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment#wait-timer" + label: "GitHub Docs: Managing environments — wait timer" + - url: "https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run" + label: "workflow_run event — decouple CI and CD pipelines" diff --git a/errors/known-unsolved/composite-action-step-timeout-minutes-ignored.yml b/errors/known-unsolved/composite-action-step-timeout-minutes-ignored.yml new file mode 100644 index 0000000..a79b980 --- /dev/null +++ b/errors/known-unsolved/composite-action-step-timeout-minutes-ignored.yml @@ -0,0 +1,146 @@ +id: known-unsolved-017 +title: "timeout-minutes Is Silently Ignored on Steps Inside Composite Actions" +category: known-unsolved +severity: limitation +tags: + - timeout-minutes + - composite-action + - limitation + - step-timeout + - composite + - hung-step + - action-yml +patterns: + - regex: "timeout.?minutes.*composite|using.*composite.*timeout" + flags: "i" + - regex: "Unexpected value 'timeout-minutes'" + flags: "i" +error_messages: + - "Unexpected value 'timeout-minutes'" +root_cause: | + GitHub Actions supports `timeout-minutes` at the job level and at individual step level + inside regular workflow files. However, `timeout-minutes` is NOT enforced on steps + inside composite actions (action.yml files using `runs.using: composite`). + + Adding `timeout-minutes` to a step within a composite action's `steps:` block is + accepted by the YAML parser without error, but the timeout is silently NOT applied + at runtime. A hung step inside a composite action will run until the parent job's + overall `timeout-minutes` is exhausted — up to 6 hours on GitHub-hosted runners by + default. + + This creates a dangerous gap: + - Network-dependent steps (downloads, API calls, package installs) inside a composite + action can hang indefinitely if the network is slow or unresponsive + - `actions/cache` intermittently stalls; wrapping it in a composite action with a + step `timeout-minutes: 5` provides NO protection + - Composite action authors cannot provide defensive step timeouts — users of the + composite action must rely entirely on the calling job's `timeout-minutes` + - Since the step timeout appears to be set (the YAML is valid), developers believe + they have a guard when they actually have none + + The limitation has been tracked and discussed in the community since 2021 + (actions/runner#1979) and remains unresolved as of 2026. + + Source: actions/runner#1979 — "We would like to see timeout-minutes supported on + steps in composite actions. Occasionally actions like actions/cache bug out and run + for the default timeout time (6 hours) which causes unnecessary costs." +fix: | + Since step-level timeouts cannot be enforced inside composite actions, use these + workarounds: + + 1. **Shell-level timeout**: Wrap commands in `timeout ` (bash) or `Start-Sleep` + + `Start-Job` with timeout (PowerShell) to kill hung processes from within the script. + 2. **Job-level `timeout-minutes`**: Set a conservative timeout on the calling job as a + safety net — it limits the composite action's maximum runtime. + 3. **Step-level timeout in the calling workflow**: When calling a composite action as + a step in a workflow file, `timeout-minutes` IS supported at the step level in the + workflow (not inside the composite itself). This provides a per-call timeout. + 4. **Split into multiple reusable actions**: Replace a large composite action with + multiple smaller ones, each invoked as a separate workflow step — where step-level + `timeout-minutes` IS enforced. +fix_code: + - language: yaml + label: "WRONG — timeout-minutes inside composite action step is silently ignored" + code: | + # action.yml — composite action + name: "Setup with Timeout" + runs: + using: composite + steps: + - name: Download dependencies + timeout-minutes: 5 # ← SILENTLY IGNORED — not enforced at runtime + shell: bash + run: curl -o deps.tar.gz "${{ inputs.deps-url }}" + + - language: yaml + label: "Workaround: shell-level timeout inside composite action" + code: | + # action.yml — composite action + name: "Setup with Shell Timeout" + runs: + using: composite + steps: + - name: Download dependencies with shell timeout + shell: bash + run: | + # timeout-minutes on this step would be ignored — use shell timeout instead + timeout 300 curl -o deps.tar.gz "${{ inputs.deps-url }}" || { + echo "::error::Download timed out after 5 minutes" + exit 1 + } + + - name: Cache restore with timeout guard + shell: bash + run: | + timeout 120 ./restore-cache.sh || { + echo "::warning::Cache restore timed out — continuing without cache" + } + + - language: yaml + label: "Workaround: step-level timeout in the calling WORKFLOW (not inside composite)" + code: | + # workflow.yml — timeout-minutes at step level in workflow DOES work + jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 # Job-level safety net + steps: + - uses: actions/checkout@v4 + + # timeout-minutes here is at the WORKFLOW step level — this IS enforced + - name: Run composite action + uses: ./.github/actions/my-composite-action + timeout-minutes: 10 # Works: this is a workflow step, not inside the composite + + - run: npm run build + + - language: yaml + label: "Workaround: split composite into multiple actions — each gets timeout" + code: | + # workflow.yml — multiple smaller actions instead of one large composite + jobs: + build: + runs-on: ubuntu-latest + steps: + # Each step in the workflow gets enforceable timeout-minutes + - uses: ./.github/actions/setup-node + timeout-minutes: 5 # Enforced — workflow step level + + - uses: ./.github/actions/restore-cache + timeout-minutes: 3 # Enforced + + - uses: ./.github/actions/install-deps + timeout-minutes: 10 # Enforced +prevention: + - "Never rely on `timeout-minutes` inside composite action `steps:` — the field is accepted but silently ignored." + - "Use `timeout ` (bash) or PowerShell job timeouts as shell-level guards for long-running composite steps." + - "Apply `timeout-minutes` at the step level in the CALLING WORKFLOW (not inside the composite) for per-invocation limits." + - "Always set a job-level `timeout-minutes` on any job that calls composite actions, as a safety net against runaway steps." + - "Composite action authors should document the expected maximum runtime so callers can set appropriate job-level timeouts." +docs: + - url: "https://github.com/actions/runner/issues/1979" + label: "actions/runner#1979 — timeout-minutes not enforced in composite action steps (open since 2021)" + - url: "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-minutes" + label: "GitHub Docs: steps[*].timeout-minutes (workflow-level steps — works)" + - url: "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#runs-for-composite-actions" + label: "GitHub Docs: Metadata syntax for composite actions" diff --git a/errors/known-unsolved/secrets-not-allowed-in-if-conditions.yml b/errors/known-unsolved/secrets-not-allowed-in-if-conditions.yml new file mode 100644 index 0000000..87c5ac5 --- /dev/null +++ b/errors/known-unsolved/secrets-not-allowed-in-if-conditions.yml @@ -0,0 +1,149 @@ +id: known-unsolved-016 +title: "secrets Context Cannot Be Directly Referenced in if: Conditions" +category: known-unsolved +severity: limitation +tags: + - secrets + - if-condition + - conditional + - env + - limitation + - expressions + - step-condition +patterns: + - regex: "if:.*\\$\\{\\{.*secrets\\..*\\}\\}" + flags: "i" + - regex: "secrets\\.[A-Z_a-z]+\\s*!=\\s*''" + flags: "i" +error_messages: + - "This job was skipped." +root_cause: | + GitHub Actions explicitly prohibits direct use of the `secrets` context in `if:` + conditional expressions at the job or step level. The platform blocks this as a + security measure to prevent secret values from being leaked through expression + evaluation in workflow logs. + + Attempting `if: ${{ secrets.MY_SECRET != '' }}` or `if: secrets.MY_SECRET` silently + fails — the condition evaluates to an empty string (falsy), causing the step or job + to be skipped with no error message, warning, or explanation. + + The same restriction applies at the job level: + ```yaml + jobs: + deploy: + if: ${{ secrets.DEPLOY_KEY != '' }} # silently evaluates wrong + ``` + + This catches developers off guard because: + - The workflow is syntactically valid and passes YAML linting — no parse error + - The failure mode is silent: the job/step is skipped with "This job was skipped" + rather than an explicit error about the secrets restriction + - The pattern looks correct by analogy with other contexts (`github.*`, `env.*`, + `vars.*`) that DO work in `if:` conditions + - Secrets used in `run:` scripts and `with:` inputs work fine — only `if:` is blocked + + GitHub's documentation explicitly states: "Secrets cannot be directly referenced + in `if:` conditionals. Instead, consider setting secrets as job-level environment + variables, then referencing the environment variables to conditionally run steps." + + As of 2026 this is a by-design limitation with no planned native fix. + `actionlint` now flags direct secret references in `if:` expressions as an error. + + Sources: + - GitHub Docs — Using secrets in GitHub Actions (use-secrets.md) + - github/docs#12722 — PR documenting the `if:` condition restriction +fix: | + Promote the secret to a job-level `env:` variable that evaluates the boolean (not the + secret value itself), then reference `env.VARIABLE_NAME` in the `if:` condition. + + For job-level `if:` (not step-level), use a preceding detection job that outputs + whether the secret is present, and condition the downstream job on that output. +fix_code: + - language: yaml + label: "WRONG — direct secrets reference in step if: condition" + code: | + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy to production + if: ${{ secrets.DEPLOY_KEY != '' }} # silently wrong — step is skipped + run: ./deploy.sh + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + + - language: yaml + label: "CORRECT — promote secret presence to job-level env, reference in step if:" + code: | + jobs: + deploy: + runs-on: ubuntu-latest + env: + # Safe: evaluates to "true"/"false" string — not the secret value itself + HAS_DEPLOY_KEY: ${{ secrets.DEPLOY_KEY != '' }} + steps: + - name: Deploy to production + if: env.HAS_DEPLOY_KEY == 'true' + run: ./deploy.sh + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + + - language: yaml + label: "CORRECT — job-level conditional via detection job + output" + code: | + jobs: + check-secrets: + runs-on: ubuntu-latest + outputs: + has_key: ${{ steps.check.outputs.has_key }} + steps: + - id: check + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + run: | + if [ -n "$DEPLOY_KEY" ]; then + echo "has_key=true" >> "$GITHUB_OUTPUT" + else + echo "has_key=false" >> "$GITHUB_OUTPUT" + fi + + deploy: + needs: check-secrets + # Job-level if: can reference job outputs — not secrets directly + if: needs.check-secrets.outputs.has_key == 'true' + runs-on: ubuntu-latest + steps: + - run: ./deploy.sh + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + + - language: yaml + label: "CORRECT — inline env check using bash within step" + code: | + jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy (skip gracefully if secret absent) + env: + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + run: | + if [ -z "$DEPLOY_KEY" ]; then + echo "No DEPLOY_KEY configured — skipping deployment" + exit 0 + fi + ./deploy.sh +prevention: + - "Never reference `secrets.*` directly in `if:` conditions — always promote to `env:` first." + - "Use `env.HAS_SECRET: ${{ secrets.MY_SECRET != '' }}` at the job level to create a safe boolean env var." + - "Install and run `actionlint` in CI — it now flags direct secret references in `if:` as an error." + - "For job-level conditionals based on secret presence, use a dedicated detection job with outputs." +docs: + - url: "https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#using-secrets-in-a-workflow" + label: "GitHub Docs: Using secrets in a workflow (secrets cannot be in if: conditionals)" + - url: "https://github.com/github/docs/pull/12722" + label: "github/docs#12722 — Document secrets in if: conditionals limitation" + - url: "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsif" + label: "Workflow syntax: steps[*].if" + - url: "https://github.com/rhysd/actionlint" + label: "actionlint — flags direct secrets in if: conditions as an error"