@htekdev/actions-debugger 1.0.51 → 1.0.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/errors/caching-artifacts/caching-artifacts-036.yml +155 -0
- package/errors/concurrency-timing/concurrency-timing-030.yml +102 -0
- package/errors/concurrency-timing/concurrency-timing-031.yml +102 -0
- package/errors/concurrency-timing/concurrency-timing-032.yml +147 -0
- package/errors/permissions-auth/permissions-auth-038.yml +148 -0
- package/errors/runner-environment/runner-environment-110.yml +113 -0
- package/errors/silent-failures/silent-failures-053.yml +111 -0
- package/errors/yaml-syntax/yaml-syntax-037.yml +105 -0
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
id: caching-artifacts-036
|
|
2
|
+
title: 'actions/cache post step skips save when job is cancelled — cache never populated after cancellation'
|
|
3
|
+
category: caching-artifacts
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- actions-cache
|
|
7
|
+
- job-cancellation
|
|
8
|
+
- post-step
|
|
9
|
+
- cache-save
|
|
10
|
+
- cancel-in-progress
|
|
11
|
+
- concurrency
|
|
12
|
+
- always
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'Post\s+Run\s+actions/cache.*skipped|Cache\s+save\s+skipped'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'uses:\s*actions/cache@'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
error_messages:
|
|
19
|
+
- 'Post Run actions/cache@v4 skipped'
|
|
20
|
+
- 'Cache save skipped'
|
|
21
|
+
- '##[warning]Cache save skipped.'
|
|
22
|
+
- 'Warning: Cache save failed.'
|
|
23
|
+
root_cause: |
|
|
24
|
+
The actions/cache action saves the cache in a lifecycle "post" step that runs
|
|
25
|
+
automatically after the main job steps complete. When a job is cancelled — via
|
|
26
|
+
the Actions UI, concurrency cancel-in-progress, or exceeding job timeout-minutes
|
|
27
|
+
— post steps do NOT execute.
|
|
28
|
+
|
|
29
|
+
This creates a cache starvation loop:
|
|
30
|
+
1. Job starts with cache miss — slow dependency installation begins (minutes)
|
|
31
|
+
2. A new push triggers another workflow run while the first is still installing
|
|
32
|
+
3. concurrency: cancel-in-progress terminates the first run mid-install
|
|
33
|
+
4. The first run never reaches its post step — cache is not saved
|
|
34
|
+
5. The second run also sees a cache miss — the same slow install repeats
|
|
35
|
+
6. Repeated cancellations mean the cache is never populated
|
|
36
|
+
|
|
37
|
+
This is invisible in workflow logs: cancelled jobs simply show as cancelled with
|
|
38
|
+
no specific warning about the cache post step being skipped. Teams diagnose it
|
|
39
|
+
as "caching isn't working" without realizing cancellation is the root cause.
|
|
40
|
+
|
|
41
|
+
Affected workflows:
|
|
42
|
+
- Any workflow with concurrency: cancel-in-progress that also uses actions/cache
|
|
43
|
+
- Workflows with aggressive job timeout-minutes that expire during installs
|
|
44
|
+
- Flaky workflows that are frequently manually cancelled and re-run
|
|
45
|
+
- Matrix workflows where one failing matrix branch cancels siblings
|
|
46
|
+
|
|
47
|
+
When a job fails (but is not cancelled), post steps DO run normally — the issue
|
|
48
|
+
is specific to cancellations and hard timeouts.
|
|
49
|
+
fix: |
|
|
50
|
+
Use the split actions/cache/restore + actions/cache/save pattern with
|
|
51
|
+
if: always() on the save step. This gives explicit control over cache saving
|
|
52
|
+
independent of the job lifecycle hooks:
|
|
53
|
+
|
|
54
|
+
- uses: actions/cache/restore@v4
|
|
55
|
+
id: cache-restore
|
|
56
|
+
with:
|
|
57
|
+
key: ...
|
|
58
|
+
path: ...
|
|
59
|
+
|
|
60
|
+
# ... install and build steps ...
|
|
61
|
+
|
|
62
|
+
- uses: actions/cache/save@v4
|
|
63
|
+
if: always()
|
|
64
|
+
with:
|
|
65
|
+
key: ${{ steps.cache-restore.outputs.cache-primary-key }}
|
|
66
|
+
path: ...
|
|
67
|
+
|
|
68
|
+
The split restore/save pattern (available since actions/cache v3.3.0 in 2023)
|
|
69
|
+
runs the save as an explicit step with if: always(), which executes during the
|
|
70
|
+
graceful shutdown window even when the job is being cancelled.
|
|
71
|
+
|
|
72
|
+
Important caveat: if the runner process is killed with SIGKILL (hard preemption,
|
|
73
|
+
host failure), no steps including if: always() will run — this is a
|
|
74
|
+
platform-level constraint outside GitHub Actions' control.
|
|
75
|
+
fix_code:
|
|
76
|
+
- language: yaml
|
|
77
|
+
label: 'Split restore/save pattern with unconditional save to survive cancellation'
|
|
78
|
+
code: |
|
|
79
|
+
jobs:
|
|
80
|
+
build:
|
|
81
|
+
runs-on: ubuntu-latest
|
|
82
|
+
# Even with cancel-in-progress, the explicit save step will run
|
|
83
|
+
concurrency:
|
|
84
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
85
|
+
cancel-in-progress: true
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v4
|
|
88
|
+
|
|
89
|
+
- name: Restore cached dependencies
|
|
90
|
+
uses: actions/cache/restore@v4
|
|
91
|
+
id: cache-restore
|
|
92
|
+
with:
|
|
93
|
+
path: ~/.npm
|
|
94
|
+
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
|
95
|
+
restore-keys: |
|
|
96
|
+
${{ runner.os }}-npm-
|
|
97
|
+
|
|
98
|
+
- name: Install dependencies
|
|
99
|
+
if: steps.cache-restore.outputs.cache-hit != 'true'
|
|
100
|
+
run: npm ci
|
|
101
|
+
|
|
102
|
+
- name: Run tests
|
|
103
|
+
run: npm test
|
|
104
|
+
|
|
105
|
+
- name: Save cache
|
|
106
|
+
uses: actions/cache/save@v4
|
|
107
|
+
if: always() # Runs during graceful cancellation shutdown window
|
|
108
|
+
with:
|
|
109
|
+
path: ~/.npm
|
|
110
|
+
key: ${{ steps.cache-restore.outputs.cache-primary-key }}
|
|
111
|
+
|
|
112
|
+
- language: yaml
|
|
113
|
+
label: 'Python pip cache with split save pattern'
|
|
114
|
+
code: |
|
|
115
|
+
jobs:
|
|
116
|
+
test:
|
|
117
|
+
runs-on: ubuntu-latest
|
|
118
|
+
steps:
|
|
119
|
+
- uses: actions/checkout@v4
|
|
120
|
+
|
|
121
|
+
- name: Restore pip cache
|
|
122
|
+
uses: actions/cache/restore@v4
|
|
123
|
+
id: pip-cache
|
|
124
|
+
with:
|
|
125
|
+
path: ~/.cache/pip
|
|
126
|
+
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
|
|
127
|
+
restore-keys: |
|
|
128
|
+
${{ runner.os }}-pip-
|
|
129
|
+
|
|
130
|
+
- name: Install Python dependencies
|
|
131
|
+
run: pip install -r requirements.txt
|
|
132
|
+
|
|
133
|
+
- name: Run pytest
|
|
134
|
+
run: pytest
|
|
135
|
+
|
|
136
|
+
- name: Save pip cache
|
|
137
|
+
uses: actions/cache/save@v4
|
|
138
|
+
if: always()
|
|
139
|
+
with:
|
|
140
|
+
path: ~/.cache/pip
|
|
141
|
+
key: ${{ steps.pip-cache.outputs.cache-primary-key }}
|
|
142
|
+
prevention:
|
|
143
|
+
- 'Prefer the split actions/cache/restore + actions/cache/save pattern over the combined actions/cache action for any workflow using concurrency: cancel-in-progress'
|
|
144
|
+
- 'Always add if: always() to explicit cache save steps so they execute regardless of upstream step failure or cancellation signal'
|
|
145
|
+
- 'Monitor workflow logs for "Post Run actions/cache skipped" messages — this is a reliable indicator that cache is never being populated after cancellations'
|
|
146
|
+
- 'Use actions/cache v3.3.0 or later — the split restore/save subactions were introduced in that release'
|
|
147
|
+
docs:
|
|
148
|
+
- url: 'https://github.com/actions/cache/blob/main/save/README.md'
|
|
149
|
+
label: 'actions/cache: Explicit save action documentation'
|
|
150
|
+
- url: 'https://github.com/actions/cache#save-action'
|
|
151
|
+
label: 'actions/cache: Split restore and save usage pattern'
|
|
152
|
+
- url: 'https://github.com/orgs/community/discussions/47469'
|
|
153
|
+
label: 'GitHub Community #47469: Cache not saved when workflow is cancelled'
|
|
154
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-syntax-for-github-actions#jobsjob_idstepsif'
|
|
155
|
+
label: 'GitHub Docs: Using conditions to control job execution'
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
id: concurrency-timing-030
|
|
2
|
+
title: 'github.sha concurrency group key makes every run unique — cancel-in-progress never fires'
|
|
3
|
+
category: concurrency-timing
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- concurrency
|
|
7
|
+
- cancel-in-progress
|
|
8
|
+
- github-sha
|
|
9
|
+
- unique-key
|
|
10
|
+
- workflow-cancellation
|
|
11
|
+
- silent-failure
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: 'group:\s*.*\$\{\{[^}]*github\.sha[^}]*\}\}'
|
|
14
|
+
flags: 'i'
|
|
15
|
+
- regex: 'cancel-in-progress:\s*true'
|
|
16
|
+
flags: 'i'
|
|
17
|
+
error_messages:
|
|
18
|
+
- 'Old workflow runs are not being cancelled despite cancel-in-progress: true'
|
|
19
|
+
- 'All workflow runs queue independently instead of cancelling previous runs'
|
|
20
|
+
- 'Concurrent workflows are running in parallel when they should be cancelled'
|
|
21
|
+
root_cause: |
|
|
22
|
+
github.sha is unique per commit. When used as (any part of) the concurrency group
|
|
23
|
+
key, every workflow run gets a different group name. Because no two runs ever share
|
|
24
|
+
the same group, the cancel-in-progress mechanism has nothing to act on — all runs
|
|
25
|
+
proceed independently and simultaneously.
|
|
26
|
+
|
|
27
|
+
This is the inverse of the intended behavior. Developers add ${{ github.sha }}
|
|
28
|
+
thinking "this will identify runs for the same commit and cancel old ones," but
|
|
29
|
+
since each commit produces a unique SHA, each run is placed in its own group with
|
|
30
|
+
no overlap. The result is effectively no concurrency control at all.
|
|
31
|
+
|
|
32
|
+
Common mistake patterns:
|
|
33
|
+
group: ${{ github.workflow }}-${{ github.sha }} # unique per commit — never cancels
|
|
34
|
+
group: ${{ github.ref }}-${{ github.sha }} # unique per commit — never cancels
|
|
35
|
+
group: deploy-${{ github.sha }} # unique per commit — never cancels
|
|
36
|
+
|
|
37
|
+
The correct key for "cancel older runs on the same branch" is a value that stays
|
|
38
|
+
constant across pushes to the same branch — github.ref, github.head_ref (for PRs),
|
|
39
|
+
or a composite like github.workflow-github.ref.
|
|
40
|
+
|
|
41
|
+
Source: Frequently reported on Stack Overflow [github-actions] tag and GitHub
|
|
42
|
+
Community discussions. GitHub Docs concurrency examples explicitly use github.ref,
|
|
43
|
+
not github.sha.
|
|
44
|
+
fix: |
|
|
45
|
+
Replace github.sha with github.ref (or github.head_ref for pull request workflows)
|
|
46
|
+
in the concurrency group key. Use a composite that stays constant for all pushes
|
|
47
|
+
to the same branch:
|
|
48
|
+
|
|
49
|
+
- For branch workflows: ${{ github.workflow }}-${{ github.ref }}
|
|
50
|
+
- For PR workflows: ${{ github.workflow }}-${{ github.head_ref }}
|
|
51
|
+
- For global uniqueness: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
|
|
52
|
+
|
|
53
|
+
Only use github.sha in the group key when you explicitly want every run to be
|
|
54
|
+
isolated (e.g., no cancellation ever — but then cancel-in-progress: true is meaningless).
|
|
55
|
+
fix_code:
|
|
56
|
+
- language: yaml
|
|
57
|
+
label: 'Wrong: github.sha makes every run unique — cancel-in-progress never fires'
|
|
58
|
+
code: |
|
|
59
|
+
# WRONG: every push creates a new unique group — nothing is ever cancelled
|
|
60
|
+
concurrency:
|
|
61
|
+
group: ${{ github.workflow }}-${{ github.sha }}
|
|
62
|
+
cancel-in-progress: true # dead code — no prior run shares this group
|
|
63
|
+
|
|
64
|
+
jobs:
|
|
65
|
+
build:
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v4
|
|
69
|
+
|
|
70
|
+
- language: yaml
|
|
71
|
+
label: 'Correct: github.ref stays constant per branch — prior runs are cancelled'
|
|
72
|
+
code: |
|
|
73
|
+
# CORRECT: all pushes to the same branch share one group
|
|
74
|
+
# cancel-in-progress: true cancels any in-flight run when a new push arrives
|
|
75
|
+
concurrency:
|
|
76
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
77
|
+
cancel-in-progress: true
|
|
78
|
+
|
|
79
|
+
jobs:
|
|
80
|
+
build:
|
|
81
|
+
runs-on: ubuntu-latest
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v4
|
|
84
|
+
|
|
85
|
+
- language: yaml
|
|
86
|
+
label: 'PR-specific: use github.head_ref to scope to the PR branch'
|
|
87
|
+
code: |
|
|
88
|
+
# For pull_request workflows, head_ref is the PR branch name (stable per PR)
|
|
89
|
+
concurrency:
|
|
90
|
+
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
|
91
|
+
cancel-in-progress: true
|
|
92
|
+
prevention:
|
|
93
|
+
- 'Never use github.sha in a concurrency group key when the goal is to cancel old runs — it makes every run unique'
|
|
94
|
+
- 'For branch-scoped cancellation use github.ref; for PR-scoped cancellation use github.head_ref'
|
|
95
|
+
- 'If cancel-in-progress: true is set, verify the group key stays constant across multiple pushes to the same branch'
|
|
96
|
+
- 'Use actionlint to catch unreachable concurrency group patterns'
|
|
97
|
+
- 'Read the GitHub Docs concurrency examples — they all use github.ref, not github.sha'
|
|
98
|
+
docs:
|
|
99
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs'
|
|
100
|
+
label: 'GitHub Docs: Controlling concurrency of workflows and jobs'
|
|
101
|
+
- url: 'https://stackoverflow.com/questions/66335225/how-to-cancel-previous-runs-in-the-pr-when-you-push-new-commitschanges'
|
|
102
|
+
label: 'Stack Overflow: How to cancel previous runs when you push new commits'
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
id: concurrency-timing-031
|
|
2
|
+
title: 'cancel-in-progress: false does not create a FIFO queue — only one run can be pending'
|
|
3
|
+
category: concurrency-timing
|
|
4
|
+
severity: warning
|
|
5
|
+
tags:
|
|
6
|
+
- concurrency
|
|
7
|
+
- cancel-in-progress
|
|
8
|
+
- queue
|
|
9
|
+
- pending-run
|
|
10
|
+
- fifo
|
|
11
|
+
- workflow-queuing
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: 'cancel-in-progress:\s*false'
|
|
14
|
+
flags: 'i'
|
|
15
|
+
error_messages:
|
|
16
|
+
- 'Queued workflow run was cancelled unexpectedly even though cancel-in-progress is false'
|
|
17
|
+
- 'Expected runs to queue up but intermediate runs are being dropped'
|
|
18
|
+
- 'Second queued run cancelled when a third run was triggered'
|
|
19
|
+
root_cause: |
|
|
20
|
+
When cancel-in-progress: false is set, many developers expect GitHub Actions to
|
|
21
|
+
maintain a FIFO queue of pending runs: Run 1 active → Run 2 queued → Run 3 waits
|
|
22
|
+
behind Run 2. This is not how it works.
|
|
23
|
+
|
|
24
|
+
GitHub Actions maintains at most ONE pending (queued) run per concurrency group.
|
|
25
|
+
The behavior with cancel-in-progress: false is:
|
|
26
|
+
|
|
27
|
+
1. Run 1: active (running)
|
|
28
|
+
2. Run 2 arrives: Run 2 enters pending state
|
|
29
|
+
3. Run 3 arrives: Run 2 is CANCELLED, Run 3 becomes the new pending run
|
|
30
|
+
4. Run 1 completes: Run 3 (latest) starts — Run 2 was silently dropped
|
|
31
|
+
|
|
32
|
+
This means: with three or more rapid commits, only the first (currently running)
|
|
33
|
+
and the last (most recently pushed) will ever execute. All intermediate runs are
|
|
34
|
+
cancelled and lost — even though cancel-in-progress is explicitly false.
|
|
35
|
+
|
|
36
|
+
This behavior is documented in GitHub Docs ("only the latest queued run will
|
|
37
|
+
start") but is widely misunderstood. The cancel-in-progress: false setting only
|
|
38
|
+
prevents cancelling the ACTIVE run — it does not prevent intermediate pending
|
|
39
|
+
runs from being replaced by newer ones.
|
|
40
|
+
|
|
41
|
+
Source: GitHub Docs concurrency documentation. Discussed in GitHub Community
|
|
42
|
+
discussions and multiple Stack Overflow questions about "runs being unexpectedly
|
|
43
|
+
cancelled with cancel-in-progress: false".
|
|
44
|
+
fix: |
|
|
45
|
+
There is no built-in FIFO queue in GitHub Actions concurrency. To process every
|
|
46
|
+
run without dropping intermediates:
|
|
47
|
+
|
|
48
|
+
1. For pull requests: use GitHub's merge queue — it processes PRs sequentially
|
|
49
|
+
2. For deployments: use a separate queuing action (e.g., softprops/turnstyle) to
|
|
50
|
+
serialize runs without skipping intermediates
|
|
51
|
+
3. For simple "don't cancel active, run latest after": accept that cancel-in-progress:
|
|
52
|
+
false means intermediates are dropped and design accordingly
|
|
53
|
+
4. If every commit must be processed: remove the concurrency block entirely and
|
|
54
|
+
accept parallel runs, or implement idempotent jobs that can run concurrently
|
|
55
|
+
fix_code:
|
|
56
|
+
- language: yaml
|
|
57
|
+
label: 'Misunderstood: cancel-in-progress: false does NOT queue all runs'
|
|
58
|
+
code: |
|
|
59
|
+
# MISUNDERSTOOD: developers expect runs 1, 2, 3 to all execute sequentially
|
|
60
|
+
# ACTUAL behavior: run 2 is cancelled when run 3 arrives
|
|
61
|
+
concurrency:
|
|
62
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
63
|
+
cancel-in-progress: false # protects ACTIVE run only; pending run is replaced
|
|
64
|
+
|
|
65
|
+
jobs:
|
|
66
|
+
deploy:
|
|
67
|
+
runs-on: ubuntu-latest
|
|
68
|
+
steps:
|
|
69
|
+
- uses: actions/checkout@v4
|
|
70
|
+
- run: echo "Deploying..."
|
|
71
|
+
|
|
72
|
+
- language: yaml
|
|
73
|
+
label: 'Explicit: accept one-pending semantics and design for idempotent jobs'
|
|
74
|
+
code: |
|
|
75
|
+
# If intermediate commits can be skipped (e.g., deploy only latest):
|
|
76
|
+
# cancel-in-progress: false ensures the active deploy finishes, then
|
|
77
|
+
# only the LATEST pending commit deploys next (intermediates dropped)
|
|
78
|
+
concurrency:
|
|
79
|
+
group: deploy-${{ github.ref }}
|
|
80
|
+
cancel-in-progress: false
|
|
81
|
+
|
|
82
|
+
jobs:
|
|
83
|
+
deploy:
|
|
84
|
+
runs-on: ubuntu-latest
|
|
85
|
+
steps:
|
|
86
|
+
- uses: actions/checkout@v4
|
|
87
|
+
- name: Deploy latest commit
|
|
88
|
+
run: |
|
|
89
|
+
echo "Deploying ${{ github.sha }}"
|
|
90
|
+
# Idempotent: deploying latest SHA is always safe
|
|
91
|
+
prevention:
|
|
92
|
+
- 'Understand that cancel-in-progress: false only protects the active run — the pending slot still holds at most one run'
|
|
93
|
+
- 'Do not rely on concurrency groups for FIFO queuing — they are a deduplication mechanism, not a queue'
|
|
94
|
+
- 'For sequential processing of every commit, use merge queues or external serialization tooling'
|
|
95
|
+
- 'If every commit must be processed, either remove the concurrency block or use an event-based queue (e.g., repository_dispatch)'
|
|
96
|
+
docs:
|
|
97
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs'
|
|
98
|
+
label: 'GitHub Docs: Controlling concurrency of workflows and jobs'
|
|
99
|
+
- url: 'https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue'
|
|
100
|
+
label: 'GitHub Docs: Managing a merge queue'
|
|
101
|
+
- url: 'https://github.com/softprops/turnstyle'
|
|
102
|
+
label: 'softprops/turnstyle: Wait for in-progress workflows'
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
id: concurrency-timing-032
|
|
2
|
+
title: 'Reusable workflow concurrency: is not inherited from caller — parallel instances can run simultaneously'
|
|
3
|
+
category: concurrency-timing
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- concurrency
|
|
7
|
+
- reusable-workflow
|
|
8
|
+
- workflow-call
|
|
9
|
+
- parallel-runs
|
|
10
|
+
- concurrency-inheritance
|
|
11
|
+
- deployment
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: 'jobs\.\w+\.uses:\s*'
|
|
14
|
+
flags: 'i'
|
|
15
|
+
- regex: '^concurrency:'
|
|
16
|
+
flags: 'im'
|
|
17
|
+
error_messages:
|
|
18
|
+
- 'Multiple simultaneous deployments triggered despite concurrency group on caller workflow'
|
|
19
|
+
- 'Reusable workflow running in parallel when caller has cancel-in-progress: true'
|
|
20
|
+
- 'Concurrency group on calling workflow does not prevent parallel reusable workflow runs'
|
|
21
|
+
root_cause: |
|
|
22
|
+
When a workflow defines concurrency: at the workflow level and calls a reusable
|
|
23
|
+
workflow via jobs.<id>.uses:, the concurrency configuration is NOT propagated
|
|
24
|
+
to the called (callee) workflow. The reusable workflow runs with its own
|
|
25
|
+
independent concurrency context.
|
|
26
|
+
|
|
27
|
+
This means:
|
|
28
|
+
- Workflow A has concurrency: group: deploy-${{ github.ref }}
|
|
29
|
+
- Workflow A calls Reusable-Deploy.yml via jobs.deploy.uses
|
|
30
|
+
- If two branches both trigger Workflow A simultaneously, each instance of Workflow A
|
|
31
|
+
serializes itself via its own concurrency group — but both instances of
|
|
32
|
+
Reusable-Deploy.yml run in parallel with no concurrency restriction
|
|
33
|
+
|
|
34
|
+
The concurrency group defined in the CALLER protects the caller's run from
|
|
35
|
+
duplicate callers on the same ref. It does NOT create any concurrency group for
|
|
36
|
+
the callee. The callee executes as a distinct workflow run with no inherited
|
|
37
|
+
concurrency settings.
|
|
38
|
+
|
|
39
|
+
This is especially problematic for shared deployment reusable workflows:
|
|
40
|
+
- Multiple repos or branches can call the same reusable deployment workflow
|
|
41
|
+
- Each caller serializes itself per its own ref, but the shared callee runs
|
|
42
|
+
multiple parallel instances simultaneously
|
|
43
|
+
- Parallel deploys to the same environment proceed without conflict detection
|
|
44
|
+
|
|
45
|
+
Source: GitHub Docs explicitly states concurrency groups are scoped to the
|
|
46
|
+
specific workflow run. GitHub Community confirmed expected behavior in multiple
|
|
47
|
+
discussions about parallel reusable workflow instances.
|
|
48
|
+
fix: |
|
|
49
|
+
Define concurrency: inside the reusable workflow itself, or accept a
|
|
50
|
+
concurrency-key input and use it within the callee:
|
|
51
|
+
|
|
52
|
+
Option 1 — Static group in reusable workflow:
|
|
53
|
+
Add concurrency: group: deploy-reusable to the callee workflow. This serializes
|
|
54
|
+
ALL calls to that reusable workflow globally.
|
|
55
|
+
|
|
56
|
+
Option 2 — Dynamic group via input:
|
|
57
|
+
Add a concurrency-key input to the reusable workflow. The caller passes
|
|
58
|
+
its own group key. The callee uses it: group: ${{ inputs.concurrency_key }}.
|
|
59
|
+
This gives callers control over which callee instances serialize against each other.
|
|
60
|
+
|
|
61
|
+
Option 3 — Environment-level protection:
|
|
62
|
+
Use GitHub deployment environments with required reviewers. Only one deployment
|
|
63
|
+
to an environment can be in progress at a time (pending review blocks others).
|
|
64
|
+
fix_code:
|
|
65
|
+
- language: yaml
|
|
66
|
+
label: 'Caller: concurrency here only protects the caller — not the callee'
|
|
67
|
+
code: |
|
|
68
|
+
# caller-workflow.yml
|
|
69
|
+
# This concurrency group only prevents duplicate CALLERS for the same ref.
|
|
70
|
+
# It does NOT propagate to the reusable workflow.
|
|
71
|
+
concurrency:
|
|
72
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
73
|
+
cancel-in-progress: true
|
|
74
|
+
|
|
75
|
+
jobs:
|
|
76
|
+
deploy:
|
|
77
|
+
uses: ./.github/workflows/reusable-deploy.yml
|
|
78
|
+
with:
|
|
79
|
+
environment: production
|
|
80
|
+
secrets: inherit
|
|
81
|
+
|
|
82
|
+
- language: yaml
|
|
83
|
+
label: 'Fix option 1: define concurrency inside the reusable workflow'
|
|
84
|
+
code: |
|
|
85
|
+
# reusable-deploy.yml
|
|
86
|
+
on:
|
|
87
|
+
workflow_call:
|
|
88
|
+
inputs:
|
|
89
|
+
environment:
|
|
90
|
+
type: string
|
|
91
|
+
required: true
|
|
92
|
+
|
|
93
|
+
# Add concurrency here to serialize all calls to this reusable workflow
|
|
94
|
+
concurrency:
|
|
95
|
+
group: reusable-deploy-${{ inputs.environment }}
|
|
96
|
+
cancel-in-progress: false # let active deploy finish; queue latest
|
|
97
|
+
|
|
98
|
+
jobs:
|
|
99
|
+
deploy:
|
|
100
|
+
runs-on: ubuntu-latest
|
|
101
|
+
environment: ${{ inputs.environment }}
|
|
102
|
+
steps:
|
|
103
|
+
- uses: actions/checkout@v4
|
|
104
|
+
- run: echo "Deploying to ${{ inputs.environment }}"
|
|
105
|
+
|
|
106
|
+
- language: yaml
|
|
107
|
+
label: 'Fix option 2: caller passes concurrency key as input'
|
|
108
|
+
code: |
|
|
109
|
+
# reusable-deploy.yml — accepts caller-controlled concurrency key
|
|
110
|
+
on:
|
|
111
|
+
workflow_call:
|
|
112
|
+
inputs:
|
|
113
|
+
concurrency_key:
|
|
114
|
+
type: string
|
|
115
|
+
required: false
|
|
116
|
+
default: 'reusable-deploy'
|
|
117
|
+
|
|
118
|
+
concurrency:
|
|
119
|
+
group: ${{ inputs.concurrency_key }}
|
|
120
|
+
cancel-in-progress: false
|
|
121
|
+
|
|
122
|
+
jobs:
|
|
123
|
+
deploy:
|
|
124
|
+
runs-on: ubuntu-latest
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@v4
|
|
127
|
+
- run: echo "Deploying..."
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
# caller-workflow.yml — passes its own ref as the key
|
|
131
|
+
jobs:
|
|
132
|
+
deploy:
|
|
133
|
+
uses: ./.github/workflows/reusable-deploy.yml
|
|
134
|
+
with:
|
|
135
|
+
concurrency_key: deploy-${{ github.ref }}
|
|
136
|
+
prevention:
|
|
137
|
+
- 'Never assume the caller''s concurrency: group propagates to called reusable workflows — it does not'
|
|
138
|
+
- 'Define concurrency: inside every reusable workflow that manages shared resources (deployments, releases, package publishes)'
|
|
139
|
+
- 'Use deployment environments with required reviewers as an additional serialization layer for production deploys'
|
|
140
|
+
- 'Test reusable workflows by triggering two parallel calls and verifying only one runs at a time'
|
|
141
|
+
docs:
|
|
142
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs'
|
|
143
|
+
label: 'GitHub Docs: Controlling concurrency of workflows and jobs'
|
|
144
|
+
- url: 'https://docs.github.com/en/actions/sharing-automations/reusing-workflows'
|
|
145
|
+
label: 'GitHub Docs: Reusing workflows'
|
|
146
|
+
- url: 'https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment'
|
|
147
|
+
label: 'GitHub Docs: Managing environments for deployment'
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
id: permissions-auth-038
|
|
2
|
+
title: 'secrets: inherit does not propagate environment-scoped secrets to reusable workflows'
|
|
3
|
+
category: permissions-auth
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- secrets-inherit
|
|
7
|
+
- reusable-workflows
|
|
8
|
+
- environment-secrets
|
|
9
|
+
- workflow-call
|
|
10
|
+
- empty-secret
|
|
11
|
+
- deployment-environment
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: 'secrets:\s*inherit'
|
|
14
|
+
flags: 'i'
|
|
15
|
+
- regex: 'uses:\s*[./\w@-]+\.ya?ml\b'
|
|
16
|
+
flags: 'i'
|
|
17
|
+
error_messages:
|
|
18
|
+
- 'Error: Input required and not supplied'
|
|
19
|
+
- 'Secret value is empty in reusable workflow'
|
|
20
|
+
- '##[error]The secret variable name is invalid'
|
|
21
|
+
root_cause: |
|
|
22
|
+
When a caller workflow uses secrets: inherit to call a reusable workflow
|
|
23
|
+
(on.workflow_call), GitHub passes repository-level and organization-level
|
|
24
|
+
secrets to the called workflow. However, environment-scoped secrets are NOT
|
|
25
|
+
inherited — they arrive as empty strings with no error or warning.
|
|
26
|
+
|
|
27
|
+
Environment secrets are tied to a named deployment environment (e.g.,
|
|
28
|
+
"production", "staging") and are only accessible to jobs that declare
|
|
29
|
+
environment: <name> in their configuration. The secrets: inherit mechanism
|
|
30
|
+
operates at the repository/org secret scope level. It cannot bridge environment
|
|
31
|
+
context to a called workflow's jobs because those jobs run in a separate
|
|
32
|
+
workflow execution context and do not inherit the caller job's environment
|
|
33
|
+
declaration.
|
|
34
|
+
|
|
35
|
+
Failure scenario:
|
|
36
|
+
1. Caller job declares environment: production (activates env secrets in the caller)
|
|
37
|
+
2. Caller job uses secrets: inherit to call ./reusable.yml
|
|
38
|
+
3. Reusable workflow job reads secrets.PROD_DB_PASSWORD (an environment secret)
|
|
39
|
+
4. Reusable workflow job sees PROD_DB_PASSWORD as empty string
|
|
40
|
+
5. No error is thrown — the secret silently resolves to ""
|
|
41
|
+
|
|
42
|
+
This is documented as a GitHub platform limitation in the reusable workflows
|
|
43
|
+
documentation. GitHub Community discussion #26671 has many reports of this
|
|
44
|
+
surprising behavior.
|
|
45
|
+
|
|
46
|
+
Distinction from permissions-auth-037 (environment-secrets-job-scope):
|
|
47
|
+
- permissions-auth-037 covers jobs in the SAME workflow that lack environment:
|
|
48
|
+
declarations failing to access environment secrets.
|
|
49
|
+
- This entry covers the cross-workflow boundary where secrets: inherit does not
|
|
50
|
+
transfer environment secrets from caller to called workflow at all.
|
|
51
|
+
fix: |
|
|
52
|
+
Explicitly declare and map environment secrets at the workflow_call boundary.
|
|
53
|
+
In the reusable workflow, declare each secret in the on.workflow_call.secrets
|
|
54
|
+
block. At the call site, explicitly pass each environment secret using the
|
|
55
|
+
secrets: mapping block (not secrets: inherit):
|
|
56
|
+
|
|
57
|
+
In the reusable workflow (workflow_call trigger):
|
|
58
|
+
on:
|
|
59
|
+
workflow_call:
|
|
60
|
+
secrets:
|
|
61
|
+
db_password:
|
|
62
|
+
required: true
|
|
63
|
+
api_key:
|
|
64
|
+
required: false
|
|
65
|
+
|
|
66
|
+
In the calling workflow:
|
|
67
|
+
jobs:
|
|
68
|
+
deploy:
|
|
69
|
+
environment: production
|
|
70
|
+
uses: ./.github/workflows/deploy.yml
|
|
71
|
+
secrets:
|
|
72
|
+
db_password: ${{ secrets.PROD_DB_PASSWORD }}
|
|
73
|
+
api_key: ${{ secrets.PROD_API_KEY }}
|
|
74
|
+
|
|
75
|
+
Alternatively, if the reusable workflow is in the same repository and the
|
|
76
|
+
environment name is fixed, you can declare environment: production on the job
|
|
77
|
+
inside the reusable workflow directly. This makes environment secrets accessible
|
|
78
|
+
in the called workflow at the cost of coupling it to a specific environment name.
|
|
79
|
+
|
|
80
|
+
Note: secrets: inherit and explicit secrets: mapping cannot be combined on the
|
|
81
|
+
same uses: step. Choose one approach: either inherit all repo/org secrets, or
|
|
82
|
+
map explicitly (which also allows passing environment secrets).
|
|
83
|
+
fix_code:
|
|
84
|
+
- language: yaml
|
|
85
|
+
label: 'Reusable workflow — declare required secrets in workflow_call trigger'
|
|
86
|
+
code: |
|
|
87
|
+
# .github/workflows/deploy-reusable.yml
|
|
88
|
+
on:
|
|
89
|
+
workflow_call:
|
|
90
|
+
secrets:
|
|
91
|
+
db_password:
|
|
92
|
+
required: true
|
|
93
|
+
api_key:
|
|
94
|
+
required: true
|
|
95
|
+
|
|
96
|
+
jobs:
|
|
97
|
+
deploy:
|
|
98
|
+
runs-on: ubuntu-latest
|
|
99
|
+
steps:
|
|
100
|
+
- name: Deploy application
|
|
101
|
+
env:
|
|
102
|
+
DB_PASSWORD: ${{ secrets.db_password }}
|
|
103
|
+
API_KEY: ${{ secrets.api_key }}
|
|
104
|
+
run: ./scripts/deploy.sh
|
|
105
|
+
|
|
106
|
+
- language: yaml
|
|
107
|
+
label: 'Caller workflow — explicitly map environment secrets (not secrets: inherit)'
|
|
108
|
+
code: |
|
|
109
|
+
# .github/workflows/deploy-caller.yml
|
|
110
|
+
jobs:
|
|
111
|
+
call-deploy:
|
|
112
|
+
environment: production # Activates environment secrets in this calling job
|
|
113
|
+
uses: ./.github/workflows/deploy-reusable.yml
|
|
114
|
+
# WRONG: secrets: inherit -- passes repo/org secrets but NOT environment secrets
|
|
115
|
+
secrets:
|
|
116
|
+
db_password: ${{ secrets.PROD_DB_PASSWORD }} # Environment secret — must be explicit
|
|
117
|
+
api_key: ${{ secrets.PROD_API_KEY }} # Environment secret — must be explicit
|
|
118
|
+
|
|
119
|
+
- language: yaml
|
|
120
|
+
label: 'Alternative — declare environment inside the reusable workflow job'
|
|
121
|
+
code: |
|
|
122
|
+
# .github/workflows/deploy-reusable.yml (alternative approach)
|
|
123
|
+
on:
|
|
124
|
+
workflow_call: {}
|
|
125
|
+
|
|
126
|
+
jobs:
|
|
127
|
+
deploy:
|
|
128
|
+
runs-on: ubuntu-latest
|
|
129
|
+
environment: production # Directly activates environment secrets here
|
|
130
|
+
steps: # Couples reusable workflow to environment name
|
|
131
|
+
- name: Deploy application
|
|
132
|
+
env:
|
|
133
|
+
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
|
|
134
|
+
run: ./scripts/deploy.sh
|
|
135
|
+
prevention:
|
|
136
|
+
- 'Never rely on secrets: inherit to pass environment-scoped secrets — always map them explicitly in the secrets: block of the workflow_call invocation'
|
|
137
|
+
- 'In reusable workflows, declare all required secrets in on.workflow_call.secrets with required: true so missing secrets fail loudly rather than silently resolving to empty strings'
|
|
138
|
+
- 'Document which secrets in your reusable workflow are environment-scoped vs repository-scoped so callers know the correct passing strategy'
|
|
139
|
+
- 'Test reusable workflows end-to-end from a caller workflow before deploying to production — secrets: inherit working in testing does not guarantee environment secrets work correctly'
|
|
140
|
+
docs:
|
|
141
|
+
- url: 'https://docs.github.com/en/actions/sharing-automations/reusing-workflows#passing-secrets-to-called-workflows'
|
|
142
|
+
label: 'GitHub Docs: Passing secrets to called workflows'
|
|
143
|
+
- url: 'https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-an-environment'
|
|
144
|
+
label: 'GitHub Docs: Creating secrets for an environment'
|
|
145
|
+
- url: 'https://github.com/orgs/community/discussions/26671'
|
|
146
|
+
label: 'GitHub Community #26671: secrets: inherit not passing environment secrets'
|
|
147
|
+
- url: 'https://docs.github.com/en/actions/sharing-automations/reusing-workflows#limitations'
|
|
148
|
+
label: 'GitHub Docs: Reusable workflows — limitations'
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
id: runner-environment-110
|
|
2
|
+
title: 'actions/checkout default fetch-depth:1 shallow clone breaks git describe and changelog generation'
|
|
3
|
+
category: runner-environment
|
|
4
|
+
severity: error
|
|
5
|
+
tags:
|
|
6
|
+
- checkout
|
|
7
|
+
- fetch-depth
|
|
8
|
+
- shallow-clone
|
|
9
|
+
- git-describe
|
|
10
|
+
- versioning
|
|
11
|
+
- changelog
|
|
12
|
+
- tags
|
|
13
|
+
- release
|
|
14
|
+
patterns:
|
|
15
|
+
- regex: 'fatal:\s*No names found.*cannot describe|No tag can describe'
|
|
16
|
+
flags: 'i'
|
|
17
|
+
- regex: 'fetch-depth:\s*[01]\b'
|
|
18
|
+
flags: 'i'
|
|
19
|
+
error_messages:
|
|
20
|
+
- 'fatal: No names found, cannot describe anything.'
|
|
21
|
+
- 'fatal: No tags can describe ''HEAD''.'
|
|
22
|
+
- 'error: No annotated tags can describe'
|
|
23
|
+
- '##[error]fatal: no tag exactly matches'
|
|
24
|
+
root_cause: |
|
|
25
|
+
actions/checkout defaults to fetch-depth: 1 — a shallow clone that downloads
|
|
26
|
+
only the single most recent commit. This optimizes CI download speed but breaks
|
|
27
|
+
any operation that requires commit history or reachable tag ancestry:
|
|
28
|
+
|
|
29
|
+
- git describe --tags: requires a tag somewhere in the commit ancestry chain.
|
|
30
|
+
With depth 1, no ancestor commits exist, so no tags are reachable unless the
|
|
31
|
+
HEAD commit IS exactly the tagged commit. Returns "fatal: No names found,
|
|
32
|
+
cannot describe anything."
|
|
33
|
+
|
|
34
|
+
- Changelog generators (conventional-changelog, release-please, git-cliff,
|
|
35
|
+
semantic-release): need commit history to determine what changed since the
|
|
36
|
+
last release. With depth 1, they see zero commits and produce empty changelogs.
|
|
37
|
+
|
|
38
|
+
- Semantic versioning tools that count commits since last tag always return 0
|
|
39
|
+
because no prior commits (and thus no tags) are in the shallow history.
|
|
40
|
+
|
|
41
|
+
- Branch comparison and merge-base operations: git merge-base fails or produces
|
|
42
|
+
incorrect results with no shared history available.
|
|
43
|
+
|
|
44
|
+
A common workaround attempt — using fetch-tags: true with fetch-depth: 1 —
|
|
45
|
+
fetches tag objects but NOT the commits they point to in history, so
|
|
46
|
+
git describe still fails. The tags exist as objects but are not reachable from
|
|
47
|
+
HEAD via the commit graph.
|
|
48
|
+
|
|
49
|
+
This is one of the most-discussed GitHub Community topics: the checkout step
|
|
50
|
+
looks correct, CI succeeds, but release version numbers or changelogs are
|
|
51
|
+
unexpectedly empty or wrong, often discovered only after a real release attempt.
|
|
52
|
+
fix: |
|
|
53
|
+
Set fetch-depth: 0 to fetch complete commit history including all tags:
|
|
54
|
+
|
|
55
|
+
- uses: actions/checkout@v4
|
|
56
|
+
with:
|
|
57
|
+
fetch-depth: 0
|
|
58
|
+
|
|
59
|
+
For large repositories where full history is prohibitively slow, use targeted
|
|
60
|
+
approaches:
|
|
61
|
+
- fetch-depth: 50 covers the last 50 commits — sufficient for active repos
|
|
62
|
+
that tag frequently, but risky for repos with infrequent tagging.
|
|
63
|
+
- fetch-depth: 0 combined with --filter=blob:none (partial clone) gets history
|
|
64
|
+
metadata without full file blobs — not directly configurable in the action but
|
|
65
|
+
achievable via fetch options in post-checkout scripts for advanced cases.
|
|
66
|
+
|
|
67
|
+
For changelog and release tools (release-please, git-cliff, semantic-release,
|
|
68
|
+
conventional-changelog), always use fetch-depth: 0. These tools explicitly
|
|
69
|
+
require full history and their documentation states this requirement.
|
|
70
|
+
fix_code:
|
|
71
|
+
- language: yaml
|
|
72
|
+
label: 'Full history checkout for git describe and changelog generation tools'
|
|
73
|
+
code: |
|
|
74
|
+
jobs:
|
|
75
|
+
release:
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
steps:
|
|
78
|
+
- uses: actions/checkout@v4
|
|
79
|
+
with:
|
|
80
|
+
fetch-depth: 0 # Full history required for version detection and changelogs
|
|
81
|
+
# Default fetch-depth: 1 (shallow) causes "No names found, cannot describe"
|
|
82
|
+
|
|
83
|
+
- name: Generate changelog
|
|
84
|
+
uses: orhun/git-cliff-action@v3
|
|
85
|
+
with:
|
|
86
|
+
config: cliff.toml
|
|
87
|
+
|
|
88
|
+
- language: yaml
|
|
89
|
+
label: 'Partial depth with fetch-tags for large repos — use with caution'
|
|
90
|
+
code: |
|
|
91
|
+
jobs:
|
|
92
|
+
version:
|
|
93
|
+
runs-on: ubuntu-latest
|
|
94
|
+
steps:
|
|
95
|
+
- uses: actions/checkout@v4
|
|
96
|
+
with:
|
|
97
|
+
fetch-depth: 100 # Last 100 commits — sufficient for frequently-tagged repos
|
|
98
|
+
fetch-tags: true # Fetch tag objects so they appear in tag list
|
|
99
|
+
# WARNING: fetch-tags: true with a shallow clone fetches tag OBJECTS
|
|
100
|
+
# but NOT their commit ancestors — git describe may still fail if
|
|
101
|
+
# the tag is older than fetch-depth commits. Use fetch-depth: 0 to be safe.
|
|
102
|
+
prevention:
|
|
103
|
+
- 'Set fetch-depth: 0 in any workflow that uses git describe, generates a changelog, or computes a semantic version from commit history'
|
|
104
|
+
- 'Document in your workflow why fetch-depth: 0 is required so future maintainers do not revert it to the shallow default'
|
|
105
|
+
- 'Do not assume fetch-tags: true solves shallow clone limitations for git describe — the tag objects must be reachable in the commit graph'
|
|
106
|
+
- 'Keep the default fetch-depth: 1 only for workflows that never need commit history — pure build and test jobs with no versioning or history analysis'
|
|
107
|
+
docs:
|
|
108
|
+
- url: 'https://github.com/actions/checkout#usage'
|
|
109
|
+
label: 'actions/checkout: fetch-depth parameter documentation'
|
|
110
|
+
- url: 'https://github.com/orgs/community/discussions/25702'
|
|
111
|
+
label: 'GitHub Community: git describe fails with actions/checkout shallow clone'
|
|
112
|
+
- url: 'https://github.com/actions/checkout/issues/701'
|
|
113
|
+
label: 'actions/checkout #701: fetch-tags does not help with git describe on shallow clone'
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
id: silent-failures-053
|
|
2
|
+
title: 'workflow_dispatch boolean inputs arrive as strings — if: inputs.flag is always truthy'
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- workflow-dispatch
|
|
7
|
+
- boolean-inputs
|
|
8
|
+
- type-coercion
|
|
9
|
+
- inputs-context
|
|
10
|
+
- string-comparison
|
|
11
|
+
- if-condition
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: 'inputs\.\w+\s*==\s*true\b'
|
|
14
|
+
flags: 'i'
|
|
15
|
+
- regex: 'if:\s*inputs\.\w+'
|
|
16
|
+
flags: 'i'
|
|
17
|
+
error_messages:
|
|
18
|
+
- 'Step always runs even when boolean input is set to false'
|
|
19
|
+
- 'Boolean workflow_dispatch input behaves as truthy regardless of value'
|
|
20
|
+
root_cause: |
|
|
21
|
+
GitHub Actions workflow_dispatch inputs defined with type: boolean are passed
|
|
22
|
+
to the workflow as strings ("true" or "false"), not as JavaScript/YAML booleans.
|
|
23
|
+
|
|
24
|
+
This means three common patterns silently misbehave:
|
|
25
|
+
- if: inputs.dry_run evaluates to always truthy (non-empty string)
|
|
26
|
+
- if: inputs.dry_run == true always false (string "true" != boolean true)
|
|
27
|
+
- if: inputs.dry_run == false always false (string "false" != boolean false)
|
|
28
|
+
|
|
29
|
+
The GitHub Actions expression evaluator converts the inputs context to strings
|
|
30
|
+
at the boundary between the event payload and the workflow context. Even though
|
|
31
|
+
the UI renders a checkbox, the underlying value is the literal string "true" or
|
|
32
|
+
"false". This behavior is consistent across workflow_dispatch and workflow_call
|
|
33
|
+
inputs of type boolean.
|
|
34
|
+
|
|
35
|
+
GitHub Community discussion #51504 documents this as expected behavior, but
|
|
36
|
+
thousands of developers are surprised by the mismatch between the checkbox UI
|
|
37
|
+
and the string runtime value.
|
|
38
|
+
|
|
39
|
+
Affected patterns:
|
|
40
|
+
- if: inputs.dry_run (truthy check — always true for any non-empty string value)
|
|
41
|
+
- if: inputs.flag == true (boolean comparison — always false for string "true")
|
|
42
|
+
- Ternary: ${{ inputs.flag && 'yes' || 'no' }} returns "yes" even when flag is "false"
|
|
43
|
+
fix: |
|
|
44
|
+
Always compare workflow_dispatch boolean inputs to the string literal 'true'
|
|
45
|
+
or 'false':
|
|
46
|
+
|
|
47
|
+
- if: inputs.dry_run == 'true' correct
|
|
48
|
+
- if: inputs.dry_run != 'true' correct negation (catches "false" and "")
|
|
49
|
+
- if: inputs.dry_run == true WRONG — string never equals boolean
|
|
50
|
+
- if: inputs.dry_run WRONG — always truthy for non-empty string
|
|
51
|
+
|
|
52
|
+
For workflow_call with boolean inputs, the same string coercion applies when
|
|
53
|
+
the calling workflow passes the value via the inputs: block.
|
|
54
|
+
|
|
55
|
+
The fromJSON() function converts the string to a real boolean if needed for
|
|
56
|
+
complex conditions: fromJSON(inputs.dry_run) returns actual true/false booleans.
|
|
57
|
+
fix_code:
|
|
58
|
+
- language: yaml
|
|
59
|
+
label: 'Correct boolean input comparison using string equality'
|
|
60
|
+
code: |
|
|
61
|
+
on:
|
|
62
|
+
workflow_dispatch:
|
|
63
|
+
inputs:
|
|
64
|
+
dry_run:
|
|
65
|
+
description: 'Run without making changes'
|
|
66
|
+
type: boolean
|
|
67
|
+
default: false
|
|
68
|
+
|
|
69
|
+
jobs:
|
|
70
|
+
deploy:
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
steps:
|
|
73
|
+
- name: Deploy (skip if dry run)
|
|
74
|
+
# WRONG: if: inputs.dry_run -- always truthy (non-empty string)
|
|
75
|
+
# WRONG: if: inputs.dry_run == true -- string never equals boolean
|
|
76
|
+
if: inputs.dry_run != 'true'
|
|
77
|
+
run: echo "Deploying..."
|
|
78
|
+
|
|
79
|
+
- name: Dry run notice
|
|
80
|
+
if: inputs.dry_run == 'true'
|
|
81
|
+
run: echo "Dry run mode — no changes made"
|
|
82
|
+
|
|
83
|
+
- language: yaml
|
|
84
|
+
label: 'Use fromJSON() to convert to real boolean for complex conditions'
|
|
85
|
+
code: |
|
|
86
|
+
jobs:
|
|
87
|
+
build:
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
steps:
|
|
90
|
+
- name: Conditional step using fromJSON conversion
|
|
91
|
+
if: fromJSON(inputs.dry_run) == false
|
|
92
|
+
run: echo "Not a dry run — proceeding"
|
|
93
|
+
|
|
94
|
+
- name: Set environment variable from boolean input
|
|
95
|
+
env:
|
|
96
|
+
# fromJSON converts "true"/"false" string to actual boolean
|
|
97
|
+
DRY_RUN: ${{ fromJSON(inputs.dry_run) }}
|
|
98
|
+
run: |
|
|
99
|
+
echo "Dry run mode: $DRY_RUN"
|
|
100
|
+
prevention:
|
|
101
|
+
- 'Always compare workflow_dispatch boolean inputs to the string literal ''true'' or ''false'', not to boolean true/false'
|
|
102
|
+
- 'Never use ''if: inputs.flag'' alone as a boolean check — the non-empty string "false" is truthy in GitHub Actions expressions'
|
|
103
|
+
- 'Use fromJSON(inputs.flag) when you need a real boolean value in expressions or env: context'
|
|
104
|
+
- 'Add a comment in your workflow noting that boolean dispatch inputs are strings at runtime to warn future maintainers'
|
|
105
|
+
docs:
|
|
106
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-syntax-for-github-actions#onworkflow_dispatchinputs'
|
|
107
|
+
label: 'GitHub Docs: workflow_dispatch inputs syntax'
|
|
108
|
+
- url: 'https://github.com/orgs/community/discussions/51504'
|
|
109
|
+
label: 'GitHub Community #51504: Boolean inputs in workflow_dispatch are strings'
|
|
110
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#fromjson'
|
|
111
|
+
label: 'GitHub Docs: fromJSON expression function'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
id: yaml-syntax-037
|
|
2
|
+
title: 'Step cannot have both uses: and run: — mutually exclusive keys cause workflow validation failure'
|
|
3
|
+
category: yaml-syntax
|
|
4
|
+
severity: error
|
|
5
|
+
tags:
|
|
6
|
+
- yaml-syntax
|
|
7
|
+
- uses
|
|
8
|
+
- run
|
|
9
|
+
- step-definition
|
|
10
|
+
- workflow-validation
|
|
11
|
+
- mutually-exclusive
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: 'uses:\s*.+\n\s+run:'
|
|
14
|
+
flags: 'im'
|
|
15
|
+
- regex: 'run:\s*.+\n\s+uses:'
|
|
16
|
+
flags: 'im'
|
|
17
|
+
error_messages:
|
|
18
|
+
- "A step must define one of ['run', 'uses']"
|
|
19
|
+
- 'Unexpected value ''run'''
|
|
20
|
+
- 'Workflow file is not valid: .github/workflows/ci.yml (Line: N, Col: M): Unexpected value ''run'''
|
|
21
|
+
- "A step must not define both 'uses' and 'run'"
|
|
22
|
+
root_cause: |
|
|
23
|
+
A GitHub Actions workflow step is either an action reference (uses:) or a
|
|
24
|
+
shell command (run:), but never both simultaneously. These two keys are mutually
|
|
25
|
+
exclusive by design: uses: runs a pre-built action with its own entry point, while
|
|
26
|
+
run: executes arbitrary shell commands in the runner's default shell.
|
|
27
|
+
|
|
28
|
+
Common scenarios that produce this error:
|
|
29
|
+
|
|
30
|
+
1. Adding a quick debug command to an existing action step:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
run: echo "debug" # INVALID — cannot add run: to a uses: step
|
|
33
|
+
|
|
34
|
+
2. Copy-paste error merging two separate steps into one:
|
|
35
|
+
- name: Setup and verify
|
|
36
|
+
uses: actions/setup-node@v4
|
|
37
|
+
run: node --version # INVALID — should be two separate steps
|
|
38
|
+
|
|
39
|
+
3. Attempting to run commands after an action in a single step:
|
|
40
|
+
- uses: docker/build-push-action@v6
|
|
41
|
+
with:
|
|
42
|
+
push: true
|
|
43
|
+
run: docker image ls # INVALID
|
|
44
|
+
|
|
45
|
+
The workflow is rejected at parse time with a validation error before any
|
|
46
|
+
job runs. The exact error message varies by GitHub Actions version but always
|
|
47
|
+
indicates that the step cannot define both keys.
|
|
48
|
+
fix: |
|
|
49
|
+
Split the step into two separate steps: one with uses: for the action, and
|
|
50
|
+
a new step with run: for the shell command. Each step in a job must be
|
|
51
|
+
exclusively one type.
|
|
52
|
+
fix_code:
|
|
53
|
+
- language: yaml
|
|
54
|
+
label: 'Wrong: uses: and run: in the same step — workflow validation fails'
|
|
55
|
+
code: |
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
|
+
steps:
|
|
60
|
+
- name: Checkout and show files # INVALID
|
|
61
|
+
uses: actions/checkout@v4
|
|
62
|
+
run: ls -la # INVALID: cannot add run: to uses: step
|
|
63
|
+
|
|
64
|
+
- language: yaml
|
|
65
|
+
label: 'Correct: split into two separate steps'
|
|
66
|
+
code: |
|
|
67
|
+
jobs:
|
|
68
|
+
build:
|
|
69
|
+
runs-on: ubuntu-latest
|
|
70
|
+
steps:
|
|
71
|
+
- name: Checkout
|
|
72
|
+
uses: actions/checkout@v4 # action step
|
|
73
|
+
|
|
74
|
+
- name: Show files # separate shell step
|
|
75
|
+
run: ls -la
|
|
76
|
+
|
|
77
|
+
- language: yaml
|
|
78
|
+
label: 'Common mistake: adding debug run: to an action step'
|
|
79
|
+
code: |
|
|
80
|
+
jobs:
|
|
81
|
+
build:
|
|
82
|
+
runs-on: ubuntu-latest
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/setup-node@v4
|
|
85
|
+
with:
|
|
86
|
+
node-version: '20'
|
|
87
|
+
# WRONG: adding run: here is invalid
|
|
88
|
+
# run: node --version
|
|
89
|
+
|
|
90
|
+
# CORRECT: add a separate step after the action
|
|
91
|
+
- name: Verify Node version
|
|
92
|
+
run: node --version
|
|
93
|
+
prevention:
|
|
94
|
+
- 'Remember: every step is either uses: (action) OR run: (shell) — never both'
|
|
95
|
+
- 'To run commands after an action, always create a new step with its own name: and run:'
|
|
96
|
+
- 'Use actionlint locally to catch uses:/run: conflicts before pushing'
|
|
97
|
+
- 'Use the GitHub Actions VS Code extension — it highlights mutually exclusive keys in the editor'
|
|
98
|
+
- 'When copying steps from documentation, verify each step has only one of uses: or run:'
|
|
99
|
+
docs:
|
|
100
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsuses'
|
|
101
|
+
label: 'GitHub Docs: jobs.<job_id>.steps[*].uses'
|
|
102
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun'
|
|
103
|
+
label: 'GitHub Docs: jobs.<job_id>.steps[*].run'
|
|
104
|
+
- url: 'https://rhysd.github.io/actionlint/'
|
|
105
|
+
label: 'actionlint: Static checker for GitHub Actions workflow files'
|
package/package.json
CHANGED