@htekdev/actions-debugger 1.0.125 → 1.0.127
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-075.yml +172 -0
- package/errors/concurrency-timing/concurrency-timing-059.yml +146 -0
- package/errors/concurrency-timing/concurrency-timing-060.yml +144 -0
- package/errors/known-unsolved/known-unsolved-073.yml +172 -0
- package/errors/known-unsolved/known-unsolved-074.yml +141 -0
- package/errors/runner-environment/runner-environment-236.yml +146 -0
- package/errors/triggers/triggers-072.yml +150 -0
- package/errors/triggers/triggers-073.yml +173 -0
- package/package.json +1 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
id: caching-artifacts-075
|
|
2
|
+
title: '`actions/cache` entries saved by GitHub-hosted runners are not accessible from self-hosted runners — cache lookup returns miss despite matching key'
|
|
3
|
+
category: caching-artifacts
|
|
4
|
+
severity: error
|
|
5
|
+
tags:
|
|
6
|
+
- actions-cache
|
|
7
|
+
- self-hosted
|
|
8
|
+
- github-hosted
|
|
9
|
+
- cache-miss
|
|
10
|
+
- cross-runner
|
|
11
|
+
- ACTIONS_CACHE_URL
|
|
12
|
+
- cache-backend
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'Cache not found for input keys'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'No cache found.*self.hosted'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'ACTIONS_CACHE_URL.*self.hosted'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
- regex: 'cache.*miss.*self.hosted|self.hosted.*cache.*miss'
|
|
21
|
+
flags: 'i'
|
|
22
|
+
error_messages:
|
|
23
|
+
- "Cache not found for input keys: my-cache-key-abc123"
|
|
24
|
+
- "Warning: Cache restore failed."
|
|
25
|
+
- "No cache found for key my-cache-key on self-hosted runner"
|
|
26
|
+
root_cause: |
|
|
27
|
+
`actions/cache` stores and retrieves cache entries via a **runner-specific
|
|
28
|
+
`ACTIONS_CACHE_URL` endpoint** that is injected into each job by the Actions
|
|
29
|
+
infrastructure. The cache URL for a GitHub-hosted runner points to GitHub's
|
|
30
|
+
hosted cache service, while the cache URL for a self-hosted runner may point to
|
|
31
|
+
a different endpoint (e.g., GHES cache, Actions Runner Controller cache proxy,
|
|
32
|
+
or a completely different ACTIONS_CACHE_URL configured by the runner operator).
|
|
33
|
+
|
|
34
|
+
When a GitHub-hosted runner saves a cache entry:
|
|
35
|
+
- The entry is stored at GitHub's hosted cache service endpoint.
|
|
36
|
+
- The entry IS visible via the GitHub Cache REST API and the repository's
|
|
37
|
+
Actions → Caches UI.
|
|
38
|
+
|
|
39
|
+
When a self-hosted runner tries to restore the same key:
|
|
40
|
+
- The runner uses its own `ACTIONS_CACHE_URL`, which may point to a different
|
|
41
|
+
service.
|
|
42
|
+
- The cache lookup returns "Cache not found" even though the entry is visible
|
|
43
|
+
in the UI.
|
|
44
|
+
- No error is surfaced — the action silently reports a cache miss and the job
|
|
45
|
+
continues.
|
|
46
|
+
|
|
47
|
+
This is commonly encountered when:
|
|
48
|
+
1. **ARC (Actions Runner Controller) on Kubernetes** — ARC runners use a
|
|
49
|
+
`gha-cache-server` proxy sidecar that fronts GitHub's cache API; the proxy's
|
|
50
|
+
cache scope or URL differs from the GitHub-hosted ACTIONS_CACHE_URL.
|
|
51
|
+
2. **GHES (GitHub Enterprise Server)** — On-prem GHES cache is a separate
|
|
52
|
+
service from github.com hosted cache.
|
|
53
|
+
3. **Mixed runner pools** — Some jobs run on GitHub-hosted (`ubuntu-latest`)
|
|
54
|
+
and others on self-hosted runners; they do not share cache entries.
|
|
55
|
+
4. **Self-hosted runners with custom ACTIONS_CACHE_URL** — Operators configure
|
|
56
|
+
a custom cache backend (e.g., Artifactory, Nexus) that doesn't contain entries
|
|
57
|
+
from GitHub's hosted cache.
|
|
58
|
+
|
|
59
|
+
Related: actions/cache#1595 (open since April 2025).
|
|
60
|
+
|
|
61
|
+
The cache entries ARE there (visible via REST API) but are stored at a different
|
|
62
|
+
cache service endpoint than the one the self-hosted runner queries.
|
|
63
|
+
fix: |
|
|
64
|
+
**Option 1 (Recommended) — Ensure all cache-sharing jobs use the same runner type:**
|
|
65
|
+
If a job saves a cache on a GitHub-hosted runner, ensure the job that needs to
|
|
66
|
+
restore it also runs on a GitHub-hosted runner. Do not rely on cross-runner-type
|
|
67
|
+
cache sharing.
|
|
68
|
+
|
|
69
|
+
**Option 2 — Configure a shared external cache backend:**
|
|
70
|
+
For mixed runner environments, configure all runners (GitHub-hosted and self-hosted)
|
|
71
|
+
to use a shared external cache backend. This requires customizing `ACTIONS_CACHE_URL`
|
|
72
|
+
on your self-hosted runners to point to the same service.
|
|
73
|
+
|
|
74
|
+
**Option 3 — Re-populate cache from self-hosted runners:**
|
|
75
|
+
Have the self-hosted runner job save its own cache entry with the same key on first
|
|
76
|
+
miss. Subsequent self-hosted runner runs will hit this entry. The GitHub-hosted and
|
|
77
|
+
self-hosted entries may diverge if they have different paths.
|
|
78
|
+
|
|
79
|
+
**Option 4 — Use artifact-based sharing instead of cache:**
|
|
80
|
+
If the data must cross runner types, use `actions/upload-artifact` and
|
|
81
|
+
`actions/download-artifact` instead of cache. Artifacts are stored and retrieved
|
|
82
|
+
via the same GitHub API endpoint regardless of runner type.
|
|
83
|
+
fix_code:
|
|
84
|
+
- language: yaml
|
|
85
|
+
label: 'Broken — cache saved by GitHub-hosted runner, restored by self-hosted (misses)'
|
|
86
|
+
code: |
|
|
87
|
+
jobs:
|
|
88
|
+
build:
|
|
89
|
+
runs-on: ubuntu-latest # GitHub-hosted runner saves cache
|
|
90
|
+
steps:
|
|
91
|
+
- uses: actions/checkout@v4
|
|
92
|
+
- uses: actions/cache@v4
|
|
93
|
+
with:
|
|
94
|
+
path: ~/.npm
|
|
95
|
+
key: npm-${{ hashFiles('**/package-lock.json') }}
|
|
96
|
+
- run: npm ci
|
|
97
|
+
|
|
98
|
+
test:
|
|
99
|
+
runs-on: self-hosted # ✗ Self-hosted runner cannot access
|
|
100
|
+
needs: build # github-hosted runner's cache entry
|
|
101
|
+
steps:
|
|
102
|
+
- uses: actions/cache@v4
|
|
103
|
+
with:
|
|
104
|
+
path: ~/.npm
|
|
105
|
+
key: npm-${{ hashFiles('**/package-lock.json') }}
|
|
106
|
+
# → Always "Cache not found for input keys: npm-..."
|
|
107
|
+
- run: npm test
|
|
108
|
+
|
|
109
|
+
- language: yaml
|
|
110
|
+
label: 'Fixed — both jobs on the same runner type share cache entries'
|
|
111
|
+
code: |
|
|
112
|
+
jobs:
|
|
113
|
+
build:
|
|
114
|
+
runs-on: ubuntu-latest # ✓ Both jobs use GitHub-hosted runners
|
|
115
|
+
steps:
|
|
116
|
+
- uses: actions/checkout@v4
|
|
117
|
+
- uses: actions/cache@v4
|
|
118
|
+
with:
|
|
119
|
+
path: ~/.npm
|
|
120
|
+
key: npm-${{ hashFiles('**/package-lock.json') }}
|
|
121
|
+
- run: npm ci
|
|
122
|
+
|
|
123
|
+
test:
|
|
124
|
+
runs-on: ubuntu-latest # ✓ Same runner type = same cache backend
|
|
125
|
+
needs: build
|
|
126
|
+
steps:
|
|
127
|
+
- uses: actions/cache@v4
|
|
128
|
+
with:
|
|
129
|
+
path: ~/.npm
|
|
130
|
+
key: npm-${{ hashFiles('**/package-lock.json') }}
|
|
131
|
+
# ✓ Cache hit — saved by github-hosted, restored by github-hosted
|
|
132
|
+
- run: npm test
|
|
133
|
+
|
|
134
|
+
- language: yaml
|
|
135
|
+
label: 'Alternative — use upload-artifact / download-artifact for cross-runner sharing'
|
|
136
|
+
code: |
|
|
137
|
+
jobs:
|
|
138
|
+
build:
|
|
139
|
+
runs-on: ubuntu-latest # GitHub-hosted builds the artifacts
|
|
140
|
+
steps:
|
|
141
|
+
- uses: actions/checkout@v4
|
|
142
|
+
- run: npm ci && npm run build
|
|
143
|
+
- uses: actions/upload-artifact@v4
|
|
144
|
+
with:
|
|
145
|
+
name: build-output
|
|
146
|
+
path: dist/
|
|
147
|
+
|
|
148
|
+
test:
|
|
149
|
+
runs-on: self-hosted # ✓ Artifacts are accessible from any runner type
|
|
150
|
+
needs: build
|
|
151
|
+
steps:
|
|
152
|
+
- uses: actions/download-artifact@v4
|
|
153
|
+
with:
|
|
154
|
+
name: build-output
|
|
155
|
+
path: dist/
|
|
156
|
+
- run: npm test
|
|
157
|
+
|
|
158
|
+
prevention:
|
|
159
|
+
- 'Treat `actions/cache` entries as scoped to the runner type that created them — GitHub-hosted and self-hosted runners do not share a cache backend by default.'
|
|
160
|
+
- 'Design workflows so that cache-save and cache-restore jobs run on the same runner type (both GitHub-hosted or both self-hosted).'
|
|
161
|
+
- 'When mixing runner types, use `actions/upload-artifact` + `actions/download-artifact` for data that must cross the runner boundary.'
|
|
162
|
+
- 'Verify cache access in ARC (Actions Runner Controller) setups: ARC uses a gha-cache-server proxy that may have different scope from the GitHub-hosted cache.'
|
|
163
|
+
- 'Inspect `ACTIONS_CACHE_URL` in debug logs (`ACTIONS_RUNNER_DEBUG=true`) to confirm both runners are pointing to the same cache endpoint.'
|
|
164
|
+
docs:
|
|
165
|
+
- url: 'https://github.com/actions/cache/issues/1595'
|
|
166
|
+
label: 'actions/cache#1595: GitHub-hosted runner cache not found from self-hosted runner (open 2025)'
|
|
167
|
+
- url: 'https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache'
|
|
168
|
+
label: 'GitHub Docs: Cache access restrictions'
|
|
169
|
+
- url: 'https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows'
|
|
170
|
+
label: 'GitHub Docs: Caching dependencies to speed up workflows'
|
|
171
|
+
- url: 'https://github.com/actions/actions-runner-controller/blob/main/docs/gha-runner-scale-set-controller/README.md'
|
|
172
|
+
label: 'ARC docs: Runner Scale Set — cache proxy configuration'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
id: concurrency-timing-059
|
|
2
|
+
title: 'Skipped downstream job satisfies required status check — PR merges despite upstream job failure'
|
|
3
|
+
category: concurrency-timing
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- needs
|
|
7
|
+
- skipped
|
|
8
|
+
- required-status-check
|
|
9
|
+
- branch-protection
|
|
10
|
+
- dependency
|
|
11
|
+
- silent-merge
|
|
12
|
+
- job-conclusion
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'This job was skipped'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'Result: skipped'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'needs\.[a-zA-Z0-9_-]+\.result.*skipped'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- "This job was skipped."
|
|
22
|
+
- "Skipping this job because a previous job in the chain was skipped or failed."
|
|
23
|
+
root_cause: |
|
|
24
|
+
GitHub Actions marks a downstream job as `skipped` (not `failure`) when an upstream
|
|
25
|
+
`needs:` dependency fails or is cancelled and the downstream job has no explicit `if:`
|
|
26
|
+
condition to handle that state.
|
|
27
|
+
|
|
28
|
+
Since April 2023, GitHub's branch protection rules treat a `skipped` check conclusion
|
|
29
|
+
as **passing** — equivalent to `success` — to support the common "aggregator job"
|
|
30
|
+
pattern. This means:
|
|
31
|
+
|
|
32
|
+
1. `build` job fails.
|
|
33
|
+
2. `test-results` job (which `needs: [build]`) is marked `skipped`.
|
|
34
|
+
3. Branch protection rule requires `test-results` to pass.
|
|
35
|
+
4. GitHub sees conclusion = `skipped` → treats it as satisfied → merge is allowed.
|
|
36
|
+
|
|
37
|
+
The PR can be merged even though the `build` step failed. There is no warning or
|
|
38
|
+
error in the UI — the required check shows a green checkmark (or neutral status)
|
|
39
|
+
rather than a red blocking indicator.
|
|
40
|
+
|
|
41
|
+
This behavior is distinct from:
|
|
42
|
+
- `cancel-in-progress` cancelling a required check (conclusion = `cancelled`, which
|
|
43
|
+
blocks merging — a separate issue).
|
|
44
|
+
- Path-filter causing a workflow to never run (check stays "Expected" / pending).
|
|
45
|
+
- The general skipped-needs cascade (which documents that downstream jobs skip, but
|
|
46
|
+
not the branch protection bypass consequence).
|
|
47
|
+
fix: |
|
|
48
|
+
Add an explicit **catch-all aggregator job** that runs whenever any dependency failed
|
|
49
|
+
or was cancelled, and exits with a non-zero code:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
ci-gate:
|
|
53
|
+
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
|
|
54
|
+
needs: [build, test, lint]
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
steps:
|
|
57
|
+
- name: Fail — one or more required jobs did not succeed
|
|
58
|
+
run: |
|
|
59
|
+
echo "Required jobs: ${{ toJSON(needs.*.result) }}"
|
|
60
|
+
exit 1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Set `ci-gate` as the sole required status check in branch protection. This aggregator
|
|
64
|
+
job only runs (and fails) when something upstream fails. When all upstream jobs
|
|
65
|
+
succeed, `ci-gate` is `skipped` → satisfies the required check. When any upstream
|
|
66
|
+
fails, `ci-gate` runs and explicitly fails → blocks the merge.
|
|
67
|
+
|
|
68
|
+
Alternatively, use the pattern that always runs the aggregator:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
ci-gate:
|
|
72
|
+
if: always()
|
|
73
|
+
needs: [build, test, lint]
|
|
74
|
+
runs-on: ubuntu-latest
|
|
75
|
+
steps:
|
|
76
|
+
- name: Check all required jobs passed
|
|
77
|
+
run: |
|
|
78
|
+
results='${{ toJSON(needs.*.result) }}'
|
|
79
|
+
if echo "$results" | grep -qE '"failure"|"cancelled"'; then
|
|
80
|
+
echo "One or more jobs failed: $results"
|
|
81
|
+
exit 1
|
|
82
|
+
fi
|
|
83
|
+
echo "All required jobs passed."
|
|
84
|
+
```
|
|
85
|
+
fix_code:
|
|
86
|
+
- language: yaml
|
|
87
|
+
label: 'Broken — downstream skipped check silently satisfies branch protection'
|
|
88
|
+
code: |
|
|
89
|
+
jobs:
|
|
90
|
+
build:
|
|
91
|
+
runs-on: ubuntu-latest
|
|
92
|
+
steps:
|
|
93
|
+
- run: ./build.sh # Fails
|
|
94
|
+
|
|
95
|
+
test-results:
|
|
96
|
+
needs: [build] # Skipped when build fails
|
|
97
|
+
runs-on: ubuntu-latest
|
|
98
|
+
# ⚠ No if: condition — job is skipped when build fails
|
|
99
|
+
# ⚠ Branch protection requires test-results to "pass"
|
|
100
|
+
# ⚠ skipped = passing in GitHub's branch protection evaluation
|
|
101
|
+
steps:
|
|
102
|
+
- run: ./test.sh
|
|
103
|
+
|
|
104
|
+
- language: yaml
|
|
105
|
+
label: 'Fixed — explicit catch-all aggregator job that fails on upstream failure'
|
|
106
|
+
code: |
|
|
107
|
+
jobs:
|
|
108
|
+
build:
|
|
109
|
+
runs-on: ubuntu-latest
|
|
110
|
+
steps:
|
|
111
|
+
- run: ./build.sh
|
|
112
|
+
|
|
113
|
+
test:
|
|
114
|
+
runs-on: ubuntu-latest
|
|
115
|
+
steps:
|
|
116
|
+
- run: ./test.sh
|
|
117
|
+
|
|
118
|
+
ci-gate:
|
|
119
|
+
if: always()
|
|
120
|
+
needs: [build, test]
|
|
121
|
+
runs-on: ubuntu-latest
|
|
122
|
+
steps:
|
|
123
|
+
- name: All required jobs must succeed
|
|
124
|
+
run: |
|
|
125
|
+
results='${{ toJSON(needs.*.result) }}'
|
|
126
|
+
if echo "$results" | grep -qE '"failure"|"cancelled"'; then
|
|
127
|
+
echo "Pipeline failed: $results"
|
|
128
|
+
exit 1
|
|
129
|
+
fi
|
|
130
|
+
echo "All jobs passed: $results"
|
|
131
|
+
|
|
132
|
+
# In branch protection: require "ci-gate" — not "build" or "test" individually
|
|
133
|
+
|
|
134
|
+
prevention:
|
|
135
|
+
- 'Use a single aggregator job (`ci-gate`) as the required status check instead of individual job names.'
|
|
136
|
+
- 'Always include `if: always()` on the aggregator and explicitly check `needs.*.result` for failure/cancelled.'
|
|
137
|
+
- 'Do NOT rely on `needs:` skipping to propagate failures to branch protection — skipped is treated as passing.'
|
|
138
|
+
- 'After changing which jobs are required status checks, verify by letting a build fail and confirm the PR is blocked.'
|
|
139
|
+
- 'actionlint does not detect this misconfiguration — manual testing is required.'
|
|
140
|
+
docs:
|
|
141
|
+
- url: 'https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks'
|
|
142
|
+
label: 'GitHub Docs: Troubleshooting required status checks'
|
|
143
|
+
- url: 'https://github.com/actions/runner/issues/2566'
|
|
144
|
+
label: 'actions/runner#2566: Skipped jobs satisfy required checks (many reactions)'
|
|
145
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/using-conditions-to-control-job-execution'
|
|
146
|
+
label: 'GitHub Docs: Using conditions to control job execution'
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
id: concurrency-timing-060
|
|
2
|
+
title: 'cancel-in-progress: false still silently drops older pending run when third concurrent dispatch arrives — GitHub enforces a 1-active + 1-pending hard limit per concurrency group'
|
|
3
|
+
category: concurrency-timing
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- concurrency
|
|
7
|
+
- cancel-in-progress
|
|
8
|
+
- pending
|
|
9
|
+
- silent-cancel
|
|
10
|
+
- dispatch
|
|
11
|
+
- queue
|
|
12
|
+
- lost-run
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'This run was cancelled'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'cancel-in-progress:\s*false'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'Run was cancelled'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- "This run was cancelled."
|
|
22
|
+
- "Run was cancelled."
|
|
23
|
+
root_cause: |
|
|
24
|
+
Setting `cancel-in-progress: false` in a concurrency group does NOT mean "queue all
|
|
25
|
+
runs indefinitely." It means "do not cancel the currently *in-progress* run."
|
|
26
|
+
|
|
27
|
+
GitHub Actions enforces a hard internal limit of **1 in-progress + 1 pending** run
|
|
28
|
+
per concurrency group (when using the default or `cancel-in-progress: false`).
|
|
29
|
+
When a third concurrent run arrives:
|
|
30
|
+
|
|
31
|
+
1. Run A is **in-progress** (held in slot 1).
|
|
32
|
+
2. Run B is **pending** (held in slot 2, waiting for Run A to finish).
|
|
33
|
+
3. Run C arrives → GitHub silently cancels Run B to free slot 2, then queues Run C.
|
|
34
|
+
|
|
35
|
+
The net effect: Run B is lost. Run C is now pending and will eventually execute.
|
|
36
|
+
With `cancel-in-progress: false`, *only Run A is protected* from cancellation — not
|
|
37
|
+
Run B. The arriving run always displaces the previously pending one.
|
|
38
|
+
|
|
39
|
+
This surprises developers who read `cancel-in-progress: false` as "let all runs
|
|
40
|
+
queue and execute in order." The actual semantics are: "don't kill the in-flight
|
|
41
|
+
run, but still replace any queued-but-not-yet-running run."
|
|
42
|
+
|
|
43
|
+
There is no log entry showing Run B was cancelled by Run C's arrival; the cancellation
|
|
44
|
+
shows as "This run was cancelled" with no attribution.
|
|
45
|
+
|
|
46
|
+
To allow more than 1 pending run, use `queue: max` (up to 100 pending slots) — but
|
|
47
|
+
note that `queue: max` and `cancel-in-progress: true` cannot be combined.
|
|
48
|
+
fix: |
|
|
49
|
+
Choose the concurrency strategy that matches your desired behavior:
|
|
50
|
+
|
|
51
|
+
**Option 1 — Allow up to 100 queued runs (strict ordering, no drops):**
|
|
52
|
+
Use `queue: max`. Every run is preserved and executes in arrival order. The 101st
|
|
53
|
+
concurrent run silently cancels the oldest pending run (see concurrency-timing-058
|
|
54
|
+
for the queue:max overflow edge case).
|
|
55
|
+
|
|
56
|
+
**Option 2 — Cancel stale runs, always run the latest (most common for CI):**
|
|
57
|
+
Use `cancel-in-progress: true`. Stale pending runs are cancelled; only the most
|
|
58
|
+
recent push/dispatch runs.
|
|
59
|
+
|
|
60
|
+
**Option 3 — Fine-grained concurrency keys to prevent grouping:**
|
|
61
|
+
Include `github.sha` or `github.run_id` in the group key so each run gets its own
|
|
62
|
+
isolated slot and nothing is ever cancelled or queued against another run.
|
|
63
|
+
|
|
64
|
+
**Option 4 — Accept the default behavior:**
|
|
65
|
+
Understand that `cancel-in-progress: false` means 1-active + 1-pending, and design
|
|
66
|
+
your pipeline around this. For example, if you need every commit tested, use
|
|
67
|
+
`cancel-in-progress: true` so you always test the latest commit, or `queue: max`
|
|
68
|
+
if you need every commit tested in order.
|
|
69
|
+
fix_code:
|
|
70
|
+
- language: yaml
|
|
71
|
+
label: 'Broken — cancel-in-progress: false does NOT queue all runs; 3rd run drops 2nd'
|
|
72
|
+
code: |
|
|
73
|
+
on: push
|
|
74
|
+
|
|
75
|
+
concurrency:
|
|
76
|
+
group: deploy-${{ github.ref }}
|
|
77
|
+
cancel-in-progress: false # ❌ Only protects the in-progress run (slot 1)
|
|
78
|
+
# ❌ Slot 2 (pending) is still replaced when a 3rd run arrives
|
|
79
|
+
|
|
80
|
+
jobs:
|
|
81
|
+
deploy:
|
|
82
|
+
runs-on: ubuntu-latest
|
|
83
|
+
steps:
|
|
84
|
+
- run: ./deploy.sh
|
|
85
|
+
|
|
86
|
+
- language: yaml
|
|
87
|
+
label: 'Fixed — use queue: max to preserve all pending runs (up to 100)'
|
|
88
|
+
code: |
|
|
89
|
+
on: push
|
|
90
|
+
|
|
91
|
+
concurrency:
|
|
92
|
+
group: deploy-${{ github.ref }}
|
|
93
|
+
queue: max # ✅ Up to 100 pending runs preserved, executed in order
|
|
94
|
+
# ✅ No run is silently dropped until the 101st arrives
|
|
95
|
+
|
|
96
|
+
jobs:
|
|
97
|
+
deploy:
|
|
98
|
+
runs-on: ubuntu-latest
|
|
99
|
+
steps:
|
|
100
|
+
- run: ./deploy.sh
|
|
101
|
+
|
|
102
|
+
- language: yaml
|
|
103
|
+
label: 'Alternative — cancel stale runs, keep only the latest (typical CI pattern)'
|
|
104
|
+
code: |
|
|
105
|
+
on: push
|
|
106
|
+
|
|
107
|
+
concurrency:
|
|
108
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
109
|
+
cancel-in-progress: true # ✅ Latest commit always runs; stale runs cancelled
|
|
110
|
+
|
|
111
|
+
jobs:
|
|
112
|
+
ci:
|
|
113
|
+
runs-on: ubuntu-latest
|
|
114
|
+
steps:
|
|
115
|
+
- run: ./test.sh
|
|
116
|
+
|
|
117
|
+
- language: yaml
|
|
118
|
+
label: 'Alternative — unique key per run (no cancellation, no queueing)'
|
|
119
|
+
code: |
|
|
120
|
+
on: push
|
|
121
|
+
|
|
122
|
+
concurrency:
|
|
123
|
+
group: ${{ github.workflow }}-${{ github.run_id }} # ✅ Each run is isolated
|
|
124
|
+
cancel-in-progress: false
|
|
125
|
+
|
|
126
|
+
jobs:
|
|
127
|
+
ci:
|
|
128
|
+
runs-on: ubuntu-latest
|
|
129
|
+
steps:
|
|
130
|
+
- run: ./test.sh
|
|
131
|
+
|
|
132
|
+
prevention:
|
|
133
|
+
- 'Read `cancel-in-progress: false` as "protect the running job, not all pending jobs."'
|
|
134
|
+
- 'Use `queue: max` when you need every dispatched run to eventually execute.'
|
|
135
|
+
- '`queue: max` and `cancel-in-progress: true` cannot be combined — validation error.'
|
|
136
|
+
- 'Include `github.sha` or `github.run_id` in the group key to give each run its own isolated slot.'
|
|
137
|
+
- 'Monitor the Actions tab for unexplained cancellations after rapid pushes to confirm you are hitting the 1-pending limit.'
|
|
138
|
+
docs:
|
|
139
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs'
|
|
140
|
+
label: 'GitHub Docs: Control the concurrency of workflows and jobs'
|
|
141
|
+
- url: 'https://github.com/orgs/community/discussions/5435'
|
|
142
|
+
label: 'GitHub Community #5435: cancel-in-progress: false still cancels pending runs'
|
|
143
|
+
- url: 'https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#concurrency'
|
|
144
|
+
label: 'GitHub Docs: Workflow syntax — concurrency'
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
id: known-unsolved-073
|
|
2
|
+
title: '`timeout-minutes:` does not accept expressions — must be a literal integer; variables, inputs, and secrets are unsupported'
|
|
3
|
+
category: known-unsolved
|
|
4
|
+
severity: limitation
|
|
5
|
+
tags:
|
|
6
|
+
- timeout-minutes
|
|
7
|
+
- expressions
|
|
8
|
+
- limitation
|
|
9
|
+
- no-dynamic-value
|
|
10
|
+
- job-timeout
|
|
11
|
+
- step-timeout
|
|
12
|
+
- vars-context
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'timeout-minutes:\s*\$\{\{'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'timeout-minutes.*vars\.|timeout-minutes.*inputs\.|timeout-minutes.*env\.'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'Unexpected value.*timeout-minutes|timeout-minutes.*invalid'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- "The workflow is not valid. .github/workflows/ci.yml (Line: N, Col: M): Unexpected value 'true'"
|
|
22
|
+
- "timeout-minutes: unexpected value"
|
|
23
|
+
- "Expected Integer, String"
|
|
24
|
+
root_cause: |
|
|
25
|
+
The `timeout-minutes:` field at both the **job level** and the **step level** only
|
|
26
|
+
accepts a **literal integer** (e.g., `30`, `120`). GitHub Actions does not evaluate
|
|
27
|
+
expressions (`${{ }}`) in this field.
|
|
28
|
+
|
|
29
|
+
Attempting to use any of the following will either produce a YAML validation error
|
|
30
|
+
or silently fall back to the default timeout (6 hours for jobs, unlimited for steps):
|
|
31
|
+
|
|
32
|
+
- `timeout-minutes: ${{ vars.JOB_TIMEOUT }}` — variables context
|
|
33
|
+
- `timeout-minutes: ${{ inputs.timeout }}` — inputs context
|
|
34
|
+
- `timeout-minutes: ${{ env.TIMEOUT_MINUTES }}` — env context
|
|
35
|
+
- `timeout-minutes: ${{ 30 * 2 }}` — arithmetic expression
|
|
36
|
+
- `timeout-minutes: ${{ matrix.timeout }}` — matrix value
|
|
37
|
+
|
|
38
|
+
This is a long-standing feature request (actions/runner#1242, opened 2021, 500+
|
|
39
|
+
reactions) with no official resolution as of June 2026. GitHub's position is that
|
|
40
|
+
expression support in `timeout-minutes:` is not currently planned.
|
|
41
|
+
|
|
42
|
+
actionlint (>=1.6.x) correctly flags `timeout-minutes: ${{ ... }}` as a type error:
|
|
43
|
+
"type of expression at 'timeout-minutes' must be number but found string type."
|
|
44
|
+
However, some older actionlint versions or CI linting setups may not catch this,
|
|
45
|
+
allowing the invalid value to reach production where it silently uses the default.
|
|
46
|
+
|
|
47
|
+
Common motivation for wanting dynamic timeouts:
|
|
48
|
+
- Different environments (staging vs production) need different timeouts.
|
|
49
|
+
- A reusable workflow caller wants to pass a timeout as an input.
|
|
50
|
+
- Matrix jobs with different test suites need proportional timeouts.
|
|
51
|
+
- Organizational policies want to set timeouts via repository variables.
|
|
52
|
+
fix: |
|
|
53
|
+
There is no native fix — expressions in `timeout-minutes:` are not supported.
|
|
54
|
+
|
|
55
|
+
**Workaround 1 — Hardcode multiple job variants with `if:` conditions:**
|
|
56
|
+
Create separate job definitions for each timeout scenario, each with a hardcoded
|
|
57
|
+
`timeout-minutes:` and an `if:` condition that selects the appropriate one based
|
|
58
|
+
on the context. This is verbose but fully supported.
|
|
59
|
+
|
|
60
|
+
**Workaround 2 — Use a wrapper script with a timeout command:**
|
|
61
|
+
Instead of relying on job-level timeout, implement a timeout inside the step's run
|
|
62
|
+
script using the OS `timeout` command (Linux/macOS) or PowerShell's `Wait-Process`:
|
|
63
|
+
```yaml
|
|
64
|
+
steps:
|
|
65
|
+
- name: Build with dynamic timeout
|
|
66
|
+
env:
|
|
67
|
+
BUILD_TIMEOUT: ${{ vars.BUILD_TIMEOUT_SECONDS || '1800' }}
|
|
68
|
+
run: timeout "$BUILD_TIMEOUT" ./build.sh
|
|
69
|
+
```
|
|
70
|
+
This gives dynamic timeout behavior but does NOT release the runner immediately —
|
|
71
|
+
the job continues running (idle) after the script times out until the job-level
|
|
72
|
+
`timeout-minutes:` (hardcoded) fires.
|
|
73
|
+
|
|
74
|
+
**Workaround 3 — Hardcode a generous upper bound:**
|
|
75
|
+
Set `timeout-minutes:` to the maximum acceptable value for any scenario, and rely
|
|
76
|
+
on internal script logic to exit early when needed. Ensures the runner is eventually
|
|
77
|
+
released even in worst-case scenarios.
|
|
78
|
+
|
|
79
|
+
**Workaround 4 — For reusable workflows, hardcode per caller:**
|
|
80
|
+
If a reusable workflow needs caller-specified timeouts, duplicate the job definition
|
|
81
|
+
per timeout tier or create multiple reusable workflows with different timeouts.
|
|
82
|
+
fix_code:
|
|
83
|
+
- language: yaml
|
|
84
|
+
label: 'Does NOT work — expression in timeout-minutes is not supported'
|
|
85
|
+
code: |
|
|
86
|
+
jobs:
|
|
87
|
+
build:
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
timeout-minutes: ${{ vars.JOB_TIMEOUT_MINUTES }} # ❌ Expression not supported
|
|
90
|
+
steps:
|
|
91
|
+
- run: ./build.sh
|
|
92
|
+
|
|
93
|
+
# Also does NOT work:
|
|
94
|
+
jobs:
|
|
95
|
+
test:
|
|
96
|
+
runs-on: ubuntu-latest
|
|
97
|
+
timeout-minutes: ${{ inputs.timeout || 30 }} # ❌ inputs not supported here
|
|
98
|
+
steps:
|
|
99
|
+
- run: ./test.sh
|
|
100
|
+
|
|
101
|
+
- language: yaml
|
|
102
|
+
label: 'Workaround — use OS timeout command inside step for dynamic behavior'
|
|
103
|
+
code: |
|
|
104
|
+
jobs:
|
|
105
|
+
build:
|
|
106
|
+
runs-on: ubuntu-latest
|
|
107
|
+
timeout-minutes: 60 # ✅ Hardcoded upper bound (releases runner if script hangs)
|
|
108
|
+
steps:
|
|
109
|
+
- uses: actions/checkout@v4
|
|
110
|
+
- name: Build with dynamic timeout from variable
|
|
111
|
+
env:
|
|
112
|
+
# Variable in seconds: default 1800 (30 min), override via repo var
|
|
113
|
+
BUILD_TIMEOUT: ${{ vars.BUILD_TIMEOUT_SECONDS || '1800' }}
|
|
114
|
+
run: timeout "$BUILD_TIMEOUT" ./build.sh
|
|
115
|
+
# The job-level 60-minute timeout catches runaway cases
|
|
116
|
+
|
|
117
|
+
- language: yaml
|
|
118
|
+
label: 'Workaround — conditional job variants for different timeout requirements'
|
|
119
|
+
code: |
|
|
120
|
+
jobs:
|
|
121
|
+
build-short:
|
|
122
|
+
if: ${{ github.event_name == 'pull_request' }}
|
|
123
|
+
runs-on: ubuntu-latest
|
|
124
|
+
timeout-minutes: 15 # ✅ Short timeout for PR checks
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@v4
|
|
127
|
+
- run: ./build.sh --quick
|
|
128
|
+
|
|
129
|
+
build-full:
|
|
130
|
+
if: ${{ github.ref == 'refs/heads/main' }}
|
|
131
|
+
runs-on: ubuntu-latest
|
|
132
|
+
timeout-minutes: 60 # ✅ Full timeout for main branch builds
|
|
133
|
+
steps:
|
|
134
|
+
- uses: actions/checkout@v4
|
|
135
|
+
- run: ./build.sh --full
|
|
136
|
+
|
|
137
|
+
- language: yaml
|
|
138
|
+
label: 'Reusable workflow workaround — hardcode tiers, expose as separate workflows'
|
|
139
|
+
code: |
|
|
140
|
+
# .github/workflows/ci-fast.yml — for PRs
|
|
141
|
+
on:
|
|
142
|
+
workflow_call:
|
|
143
|
+
jobs:
|
|
144
|
+
ci:
|
|
145
|
+
runs-on: ubuntu-latest
|
|
146
|
+
timeout-minutes: 15 # ✅ Fast tier
|
|
147
|
+
steps:
|
|
148
|
+
- run: ./ci.sh
|
|
149
|
+
|
|
150
|
+
# .github/workflows/ci-full.yml — for main branch
|
|
151
|
+
on:
|
|
152
|
+
workflow_call:
|
|
153
|
+
jobs:
|
|
154
|
+
ci:
|
|
155
|
+
runs-on: ubuntu-latest
|
|
156
|
+
timeout-minutes: 60 # ✅ Full tier
|
|
157
|
+
steps:
|
|
158
|
+
- run: ./ci.sh
|
|
159
|
+
|
|
160
|
+
prevention:
|
|
161
|
+
- 'Accept that `timeout-minutes:` requires a literal integer — design your timeouts with hardcoded values from the start.'
|
|
162
|
+
- 'Use actionlint in CI to catch `timeout-minutes: ${{ ... }}` during PR review before it reaches production.'
|
|
163
|
+
- 'Set `timeout-minutes:` to a safe upper bound to ensure runners are eventually released even when scripts hang.'
|
|
164
|
+
- 'Use step-level `run: timeout N command` for dynamic per-step timeouts without changing job-level `timeout-minutes:`.'
|
|
165
|
+
- 'Upvote actions/runner#1242 — expression support in timeout-minutes is a high-demand feature request.'
|
|
166
|
+
docs:
|
|
167
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes'
|
|
168
|
+
label: 'GitHub Docs: Workflow syntax — jobs.<id>.timeout-minutes (literal integer only)'
|
|
169
|
+
- url: 'https://github.com/actions/runner/issues/1242'
|
|
170
|
+
label: 'actions/runner#1242: Support expressions in timeout-minutes (500+ reactions, open since 2021)'
|
|
171
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepstimeout-minutes'
|
|
172
|
+
label: 'GitHub Docs: Workflow syntax — jobs.<id>.steps[*].timeout-minutes'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
id: known-unsolved-074
|
|
2
|
+
title: '`hashFiles()` has a hardcoded 120-second timeout — fails silently on large repos with many files'
|
|
3
|
+
category: known-unsolved
|
|
4
|
+
severity: limitation
|
|
5
|
+
tags:
|
|
6
|
+
- hashFiles
|
|
7
|
+
- timeout
|
|
8
|
+
- cache-key
|
|
9
|
+
- large-repo
|
|
10
|
+
- actions-cache
|
|
11
|
+
- 120-seconds
|
|
12
|
+
- known-limitation
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'hashFiles\(.+\) couldn''t finish within 120 seconds'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'hashFiles.*couldn''t finish'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'The template is not valid.*hashFiles.*couldn''t finish'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- "Error: The template is not valid. .github/workflows/ci.yml (Line: N, Col: M): hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') couldn't finish within 120 seconds."
|
|
22
|
+
- "hashFiles('**/pom.xml') couldn't finish within 120 seconds."
|
|
23
|
+
- "hashFiles('**/Podfile.lock') couldn't finish within 120 seconds."
|
|
24
|
+
root_cause: |
|
|
25
|
+
The `hashFiles()` expression function has a **hardcoded 120-second timeout** inside the
|
|
26
|
+
GitHub Actions runner. When hashing many files — large game projects, monorepos with
|
|
27
|
+
thousands of dependencies, or deeply nested directory trees — the hash process (a separate
|
|
28
|
+
Node.js subprocess) can exceed this limit.
|
|
29
|
+
|
|
30
|
+
The timeout is set in the runner source:
|
|
31
|
+
`src/Runner.Worker/Expressions/HashFilesFunction.cs` as a fixed constant. There is no
|
|
32
|
+
workflow-level configuration to extend or remove it. Pull Request actions/runner#1844,
|
|
33
|
+
opened in April 2022 to make the timeout configurable, has remained unmerged for years.
|
|
34
|
+
|
|
35
|
+
**Common triggers:**
|
|
36
|
+
- Unity game projects hashing `Assets/**`, `Packages/**`, `ProjectSettings/**` (tens of
|
|
37
|
+
thousands of binary and text assets).
|
|
38
|
+
- Java monorepos hashing `**/pom.xml` across hundreds of modules.
|
|
39
|
+
- iOS projects hashing `**/Podfile.lock` where CocoaPods creates large lock files.
|
|
40
|
+
- `actions/cache` Post step re-computing `hashFiles()` at the **end** of the job (to
|
|
41
|
+
check whether the cache should be saved), by which point many new files may exist in
|
|
42
|
+
the hashed path, making the second hash slower than the first.
|
|
43
|
+
- Self-hosted Windows runners, which tend to have slower file system scan speeds than
|
|
44
|
+
Linux GitHub-hosted runners for large directory trees.
|
|
45
|
+
|
|
46
|
+
The error surfaces as a YAML template evaluation failure before any step runs, so the
|
|
47
|
+
entire job fails immediately with no useful diagnostic beyond the 120-second error.
|
|
48
|
+
|
|
49
|
+
Related: actions/runner#1840 (12 reactions, open since April 2022, last active May 2024).
|
|
50
|
+
fix: |
|
|
51
|
+
There is **no native fix** — `hashFiles()` timeout is hardcoded and cannot be increased
|
|
52
|
+
in workflow YAML.
|
|
53
|
+
|
|
54
|
+
**Workaround 1 — Pre-compute the hash in a shell step:**
|
|
55
|
+
Run a shell command to hash the target files and write the result to a single file, then
|
|
56
|
+
use `hashFiles()` on only that single file. The shell command runs without the 120-second
|
|
57
|
+
runner constraint, and hashing one file is nearly instant.
|
|
58
|
+
|
|
59
|
+
**Workaround 2 — Use the last Git commit hash for the target directory:**
|
|
60
|
+
`git log` is much faster than file-content hashing. If cache invalidation on the last
|
|
61
|
+
commit touching the directory is acceptable (rather than on file-content change), use
|
|
62
|
+
`git log -1 --pretty=format:%H -- <directory>` to generate the key.
|
|
63
|
+
|
|
64
|
+
**Workaround 3 — Narrow the glob pattern:**
|
|
65
|
+
Instead of `Assets/**`, narrow to a specific file type:
|
|
66
|
+
`Assets/**/*.meta` or `Assets/**/*.asset`. Fewer files = faster hash = less chance of
|
|
67
|
+
timeout. Acceptable when only certain file types are meaningful for cache invalidation.
|
|
68
|
+
|
|
69
|
+
**Workaround 4 — Reduce what goes in the hashed path:**
|
|
70
|
+
If you control the directory structure, move frequently-updated large binary files out
|
|
71
|
+
of the hashed path so the glob matches fewer files.
|
|
72
|
+
fix_code:
|
|
73
|
+
- language: yaml
|
|
74
|
+
label: 'Broken — hashFiles() times out on large directory tree'
|
|
75
|
+
code: |
|
|
76
|
+
- uses: actions/cache@v4
|
|
77
|
+
with:
|
|
78
|
+
path: Library
|
|
79
|
+
# ✗ hashFiles() will time out if Assets/**, Packages/**, ProjectSettings/**
|
|
80
|
+
# contains thousands of files (e.g. large Unity project)
|
|
81
|
+
key: Library-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
|
|
82
|
+
|
|
83
|
+
- language: yaml
|
|
84
|
+
label: 'Fixed — pre-compute hash in a shell step, then hashFiles() on single file'
|
|
85
|
+
code: |
|
|
86
|
+
- name: Generate cache key from directory contents
|
|
87
|
+
run: |
|
|
88
|
+
find Assets Packages ProjectSettings \
|
|
89
|
+
-type f | sort | xargs sha256sum > /tmp/unity-hash-input.txt
|
|
90
|
+
# On Windows self-hosted runner, use PowerShell instead:
|
|
91
|
+
# Get-ChildItem -Recurse Assets,Packages,ProjectSettings |
|
|
92
|
+
# Where-Object { !$_.PSIsContainer } |
|
|
93
|
+
# Sort-Object FullName |
|
|
94
|
+
# ForEach-Object { sha256sum $_.FullName } |
|
|
95
|
+
# Out-File /tmp/unity-hash-input.txt -Encoding utf8
|
|
96
|
+
|
|
97
|
+
- uses: actions/cache@v4
|
|
98
|
+
with:
|
|
99
|
+
path: Library
|
|
100
|
+
# ✓ hashFiles() on a single pre-computed file is nearly instant
|
|
101
|
+
key: Library-${{ hashFiles('/tmp/unity-hash-input.txt') }}
|
|
102
|
+
|
|
103
|
+
- language: yaml
|
|
104
|
+
label: 'Alternative — use last Git commit hash touching the cached directory'
|
|
105
|
+
code: |
|
|
106
|
+
- name: Get cache key from last commit touching packages
|
|
107
|
+
run: |
|
|
108
|
+
echo "PKG_HASH=$(git log -1 --pretty=format:%H -- packages/)" >> $GITHUB_ENV
|
|
109
|
+
|
|
110
|
+
- uses: actions/cache@v4
|
|
111
|
+
with:
|
|
112
|
+
path: ~/.m2/repository
|
|
113
|
+
# ✓ git log is instant regardless of how many files are in packages/
|
|
114
|
+
# Note: invalidates on commit, not on file-content change
|
|
115
|
+
key: maven-${{ runner.os }}-${{ env.PKG_HASH }}
|
|
116
|
+
|
|
117
|
+
- language: yaml
|
|
118
|
+
label: 'Alternative — narrow glob to only meaningful file types'
|
|
119
|
+
code: |
|
|
120
|
+
- uses: actions/cache@v4
|
|
121
|
+
with:
|
|
122
|
+
path: Library
|
|
123
|
+
# ✓ Narrow glob: only hash .meta files instead of all assets
|
|
124
|
+
# Fewer matched files = faster hash = less risk of timeout
|
|
125
|
+
key: Library-${{ hashFiles('Assets/**/*.meta', 'Packages/**/package.json') }}
|
|
126
|
+
|
|
127
|
+
prevention:
|
|
128
|
+
- 'For any project with more than ~10,000 files in the hashed path, pre-compute the hash in a shell step instead of using `hashFiles()` directly in the cache key expression.'
|
|
129
|
+
- 'Test `hashFiles()` locally by adding a step that logs the evaluation time: the timeout occurs during expression evaluation, before the step executes.'
|
|
130
|
+
- 'On Windows self-hosted runners, file system scan is slower — lower the threshold for when to switch to a pre-computed hash (≥5,000 files).'
|
|
131
|
+
- 'Watch the `actions/cache` Post step — it re-evaluates `hashFiles()` at job end to decide whether to save. If you add files to the hashed path during the job, the Post step can time out even when the Pre step succeeded.'
|
|
132
|
+
- 'Upvote actions/runner#1844 — the PR to make the timeout configurable. No merge timeline as of June 2026.'
|
|
133
|
+
docs:
|
|
134
|
+
- url: 'https://github.com/actions/runner/issues/1840'
|
|
135
|
+
label: 'actions/runner#1840: hashFiles() couldn''t finish within 120 seconds (12 reactions, open since 2022)'
|
|
136
|
+
- url: 'https://github.com/actions/runner/pull/1844'
|
|
137
|
+
label: 'actions/runner#1844: PR to make hashFiles() timeout configurable (unmerged)'
|
|
138
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#hashfiles'
|
|
139
|
+
label: 'GitHub Docs: hashFiles() expression function'
|
|
140
|
+
- url: 'https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows'
|
|
141
|
+
label: 'GitHub Docs: Caching dependencies to speed up workflows'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
id: runner-environment-236
|
|
2
|
+
title: '`ubuntu-slim` runner has Docker CLI but no Docker daemon — docker build/pull and Dockerfile container actions fail'
|
|
3
|
+
category: runner-environment
|
|
4
|
+
severity: error
|
|
5
|
+
tags:
|
|
6
|
+
- ubuntu-slim
|
|
7
|
+
- docker
|
|
8
|
+
- docker-daemon
|
|
9
|
+
- container-action
|
|
10
|
+
- docker-build
|
|
11
|
+
- dockerfile
|
|
12
|
+
- lightweight-runner
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'Cannot connect to the Docker daemon at unix:///var/run/docker\.sock'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'docker\.sock.*no such file or directory'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'ERROR: Cannot connect to the Docker daemon'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
- regex: 'ubuntu-slim.*docker|docker.*ubuntu-slim'
|
|
21
|
+
flags: 'i'
|
|
22
|
+
error_messages:
|
|
23
|
+
- "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"
|
|
24
|
+
- "ERROR: Cannot connect to the Docker daemon at unix:///var/run/docker.sock: connect: no such file or directory"
|
|
25
|
+
- "Docker build failed with exit code 1, back off 3.588 seconds before retry."
|
|
26
|
+
- "failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory"
|
|
27
|
+
- "/usr/bin/docker build ... ERROR: Cannot connect to the Docker daemon"
|
|
28
|
+
root_cause: |
|
|
29
|
+
The `ubuntu-slim` GitHub-hosted runner image (added January 2026 via runner-images
|
|
30
|
+
PR#13542) is a **lightweight Ubuntu image intentionally stripped of the Docker daemon**.
|
|
31
|
+
It is designed for fast, low-overhead CI jobs (linting, unit tests, scripting) that
|
|
32
|
+
do not need container tooling.
|
|
33
|
+
|
|
34
|
+
**What ubuntu-slim DOES have:**
|
|
35
|
+
- Docker CLI (`docker` command is on `PATH`)
|
|
36
|
+
- Standard shell tooling, git, curl, Node.js, Python
|
|
37
|
+
|
|
38
|
+
**What ubuntu-slim does NOT have:**
|
|
39
|
+
- Docker daemon (no `/var/run/docker.sock` socket)
|
|
40
|
+
- Docker daemon service (not running — not even stopped, just not installed)
|
|
41
|
+
- BuildKit / buildx (requires daemon)
|
|
42
|
+
|
|
43
|
+
This affects several common patterns:
|
|
44
|
+
1. **`docker build` / `docker run` / `docker pull` in `run:` steps** — All fail
|
|
45
|
+
immediately because there is no daemon to dispatch commands to.
|
|
46
|
+
2. **Container actions** (`uses: some-org/docker-action@v1` where the action has a
|
|
47
|
+
`Dockerfile`) — The Actions runner tries to build the action's `Dockerfile` using
|
|
48
|
+
the host Docker daemon before executing the step. On `ubuntu-slim`, this fails during
|
|
49
|
+
job setup with "Cannot connect to the Docker daemon."
|
|
50
|
+
3. **`docker/setup-buildx-action`** — Fails because BuildKit requires a Docker daemon.
|
|
51
|
+
4. **Service containers** (`services:` block using Docker images) — Fail because
|
|
52
|
+
pulling and starting service containers requires the Docker daemon.
|
|
53
|
+
|
|
54
|
+
GitHub has closed requests to add Docker daemon to ubuntu-slim as "not_planned"
|
|
55
|
+
(runner-images#13583 Feb 2026), confirming this is **by design**.
|
|
56
|
+
|
|
57
|
+
This is distinct from `runner-environment-030` (ubuntu-arm64-docker-not-preinstalled),
|
|
58
|
+
which covers ARM64 partner runners where Docker CLI itself is missing. On ubuntu-slim,
|
|
59
|
+
Docker CLI IS present — only the daemon is absent.
|
|
60
|
+
fix: |
|
|
61
|
+
**Option 1 (Recommended) — Switch to `ubuntu-latest` or `ubuntu-24.04`:**
|
|
62
|
+
If your workflow uses Docker in any form (docker build, container actions, service
|
|
63
|
+
containers), use a full Ubuntu image. Docker daemon is pre-installed and running
|
|
64
|
+
on `ubuntu-latest` and `ubuntu-24.04`.
|
|
65
|
+
|
|
66
|
+
**Option 2 — Conditionally skip Docker-dependent steps:**
|
|
67
|
+
If you want to use ubuntu-slim for most of a workflow but have one Docker step, you
|
|
68
|
+
cannot run that step on ubuntu-slim. Restructure into two jobs: one on ubuntu-slim for
|
|
69
|
+
fast non-Docker steps, one on ubuntu-latest for Docker steps.
|
|
70
|
+
|
|
71
|
+
**Option 3 — Use a Docker-in-Docker container (advanced, not recommended):**
|
|
72
|
+
You can run a `dind` (Docker-in-Docker) container as a service and set `DOCKER_HOST`
|
|
73
|
+
to point to it, but this is complex, slow, and defeats the purpose of ubuntu-slim.
|
|
74
|
+
|
|
75
|
+
**Option 4 — Replace Dockerfile container actions with JS/composite alternatives:**
|
|
76
|
+
Some Docker-based third-party actions have JavaScript equivalents that don't require
|
|
77
|
+
a daemon. Check if the action you're using has a non-Docker version.
|
|
78
|
+
fix_code:
|
|
79
|
+
- language: yaml
|
|
80
|
+
label: 'Broken — docker commands and container actions fail on ubuntu-slim'
|
|
81
|
+
code: |
|
|
82
|
+
jobs:
|
|
83
|
+
build:
|
|
84
|
+
runs-on: ubuntu-slim # ✗ No Docker daemon available
|
|
85
|
+
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v4
|
|
88
|
+
|
|
89
|
+
# ✗ Fails: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock"
|
|
90
|
+
- name: Build Docker image
|
|
91
|
+
run: docker build -t myapp:latest .
|
|
92
|
+
|
|
93
|
+
# ✗ Also fails: action uses Dockerfile, requires Docker daemon to build
|
|
94
|
+
- name: Run Docker-based action
|
|
95
|
+
uses: some-org/docker-based-action@v2
|
|
96
|
+
|
|
97
|
+
- language: yaml
|
|
98
|
+
label: 'Fixed — use ubuntu-latest for any workflow that needs Docker'
|
|
99
|
+
code: |
|
|
100
|
+
jobs:
|
|
101
|
+
build:
|
|
102
|
+
runs-on: ubuntu-latest # ✓ Docker daemon pre-installed and running
|
|
103
|
+
|
|
104
|
+
steps:
|
|
105
|
+
- uses: actions/checkout@v4
|
|
106
|
+
|
|
107
|
+
# ✓ Works: Docker daemon available on ubuntu-latest
|
|
108
|
+
- name: Build Docker image
|
|
109
|
+
run: docker build -t myapp:latest .
|
|
110
|
+
|
|
111
|
+
# ✓ Works: container actions can build their Dockerfiles
|
|
112
|
+
- name: Run Docker-based action
|
|
113
|
+
uses: some-org/docker-based-action@v2
|
|
114
|
+
|
|
115
|
+
- language: yaml
|
|
116
|
+
label: 'Pattern — split jobs to use ubuntu-slim for fast steps, ubuntu-latest for Docker'
|
|
117
|
+
code: |
|
|
118
|
+
jobs:
|
|
119
|
+
lint:
|
|
120
|
+
runs-on: ubuntu-slim # ✓ Fast, no Docker needed for lint
|
|
121
|
+
steps:
|
|
122
|
+
- uses: actions/checkout@v4
|
|
123
|
+
- run: npm run lint
|
|
124
|
+
|
|
125
|
+
docker-build:
|
|
126
|
+
runs-on: ubuntu-latest # ✓ Docker available for build/push steps
|
|
127
|
+
needs: lint
|
|
128
|
+
steps:
|
|
129
|
+
- uses: actions/checkout@v4
|
|
130
|
+
- run: docker build -t myapp:latest .
|
|
131
|
+
- run: docker push myregistry/myapp:latest
|
|
132
|
+
|
|
133
|
+
prevention:
|
|
134
|
+
- 'Before switching to `ubuntu-slim`, audit your workflow for `docker` commands, Dockerfile-based `uses:` steps, and `services:` blocks — any of these require a Docker daemon.'
|
|
135
|
+
- 'Container actions (actions with a `Dockerfile`) silently fail during job setup on ubuntu-slim, making the error appear before any step runs — check the "Set up job" section of the logs.'
|
|
136
|
+
- 'Check the ubuntu-slim software list at https://github.com/actions/runner-images to confirm Docker daemon is not present before migrating workflows.'
|
|
137
|
+
- '`service containers` defined in the `services:` block also require the Docker daemon — they will also fail on ubuntu-slim.'
|
|
138
|
+
docs:
|
|
139
|
+
- url: 'https://github.com/actions/runner-images/issues/13583'
|
|
140
|
+
label: 'runner-images#13583: Docker pull failed on ubuntu-slim (closed as not_planned — by design)'
|
|
141
|
+
- url: 'https://github.com/actions/runner-images/pull/13542'
|
|
142
|
+
label: 'runner-images PR#13542: Add ubuntu-slim image (Jan 2026)'
|
|
143
|
+
- url: 'https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners'
|
|
144
|
+
label: 'GitHub Docs: About GitHub-hosted runners'
|
|
145
|
+
- url: 'https://docs.github.com/en/actions/creating-actions/creating-a-docker-container-action'
|
|
146
|
+
label: 'GitHub Docs: Creating a Docker container action'
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
id: triggers-072
|
|
2
|
+
title: 'Scheduled workflow silently disabled after 60 days of public repository inactivity — distinct from the fork schedule disabling rule'
|
|
3
|
+
category: triggers
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- schedule
|
|
7
|
+
- cron
|
|
8
|
+
- disabled
|
|
9
|
+
- inactivity
|
|
10
|
+
- public-repo
|
|
11
|
+
- silent-disable
|
|
12
|
+
- 60-days
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'on:\s*\n\s+schedule:'
|
|
15
|
+
flags: 'im'
|
|
16
|
+
- regex: 'This workflow was disabled'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'Workflow.*disabled.*inactiv'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- "This workflow was disabled."
|
|
22
|
+
- "Scheduled workflows are disabled for repositories with no recent activity."
|
|
23
|
+
root_cause: |
|
|
24
|
+
GitHub automatically disables scheduled (`on: schedule:`) workflows in **public
|
|
25
|
+
repositories** that have had no activity for more than **60 days**. "Activity"
|
|
26
|
+
includes pushes, pull requests, issue comments, or other events that generate
|
|
27
|
+
a repository event payload. Pure time-passing without any interaction does not
|
|
28
|
+
reset the timer.
|
|
29
|
+
|
|
30
|
+
The disabling happens silently:
|
|
31
|
+
- No email notification is sent to repository owners or workflow authors.
|
|
32
|
+
- No GitHub notification appears in the repository's notification feed.
|
|
33
|
+
- The scheduled run simply does not appear in the Actions run history.
|
|
34
|
+
- The workflow file is not changed — the `on: schedule:` trigger is still present
|
|
35
|
+
in the YAML, but the schedule is inactive in GitHub's internal scheduler.
|
|
36
|
+
|
|
37
|
+
The Actions tab will show the workflow listed but with a note that it has been
|
|
38
|
+
disabled. The **"Enable workflow"** button must be clicked manually to re-activate it.
|
|
39
|
+
|
|
40
|
+
This behavior is separate from the fork schedule-disabling rule (where ALL
|
|
41
|
+
scheduled workflows are disabled on forked repos regardless of activity). The
|
|
42
|
+
inactivity rule applies to non-fork public repositories.
|
|
43
|
+
|
|
44
|
+
Common scenarios:
|
|
45
|
+
- Maintenance scripts (e.g., weekly dependency updates, cleanup jobs) in repos
|
|
46
|
+
that receive no push activity between runs.
|
|
47
|
+
- Documentation or static-site repos where content is rarely updated but a nightly
|
|
48
|
+
build/deploy workflow is expected to run.
|
|
49
|
+
- Monitoring or alerting workflows in repos with no other CI triggers.
|
|
50
|
+
- Open-source libraries with stable codebases and monthly or quarterly releases.
|
|
51
|
+
fix: |
|
|
52
|
+
**Immediate fix:** Navigate to the repository → Actions tab → find the disabled
|
|
53
|
+
workflow → click "Enable workflow."
|
|
54
|
+
|
|
55
|
+
**Prevent future silent disabling — Option 1 (Recommended): Add a dummy commit
|
|
56
|
+
or workflow_dispatch trigger:**
|
|
57
|
+
Add `workflow_dispatch:` to the workflow so it can be triggered manually and also
|
|
58
|
+
resets the inactivity timer. Then use `gh workflow run` or the UI to manually
|
|
59
|
+
trigger it if needed.
|
|
60
|
+
|
|
61
|
+
**Prevent future silent disabling — Option 2: Artificially keep the repo active:**
|
|
62
|
+
Add a companion workflow that runs on schedule and updates a file (e.g., a
|
|
63
|
+
`last-run.txt` with the current timestamp) via a commit. This creates repository
|
|
64
|
+
activity and resets the 60-day timer. Warning: this generates commit noise.
|
|
65
|
+
|
|
66
|
+
**Prevent future silent disabling — Option 3: Convert to a self-hosted or
|
|
67
|
+
external scheduler:**
|
|
68
|
+
Use an external cron service (e.g., a cron job in a cloud provider) to trigger
|
|
69
|
+
the workflow via `workflow_dispatch` or `repository_dispatch`. External triggers
|
|
70
|
+
count as activity and keep the workflow enabled.
|
|
71
|
+
|
|
72
|
+
**Long-term design:** For workflows that are truly meant to run on a schedule
|
|
73
|
+
without any other repo activity, always add `workflow_dispatch:` as a co-trigger.
|
|
74
|
+
This makes the workflow manually invocable AND provides a path to re-enable it if
|
|
75
|
+
it is ever silently disabled.
|
|
76
|
+
fix_code:
|
|
77
|
+
- language: yaml
|
|
78
|
+
label: 'At-risk — schedule-only workflow in a low-activity public repo'
|
|
79
|
+
code: |
|
|
80
|
+
# ⚠ This workflow will be silently disabled after 60 days with no repo activity
|
|
81
|
+
name: Weekly Dependency Update
|
|
82
|
+
|
|
83
|
+
on:
|
|
84
|
+
schedule:
|
|
85
|
+
- cron: '0 9 * * 1' # Every Monday at 09:00 UTC
|
|
86
|
+
|
|
87
|
+
jobs:
|
|
88
|
+
update:
|
|
89
|
+
runs-on: ubuntu-latest
|
|
90
|
+
steps:
|
|
91
|
+
- uses: actions/checkout@v4
|
|
92
|
+
- run: npm update && npm audit fix
|
|
93
|
+
|
|
94
|
+
- language: yaml
|
|
95
|
+
label: 'Fixed — add workflow_dispatch to keep the workflow manageable and prevent inactivity disable'
|
|
96
|
+
code: |
|
|
97
|
+
name: Weekly Dependency Update
|
|
98
|
+
|
|
99
|
+
on:
|
|
100
|
+
schedule:
|
|
101
|
+
- cron: '0 9 * * 1' # Every Monday at 09:00 UTC
|
|
102
|
+
workflow_dispatch: # ✅ Allows manual trigger; manual runs also reset the inactivity timer
|
|
103
|
+
|
|
104
|
+
jobs:
|
|
105
|
+
update:
|
|
106
|
+
runs-on: ubuntu-latest
|
|
107
|
+
steps:
|
|
108
|
+
- uses: actions/checkout@v4
|
|
109
|
+
- run: npm update && npm audit fix
|
|
110
|
+
|
|
111
|
+
- language: yaml
|
|
112
|
+
label: 'Alternative — heartbeat workflow that commits a timestamp to keep the repo active'
|
|
113
|
+
code: |
|
|
114
|
+
name: Activity Heartbeat (prevents schedule auto-disable)
|
|
115
|
+
|
|
116
|
+
on:
|
|
117
|
+
schedule:
|
|
118
|
+
- cron: '0 0 * * 0' # Every Sunday midnight UTC — reset 60-day timer
|
|
119
|
+
workflow_dispatch:
|
|
120
|
+
|
|
121
|
+
permissions:
|
|
122
|
+
contents: write
|
|
123
|
+
|
|
124
|
+
jobs:
|
|
125
|
+
heartbeat:
|
|
126
|
+
runs-on: ubuntu-latest
|
|
127
|
+
steps:
|
|
128
|
+
- uses: actions/checkout@v4
|
|
129
|
+
- name: Update heartbeat timestamp
|
|
130
|
+
run: |
|
|
131
|
+
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .github/heartbeat.txt
|
|
132
|
+
git config user.name 'github-actions[bot]'
|
|
133
|
+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
|
134
|
+
git add .github/heartbeat.txt
|
|
135
|
+
git diff --staged --quiet || git commit -m 'chore: activity heartbeat [skip ci]'
|
|
136
|
+
git push
|
|
137
|
+
|
|
138
|
+
prevention:
|
|
139
|
+
- 'Always add `workflow_dispatch:` alongside `on: schedule:` for any workflow that should run reliably in a low-activity repo.'
|
|
140
|
+
- 'Check the Actions tab after a period of low repository activity to verify scheduled workflows are still enabled.'
|
|
141
|
+
- 'The 60-day inactivity rule applies to non-fork public repositories — private repos are NOT subject to this rule.'
|
|
142
|
+
- 'This is distinct from the fork schedule-disabling rule: forks disable schedules immediately on fork creation, not after inactivity.'
|
|
143
|
+
- 'Set up a GitHub Actions alert or monitoring workflow that notifies you if expected scheduled runs stop appearing.'
|
|
144
|
+
docs:
|
|
145
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule'
|
|
146
|
+
label: 'GitHub Docs: Events that trigger workflows — schedule (inactivity disable note)'
|
|
147
|
+
- url: 'https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/disabling-and-enabling-a-workflow'
|
|
148
|
+
label: 'GitHub Docs: Disabling and enabling a workflow'
|
|
149
|
+
- url: 'https://github.com/orgs/community/discussions/134086'
|
|
150
|
+
label: 'GitHub Community #134086: Scheduled workflows not running — inactivity disable reports'
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
id: triggers-073
|
|
2
|
+
title: '`on: workflow_run: branches:` filter matches head_branch of triggering run — PR merge triggers are silently skipped when head_branch is the PR branch, not the merge target'
|
|
3
|
+
category: triggers
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- workflow_run
|
|
7
|
+
- branches-filter
|
|
8
|
+
- head-branch
|
|
9
|
+
- pr-merge
|
|
10
|
+
- silent-skip
|
|
11
|
+
- trigger-not-firing
|
|
12
|
+
- deploy-workflow
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'workflow_run:[\s\S]{0,200}branches:'
|
|
15
|
+
flags: 'im'
|
|
16
|
+
- regex: 'github\.event\.workflow_run\.head_branch'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'workflow_run.*branches.*\[.*main.*\]'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- "workflow not triggering after PR merge"
|
|
22
|
+
- "workflow_run with branches filter not firing on merge to main"
|
|
23
|
+
root_cause: |
|
|
24
|
+
The `branches:` filter in an `on: workflow_run:` trigger compares against
|
|
25
|
+
`github.event.workflow_run.head_branch` — the branch the **source workflow** ran on.
|
|
26
|
+
|
|
27
|
+
When a pull request is opened, the source workflow (e.g., CI) runs with
|
|
28
|
+
`head_branch` = the feature branch name (e.g., `feat/my-change`). When GitHub
|
|
29
|
+
handles the PR merge, it creates a push to `main`, but it **reuses the PR's existing
|
|
30
|
+
check suite** rather than creating a new check run for the merge commit. As a result,
|
|
31
|
+
the `workflow_run` event that fires after the merge has:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
github.event.workflow_run.head_branch = "feat/my-change" # PR branch, not "main"
|
|
35
|
+
github.event.workflow_run.head_sha = "<merge-commit-sha>"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If a downstream workflow has `branches: [main]`, this filter is evaluated against
|
|
39
|
+
`head_branch` = `"feat/my-change"`. The filter does NOT match — the downstream
|
|
40
|
+
workflow **silently never runs** after the PR merge.
|
|
41
|
+
|
|
42
|
+
This is counterintuitive because developers expect `branches: [main]` to mean
|
|
43
|
+
"trigger my deployment workflow only when CI ran on the main branch," not "only
|
|
44
|
+
when the source workflow's head_branch exactly equals main." The distinction matters
|
|
45
|
+
specifically for PR merges, where the push IS to main but the check run is attributed
|
|
46
|
+
to the PR branch.
|
|
47
|
+
|
|
48
|
+
**Who is affected:**
|
|
49
|
+
- Any deploy-on-merge pattern using `workflow_run` with `branches: [main]` or
|
|
50
|
+
`branches: [master]`
|
|
51
|
+
- Workflows that use `workflow_run` to chain CI → deploy only for the default branch
|
|
52
|
+
|
|
53
|
+
**Note:** Workflows triggered by a direct push to `main` (not a PR merge) DO have
|
|
54
|
+
`head_branch = "main"` and ARE correctly triggered by the `branches: [main]` filter.
|
|
55
|
+
The problem is specific to PR merges that go through the PR check-suite flow.
|
|
56
|
+
|
|
57
|
+
Source: observed in production deployments (commit Skretzo/shortest-path@8254fb4),
|
|
58
|
+
GitHub docs issue github/docs#42813 (Feb 2026).
|
|
59
|
+
fix: |
|
|
60
|
+
**Remove `branches:` from the `workflow_run` trigger** and instead enforce the branch
|
|
61
|
+
restriction in a **job-level `if:` condition** that explicitly checks
|
|
62
|
+
`github.event.workflow_run.head_branch`:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
on:
|
|
66
|
+
workflow_run:
|
|
67
|
+
workflows: ["CI"]
|
|
68
|
+
types: [completed]
|
|
69
|
+
# ✗ Remove: branches: [main]
|
|
70
|
+
|
|
71
|
+
jobs:
|
|
72
|
+
deploy:
|
|
73
|
+
# ✓ Check head_branch here instead — this sees the actual branch
|
|
74
|
+
if: >-
|
|
75
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
76
|
+
github.event.workflow_run.head_branch == 'main'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This approach works for both direct pushes to main AND PR merges to main,
|
|
80
|
+
because the `if:` condition is evaluated at job execution time with the full
|
|
81
|
+
`workflow_run` event payload, while the `branches:` filter is evaluated at
|
|
82
|
+
trigger time and uses different matching semantics.
|
|
83
|
+
|
|
84
|
+
Alternatively, use the `workflow_run` trigger WITHOUT a branches filter and
|
|
85
|
+
rely entirely on job conditions to control which branches proceed to deployment.
|
|
86
|
+
fix_code:
|
|
87
|
+
- language: yaml
|
|
88
|
+
label: 'Broken — branches: [main] silently blocks PR merge triggers'
|
|
89
|
+
code: |
|
|
90
|
+
# .github/workflows/deploy.yml
|
|
91
|
+
on:
|
|
92
|
+
workflow_run:
|
|
93
|
+
workflows: ["CI"]
|
|
94
|
+
types: [completed]
|
|
95
|
+
branches: [main] # ✗ Matches head_branch of triggering run
|
|
96
|
+
# PR merges have head_branch = PR branch name
|
|
97
|
+
# → deploy never fires after PR merges to main
|
|
98
|
+
|
|
99
|
+
jobs:
|
|
100
|
+
deploy:
|
|
101
|
+
runs-on: ubuntu-latest
|
|
102
|
+
if: github.event.workflow_run.conclusion == 'success'
|
|
103
|
+
steps:
|
|
104
|
+
- run: ./deploy.sh
|
|
105
|
+
|
|
106
|
+
- language: yaml
|
|
107
|
+
label: 'Fixed — remove branches filter; check head_branch in job condition'
|
|
108
|
+
code: |
|
|
109
|
+
# .github/workflows/deploy.yml
|
|
110
|
+
on:
|
|
111
|
+
workflow_run:
|
|
112
|
+
workflows: ["CI"]
|
|
113
|
+
types: [completed]
|
|
114
|
+
# ✓ No branches filter — let all workflow_run events through
|
|
115
|
+
|
|
116
|
+
jobs:
|
|
117
|
+
deploy:
|
|
118
|
+
runs-on: ubuntu-latest
|
|
119
|
+
# ✓ Check head_branch in the if: condition
|
|
120
|
+
# head_branch == 'main' correctly identifies merges to main
|
|
121
|
+
if: >-
|
|
122
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
123
|
+
github.event.workflow_run.head_branch == 'main'
|
|
124
|
+
steps:
|
|
125
|
+
- uses: actions/checkout@v4
|
|
126
|
+
with:
|
|
127
|
+
ref: ${{ github.event.workflow_run.head_sha }}
|
|
128
|
+
- run: ./deploy.sh
|
|
129
|
+
|
|
130
|
+
- language: yaml
|
|
131
|
+
label: 'Pattern — full deploy-on-merge via workflow_run with branch guard'
|
|
132
|
+
code: |
|
|
133
|
+
# .github/workflows/deploy.yml
|
|
134
|
+
# Runs after CI completes successfully on any branch,
|
|
135
|
+
# but only deploys when it ran on main (direct push OR PR merge)
|
|
136
|
+
name: Deploy
|
|
137
|
+
|
|
138
|
+
on:
|
|
139
|
+
workflow_run:
|
|
140
|
+
workflows: ["CI"]
|
|
141
|
+
types: [completed]
|
|
142
|
+
|
|
143
|
+
jobs:
|
|
144
|
+
deploy:
|
|
145
|
+
name: Deploy to production
|
|
146
|
+
runs-on: ubuntu-latest
|
|
147
|
+
if: >-
|
|
148
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
149
|
+
github.event.workflow_run.head_branch == 'main'
|
|
150
|
+
|
|
151
|
+
steps:
|
|
152
|
+
- uses: actions/checkout@v4
|
|
153
|
+
with:
|
|
154
|
+
# ✓ Check out the exact commit that CI ran on
|
|
155
|
+
ref: ${{ github.event.workflow_run.head_sha }}
|
|
156
|
+
|
|
157
|
+
- name: Deploy
|
|
158
|
+
run: |
|
|
159
|
+
echo "Deploying commit ${{ github.event.workflow_run.head_sha }}"
|
|
160
|
+
./deploy.sh
|
|
161
|
+
|
|
162
|
+
prevention:
|
|
163
|
+
- 'Never use `branches:` in `on: workflow_run:` if your deployment pattern relies on PR merges — always enforce branch restrictions in `jobs.<id>.if` conditions instead.'
|
|
164
|
+
- 'Test the pattern by creating a PR, merging it, and checking the Actions tab — the `workflow_run` event should appear even without a `branches` filter.'
|
|
165
|
+
- 'Use `github.event.workflow_run.head_branch` in the `if:` condition to distinguish deploy-worthy runs from feature-branch CI runs.'
|
|
166
|
+
- 'Document this behavior in your workflow comments so future contributors understand why the `branches:` filter is not used.'
|
|
167
|
+
docs:
|
|
168
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run'
|
|
169
|
+
label: 'GitHub Docs: workflow_run trigger — branches filter behavior'
|
|
170
|
+
- url: 'https://github.com/github/docs/issues/42813'
|
|
171
|
+
label: 'github/docs#42813: Clarify branches filter on workflow_run (Feb 2026)'
|
|
172
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#using-data-from-the-triggering-workflow'
|
|
173
|
+
label: 'GitHub Docs: Using data from the triggering workflow'
|
package/package.json
CHANGED