Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
id: ct-104
title: 'Branch-Scoped Concurrency Group Allows Simultaneous Deployments to the Same Environment from Different Branches'
category: concurrency-timing
severity: silent-failure
tags:
- concurrency
- environment
- deployment
- branch
- github-ref
- cross-branch
- concurrent-deploy
- job-environment
patterns:
- regex: 'group:\s*[''"]?[^''"\n]*\$\{\{\s*github\.ref[^}]*\}\}[^''"\n]*'
flags: 'i'
- regex: 'group:\s*[''"]?[^''"\n]*\$\{\{\s*github\.ref_name[^}]*\}\}[^''"\n]*'
flags: 'i'
- regex: 'group:\s*[''"]?[^''"\n]*\$\{\{\s*github\.head_ref[^}]*\}\}[^''"\n]*'
flags: 'i'
error_messages:
- "# No error — two branches deploy to the same environment simultaneously; may cause partial state or deployment races"
root_cause: |
Workflows that include `github.ref`, `github.ref_name`, or `github.head_ref` in
their concurrency group key create **separate concurrency groups per branch**. When
two branches push to a workflow that deploys to the same environment, they get
different concurrency groups and do NOT queue or cancel each other.

Example:
- Branch `feature/alpha` pushes → group: `deploy-staging-refs/heads/feature/alpha`
- Branch `main` pushes → group: `deploy-staging-refs/heads/main`
- Groups are different → both jobs run simultaneously → both deploy to `staging`

This pattern is correct for CI workflows (each branch's tests should run
independently), but incorrect for shared deployment environments where only one
deployment should be active at a time.

The confusion is common because teams copy a per-branch concurrency pattern from
CI into deploy workflows without adjusting the key. Environment protection rules
(required reviewers, wait timers) gate each individual job but do NOT prevent
multiple simultaneous deployments from different concurrency groups.

GitHub Actions provides the `job.environment` context — the environment name string
for the current job — which creates a stable, per-environment concurrency key that
applies across all branches deploying to that environment.
fix: |
Key the concurrency group on the environment name, not the branch ref, so all
branches deploying to the same environment share one concurrency slot:

concurrency:
group: deploy-${{ job.environment }}
cancel-in-progress: false # queue; do not discard deploys

Important: `job.environment` is only populated inside a job that declares
`environment:`. Set the concurrency group at the **job level**, not at the
workflow level, when using `job.environment`.

Use `cancel-in-progress: false` for deployments to ensure every triggered deploy
runs in order rather than being silently dropped.
fix_code:
- language: yaml
label: 'WRONG — branch-scoped group; feature/ and main can deploy to staging simultaneously'
code: |
on: [push]

jobs:
deploy:
runs-on: ubuntu-latest
environment: staging
concurrency:
# BAD: different branches get different concurrency slots
# feature/alpha and main can both deploy to staging at the same time
group: deploy-staging-${{ github.ref }}
cancel-in-progress: true
steps:
- run: ./deploy.sh staging
- language: yaml
label: 'CORRECT — environment-scoped group; only one deploy to staging at a time'
code: |
on: [push]

jobs:
deploy:
runs-on: ubuntu-latest
environment: staging
concurrency:
# GOOD: all branches deploying to staging share one concurrency slot
# job.environment is the environment name ("staging")
group: deploy-${{ job.environment }}
cancel-in-progress: false # queue — do not skip any deploys
steps:
- run: ./deploy.sh staging
- language: yaml
label: 'MULTI-ENV — staging and production each get their own independent slot'
code: |
on:
push:
branches: [main]

jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
concurrency:
group: deploy-${{ job.environment }} # "staging" slot
cancel-in-progress: false
steps:
- run: ./deploy.sh staging

deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-${{ job.environment }} # "production" slot (separate)
cancel-in-progress: false
steps:
- run: ./deploy.sh production
prevention:
- 'For deployment workflows, key concurrency groups on the environment name (`job.environment`), not on the branch ref.'
- 'Use `cancel-in-progress: false` for deployment jobs — silently dropping a deploy means a commit never reaches the environment.'
- 'Apply per-branch concurrency groups to CI jobs only; apply per-environment concurrency groups to deploy jobs.'
- 'Set job-level `concurrency:` (not workflow-level) when using `job.environment`, since that context is only available within a job.'
docs:
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-concurrency'
label: 'GitHub Docs — Using concurrency'
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#job-context'
label: 'GitHub Docs — job context (job.environment)'
- url: 'https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment'
label: 'GitHub Docs — Managing environments for deployment'
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
id: tr-118
title: '`workflow_run` `requested` Activity Type Fires When Upstream Is Queued — Before Execution, Artifacts Unavailable'
category: triggers
severity: silent-failure
tags:
- workflow_run
- requested
- activity-types
- artifacts
- default-branch
- timing
- pre-execution
patterns:
- regex: 'types:\s*\[.*\brequested\b'
flags: 'si'
- regex: 'types:\s*\n(\s+- [^\n]+\n)*\s+- requested'
flags: 'si'
error_messages:
- "Unable to find artifact"
- "Error: Artifact not found for associated workflow run"
- "No artifact found with name"
root_cause: |
The `workflow_run` event supports three activity types: `completed`, `in_progress`,
and `requested`. The `requested` type fires when the upstream workflow run is
**queued** — before any steps have executed.

