@htekdev/actions-debugger 1.0.11 → 1.0.13
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/cache-path-exclusion-glob-silent-noop.yml +83 -0
- package/errors/known-unsolved/composite-action-env-not-updated-on-reuse.yml +114 -0
- package/errors/known-unsolved/matrix-outputs-last-writer-wins.yml +137 -0
- package/errors/known-unsolved/uses-key-no-expression-support.yml +134 -0
- package/errors/runner-environment/windows-default-shell-powershell-not-bash.yml +137 -0
- package/errors/silent-failures/continue-on-error-failure-fn-bypassed.yml +90 -0
- package/errors/silent-failures/if-mixed-expression-always-true.yml +71 -0
- package/errors/silent-failures/pull-request-ref-is-merge-ref.yml +134 -0
- package/errors/triggers/push-paths-filter-bypassed-by-tag.yml +123 -0
- package/errors/yaml-syntax/needs-skipped-if-requires-always.yml +100 -0
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
id: caching-artifacts-014
|
|
2
|
+
title: "actions/cache Exclusion Globs (! patterns) Silently Ignored"
|
|
3
|
+
category: caching-artifacts
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- cache
|
|
7
|
+
- glob
|
|
8
|
+
- exclusion
|
|
9
|
+
- negation
|
|
10
|
+
- silent-failure
|
|
11
|
+
patterns:
|
|
12
|
+
- regex: "path:(?:[^#\\n]*\\n)+\\s*!\\S"
|
|
13
|
+
flags: "im"
|
|
14
|
+
- regex: "path:.*\\n.*!~/"
|
|
15
|
+
flags: "im"
|
|
16
|
+
error_messages:
|
|
17
|
+
- "Cache saved with key"
|
|
18
|
+
- "Cache hit occurred on the primary key"
|
|
19
|
+
root_cause: |
|
|
20
|
+
The `actions/cache` action uses `@actions/glob` with `implicitDescendants: false`
|
|
21
|
+
to resolve the `path:` input into a file list for `tar`. With this setting, each
|
|
22
|
+
pattern is matched only at the top level — subdirectory enumeration does not happen.
|
|
23
|
+
As a result, negation patterns like `!~/cache/temp` never find anything to exclude
|
|
24
|
+
because there are no implicitly enumerated descendants to match against.
|
|
25
|
+
|
|
26
|
+
The cache save and restore operations complete successfully with no error or warning.
|
|
27
|
+
The "excluded" directory is silently included in the archive, leading to:
|
|
28
|
+
- Larger caches than intended, consuming quota faster
|
|
29
|
+
- Sensitive or temporary files accidentally persisted across runs
|
|
30
|
+
- False-positive cache key hits (excluded dir content changes don't invalidate cache)
|
|
31
|
+
|
|
32
|
+
This is a long-standing known limitation of `actions/cache` that has been reported
|
|
33
|
+
since v1 and remains unfixed in v4.
|
|
34
|
+
fix: |
|
|
35
|
+
`actions/cache` does not support exclusion globs. Restructure directories so the
|
|
36
|
+
content you want to cache lives in a dedicated subdirectory, and reference only that
|
|
37
|
+
path. If directory restructuring is not feasible, use a `run` step to create a
|
|
38
|
+
filtered `tar` archive before caching.
|
|
39
|
+
fix_code:
|
|
40
|
+
- language: yaml
|
|
41
|
+
label: "BROKEN — exclusion pattern silently does nothing"
|
|
42
|
+
code: |
|
|
43
|
+
- uses: actions/cache@v4
|
|
44
|
+
with:
|
|
45
|
+
path: |
|
|
46
|
+
~/cache
|
|
47
|
+
!~/cache/temp # Silently ignored — temp/ is still cached
|
|
48
|
+
key: ${{ runner.os }}-cache-${{ hashFiles('**/package-lock.json') }}
|
|
49
|
+
- language: yaml
|
|
50
|
+
label: "CORRECT — restructure: cache only the subdirectory you need"
|
|
51
|
+
code: |
|
|
52
|
+
# Put persistent cache content in ~/cache/persistent/
|
|
53
|
+
# Put temp files in ~/cache/temp/ (not cached)
|
|
54
|
+
- uses: actions/cache@v4
|
|
55
|
+
with:
|
|
56
|
+
path: ~/cache/persistent # Cache only the isolated subdirectory
|
|
57
|
+
key: ${{ runner.os }}-cache-${{ hashFiles('**/package-lock.json') }}
|
|
58
|
+
- language: yaml
|
|
59
|
+
label: "CORRECT — manual tar with --exclude for complex cases"
|
|
60
|
+
code: |
|
|
61
|
+
- name: Save cache with exclusion
|
|
62
|
+
run: |
|
|
63
|
+
tar --exclude='$HOME/cache/temp' \
|
|
64
|
+
--exclude='$HOME/cache/.git' \
|
|
65
|
+
-czf /tmp/my-cache.tar.gz \
|
|
66
|
+
~/cache
|
|
67
|
+
|
|
68
|
+
- uses: actions/cache@v4
|
|
69
|
+
with:
|
|
70
|
+
path: /tmp/my-cache.tar.gz
|
|
71
|
+
key: ${{ runner.os }}-filtered-${{ hashFiles('**/package-lock.json') }}
|
|
72
|
+
prevention:
|
|
73
|
+
- "Never rely on `!` negation patterns in `actions/cache` `path:` — they are silently ignored."
|
|
74
|
+
- "Audit cache paths to confirm no sensitive, secret, or large temporary files are included."
|
|
75
|
+
- "Isolate cacheable build artifacts into dedicated subdirectories at project setup time."
|
|
76
|
+
- "Set `enableCrossOsArchive: false` (default) and test on each OS independently."
|
|
77
|
+
docs:
|
|
78
|
+
- url: "https://github.com/actions/toolkit/issues/713"
|
|
79
|
+
label: "actions/toolkit#713 — Cache: excluding files or folders with ! not working"
|
|
80
|
+
- url: "https://github.com/actions/cache/blob/main/tips-and-workarounds.md"
|
|
81
|
+
label: "actions/cache — Tips and workarounds"
|
|
82
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows"
|
|
83
|
+
label: "Caching dependencies to speed up workflows"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
id: known-unsolved-013
|
|
2
|
+
title: "GITHUB_ENV Set in Composite Action Not Updated on Repeated Calls in Same Job"
|
|
3
|
+
category: known-unsolved
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- composite-action
|
|
7
|
+
- GITHUB_ENV
|
|
8
|
+
- env-var
|
|
9
|
+
- repeated-calls
|
|
10
|
+
- known-limitation
|
|
11
|
+
patterns:
|
|
12
|
+
- regex: "echo.*>>\\s*\\$GITHUB_ENV"
|
|
13
|
+
flags: "i"
|
|
14
|
+
- regex: "GITHUB_ENV.*composite"
|
|
15
|
+
flags: "i"
|
|
16
|
+
error_messages:
|
|
17
|
+
- "environment variable not updated on second call"
|
|
18
|
+
- "variable retains value from first call"
|
|
19
|
+
root_cause: |
|
|
20
|
+
When a composite action sets an environment variable by appending to $GITHUB_ENV,
|
|
21
|
+
that variable is propagated to the parent job context after the composite action
|
|
22
|
+
completes. On the first call this works correctly. However, if the same composite
|
|
23
|
+
action is called a second time in the same job, the GITHUB_ENV write succeeds
|
|
24
|
+
internally but the parent job environment variable retains the value from the
|
|
25
|
+
first call.
|
|
26
|
+
|
|
27
|
+
This is a known platform limitation: GITHUB_ENV writes from within composite
|
|
28
|
+
actions are processed at step-boundary transitions. On repeated calls to the
|
|
29
|
+
same composite action in the same job, the runner does not re-apply the env
|
|
30
|
+
file changes correctly for variables already set — the first written value
|
|
31
|
+
persists for the job duration.
|
|
32
|
+
|
|
33
|
+
The limitation affects parameterized composite actions used as reusable units
|
|
34
|
+
that produce environment state, especially when called in loops, matrices, or
|
|
35
|
+
sequential steps with different inputs.
|
|
36
|
+
|
|
37
|
+
GitHub has not fixed this behavior; the recommended migration path is to use
|
|
38
|
+
GITHUB_OUTPUT instead of GITHUB_ENV for composite action outputs.
|
|
39
|
+
fix: |
|
|
40
|
+
Replace $GITHUB_ENV writes with $GITHUB_OUTPUT writes inside composite actions.
|
|
41
|
+
Step outputs are properly scoped per-invocation and do not have the repeated-call
|
|
42
|
+
limitation. Read the output in the calling workflow using steps.<id>.outputs.<name>.
|
|
43
|
+
|
|
44
|
+
If environment variables are strictly required at the job level, set them in the
|
|
45
|
+
calling workflow from the composite action outputs, not inside the composite action.
|
|
46
|
+
fix_code:
|
|
47
|
+
- language: yaml
|
|
48
|
+
label: "BROKEN composite action — GITHUB_ENV silently not updated on 2nd call"
|
|
49
|
+
code: |
|
|
50
|
+
# my-action/action.yml
|
|
51
|
+
inputs:
|
|
52
|
+
label:
|
|
53
|
+
required: true
|
|
54
|
+
runs:
|
|
55
|
+
using: composite
|
|
56
|
+
steps:
|
|
57
|
+
- run: echo "MY_LABEL=${{ inputs.label }}" >> $GITHUB_ENV
|
|
58
|
+
shell: bash
|
|
59
|
+
# Calling workflow:
|
|
60
|
+
# steps:
|
|
61
|
+
# - uses: ./my-action
|
|
62
|
+
# with: { label: "first" } # MY_LABEL = "first" ✓
|
|
63
|
+
# - uses: ./my-action
|
|
64
|
+
# with: { label: "second" } # MY_LABEL still "first" — silent bug!
|
|
65
|
+
- language: yaml
|
|
66
|
+
label: "CORRECT composite action — use GITHUB_OUTPUT"
|
|
67
|
+
code: |
|
|
68
|
+
# my-action/action.yml
|
|
69
|
+
inputs:
|
|
70
|
+
label:
|
|
71
|
+
required: true
|
|
72
|
+
outputs:
|
|
73
|
+
label:
|
|
74
|
+
description: "The resolved label"
|
|
75
|
+
value: ${{ steps.set.outputs.label }}
|
|
76
|
+
runs:
|
|
77
|
+
using: composite
|
|
78
|
+
steps:
|
|
79
|
+
- id: set
|
|
80
|
+
run: echo "label=${{ inputs.label }}" >> $GITHUB_OUTPUT
|
|
81
|
+
shell: bash
|
|
82
|
+
- language: yaml
|
|
83
|
+
label: "CORRECT calling workflow — propagate to env if needed"
|
|
84
|
+
code: |
|
|
85
|
+
steps:
|
|
86
|
+
- id: first
|
|
87
|
+
uses: ./my-action
|
|
88
|
+
with:
|
|
89
|
+
label: build-${{ github.sha }}
|
|
90
|
+
|
|
91
|
+
- id: second
|
|
92
|
+
uses: ./my-action
|
|
93
|
+
with:
|
|
94
|
+
label: deploy-${{ github.sha }}
|
|
95
|
+
|
|
96
|
+
- name: Use outputs from both calls
|
|
97
|
+
env:
|
|
98
|
+
FIRST_LABEL: ${{ steps.first.outputs.label }}
|
|
99
|
+
SECOND_LABEL: ${{ steps.second.outputs.label }}
|
|
100
|
+
run: |
|
|
101
|
+
echo "First: $FIRST_LABEL"
|
|
102
|
+
echo "Second: $SECOND_LABEL"
|
|
103
|
+
prevention:
|
|
104
|
+
- "Never use $GITHUB_ENV in composite actions that may be called more than once per job."
|
|
105
|
+
- "Migrate all composite action side effects from GITHUB_ENV to GITHUB_OUTPUT."
|
|
106
|
+
- "Test composite actions by calling them twice in the same job to catch this bug early."
|
|
107
|
+
- "Prefer outputs over env-var side effects for cleaner and predictable reuse semantics."
|
|
108
|
+
docs:
|
|
109
|
+
- url: "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#outputs-for-composite-actions"
|
|
110
|
+
label: "Outputs for composite actions"
|
|
111
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-an-environment-variable"
|
|
112
|
+
label: "Workflow commands — setting an environment variable"
|
|
113
|
+
- url: "https://github.com/actions/runner/issues/789"
|
|
114
|
+
label: "actions/runner#789 — GITHUB_ENV does not update when running composite actions multiple times"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
id: known-unsolved-011
|
|
2
|
+
title: "Matrix Job Outputs Are Non-Deterministic — Only Last Completed Job's Value Is Used"
|
|
3
|
+
category: known-unsolved
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- matrix
|
|
7
|
+
- outputs
|
|
8
|
+
- non-deterministic
|
|
9
|
+
- race-condition
|
|
10
|
+
- jobs-outputs
|
|
11
|
+
- strategy
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: "jobs\\.\\w+\\.outputs\\.\\w+"
|
|
14
|
+
flags: "i"
|
|
15
|
+
- regex: "matrix.*output.*overwrite"
|
|
16
|
+
flags: "i"
|
|
17
|
+
- regex: "only.*last.*matrix.*job.*output"
|
|
18
|
+
flags: "i"
|
|
19
|
+
error_messages:
|
|
20
|
+
- "outputs from matrix returns only the last value"
|
|
21
|
+
- "matrix job output overwritten by later-completing job"
|
|
22
|
+
root_cause: |
|
|
23
|
+
GitHub Actions does not support aggregating outputs across matrix jobs. When a job uses
|
|
24
|
+
`strategy: matrix:` and defines `outputs:`, all matrix instances write to the SAME
|
|
25
|
+
output key. The value that survives is whichever matrix job completes LAST — and
|
|
26
|
+
completion order is non-deterministic.
|
|
27
|
+
|
|
28
|
+
Example: a 3-element matrix writing `result` as an output. Job [A, B, C] may complete
|
|
29
|
+
in any order — the downstream job receives only the output from whichever finished last.
|
|
30
|
+
This can silently produce wrong results that vary between runs.
|
|
31
|
+
|
|
32
|
+
The underlying limitation: `jobs.<job_id>.outputs` maps to a single scalar value
|
|
33
|
+
per key, with no way to collect values from all matrix instances into a structured
|
|
34
|
+
result. The runner simply last-writes-wins with no conflict detection.
|
|
35
|
+
|
|
36
|
+
This is a long-standing limitation tracked in actions/runner#1835 (opened April 2022)
|
|
37
|
+
and actions/runner#2477. A partial improvement in runner v2.303.0 added `$matrix`
|
|
38
|
+
context to outputs expressions, but it is still not generally available and has known
|
|
39
|
+
bugs (runner#2499: workflow won't start with the new syntax on older runners).
|
|
40
|
+
|
|
41
|
+
Source: actions/runner#1835 (Outputs from matrix returns only the last value)
|
|
42
|
+
Source: Stack Overflow #70287603 (Dynamic outputs for job with strategy.matrix)
|
|
43
|
+
fix: |
|
|
44
|
+
There is no native GitHub Actions solution for collecting all matrix job outputs into
|
|
45
|
+
a structured result. Use one of these workarounds:
|
|
46
|
+
|
|
47
|
+
Workaround 1 — Write to artifacts, aggregate downstream:
|
|
48
|
+
Each matrix job writes its result to a uniquely-named artifact file. A downstream
|
|
49
|
+
aggregator job downloads all artifacts and processes them.
|
|
50
|
+
|
|
51
|
+
Workaround 2 — Encode outputs in artifact JSON, then use as dynamic matrix:
|
|
52
|
+
Popularized by the `matrix-output` marketplace action. Each job appends a JSON row
|
|
53
|
+
to an artifact, and a reporting job downloads and merges them.
|
|
54
|
+
|
|
55
|
+
Workaround 3 — Use reusable workflows with known job indices:
|
|
56
|
+
Reusable workflows allow accessing specific job outputs via the caller's
|
|
57
|
+
`jobs.<reusable_workflow_job>.outputs` context.
|
|
58
|
+
|
|
59
|
+
Workaround 4 — Consolidate into a single non-matrix job:
|
|
60
|
+
If the matrix outputs must be consumed, consider restructuring to run the work
|
|
61
|
+
sequentially in one job or use a scripted loop.
|
|
62
|
+
fix_code:
|
|
63
|
+
- language: yaml
|
|
64
|
+
label: "Workaround: write output to artifact file, aggregate in downstream job"
|
|
65
|
+
code: |
|
|
66
|
+
jobs:
|
|
67
|
+
build:
|
|
68
|
+
strategy:
|
|
69
|
+
matrix:
|
|
70
|
+
component: [api, web, worker]
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
steps:
|
|
73
|
+
- name: Build component
|
|
74
|
+
run: |
|
|
75
|
+
# ... build logic ...
|
|
76
|
+
echo "build_status=success" >> result-${{ matrix.component }}.txt
|
|
77
|
+
|
|
78
|
+
- name: Upload result artifact
|
|
79
|
+
uses: actions/upload-artifact@v4
|
|
80
|
+
with:
|
|
81
|
+
name: result-${{ matrix.component }}
|
|
82
|
+
path: result-${{ matrix.component }}.txt
|
|
83
|
+
|
|
84
|
+
aggregate:
|
|
85
|
+
needs: build
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
steps:
|
|
88
|
+
- name: Download all results
|
|
89
|
+
uses: actions/download-artifact@v4
|
|
90
|
+
with:
|
|
91
|
+
pattern: result-*
|
|
92
|
+
merge-multiple: true
|
|
93
|
+
|
|
94
|
+
- name: Aggregate results
|
|
95
|
+
run: |
|
|
96
|
+
for f in result-*.txt; do
|
|
97
|
+
echo "=== $f ==="
|
|
98
|
+
cat "$f"
|
|
99
|
+
done
|
|
100
|
+
|
|
101
|
+
- language: yaml
|
|
102
|
+
label: "Anti-pattern: matrix outputs — only one value survives (last writer wins)"
|
|
103
|
+
code: |
|
|
104
|
+
# ❌ WRONG — only last-completing job's output is used by downstream-job
|
|
105
|
+
jobs:
|
|
106
|
+
build:
|
|
107
|
+
strategy:
|
|
108
|
+
matrix:
|
|
109
|
+
component: [api, web, worker]
|
|
110
|
+
runs-on: ubuntu-latest
|
|
111
|
+
outputs:
|
|
112
|
+
status: ${{ steps.build.outputs.status }} # ← non-deterministic!
|
|
113
|
+
steps:
|
|
114
|
+
- id: build
|
|
115
|
+
run: echo "status=done-${{ matrix.component }}" >> $GITHUB_OUTPUT
|
|
116
|
+
|
|
117
|
+
downstream-job:
|
|
118
|
+
needs: build
|
|
119
|
+
runs-on: ubuntu-latest
|
|
120
|
+
steps:
|
|
121
|
+
- run: echo "${{ needs.build.outputs.status }}"
|
|
122
|
+
# Prints ONE value (whichever matrix job finished last) — unpredictable
|
|
123
|
+
|
|
124
|
+
prevention:
|
|
125
|
+
- "Never rely on matrix job outputs for per-job values — outputs are not aggregated."
|
|
126
|
+
- "Use upload-artifact/download-artifact with unique names per matrix dimension to collect per-job results."
|
|
127
|
+
- "If a downstream job needs all matrix results, prefer artifact-based aggregation over job outputs."
|
|
128
|
+
- "Document in your workflow comments that matrix outputs are single-valued and non-deterministic."
|
|
129
|
+
docs:
|
|
130
|
+
- url: "https://github.com/actions/runner/issues/1835"
|
|
131
|
+
label: "actions/runner#1835: Outputs from matrix returns only the last value"
|
|
132
|
+
- url: "https://stackoverflow.com/questions/70287603/dynamic-outputs-for-job-with-strategy-matrix"
|
|
133
|
+
label: "Stack Overflow: Dynamic outputs for job with strategy.matrix"
|
|
134
|
+
- url: "https://github.com/marketplace/actions/matrix-output"
|
|
135
|
+
label: "Marketplace: matrix-output action (artifact-based aggregation workaround)"
|
|
136
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/passing-information-between-jobs"
|
|
137
|
+
label: "GitHub Docs: Passing information between jobs"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
id: known-unsolved-012
|
|
2
|
+
title: "uses: Key Does Not Support Expressions or Context Variables — Must Be a Static String"
|
|
3
|
+
category: known-unsolved
|
|
4
|
+
severity: error
|
|
5
|
+
tags:
|
|
6
|
+
- uses
|
|
7
|
+
- expression
|
|
8
|
+
- context
|
|
9
|
+
- dynamic-action
|
|
10
|
+
- composite
|
|
11
|
+
- local-action
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: "uses:\\s*\\.?/\\$\\{\\{"
|
|
14
|
+
flags: "i"
|
|
15
|
+
- regex: "uses:\\s*['\"]?\\$\\{\\{"
|
|
16
|
+
flags: "i"
|
|
17
|
+
- regex: "Unrecognized named-value.*uses"
|
|
18
|
+
flags: "i"
|
|
19
|
+
- regex: "Invalid workflow file.*uses.*expression"
|
|
20
|
+
flags: "i"
|
|
21
|
+
error_messages:
|
|
22
|
+
- "Invalid workflow file: .github/workflows/ci.yml — uses: does not support expression syntax"
|
|
23
|
+
- "The 'uses' field must be a literal string and cannot contain expressions"
|
|
24
|
+
- "Unrecognized named-value: 'env' at position 1 within expression in uses:"
|
|
25
|
+
root_cause: |
|
|
26
|
+
GitHub Actions resolves all `uses:` references (actions and reusable workflows) BEFORE
|
|
27
|
+
the job starts executing — at workflow evaluation / job queue time. At that point,
|
|
28
|
+
no runtime contexts (env, inputs, github, matrix, steps, needs) are available.
|
|
29
|
+
|
|
30
|
+
As a result, you cannot use `${{ }}` expression syntax in a `uses:` key at any level:
|
|
31
|
+
- Step-level: `uses: ${{ env.MY_ACTION }}@${{ env.VERSION }}` ❌
|
|
32
|
+
- Local path with variable: `uses: ./${{ env.DIR }}/my-action` ❌
|
|
33
|
+
- Dynamic version pin: `uses: actions/setup-node@${{ inputs.node-version }}` ❌
|
|
34
|
+
- Reusable workflow: `uses: ${{ github.repository }}/.github/workflows/ci.yml@main` ❌
|
|
35
|
+
|
|
36
|
+
This is an architectural constraint: the runner must download and validate actions
|
|
37
|
+
(and reusable workflows) before any step code can run. Allowing runtime expressions
|
|
38
|
+
would break policy enforcement (org-level allowed-actions lists) and make workflow
|
|
39
|
+
graphs non-deterministic at parse time.
|
|
40
|
+
|
|
41
|
+
Note: `env:` and `with:` inputs to an action DO support expressions — only the `uses:`
|
|
42
|
+
key itself is restricted.
|
|
43
|
+
|
|
44
|
+
Source: actions/runner#1479 (Support context/matrix variables in steps of type 'uses')
|
|
45
|
+
Source: actions/runner#895 (Allow expressions in uses:)
|
|
46
|
+
Source: Stack Overflow #75373413 (Reference a variable in uses when pointing to a path)
|
|
47
|
+
fix: |
|
|
48
|
+
There is no native GitHub Actions solution for dynamic `uses:` values.
|
|
49
|
+
|
|
50
|
+
Option 1 — Hard-code the version or path (recommended for most cases):
|
|
51
|
+
Pin versions explicitly in the workflow file. Use Dependabot or Renovate to keep
|
|
52
|
+
action versions updated automatically.
|
|
53
|
+
|
|
54
|
+
Option 2 — Use a composite action or script as a dispatcher:
|
|
55
|
+
Create a wrapper composite action that accepts an input and uses `if:` conditions
|
|
56
|
+
to call different static actions based on the input value.
|
|
57
|
+
|
|
58
|
+
Option 3 — Use step-security/dynamic-uses (third-party workaround):
|
|
59
|
+
The `dynamic-uses` action by step-security resolves and invokes actions dynamically
|
|
60
|
+
at runtime. This works around the limitation but adds a third-party dependency.
|
|
61
|
+
|
|
62
|
+
Option 4 — Restructure to not need dynamic action references:
|
|
63
|
+
If you're trying to select between local action paths, check out the repo first and
|
|
64
|
+
use a scripted approach instead of composite action dispatch.
|
|
65
|
+
fix_code:
|
|
66
|
+
- language: yaml
|
|
67
|
+
label: "Anti-pattern — expressions in uses: cause parse error"
|
|
68
|
+
code: |
|
|
69
|
+
# ❌ WRONG — none of these are supported
|
|
70
|
+
steps:
|
|
71
|
+
- uses: ./${{ env.ACTION_DIR }}/my-action # ← parse error
|
|
72
|
+
- uses: actions/setup-node@${{ inputs.version }} # ← parse error
|
|
73
|
+
- uses: ${{ github.repository }}/.github/workflows/deploy.yml@main # ← error
|
|
74
|
+
|
|
75
|
+
- language: yaml
|
|
76
|
+
label: "Fix: hard-code the version, use Dependabot to keep it updated"
|
|
77
|
+
code: |
|
|
78
|
+
# ✅ CORRECT — static string in uses:
|
|
79
|
+
steps:
|
|
80
|
+
- uses: actions/setup-node@v4 # pin to major version
|
|
81
|
+
- uses: actions/setup-node@11.0.0 # or exact version
|
|
82
|
+
- uses: ./.github/actions/my-action # local action: always relative to repo root
|
|
83
|
+
|
|
84
|
+
# Keep versions current with Dependabot (.github/dependabot.yml):
|
|
85
|
+
# version: 2
|
|
86
|
+
# updates:
|
|
87
|
+
# - package-ecosystem: "github-actions"
|
|
88
|
+
# directory: "/"
|
|
89
|
+
# schedule:
|
|
90
|
+
# interval: "weekly"
|
|
91
|
+
|
|
92
|
+
- language: yaml
|
|
93
|
+
label: "Workaround: if-based dispatch when choosing between known static actions"
|
|
94
|
+
code: |
|
|
95
|
+
# ✅ Conditional dispatch using if: conditions on static uses:
|
|
96
|
+
steps:
|
|
97
|
+
- name: Setup Node (version from input)
|
|
98
|
+
uses: actions/setup-node@v4
|
|
99
|
+
with:
|
|
100
|
+
node-version: ${{ inputs.node-version }} # ← with: supports expressions
|
|
101
|
+
|
|
102
|
+
# If you truly need different actions based on input:
|
|
103
|
+
- name: Use action for staging
|
|
104
|
+
if: inputs.environment == 'staging'
|
|
105
|
+
uses: ./actions/deploy-staging # static path
|
|
106
|
+
|
|
107
|
+
- name: Use action for production
|
|
108
|
+
if: inputs.environment == 'production'
|
|
109
|
+
uses: ./actions/deploy-production # static path
|
|
110
|
+
|
|
111
|
+
- language: yaml
|
|
112
|
+
label: "Workaround: step-security/dynamic-uses third-party action"
|
|
113
|
+
code: |
|
|
114
|
+
# Third-party workaround — resolves expressions in uses: at runtime
|
|
115
|
+
steps:
|
|
116
|
+
- uses: step-security/dynamic-uses@v1
|
|
117
|
+
with:
|
|
118
|
+
uses: actions/setup-node@${{ inputs.node-version }}
|
|
119
|
+
with: '{ "node-version": 18 }'
|
|
120
|
+
|
|
121
|
+
prevention:
|
|
122
|
+
- "Always use static literal strings in `uses:` keys — no `${{ }}` expressions."
|
|
123
|
+
- "Use `with:` inputs (which DO support expressions) to pass dynamic values to actions."
|
|
124
|
+
- "Use Dependabot or Renovate to automate action version bumps so manual pinning stays current."
|
|
125
|
+
- "For environment-specific action selection, use `if:` conditions on separate steps with static `uses:` values."
|
|
126
|
+
docs:
|
|
127
|
+
- url: "https://github.com/actions/runner/issues/1479"
|
|
128
|
+
label: "actions/runner#1479: Support context/matrix variables in steps of type 'uses'"
|
|
129
|
+
- url: "https://github.com/actions/runner/issues/895"
|
|
130
|
+
label: "actions/runner#895: Allow expressions in uses: key"
|
|
131
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-pre-written-building-blocks-in-your-workflow"
|
|
132
|
+
label: "GitHub Docs: Using pre-written building blocks (actions)"
|
|
133
|
+
- url: "https://github.com/step-security/dynamic-uses"
|
|
134
|
+
label: "step-security/dynamic-uses: third-party workaround for dynamic uses:"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
id: runner-environment-033
|
|
2
|
+
title: "Windows Runner Default Shell Is PowerShell — Bash Scripts Silently Fail Without shell: bash"
|
|
3
|
+
category: runner-environment
|
|
4
|
+
severity: error
|
|
5
|
+
tags:
|
|
6
|
+
- windows
|
|
7
|
+
- shell
|
|
8
|
+
- bash
|
|
9
|
+
- powershell
|
|
10
|
+
- default-shell
|
|
11
|
+
- run-step
|
|
12
|
+
- cross-platform
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: "shell:\\s*C:\\\\Windows\\\\system32\\\\bash\\.EXE"
|
|
15
|
+
flags: "i"
|
|
16
|
+
- regex: "The term '.*' is not recognized.*cmdlet.*function.*script"
|
|
17
|
+
flags: "i"
|
|
18
|
+
- regex: "pwsh.*noprofile.*noninteractive.*command"
|
|
19
|
+
flags: "i"
|
|
20
|
+
- regex: "bash.*error.*not.*recognized.*windows"
|
|
21
|
+
flags: "i"
|
|
22
|
+
error_messages:
|
|
23
|
+
- "The term 'set' is not recognized as the name of a cmdlet, function, script file"
|
|
24
|
+
- "Unrecognized token '||' in pipeline"
|
|
25
|
+
- "shell: C:\\Windows\\system32\\bash.EXE --noprofile --norc -e -o pipefail {0}: No such file or directory"
|
|
26
|
+
- "export: The term 'export' is not recognized as the name of a cmdlet"
|
|
27
|
+
root_cause: |
|
|
28
|
+
GitHub Actions uses a different default shell depending on the runner OS:
|
|
29
|
+
- Linux (ubuntu-*): bash
|
|
30
|
+
- macOS (macos-*): bash
|
|
31
|
+
- Windows (windows-*): PowerShell (pwsh)
|
|
32
|
+
|
|
33
|
+
When you write `run:` steps using bash syntax (e.g., `export VAR=value`, `set -e`,
|
|
34
|
+
`||`, `&&`, heredocs, `$(...)` subshells) without explicitly specifying `shell: bash`,
|
|
35
|
+
those steps will execute under PowerShell on Windows runners and fail with confusing
|
|
36
|
+
errors.
|
|
37
|
+
|
|
38
|
+
This is especially problematic in:
|
|
39
|
+
- Matrix workflows that include both Linux and Windows OS variants
|
|
40
|
+
- Composite actions where the shell is not explicitly set per step
|
|
41
|
+
- Reusable workflows called from both Linux and Windows jobs
|
|
42
|
+
- Migration from Linux-only workflows to cross-platform
|
|
43
|
+
|
|
44
|
+
A secondary issue: on Windows runners, if `C:\Windows\System32\bash.exe` exists
|
|
45
|
+
(WSL stub), specifying `shell: bash` may invoke the WSL stub instead of Git Bash
|
|
46
|
+
(`C:\Program Files\Git\bin\bash.EXE`). This causes "No such file or directory"
|
|
47
|
+
errors because the WSL stub requires a Linux distribution to be installed.
|
|
48
|
+
Since runner-images#12646, this is a known issue with no GitHub-side fix.
|
|
49
|
+
|
|
50
|
+
Source: actions/runner#1328 (Unable to run bash scripts on Windows runner)
|
|
51
|
+
Source: runner-images#12646 (Windows WSL bash.exe stub causes shell: bash failures)
|
|
52
|
+
fix: |
|
|
53
|
+
Always explicitly set `shell: bash` on any `run:` step that uses bash syntax.
|
|
54
|
+
|
|
55
|
+
For cross-platform matrix workflows, there are three approaches:
|
|
56
|
+
|
|
57
|
+
1. Explicit shell per step — add `shell: bash` to every bash step.
|
|
58
|
+
|
|
59
|
+
2. Workflow-level default — set `defaults.run.shell: bash` to apply bash to ALL
|
|
60
|
+
run steps in the workflow (be consistent — all steps must then use bash syntax).
|
|
61
|
+
|
|
62
|
+
3. Job-level default — set `defaults.run.shell: bash` at the job level to scope it.
|
|
63
|
+
|
|
64
|
+
If you need PowerShell on Windows AND bash on Linux in the same matrix, use a
|
|
65
|
+
matrix-aware condition to selectively set the shell, or split into separate jobs.
|
|
66
|
+
fix_code:
|
|
67
|
+
- language: yaml
|
|
68
|
+
label: "Fix: explicit shell: bash on individual steps"
|
|
69
|
+
code: |
|
|
70
|
+
jobs:
|
|
71
|
+
test:
|
|
72
|
+
strategy:
|
|
73
|
+
matrix:
|
|
74
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
75
|
+
runs-on: ${{ matrix.os }}
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/checkout@v4
|
|
78
|
+
|
|
79
|
+
# ✅ Always specify shell: bash for bash scripts
|
|
80
|
+
- name: Run build script
|
|
81
|
+
shell: bash # required on Windows — default is PowerShell
|
|
82
|
+
run: |
|
|
83
|
+
export BUILD_ENV=production
|
|
84
|
+
set -euo pipefail
|
|
85
|
+
./scripts/build.sh
|
|
86
|
+
|
|
87
|
+
- language: yaml
|
|
88
|
+
label: "Fix: workflow-level default shell (applies to all run steps)"
|
|
89
|
+
code: |
|
|
90
|
+
name: Cross-Platform CI
|
|
91
|
+
|
|
92
|
+
defaults:
|
|
93
|
+
run:
|
|
94
|
+
shell: bash # override default — all run steps use bash on all OS
|
|
95
|
+
|
|
96
|
+
jobs:
|
|
97
|
+
test:
|
|
98
|
+
strategy:
|
|
99
|
+
matrix:
|
|
100
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
101
|
+
runs-on: ${{ matrix.os }}
|
|
102
|
+
steps:
|
|
103
|
+
- uses: actions/checkout@v4
|
|
104
|
+
- name: Run tests
|
|
105
|
+
run: |
|
|
106
|
+
# This runs in bash on all platforms
|
|
107
|
+
./scripts/run-tests.sh
|
|
108
|
+
|
|
109
|
+
- language: yaml
|
|
110
|
+
label: "Anti-pattern: bash syntax without shell: bash on Windows fails"
|
|
111
|
+
code: |
|
|
112
|
+
# ❌ WRONG on Windows — default shell is PowerShell, bash syntax breaks
|
|
113
|
+
jobs:
|
|
114
|
+
build:
|
|
115
|
+
runs-on: windows-latest
|
|
116
|
+
steps:
|
|
117
|
+
- name: Set env
|
|
118
|
+
run: |
|
|
119
|
+
export DEPLOY_ENV=production # ← ERROR: 'export' not recognized
|
|
120
|
+
echo "env: $DEPLOY_ENV" # ← ERROR: bash variable expansion
|
|
121
|
+
ls -la # ← ERROR: ls -la not valid in pwsh
|
|
122
|
+
|
|
123
|
+
prevention:
|
|
124
|
+
- "Always specify `shell: bash` (or `shell: pwsh`) explicitly on every `run:` step in cross-platform workflows."
|
|
125
|
+
- "Use `defaults.run.shell: bash` at the workflow or job level to reduce repetition."
|
|
126
|
+
- "Test all matrix OS variants in CI — don't assume Linux behavior translates to Windows."
|
|
127
|
+
- "For PowerShell-specific steps on Windows, use `shell: pwsh` explicitly rather than relying on the default."
|
|
128
|
+
- "Avoid relying on the runner's WSL bash stub on Windows — it requires a WSL distribution installed."
|
|
129
|
+
docs:
|
|
130
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/setting-a-default-shell-and-working-directory"
|
|
131
|
+
label: "GitHub Docs: Setting a default shell and working directory"
|
|
132
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell"
|
|
133
|
+
label: "GitHub Docs: jobs.<job_id>.steps[*].shell"
|
|
134
|
+
- url: "https://github.com/actions/runner/issues/1328"
|
|
135
|
+
label: "actions/runner#1328: Unable to run bash scripts on Windows runner"
|
|
136
|
+
- url: "https://github.com/actions/runner-images/issues/12646"
|
|
137
|
+
label: "runner-images#12646: Windows WSL bash.exe stub causes shell: bash failures"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
id: silent-failures-016
|
|
2
|
+
title: "failure() Returns false After continue-on-error Step"
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- continue-on-error
|
|
7
|
+
- failure
|
|
8
|
+
- status-functions
|
|
9
|
+
- error-handler
|
|
10
|
+
- silent-failure
|
|
11
|
+
patterns:
|
|
12
|
+
- regex: "continue-on-error:\\s*true"
|
|
13
|
+
flags: "i"
|
|
14
|
+
- regex: "if:\\s*(\\$\\{\\{\\s*)?failure\\(\\)"
|
|
15
|
+
flags: "i"
|
|
16
|
+
error_messages:
|
|
17
|
+
- "Skipping step: due to a failed condition"
|
|
18
|
+
root_cause: |
|
|
19
|
+
When a step has `continue-on-error: true` and that step fails, GitHub Actions
|
|
20
|
+
records the step outcome as failure but immediately converts the job aggregate
|
|
21
|
+
status back to success before evaluating the next step. This means that
|
|
22
|
+
failure() in a subsequent step's if condition evaluates to false — the runner
|
|
23
|
+
sees the job as currently-successful, not currently-failed.
|
|
24
|
+
|
|
25
|
+
The continue-on-error flag was designed to allow a job to proceed past a failing
|
|
26
|
+
step, but the side effect is that it consumes the failure signal from all
|
|
27
|
+
status-check functions. Developers commonly write error-handler steps using
|
|
28
|
+
if: failure(), expecting them to trigger when the prior step fails, but this
|
|
29
|
+
pattern silently breaks when continue-on-error: true is present on that prior step.
|
|
30
|
+
|
|
31
|
+
The step individual outcome context (steps.<id>.outcome) is the only reliable
|
|
32
|
+
way to detect the specific step result after continue-on-error.
|
|
33
|
+
fix: |
|
|
34
|
+
Replace if: failure() with if: steps.<step_id>.outcome == 'failure' when
|
|
35
|
+
writing error handlers for specific steps that use continue-on-error: true.
|
|
36
|
+
The steps.<id>.outcome context holds the actual step result regardless of
|
|
37
|
+
job-level status modifications.
|
|
38
|
+
fix_code:
|
|
39
|
+
- language: yaml
|
|
40
|
+
label: "WRONG — failure() never triggers after continue-on-error"
|
|
41
|
+
code: |
|
|
42
|
+
steps:
|
|
43
|
+
- name: Risky operation
|
|
44
|
+
id: risky
|
|
45
|
+
continue-on-error: true
|
|
46
|
+
run: ./might-fail.sh
|
|
47
|
+
|
|
48
|
+
- name: Handle failure
|
|
49
|
+
if: failure() # NEVER RUNS — job status was reset to success
|
|
50
|
+
run: ./notify-on-failure.sh
|
|
51
|
+
- language: yaml
|
|
52
|
+
label: "CORRECT — check specific step outcome"
|
|
53
|
+
code: |
|
|
54
|
+
steps:
|
|
55
|
+
- name: Risky operation
|
|
56
|
+
id: risky
|
|
57
|
+
continue-on-error: true
|
|
58
|
+
run: ./might-fail.sh
|
|
59
|
+
|
|
60
|
+
- name: Handle failure
|
|
61
|
+
if: steps.risky.outcome == 'failure'
|
|
62
|
+
run: ./notify-on-failure.sh
|
|
63
|
+
|
|
64
|
+
- name: Continue regardless
|
|
65
|
+
run: echo "Job continues either way"
|
|
66
|
+
- language: yaml
|
|
67
|
+
label: "CORRECT — combine with always() for comprehensive guards"
|
|
68
|
+
code: |
|
|
69
|
+
steps:
|
|
70
|
+
- name: Risky operation
|
|
71
|
+
id: risky
|
|
72
|
+
continue-on-error: true
|
|
73
|
+
run: ./might-fail.sh
|
|
74
|
+
|
|
75
|
+
- name: Handle step failure
|
|
76
|
+
if: always() && steps.risky.outcome == 'failure'
|
|
77
|
+
run: |
|
|
78
|
+
echo "::warning::Risky operation failed"
|
|
79
|
+
echo "## Failed" >> $GITHUB_STEP_SUMMARY
|
|
80
|
+
prevention:
|
|
81
|
+
- "Never use if: failure() as handler for a step with continue-on-error: true — use steps.<id>.outcome."
|
|
82
|
+
- "Reserve failure() for handlers that should trigger on any upstream job failure."
|
|
83
|
+
- "Add step IDs to all continue-on-error steps so outcome checking is always possible."
|
|
84
|
+
docs:
|
|
85
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-conditions-to-control-job-execution"
|
|
86
|
+
label: "Using conditions to control job execution"
|
|
87
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepscontinue-on-error"
|
|
88
|
+
label: "continue-on-error workflow syntax"
|
|
89
|
+
- url: "https://github.com/actions/toolkit/issues/1034"
|
|
90
|
+
label: "actions/toolkit#1034 — Wrong behaviour combining continue-on-error and failure()"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
id: silent-failures-015
|
|
2
|
+
title: "Mixing ${{ }} Interpolation Inside if: Condition Causes Step to Always Run"
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- if-condition
|
|
7
|
+
- expression
|
|
8
|
+
- interpolation
|
|
9
|
+
- always-runs
|
|
10
|
+
- silent-failure
|
|
11
|
+
patterns:
|
|
12
|
+
- regex: "if:.*==.*'[^']*\\$\\{\\{[^}]+\\}\\}[^']*'"
|
|
13
|
+
flags: "i"
|
|
14
|
+
- regex: "if:.*'[^']*\\$\\{\\{[^}]+\\}\\}[^']*'.*=="
|
|
15
|
+
flags: "i"
|
|
16
|
+
error_messages:
|
|
17
|
+
- "step runs unexpectedly even when condition should be false"
|
|
18
|
+
- "if condition always evaluates true"
|
|
19
|
+
root_cause: |
|
|
20
|
+
When the `if:` field contains BOTH bare comparison operators AND a `${{ }}`
|
|
21
|
+
interpolation fragment within a quoted string (e.g.,
|
|
22
|
+
`if: github.ref == 'refs/heads/${{ env.BRANCH }}'`), GitHub Actions wraps the
|
|
23
|
+
entire bare-string value in `${{ ... }}` for expression evaluation. This makes
|
|
24
|
+
the outer expression evaluate the literal string
|
|
25
|
+
`"github.ref == 'refs/heads/main'"` — a non-empty string — which is always truthy.
|
|
26
|
+
|
|
27
|
+
The runner does not parse the embedded `${{ }}` fragment as an inner expression;
|
|
28
|
+
it only processes the outer auto-wrap. The result is that the step runs on every
|
|
29
|
+
trigger regardless of the actual comparison result — a textbook silent failure
|
|
30
|
+
since the workflow file looks syntactically correct and no error is emitted.
|
|
31
|
+
fix: |
|
|
32
|
+
Wrap the ENTIRE `if:` condition in `${{ }}` so the runner treats it as an
|
|
33
|
+
expression from the start. Use the `format()` function or direct context
|
|
34
|
+
comparisons instead of embedding `${{ }}` inside quoted strings.
|
|
35
|
+
fix_code:
|
|
36
|
+
- language: yaml
|
|
37
|
+
label: "WRONG — mixed interpolation, step always runs"
|
|
38
|
+
code: |
|
|
39
|
+
env:
|
|
40
|
+
TARGET_BRANCH: main
|
|
41
|
+
steps:
|
|
42
|
+
- name: Deploy to production
|
|
43
|
+
# This ALWAYS runs — the string is truthy regardless of branch
|
|
44
|
+
if: github.ref == 'refs/heads/${{ env.TARGET_BRANCH }}'
|
|
45
|
+
run: ./deploy.sh
|
|
46
|
+
- language: yaml
|
|
47
|
+
label: "CORRECT — wrap entire condition in ${{ }}"
|
|
48
|
+
code: |
|
|
49
|
+
env:
|
|
50
|
+
TARGET_BRANCH: main
|
|
51
|
+
steps:
|
|
52
|
+
- name: Deploy to production
|
|
53
|
+
if: ${{ github.ref == format('refs/heads/{0}', env.TARGET_BRANCH) }}
|
|
54
|
+
run: ./deploy.sh
|
|
55
|
+
- language: yaml
|
|
56
|
+
label: "CORRECT — direct string literal comparison"
|
|
57
|
+
code: |
|
|
58
|
+
steps:
|
|
59
|
+
- name: Deploy to production
|
|
60
|
+
if: ${{ github.ref == 'refs/heads/main' }}
|
|
61
|
+
run: ./deploy.sh
|
|
62
|
+
prevention:
|
|
63
|
+
- "Never embed `${{ }}` fragments inside quoted strings within `if:` — always wrap the entire condition."
|
|
64
|
+
- "Lint workflows with `actionlint` — it flags mixed expression/literal patterns in `if:` fields."
|
|
65
|
+
- "Prefer `format()` or direct context references over string interpolation in conditionals."
|
|
66
|
+
- "Test conditions on branches that should NOT match to verify they don't run."
|
|
67
|
+
docs:
|
|
68
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions"
|
|
69
|
+
label: "Evaluate expressions in workflows and actions"
|
|
70
|
+
- url: "https://github.com/actions/runner/issues/1173"
|
|
71
|
+
label: "actions/runner#1173 — If condition always evaluated as true when containing ${{ }} inside"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
id: silent-failures-014
|
|
2
|
+
title: "pull_request github.ref Is refs/pull/N/merge — Branch-Name Conditions Silently Evaluate to False"
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- pull_request
|
|
7
|
+
- github-ref
|
|
8
|
+
- merge-ref
|
|
9
|
+
- branch-name
|
|
10
|
+
- if-condition
|
|
11
|
+
- head-ref
|
|
12
|
+
- base-ref
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: "github\\.ref\\s*==\\s*['\"]refs/heads/"
|
|
15
|
+
flags: "i"
|
|
16
|
+
- regex: "GITHUB_REF.*refs/pull/\\d+/merge"
|
|
17
|
+
flags: "i"
|
|
18
|
+
- regex: "github\\.ref.*startsWith.*refs/heads"
|
|
19
|
+
flags: "i"
|
|
20
|
+
error_messages:
|
|
21
|
+
- "refs/pull/123/merge"
|
|
22
|
+
- "Conditional check 'github.ref == refs/heads/main' evaluated to false on pull_request event"
|
|
23
|
+
- "GITHUB_REF=refs/pull/47/merge"
|
|
24
|
+
root_cause: |
|
|
25
|
+
When a workflow is triggered by the `pull_request` (or `pull_request_target`) event,
|
|
26
|
+
`github.ref` is set to `refs/pull/<number>/merge` — NOT the source branch name.
|
|
27
|
+
|
|
28
|
+
This `refs/pull/<N>/merge` is a synthetic Git reference created by GitHub representing
|
|
29
|
+
the prospective merge commit (the merge of the PR's head branch into the base branch).
|
|
30
|
+
It does not correspond to any named branch.
|
|
31
|
+
|
|
32
|
+
Developers commonly write conditions expecting `github.ref` to contain the branch name:
|
|
33
|
+
- `if: github.ref == 'refs/heads/feature-branch'` → always false on PR events
|
|
34
|
+
- `if: startsWith(github.ref, 'refs/heads/')` → always false on PR events
|
|
35
|
+
- `if: github.ref != 'refs/heads/main'` → always true on PR events
|
|
36
|
+
|
|
37
|
+
These conditions silently pass or silently skip — no error is reported. The job
|
|
38
|
+
simply runs (or doesn't run) with no indication that the ref check was wrong.
|
|
39
|
+
|
|
40
|
+
The correct context variables for PR events are:
|
|
41
|
+
- `github.head_ref` — the source branch name (e.g., "feature/my-branch")
|
|
42
|
+
- `github.base_ref` — the target branch name (e.g., "main")
|
|
43
|
+
- `github.event.pull_request.head.ref` — same as head_ref
|
|
44
|
+
- `github.event.pull_request.base.ref` — same as base_ref
|
|
45
|
+
|
|
46
|
+
Note: `github.head_ref` and `github.base_ref` are ONLY set for pull_request events.
|
|
47
|
+
For push events, use `github.ref_name` instead.
|
|
48
|
+
|
|
49
|
+
Source: Stack Overflow #68708792 (what is github.ref when merging PR to master)
|
|
50
|
+
Source: Stack Overflow #78162051 (obtain branch name in github action on pull_request)
|
|
51
|
+
Docs: https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context
|
|
52
|
+
fix: |
|
|
53
|
+
Use the correct context variable depending on what information you need:
|
|
54
|
+
|
|
55
|
+
For the PR source branch:
|
|
56
|
+
Use `github.head_ref` (not `github.ref`)
|
|
57
|
+
|
|
58
|
+
For the PR target branch:
|
|
59
|
+
Use `github.base_ref` (not `github.ref`)
|
|
60
|
+
|
|
61
|
+
For branch-scoped conditions that need to work across BOTH push and pull_request:
|
|
62
|
+
Use `github.ref_name` (gives branch/tag name without refs/heads/ prefix)
|
|
63
|
+
Or use `github.event_name` to gate the condition by event type
|
|
64
|
+
|
|
65
|
+
To check if a push or PR targets the main branch:
|
|
66
|
+
- Push: `github.ref == 'refs/heads/main'`
|
|
67
|
+
- PR: `github.base_ref == 'main'`
|
|
68
|
+
- Both: use separate conditions gated by `github.event_name`
|
|
69
|
+
fix_code:
|
|
70
|
+
- language: yaml
|
|
71
|
+
label: "Fix: use github.head_ref for PR source branch, base_ref for target"
|
|
72
|
+
code: |
|
|
73
|
+
jobs:
|
|
74
|
+
deploy:
|
|
75
|
+
runs-on: ubuntu-latest
|
|
76
|
+
steps:
|
|
77
|
+
# ❌ WRONG: github.ref on pull_request is refs/pull/N/merge — never matches heads/*
|
|
78
|
+
- name: Wrong branch check
|
|
79
|
+
if: github.ref == 'refs/heads/feature-branch' # always false on PR!
|
|
80
|
+
run: echo "this never runs on PR events"
|
|
81
|
+
|
|
82
|
+
# ✅ CORRECT: use head_ref for the source branch name
|
|
83
|
+
- name: Correct branch check
|
|
84
|
+
if: github.head_ref == 'feature-branch'
|
|
85
|
+
run: echo "this runs when PR source branch is feature-branch"
|
|
86
|
+
|
|
87
|
+
# ✅ CORRECT: use base_ref for the target branch name
|
|
88
|
+
- name: Check if targeting main
|
|
89
|
+
if: github.base_ref == 'main'
|
|
90
|
+
run: echo "this PR is targeting main"
|
|
91
|
+
|
|
92
|
+
- language: yaml
|
|
93
|
+
label: "Cross-event condition: works for both push and pull_request"
|
|
94
|
+
code: |
|
|
95
|
+
jobs:
|
|
96
|
+
conditional:
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
steps:
|
|
99
|
+
# Works for both push (github.ref_name) and PR (github.base_ref)
|
|
100
|
+
- name: Is this targeting/on main?
|
|
101
|
+
if: |
|
|
102
|
+
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
|
|
103
|
+
(github.event_name == 'pull_request' && github.base_ref == 'main')
|
|
104
|
+
run: echo "targeting or on main branch"
|
|
105
|
+
|
|
106
|
+
# Simpler: github.ref_name works for push; for PR it returns the merge ref number
|
|
107
|
+
# Best to keep push and pull_request logic separate with event_name guards
|
|
108
|
+
|
|
109
|
+
- language: yaml
|
|
110
|
+
label: "Debug step: print all relevant refs for troubleshooting"
|
|
111
|
+
code: |
|
|
112
|
+
- name: Debug refs
|
|
113
|
+
run: |
|
|
114
|
+
echo "event_name: ${{ github.event_name }}"
|
|
115
|
+
echo "ref: ${{ github.ref }}"
|
|
116
|
+
echo "ref_name: ${{ github.ref_name }}"
|
|
117
|
+
echo "head_ref: ${{ github.head_ref }}"
|
|
118
|
+
echo "base_ref: ${{ github.base_ref }}"
|
|
119
|
+
# On push/branch: ref=refs/heads/main, head_ref='', base_ref=''
|
|
120
|
+
# On pull_request: ref=refs/pull/47/merge, head_ref=feature-x, base_ref=main
|
|
121
|
+
|
|
122
|
+
prevention:
|
|
123
|
+
- "Never use `github.ref` to get the branch name in `pull_request` triggered workflows — it is not a branch ref."
|
|
124
|
+
- "Use `github.head_ref` for the PR source branch and `github.base_ref` for the target branch."
|
|
125
|
+
- "For conditions that span both push and pull_request events, gate on `github.event_name` first."
|
|
126
|
+
- "Add a debug step printing `github.ref`, `github.head_ref`, and `github.base_ref` when debugging trigger conditions."
|
|
127
|
+
- "Use `github.ref_name` (branch/tag name without prefix) for push events instead of stripping `refs/heads/` manually."
|
|
128
|
+
docs:
|
|
129
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context"
|
|
130
|
+
label: "GitHub Docs: github context (ref, head_ref, base_ref, ref_name)"
|
|
131
|
+
- url: "https://stackoverflow.com/questions/68708792/what-is-github-ref-when-merging-pr-to-master"
|
|
132
|
+
label: "Stack Overflow: What is github.ref when merging PR to master?"
|
|
133
|
+
- url: "https://stackoverflow.com/questions/78162051/obtain-branch-name-in-github-action-on-pull-request"
|
|
134
|
+
label: "Stack Overflow: Obtain branch name in github action on pull request"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
id: triggers-012
|
|
2
|
+
title: "Tag Pushes Bypass paths: Filter — Workflow Fires on Every Tag Regardless of Changed Files"
|
|
3
|
+
category: triggers
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- push
|
|
7
|
+
- tags
|
|
8
|
+
- paths-filter
|
|
9
|
+
- tag-push
|
|
10
|
+
- trigger
|
|
11
|
+
- branches
|
|
12
|
+
patterns:
|
|
13
|
+
- regex: "on:\\s*\\n\\s*push:\\s*\\n\\s*(\\w[^\\n]*\\n\\s*)*paths:"
|
|
14
|
+
flags: "im"
|
|
15
|
+
- regex: "Triggered by refs/tags/"
|
|
16
|
+
flags: "i"
|
|
17
|
+
- regex: "push.*paths.*tags.*ignored"
|
|
18
|
+
flags: "i"
|
|
19
|
+
error_messages:
|
|
20
|
+
- "Run triggered by push to refs/tags/v1.0.0"
|
|
21
|
+
- "Evaluation skipped: tag push does not evaluate path filters"
|
|
22
|
+
root_cause: |
|
|
23
|
+
GitHub Actions evaluates `paths:` and `paths-ignore:` filters ONLY for branch pushes.
|
|
24
|
+
When a tag is pushed, path filters are completely bypassed — the workflow runs regardless
|
|
25
|
+
of which files were changed.
|
|
26
|
+
|
|
27
|
+
This means a workflow like:
|
|
28
|
+
|
|
29
|
+
on:
|
|
30
|
+
push:
|
|
31
|
+
paths:
|
|
32
|
+
- "src/**"
|
|
33
|
+
|
|
34
|
+
will fire on every tag push (e.g., `git push origin v1.2.3`) even if no files under
|
|
35
|
+
`src/` were modified in the tagged commit.
|
|
36
|
+
|
|
37
|
+
Additionally, if a workflow has `on: push:` with NO branches/tags filter, a tag push
|
|
38
|
+
produces a `push` webhook event that triggers the workflow. Developers expect tag pushes
|
|
39
|
+
to be governed by path filters, but the docs explicitly state path filters are not
|
|
40
|
+
evaluated for tag events.
|
|
41
|
+
|
|
42
|
+
Source: actions/runner#3933 (path filters not respected for tag pushes)
|
|
43
|
+
Source: Stack Overflow #76037078 (paths also triggered when pushing a new tag)
|
|
44
|
+
Docs: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow#using-filters-to-target-specific-paths-for-pull-request-or-push-events
|
|
45
|
+
fix: |
|
|
46
|
+
If you want path-filtered push workflows to run ONLY on branch pushes (not tag pushes),
|
|
47
|
+
explicitly add a `tags-ignore: ["**"]` filter or restrict to branches explicitly.
|
|
48
|
+
|
|
49
|
+
Option A — Add `tags-ignore` to suppress all tag events:
|
|
50
|
+
on:
|
|
51
|
+
push:
|
|
52
|
+
paths:
|
|
53
|
+
- "src/**"
|
|
54
|
+
tags-ignore:
|
|
55
|
+
- "**"
|
|
56
|
+
|
|
57
|
+
Option B — Add an explicit `branches` filter (only branch pushes match):
|
|
58
|
+
on:
|
|
59
|
+
push:
|
|
60
|
+
branches:
|
|
61
|
+
- "**" # all branches
|
|
62
|
+
paths:
|
|
63
|
+
- "src/**"
|
|
64
|
+
|
|
65
|
+
Note: Option B is equivalent — defining `branches` or `tags` implicitly excludes the
|
|
66
|
+
other git ref type from triggering the workflow.
|
|
67
|
+
fix_code:
|
|
68
|
+
- language: yaml
|
|
69
|
+
label: "Add tags-ignore to prevent tag pushes from bypassing path filters"
|
|
70
|
+
code: |
|
|
71
|
+
on:
|
|
72
|
+
push:
|
|
73
|
+
# Path filter only works for branch pushes — add tags-ignore to prevent
|
|
74
|
+
# tag pushes from triggering this workflow unconditionally.
|
|
75
|
+
tags-ignore:
|
|
76
|
+
- "**"
|
|
77
|
+
paths:
|
|
78
|
+
- "src/**"
|
|
79
|
+
- "lib/**"
|
|
80
|
+
|
|
81
|
+
- language: yaml
|
|
82
|
+
label: "Restrict to all branches (implicitly excludes tags)"
|
|
83
|
+
code: |
|
|
84
|
+
on:
|
|
85
|
+
push:
|
|
86
|
+
branches:
|
|
87
|
+
- "**" # matches all branch pushes, ignores tag pushes
|
|
88
|
+
paths:
|
|
89
|
+
- "src/**"
|
|
90
|
+
|
|
91
|
+
- language: yaml
|
|
92
|
+
label: "Separate workflows for branch CI vs tag release"
|
|
93
|
+
code: |
|
|
94
|
+
# ci.yml — triggered by branch pushes with path filtering
|
|
95
|
+
on:
|
|
96
|
+
push:
|
|
97
|
+
branches:
|
|
98
|
+
- main
|
|
99
|
+
- "feature/**"
|
|
100
|
+
paths:
|
|
101
|
+
- "src/**"
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
# release.yml — triggered by version tags
|
|
105
|
+
on:
|
|
106
|
+
push:
|
|
107
|
+
tags:
|
|
108
|
+
- "v*.*.*"
|
|
109
|
+
|
|
110
|
+
prevention:
|
|
111
|
+
- "Never rely on `paths:` filters to suppress tag-push triggers — they are not evaluated for tags."
|
|
112
|
+
- "Always add an explicit `branches:` or `tags-ignore: [\"**\"]` when your workflow is path-filtered and meant only for code changes."
|
|
113
|
+
- "Keep branch-CI workflows and release/tag workflows in separate files to avoid trigger ambiguity."
|
|
114
|
+
- "Test your trigger logic with `act` locally — push a tag and verify the workflow does not fire unexpectedly."
|
|
115
|
+
docs:
|
|
116
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow#using-filters-to-target-specific-paths-for-pull-request-or-push-events"
|
|
117
|
+
label: "GitHub Docs: Using filters to target specific paths"
|
|
118
|
+
- url: "https://github.com/actions/runner/issues/3933"
|
|
119
|
+
label: "actions/runner#3933: Path filters not respected for tag pushes"
|
|
120
|
+
- url: "https://stackoverflow.com/questions/76037078/why-is-my-github-action-on-paths-also-triggered-when-pushing-a-new-tag"
|
|
121
|
+
label: "Stack Overflow: Why is paths: also triggered when pushing a new tag?"
|
|
122
|
+
- url: "https://stackoverflow.com/questions/70743715/how-do-i-configure-a-github-actions-workflow-so-it-does-not-run-on-a-tag-push"
|
|
123
|
+
label: "Stack Overflow: How to prevent workflow from running on tag push"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
id: yaml-syntax-018
|
|
2
|
+
title: "Job if: Condition Skipped When Needed Job Is Skipped — Requires always() &&"
|
|
3
|
+
category: yaml-syntax
|
|
4
|
+
severity: error
|
|
5
|
+
tags:
|
|
6
|
+
- needs
|
|
7
|
+
- skipped
|
|
8
|
+
- if-condition
|
|
9
|
+
- always
|
|
10
|
+
- job-level
|
|
11
|
+
patterns:
|
|
12
|
+
- regex: "needs.*result.*skipped"
|
|
13
|
+
flags: "i"
|
|
14
|
+
- regex: "if:.*needs\\.[a-z_-]+\\.outputs"
|
|
15
|
+
flags: "i"
|
|
16
|
+
error_messages:
|
|
17
|
+
- "Skipping job: due to job needs not meeting conditions"
|
|
18
|
+
- "Job was skipped"
|
|
19
|
+
root_cause: |
|
|
20
|
+
When a job declares `needs: [job_a, job_b]` and any of those upstream jobs is in
|
|
21
|
+
a `skipped` state, GitHub Actions propagates the skip to the dependent job by
|
|
22
|
+
default — even when that job has its own `if:` condition that evaluates to `true`.
|
|
23
|
+
|
|
24
|
+
GitHub Actions imposes an implicit rule: all `needs` jobs must have
|
|
25
|
+
`result == 'success'` unless the dependent job's condition explicitly overrides
|
|
26
|
+
the check. A skipped upstream job does NOT satisfy the implicit success requirement.
|
|
27
|
+
The `if:` condition is NOT evaluated as a full override — it is only evaluated
|
|
28
|
+
AFTER the implicit needs-success check passes, so if that check fails the `if:`
|
|
29
|
+
is never reached.
|
|
30
|
+
|
|
31
|
+
The fix is `always() &&` at the start of the `if:` condition, which bypasses the
|
|
32
|
+
implicit needs-success requirement and forces the runner to evaluate the full condition.
|
|
33
|
+
fix: |
|
|
34
|
+
Prepend `always() &&` to any job-level `if:` where an upstream `needs` job may be
|
|
35
|
+
legitimately skipped. Alternatively, explicitly allow skipped results:
|
|
36
|
+
`needs.job_name.result == 'success' || needs.job_name.result == 'skipped'`.
|
|
37
|
+
fix_code:
|
|
38
|
+
- language: yaml
|
|
39
|
+
label: "WRONG — deploy skipped even though if evaluates true"
|
|
40
|
+
code: |
|
|
41
|
+
jobs:
|
|
42
|
+
build:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
outputs:
|
|
45
|
+
should_deploy: ${{ steps.check.outputs.deploy }}
|
|
46
|
+
steps:
|
|
47
|
+
- id: check
|
|
48
|
+
run: echo "deploy=true" >> $GITHUB_OUTPUT
|
|
49
|
+
|
|
50
|
+
integration-tests:
|
|
51
|
+
needs: build
|
|
52
|
+
if: ${{ needs.build.outputs.should_deploy == 'false' }}
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
steps:
|
|
55
|
+
- run: echo "optional integration tests"
|
|
56
|
+
|
|
57
|
+
deploy:
|
|
58
|
+
needs: [build, integration-tests]
|
|
59
|
+
# PROBLEM: integration-tests was skipped → deploy is also skipped
|
|
60
|
+
# even though the condition below is true
|
|
61
|
+
if: ${{ needs.build.outputs.should_deploy == 'true' }}
|
|
62
|
+
runs-on: ubuntu-latest
|
|
63
|
+
steps:
|
|
64
|
+
- run: ./deploy.sh
|
|
65
|
+
- language: yaml
|
|
66
|
+
label: "CORRECT — prepend always() to force evaluation"
|
|
67
|
+
code: |
|
|
68
|
+
jobs:
|
|
69
|
+
deploy:
|
|
70
|
+
needs: [build, integration-tests]
|
|
71
|
+
if: always() && needs.build.outputs.should_deploy == 'true'
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
steps:
|
|
74
|
+
- run: ./deploy.sh
|
|
75
|
+
- language: yaml
|
|
76
|
+
label: "CORRECT — explicit skipped result check"
|
|
77
|
+
code: |
|
|
78
|
+
jobs:
|
|
79
|
+
deploy:
|
|
80
|
+
needs: [build, integration-tests]
|
|
81
|
+
if: |
|
|
82
|
+
always() &&
|
|
83
|
+
needs.build.result == 'success' &&
|
|
84
|
+
(needs.integration-tests.result == 'success' ||
|
|
85
|
+
needs.integration-tests.result == 'skipped')
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
steps:
|
|
88
|
+
- run: ./deploy.sh
|
|
89
|
+
prevention:
|
|
90
|
+
- "Add `always() &&` to any job `if:` condition where upstream `needs` jobs may be skipped."
|
|
91
|
+
- "Treat a skipped upstream need as equivalent to a failed need unless `always()` is used."
|
|
92
|
+
- "Map your workflow DAG — anywhere a branch is optionally skipped, downstream jobs need `always()`."
|
|
93
|
+
- "Use `actionlint` to surface potential needs/if interaction issues statically."
|
|
94
|
+
docs:
|
|
95
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idif"
|
|
96
|
+
label: "jobs.<job_id>.if — Workflow syntax"
|
|
97
|
+
- url: "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-conditions-to-control-job-execution#using-status-check-functions"
|
|
98
|
+
label: "Using status check functions (always, failure, success, cancelled)"
|
|
99
|
+
- url: "https://github.com/actions/runner/issues/491"
|
|
100
|
+
label: "actions/runner#491 — Job if condition not evaluated correctly if needs job is skipped"
|
package/package.json
CHANGED