Developers who use `types: [requested]` expecting to access upstream workflow
results (artifacts, job outputs, step conclusions) will find nothing is available.
The upstream workflow has not run yet.

Key behaviors of the `requested` type:

1. **No artifacts** — The upstream workflow has not uploaded anything yet.
2. **Null conclusion** — `github.event.workflow_run.conclusion` is `null` at
request time; the run has not completed.
3. **Fires for subsequently cancelled runs** — If the upstream workflow is cancelled
before execution (e.g., superseded by a concurrency group), the downstream
`requested`-triggered workflow still runs to completion, wasting runner minutes
processing a run that never produced output.
4. **Default-branch requirement** — Like ALL `workflow_run` triggers, the listening
workflow must be on the repository's default branch. Even if the upstream runs
on a feature branch, the downstream always executes from the default branch.
5. **No upstream-blocking capability** — `workflow_run` is downstream-only. A
`requested` listener cannot pause or influence the upstream workflow.

The docs say `requested` fires "when a workflow run is requested", which developers
interpret as "just before my CI runs — time to prepare". In practice, it fires the
moment the run is queued, with zero upstream data available.
fix: |
Use `completed` to access upstream artifacts, outputs, and conclusion:

on:
workflow_run:
workflows: ["CI"]
types: [completed]

Use `in_progress` to react as soon as the upstream workflow begins executing
(artifacts still unavailable but the run is confirmed started and not cancelled).

Use `requested` only for pure notification/audit workflows that need no upstream
results — for example, posting "CI run queued" to a webhook using only metadata
fields like `github.event.workflow_run.html_url` and `github.event.workflow_run.id`.

If you need to set up resources BEFORE an upstream workflow runs, `workflow_run`
is not the right tool — it cannot block the upstream. Use job-dependency ordering
within a single workflow, or a `repository_dispatch` from a setup job.
fix_code:
- language: yaml
label: 'WRONG — requested type; artifacts unavailable; upstream may be queued but not run'
code: |
on:
workflow_run:
workflows: ["Build"]
types: [requested] # fires when Build is QUEUED, not when it finishes

jobs:
process:
runs-on: ubuntu-latest
steps:
# FAILS: No artifact exists — Build has not executed yet
- uses: actions/download-artifact@v4
with:
run-id: ${{ github.event.workflow_run.id }}
name: build-output
- language: yaml
label: 'CORRECT — use completed type and guard on conclusion'
code: |
on:
workflow_run:
workflows: ["Build"]
types: [completed] # fires AFTER Build finishes (artifacts available)

jobs:
process:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
run-id: ${{ github.event.workflow_run.id }}
name: build-output
- run: echo "Processing build artifact..."
- language: yaml
label: 'VALID use of requested — notification only (no upstream artifacts needed)'
code: |
on:
workflow_run:
workflows: ["CI"]
types: [requested] # appropriate: notification only, no artifacts needed

jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Post CI started notification
run: |
curl -s -X POST "$WEBHOOK_URL" \
-d "CI run queued: ${{ github.event.workflow_run.html_url }}"
env:
WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
prevention:
- 'Use `completed` type when your downstream workflow needs upstream artifacts, outputs, or conclusion.'
- 'Use `in_progress` to react as soon as the upstream workflow begins executing (no artifacts yet, but run is confirmed started).'
- '`requested` is only appropriate for workflows that need NO upstream results — pure notifications or audit logs.'
- 'Always guard `completed`-triggered jobs with `if: github.event.workflow_run.conclusion == ''success''` to skip failed upstream runs.'
- 'Remember: all `workflow_run` listeners execute from the default branch regardless of the upstream workflow''s branch.'
docs:
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run'
label: 'GitHub Docs — workflow_run event and activity types'
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#using-data-from-the-triggering-workflow'
label: 'GitHub Docs — Using data from the triggering workflow'
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
id: ys-120
title: '`startsWith()`, `endsWith()`, and `contains()` Are Case-Insensitive in GitHub Actions Expressions'
category: yaml-syntax
severity: silent-failure
tags:
- expressions
- startsWith
- endsWith
- contains
- case-insensitive
- if-condition
patterns:
- regex: '\$\{\{[^}]*\bstartsWith\s*\([^}]*\}\}'
flags: 'i'
- regex: '\$\{\{[^}]*\bendsWith\s*\([^}]*\}\}'
flags: 'i'
- regex: '\$\{\{[^}]*\bcontains\s*\([^}]*\}\}'
flags: 'i'
error_messages:
- "# No error message — case-insensitive match succeeds silently when developer expects case-sensitive behavior"
root_cause: |
The `startsWith()`, `endsWith()`, and `contains()` functions in GitHub Actions
expressions are **case-insensitive** — they match regardless of letter casing. This
is documented but commonly overlooked, because the same functions in most programming
languages (JavaScript, Python, Go) perform case-sensitive comparisons.

Example: `startsWith('refs/heads/Release/1.0', 'refs/heads/release/')` evaluates
to `true` despite the capital `R` in `Release`, because the comparison ignores case.

This causes two categories of problems:

1. **Unintended matches** — A condition meant to fire only for lowercase `release/`
branches also fires for `RELEASE/`, `Release/`, or any mixed-case variant. Branch
protection gates, deploy guards, and environment filters may activate unexpectedly.

2. **Misleading specificity** — Developers write precise-looking conditions such as
`startsWith(github.ref, 'refs/heads/Hotfix/')` assuming only that exact casing
matches, but the condition matches `hotfix/`, `HOTFIX/`, and every other variant.

GitHub Actions expressions use `System.String.StartsWith` with `OrdinalIgnoreCase`
internally, making these three functions behave differently from most languages.
fix: |
To perform a **case-sensitive** comparison, use the `==` operator directly, or
normalize casing with `toLower()` / `toUpper()` before comparing:

- Exact case-sensitive equality: `github.ref == 'refs/heads/release/1.0'`
- Explicit case-insensitive prefix: `startsWith(toLower(github.ref), 'refs/heads/release/')`
(use `toLower()` to document the intent explicitly, even though `startsWith` is
already case-insensitive without it)

If you WANT case-insensitive matching, no change is needed — just add a comment
to document that the behavior is intentionally case-insensitive so future
maintainers do not "fix" it.
fix_code:
- language: yaml
label: 'SURPRISING — startsWith matches Release/ even though pattern is lowercase release/'
code: |
on: [push]
jobs:
deploy:
runs-on: ubuntu-latest
# Developer assumes this only matches refs/heads/release/* (lowercase)
# ACTUAL: also matches refs/heads/Release/*, refs/heads/RELEASE/*, etc.
if: ${{ startsWith(github.ref, 'refs/heads/release/') }}
steps:
- run: echo "Deploying release branch"
- language: yaml
label: 'EXPLICIT — toLower() makes case-insensitive intent obvious'
code: |
on: [push]
jobs:
deploy:
runs-on: ubuntu-latest
# toLower() is redundant (startsWith is already case-insensitive)
# but documents the intent clearly for future maintainers
if: ${{ startsWith(toLower(github.ref), 'refs/heads/release/') }}
steps:
- run: echo "Deploying release branch (any casing)"
- language: yaml
label: 'CASE-SENSITIVE — use == for exact case-sensitive matches'
code: |
on: [push]
jobs:
deploy-exact:
runs-on: ubuntu-latest
# Only matches exact lowercase ref — use == not startsWith
if: ${{ github.ref == 'refs/heads/release/1.0' }}
steps:
- run: echo "Deploying exactly refs/heads/release/1.0"
prevention:
- 'Treat `startsWith()`, `endsWith()`, and `contains()` as case-insensitive in GitHub Actions — document this in workflow comments when the behavior matters.'
- 'Use `toLower()` before comparisons when you want to make case-normalization visible to future readers.'
- 'Use `==` for exact case-sensitive equality checks instead of `startsWith`/`endsWith` when casing must be preserved.'
- 'Test branch-name filters with uppercase and mixed-case branch names to confirm the intended match behavior.'
docs:
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#startswith'
label: 'GitHub Docs — startsWith function (case-insensitive note)'
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#endswith'
label: 'GitHub Docs — endsWith function (case-insensitive note)'
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#contains'
label: 'GitHub Docs — contains function (case-insensitive note)'
Loading