@htekdev/actions-debugger 1.0.124 → 1.0.126
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-073.yml +100 -0
- package/errors/caching-artifacts/caching-artifacts-074.yml +117 -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-071.yml +122 -0
- package/errors/known-unsolved/known-unsolved-072.yml +143 -0
- package/errors/known-unsolved/known-unsolved-073.yml +172 -0
- package/errors/permissions-auth/permissions-auth-071.yml +144 -0
- package/errors/permissions-auth/permissions-auth-072.yml +112 -0
- package/errors/permissions-auth/permissions-auth-073.yml +127 -0
- package/errors/permissions-auth/permissions-auth-074.yml +106 -0
- package/errors/permissions-auth/permissions-auth-075.yml +137 -0
- package/errors/runner-environment/runner-environment-227.yml +106 -0
- package/errors/runner-environment/runner-environment-228.yml +117 -0
- package/errors/runner-environment/runner-environment-229.yml +119 -0
- package/errors/runner-environment/runner-environment-230.yml +129 -0
- package/errors/runner-environment/runner-environment-231.yml +90 -0
- package/errors/runner-environment/runner-environment-232.yml +131 -0
- package/errors/runner-environment/runner-environment-233.yml +90 -0
- package/errors/runner-environment/runner-environment-234.yml +114 -0
- package/errors/runner-environment/runner-environment-235.yml +151 -0
- package/errors/silent-failures/silent-failures-112.yml +97 -0
- package/errors/silent-failures/silent-failures-113.yml +110 -0
- package/errors/silent-failures/silent-failures-114.yml +116 -0
- package/errors/silent-failures/silent-failures-115.yml +130 -0
- package/errors/silent-failures/silent-failures-116.yml +117 -0
- package/errors/silent-failures/silent-failures-117.yml +137 -0
- package/errors/silent-failures/silent-failures-118.yml +156 -0
- package/errors/triggers/triggers-072.yml +150 -0
- package/errors/yaml-syntax/yaml-syntax-075.yml +128 -0
- package/errors/yaml-syntax/yaml-syntax-076.yml +107 -0
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
id: silent-failures-115
|
|
2
|
+
title: 'actions/cache save exits 0 and logs "Cache saved" despite 503 backend upload failures'
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- actions/cache
|
|
7
|
+
- cache-save
|
|
8
|
+
- 503
|
|
9
|
+
- backend-error
|
|
10
|
+
- silent-success
|
|
11
|
+
- false-positive
|
|
12
|
+
- upload
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'Cache service responded with 503'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'uploadChunk .+ failed: Cache service responded with'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'Warning: Failed to save: uploadChunk .+ failed'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- 'Warning: Failed to save: uploadChunk (start: 67108864, end: 100663295) failed: Cache service responded with 503'
|
|
22
|
+
- 'Cache saved with key: Linux-<run_id>-build'
|
|
23
|
+
root_cause: |
|
|
24
|
+
When `actions/cache` (or `actions/cache/save@v4`) uploads a cache, it
|
|
25
|
+
splits the archive into chunks and uploads them in parallel using the Azure
|
|
26
|
+
SDK `BlobClient`. If the backend returns HTTP 503 errors for individual
|
|
27
|
+
chunks, the action retries once but ultimately logs the failure as a
|
|
28
|
+
`Warning:` line and continues.
|
|
29
|
+
|
|
30
|
+
The critical flaw is that the action then calls `commitCache()` to finalize
|
|
31
|
+
the cache entry regardless of whether all chunks uploaded successfully.
|
|
32
|
+
The cache entry is committed to the Actions service database even though
|
|
33
|
+
the underlying blob storage is corrupt or incomplete.
|
|
34
|
+
|
|
35
|
+
This results in a silently false "cache saved" state:
|
|
36
|
+
- The action logs `Cache saved with key: <key>` (green success)
|
|
37
|
+
- The job exits with code 0 (no failure)
|
|
38
|
+
- BUT the stored cache entry is corrupt or truncated
|
|
39
|
+
|
|
40
|
+
On the next run, `actions/cache/restore` (or the inline `actions/cache`
|
|
41
|
+
restore phase) either fails with a decompression/extraction error or
|
|
42
|
+
silently falls back to "Cache not found" — depending on the extent of
|
|
43
|
+
corruption. The developer sees a cache miss on the restore side and
|
|
44
|
+
investigates there, never realizing the root cause was a silent save failure
|
|
45
|
+
several runs earlier.
|
|
46
|
+
|
|
47
|
+
This pattern is especially harmful when:
|
|
48
|
+
- The cache backend is temporarily degraded (503 storms during peak usage)
|
|
49
|
+
- The cache key includes the run_id, making the corrupt entry unique and
|
|
50
|
+
never overwritten by a subsequent run with the same key
|
|
51
|
+
- The project has long build times that the cache was supposed to skip
|
|
52
|
+
fix: |
|
|
53
|
+
1. **Add a post-save validation step** that calls the GitHub Actions cache
|
|
54
|
+
REST API to confirm the cache entry was actually committed:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
gh api "/repos/${{ github.repository }}/actions/caches?key=${{ steps.cache.outputs.cache-primary-key }}" \
|
|
58
|
+
| jq -e '.actions_caches | length > 0' || { echo "Cache save failed — no entry in API"; exit 1; }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
2. **Use `save-always: false` (the default)** — only invoke `actions/cache`
|
|
62
|
+
for save when you know the prior job succeeded. If you use the `save`
|
|
63
|
+
sub-action with `save-always: true`, be aware that backend failures are
|
|
64
|
+
swallowed.
|
|
65
|
+
|
|
66
|
+
3. **Separate save from restore** using the `actions/cache/save` and
|
|
67
|
+
`actions/cache/restore` sub-actions so you can add explicit error
|
|
68
|
+
handling around the save step via `continue-on-error: false`.
|
|
69
|
+
|
|
70
|
+
4. **Monitor for the upstream fix** in actions/cache#1416. Once the action
|
|
71
|
+
propagates chunk upload failures to the overall exit code, the job will
|
|
72
|
+
correctly fail on corrupt saves.
|
|
73
|
+
fix_code:
|
|
74
|
+
- language: yaml
|
|
75
|
+
label: 'Validate cache was committed after save using the REST API'
|
|
76
|
+
code: |
|
|
77
|
+
- name: Cache node modules
|
|
78
|
+
id: cache
|
|
79
|
+
uses: actions/cache@v4
|
|
80
|
+
with:
|
|
81
|
+
path: ~/.npm
|
|
82
|
+
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
|
83
|
+
|
|
84
|
+
- name: Verify cache entry was committed
|
|
85
|
+
if: steps.cache.outputs.cache-hit != 'true'
|
|
86
|
+
env:
|
|
87
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
88
|
+
run: |
|
|
89
|
+
KEY="${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}"
|
|
90
|
+
COUNT=$(gh api \
|
|
91
|
+
"/repos/${{ github.repository }}/actions/caches?key=${KEY}" \
|
|
92
|
+
--jq '.actions_caches | length')
|
|
93
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
94
|
+
echo "::error::Cache save reported success but no entry found in API. Backend may have returned 503."
|
|
95
|
+
exit 1
|
|
96
|
+
fi
|
|
97
|
+
- language: yaml
|
|
98
|
+
label: 'Separate save from restore to add explicit error handling'
|
|
99
|
+
code: |
|
|
100
|
+
# In your build job:
|
|
101
|
+
- name: Restore cache
|
|
102
|
+
id: cache-restore
|
|
103
|
+
uses: actions/cache/restore@v4
|
|
104
|
+
with:
|
|
105
|
+
path: ~/.npm
|
|
106
|
+
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
|
107
|
+
|
|
108
|
+
- run: npm ci
|
|
109
|
+
|
|
110
|
+
- name: Save cache
|
|
111
|
+
if: steps.cache-restore.outputs.cache-hit != 'true'
|
|
112
|
+
uses: actions/cache/save@v4
|
|
113
|
+
with:
|
|
114
|
+
path: ~/.npm
|
|
115
|
+
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
|
116
|
+
# NOTE: If this step shows "Warning: Failed to save: uploadChunk..."
|
|
117
|
+
# but still exits 0, the cache is corrupt. Add the REST API validation
|
|
118
|
+
# step above to catch this scenario.
|
|
119
|
+
prevention:
|
|
120
|
+
- 'Watch for "Warning: Failed to save: uploadChunk ... failed: Cache service responded with 503" in your workflow logs — this indicates a silent corrupt save even though the step shows green.'
|
|
121
|
+
- 'If you see intermittent cache misses that cannot be explained by key changes, check whether the previous save step logged any 503 chunk-upload warnings.'
|
|
122
|
+
- 'Use time-bounded cache keys (e.g. including the week number) so a corrupt entry from a bad save is overwritten on the next successful run rather than being cached indefinitely.'
|
|
123
|
+
- 'For critical caches that must succeed, add a post-save REST API validation step to fail fast when the backend silently corrupted the save.'
|
|
124
|
+
docs:
|
|
125
|
+
- url: 'https://github.com/actions/cache/issues/1416'
|
|
126
|
+
label: 'actions/cache#1416 — actions/cache and actions/cache/save consider cache uploaded successfully even with backend errors'
|
|
127
|
+
- url: 'https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28#list-github-actions-caches-for-a-repository'
|
|
128
|
+
label: 'GitHub REST API — List GitHub Actions caches for a repository'
|
|
129
|
+
- url: 'https://github.com/actions/cache/blob/main/tips-and-workarounds.md'
|
|
130
|
+
label: 'actions/cache — Tips and Workarounds'
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
id: silent-failures-116
|
|
2
|
+
title: 'actions/checkout sparse-checkout silently falls back to full REST API download when git < 2.28'
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- checkout
|
|
7
|
+
- sparse-checkout
|
|
8
|
+
- git-version
|
|
9
|
+
- self-hosted
|
|
10
|
+
- rest-api-fallback
|
|
11
|
+
- silent
|
|
12
|
+
- git
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'Minimum Git version required for sparse checkout is 2\.28'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'Failed to initialize CommandManager.*sparse'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
- regex: 'Falling back to downloading using the GitHub REST API'
|
|
19
|
+
flags: 'i'
|
|
20
|
+
error_messages:
|
|
21
|
+
- 'Minimum Git version required for sparse checkout is 2.28. Your git (/path/to/git) is 2.23'
|
|
22
|
+
- 'Falling back to downloading using the GitHub REST API'
|
|
23
|
+
- 'Warning: The git version installed on this runner is 2.x which is lower than the minimum required version 2.28 for sparse checkout'
|
|
24
|
+
root_cause: |
|
|
25
|
+
actions/checkout supports `sparse-checkout` configuration, which requires git 2.28 or
|
|
26
|
+
later (because sparse-checkout with cone-mode patterns uses `git sparse-checkout init`,
|
|
27
|
+
introduced in that version).
|
|
28
|
+
|
|
29
|
+
When checkout attempts to initialise the git CommandManager and git is present on the
|
|
30
|
+
runner but too old to satisfy the sparse-checkout requirement, the action's
|
|
31
|
+
`getGitCommandManager` function throws an error. However, this error is caught in a
|
|
32
|
+
broad try/catch block:
|
|
33
|
+
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// Git is required for LFS
|
|
36
|
+
if (settings.lfs) { throw err }
|
|
37
|
+
// otherwise: silently continue without git
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Because `lfs` is not enabled, the error is swallowed without being logged to the
|
|
41
|
+
workflow output. Checkout falls back to downloading the repository as a ZIP archive
|
|
42
|
+
via the GitHub REST API and extracts it into the workspace.
|
|
43
|
+
|
|
44
|
+
The result is a **full clone** of the repository (subject to `fetch-depth`), not a
|
|
45
|
+
sparse checkout. All files are present in the workspace instead of only the requested
|
|
46
|
+
sparse paths. Workflows relying on sparse checkout to reduce disk usage or build time
|
|
47
|
+
proceed with the full repository content, potentially causing subtle downstream failures
|
|
48
|
+
(out-of-disk, dependency version mismatches, unexpected files in build artefacts) with
|
|
49
|
+
no indication that sparse-checkout was skipped.
|
|
50
|
+
|
|
51
|
+
This primarily affects **self-hosted runners** where a locally installed git binary is
|
|
52
|
+
older than 2.28 (e.g., git 2.17 shipped with Ubuntu 18.04, or git 2.23 on Amazon Linux
|
|
53
|
+
running in an older container). GitHub-hosted runners ship git 2.43+ and are not affected.
|
|
54
|
+
|
|
55
|
+
Reported in actions/checkout#2435 (May 2026).
|
|
56
|
+
fix: |
|
|
57
|
+
**Step 1: Identify the failure.**
|
|
58
|
+
Add a step to print the git version before checkout:
|
|
59
|
+
- run: git --version
|
|
60
|
+
|
|
61
|
+
If the version is below 2.28, that is the root cause of the silent fallback.
|
|
62
|
+
|
|
63
|
+
**Step 2: Upgrade git on self-hosted runners.**
|
|
64
|
+
Install a modern git version (2.28+) on the runner. For Ubuntu:
|
|
65
|
+
sudo add-apt-repository ppa:git-core/ppa
|
|
66
|
+
sudo apt-get update && sudo apt-get install -y git
|
|
67
|
+
|
|
68
|
+
**Step 3: Until git is upgraded, disable sparse-checkout.**
|
|
69
|
+
Remove or guard the `sparse-checkout` input to avoid the silent fallback:
|
|
70
|
+
sparse-checkout: ''
|
|
71
|
+
|
|
72
|
+
**Alternative: require a minimum git version in the workflow.**
|
|
73
|
+
Fail fast with an explicit error rather than a silent fallback by checking the version
|
|
74
|
+
before the checkout step.
|
|
75
|
+
fix_code:
|
|
76
|
+
- language: yaml
|
|
77
|
+
label: 'Add explicit git version gate before checkout with sparse-checkout'
|
|
78
|
+
code: |
|
|
79
|
+
- name: Verify git version supports sparse-checkout
|
|
80
|
+
run: |
|
|
81
|
+
GIT_VERSION=$(git --version | awk '{print $3}')
|
|
82
|
+
MIN_VERSION="2.28.0"
|
|
83
|
+
if [ "$(printf '%s\n' "$MIN_VERSION" "$GIT_VERSION" | sort -V | head -n1)" != "$MIN_VERSION" ]; then
|
|
84
|
+
echo "::error::git $GIT_VERSION is too old for sparse-checkout (requires >= 2.28)"
|
|
85
|
+
exit 1
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
- uses: actions/checkout@v6
|
|
89
|
+
with:
|
|
90
|
+
sparse-checkout: |
|
|
91
|
+
src/
|
|
92
|
+
tests/
|
|
93
|
+
- language: yaml
|
|
94
|
+
label: 'Upgrade git on Ubuntu self-hosted runner before checkout'
|
|
95
|
+
code: |
|
|
96
|
+
- name: Ensure git >= 2.28 for sparse-checkout support
|
|
97
|
+
run: |
|
|
98
|
+
sudo add-apt-repository -y ppa:git-core/ppa
|
|
99
|
+
sudo apt-get update -q
|
|
100
|
+
sudo apt-get install -y git
|
|
101
|
+
git --version
|
|
102
|
+
|
|
103
|
+
- uses: actions/checkout@v6
|
|
104
|
+
with:
|
|
105
|
+
sparse-checkout: |
|
|
106
|
+
src/
|
|
107
|
+
prevention:
|
|
108
|
+
- 'Always confirm the git version on self-hosted runners meets the minimum requirements for checkout features you use; add a preflight check step if runners may have older git installs'
|
|
109
|
+
- 'Pin to a specific version of actions/checkout and audit the changelog when upgrading — the sparse-checkout silent fallback has existed across multiple major versions'
|
|
110
|
+
- 'If disk usage is a concern and sparse-checkout is required, add a post-checkout assertion that only the expected sparse paths exist, which will catch the REST API fallback'
|
|
111
|
+
docs:
|
|
112
|
+
- url: 'https://github.com/actions/checkout/issues/2435'
|
|
113
|
+
label: 'actions/checkout#2435 — Errors from initializing gitCommandManager are silently ignored'
|
|
114
|
+
- url: 'https://github.com/actions/checkout#usage'
|
|
115
|
+
label: 'actions/checkout README — sparse-checkout input and requirements'
|
|
116
|
+
- url: 'https://git-scm.com/docs/git-sparse-checkout'
|
|
117
|
+
label: 'Git documentation — git-sparse-checkout (requires git 2.25+, cone mode 2.26+)'
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
id: silent-failures-117
|
|
2
|
+
title: 'if: always() runs steps even when the workflow is manually cancelled — use success() || failure() to respect cancellation'
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- if-condition
|
|
7
|
+
- always
|
|
8
|
+
- cancelled
|
|
9
|
+
- cleanup
|
|
10
|
+
- notification
|
|
11
|
+
- status-check
|
|
12
|
+
- silent-failure
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'if:\s*always\(\)'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'if:\s*\$\{\{\s*always\(\)\s*\}\}'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
error_messages:
|
|
19
|
+
- '(no error emitted — steps run unexpectedly during workflow cancellation)'
|
|
20
|
+
root_cause: |
|
|
21
|
+
Developers commonly add `if: always()` to steps or jobs that should run even when
|
|
22
|
+
a previous step fails — for example, uploading test reports after a failed test run,
|
|
23
|
+
or sending failure notifications.
|
|
24
|
+
|
|
25
|
+
The silent failure is that `always()` evaluates to `true` in ALL cases including
|
|
26
|
+
when the workflow is manually cancelled. When a user or a concurrency group cancellation
|
|
27
|
+
triggers, any step or job with `if: always()` still executes. This is often unintended:
|
|
28
|
+
the developer expected "run on failure" not "run even when someone cancels".
|
|
29
|
+
|
|
30
|
+
GitHub Actions documentation explicitly warns about this:
|
|
31
|
+
"Consider using if: !cancelled() if you want the step or job to continue
|
|
32
|
+
executing when the current run is cancelled. [...] Using always() is not
|
|
33
|
+
recommended as it causes a step to run when a workflow is cancelled, which
|
|
34
|
+
can result in unintended behavior."
|
|
35
|
+
|
|
36
|
+
Common scenarios where this causes problems:
|
|
37
|
+
- Test report upload step runs when a PR author cancels a run, unnecessarily consuming
|
|
38
|
+
runner minutes and potentially posting partial results to a PR check.
|
|
39
|
+
- Slack/PagerDuty notification fires for a manual cancellation, producing false alerts.
|
|
40
|
+
- Deploy rollback job runs during a cancellation of a non-deployment workflow, triggering
|
|
41
|
+
rollback unexpectedly.
|
|
42
|
+
- Cleanup job executes with only partial data because the workflow was cut short.
|
|
43
|
+
|
|
44
|
+
The correct alternative for "run when success OR failure, but NOT when cancelled" is:
|
|
45
|
+
if: success() || failure()
|
|
46
|
+
or equivalently:
|
|
47
|
+
if: ${{ !cancelled() }}
|
|
48
|
+
|
|
49
|
+
Source: Stack Overflow q/58858429 (415 upvotes, 327k views), GitHub Docs always() warning,
|
|
50
|
+
test-summary/action#51 community discussion (Jan 2025).
|
|
51
|
+
fix: |
|
|
52
|
+
Replace `if: always()` with `if: success() || failure()` for steps and jobs that
|
|
53
|
+
should run on success or failure but NOT when the workflow is cancelled.
|
|
54
|
+
|
|
55
|
+
Use `if: always()` only when you explicitly want the step to run even during
|
|
56
|
+
cancellation — for example, an emergency teardown that must run regardless.
|
|
57
|
+
|
|
58
|
+
Three options depending on intent:
|
|
59
|
+
|
|
60
|
+
1. Run on success or failure (skip on cancel): `if: success() || failure()`
|
|
61
|
+
2. Run on any non-success outcome (skip on cancel): `if: failure()`
|
|
62
|
+
3. Run always including on cancel (current behaviour): `if: always()`
|
|
63
|
+
|
|
64
|
+
GitHub also documents `if: ${{ !cancelled() }}` as equivalent to option 1 in
|
|
65
|
+
most contexts.
|
|
66
|
+
fix_code:
|
|
67
|
+
- language: yaml
|
|
68
|
+
label: 'Before: if: always() fires even on manual cancellation'
|
|
69
|
+
code: |
|
|
70
|
+
jobs:
|
|
71
|
+
test:
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
steps:
|
|
74
|
+
- run: pytest --junitxml=results.xml
|
|
75
|
+
|
|
76
|
+
- name: Upload test results
|
|
77
|
+
if: always() # fires even when the job is cancelled mid-run
|
|
78
|
+
uses: actions/upload-artifact@v4
|
|
79
|
+
with:
|
|
80
|
+
name: test-results
|
|
81
|
+
path: results.xml
|
|
82
|
+
- language: yaml
|
|
83
|
+
label: 'After: if: success() || failure() respects cancellation'
|
|
84
|
+
code: |
|
|
85
|
+
jobs:
|
|
86
|
+
test:
|
|
87
|
+
runs-on: ubuntu-latest
|
|
88
|
+
steps:
|
|
89
|
+
- run: pytest --junitxml=results.xml
|
|
90
|
+
|
|
91
|
+
- name: Upload test results
|
|
92
|
+
if: success() || failure() # runs on pass or fail; skips on cancel
|
|
93
|
+
uses: actions/upload-artifact@v4
|
|
94
|
+
with:
|
|
95
|
+
name: test-results
|
|
96
|
+
path: results.xml
|
|
97
|
+
- language: yaml
|
|
98
|
+
label: 'Notification job: skip Slack alert on cancellation'
|
|
99
|
+
code: |
|
|
100
|
+
jobs:
|
|
101
|
+
build:
|
|
102
|
+
runs-on: ubuntu-latest
|
|
103
|
+
steps:
|
|
104
|
+
- run: make build
|
|
105
|
+
|
|
106
|
+
notify:
|
|
107
|
+
needs: build
|
|
108
|
+
# success() || failure() fires on build success or failure, not on cancel
|
|
109
|
+
if: success() || failure()
|
|
110
|
+
runs-on: ubuntu-latest
|
|
111
|
+
steps:
|
|
112
|
+
- name: Notify Slack
|
|
113
|
+
run: |
|
|
114
|
+
echo "Build result: ${{ needs.build.result }}"
|
|
115
|
+
# Sends alert only on success or failure — not on manual cancel
|
|
116
|
+
|
|
117
|
+
# Only use always() when you MUST run on cancellation too:
|
|
118
|
+
emergency-cleanup:
|
|
119
|
+
needs: build
|
|
120
|
+
if: always() # intentional: must clean up even if cancelled
|
|
121
|
+
runs-on: ubuntu-latest
|
|
122
|
+
timeout-minutes: 4 # stay under the 5-min forced-kill window
|
|
123
|
+
steps:
|
|
124
|
+
- run: ./cleanup.sh
|
|
125
|
+
prevention:
|
|
126
|
+
- 'Default to `if: success() || failure()` for upload, notification, and cleanup steps — reserve `if: always()` only for teardown steps that truly must run on cancellation'
|
|
127
|
+
- 'Remember: `always()` is not "run when failure"; it is "run even when cancelled" — the distinction matters for notification jobs'
|
|
128
|
+
- 'GitHub docs recommend `if: ${{ !cancelled() }}` as an alternative to `if: success() || failure()` for the same effect'
|
|
129
|
+
- 'Combine `if: always()` with `timeout-minutes:` on any job that runs after cancellation; GitHub force-kills all jobs after 5 minutes of the cancellation signal'
|
|
130
|
+
- 'Run a quick cancellation test: manually cancel the workflow and verify whether notification steps fire unexpectedly'
|
|
131
|
+
docs:
|
|
132
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#always'
|
|
133
|
+
label: 'GitHub Docs: always() status check function — cancellation warning'
|
|
134
|
+
- url: 'https://stackoverflow.com/questions/58858429/how-to-run-a-github-actions-step-even-if-the-previous-step-fails-while-still-failing-the-job'
|
|
135
|
+
label: 'Stack Overflow 58858429: Run step on failure while still failing the job (415 votes)'
|
|
136
|
+
- url: 'https://github.com/test-summary/action/issues/51'
|
|
137
|
+
label: 'test-summary/action#51: Use success() || failure() instead of always() to prevent running on cancel'
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
id: silent-failures-118
|
|
2
|
+
title: 'Reusable workflow jobs.<name>.result is always empty string in on.workflow_call.outputs value'
|
|
3
|
+
category: silent-failures
|
|
4
|
+
severity: silent-failure
|
|
5
|
+
tags:
|
|
6
|
+
- reusable-workflow
|
|
7
|
+
- workflow_call
|
|
8
|
+
- outputs
|
|
9
|
+
- jobs-result
|
|
10
|
+
- empty-string
|
|
11
|
+
- silent-failure
|
|
12
|
+
- job-outcome
|
|
13
|
+
patterns:
|
|
14
|
+
- regex: 'value:\s*\$\{\{\s*jobs\.[a-zA-Z0-9_-]+\.result\s*\}\}'
|
|
15
|
+
flags: 'i'
|
|
16
|
+
- regex: 'needs\.[a-zA-Z0-9_-]+\.outputs\.[a-zA-Z0-9_-]+.*result'
|
|
17
|
+
flags: 'i'
|
|
18
|
+
error_messages:
|
|
19
|
+
- '(no error emitted — needs.<job>.outputs.<name> resolves to empty string in the caller)'
|
|
20
|
+
root_cause: |
|
|
21
|
+
GitHub Actions documentation states that the `jobs` context is available in
|
|
22
|
+
reusable workflows and includes `jobs.<job_id>.result` — the job conclusion value
|
|
23
|
+
("success", "failure", "cancelled", or "skipped"). Developers use this to expose job
|
|
24
|
+
outcomes to calling workflows via `on.workflow_call.outputs`.
|
|
25
|
+
|
|
26
|
+
However, `jobs.<job_id>.result` does NOT work when used in the `value:` expression of
|
|
27
|
+
`on.workflow_call.outputs`. It always resolves to an empty string ("") in the caller's
|
|
28
|
+
`needs.<called_job>.outputs.<name>`.
|
|
29
|
+
|
|
30
|
+
This is distinct from the two-level output declaration issue (sf-060) where developers
|
|
31
|
+
forget to declare outputs at the workflow_call level. Here the developer correctly
|
|
32
|
+
declares both levels, but uses `jobs.<name>.result` (the job outcome) instead of
|
|
33
|
+
`jobs.<name>.outputs.<step-output>` (a custom output). The `.result` property is
|
|
34
|
+
simply not available via this mechanism.
|
|
35
|
+
|
|
36
|
+
Workarounds discovered by the community:
|
|
37
|
+
1. Explicitly capture the outcome as a step output and then chain it through job outputs:
|
|
38
|
+
steps:
|
|
39
|
+
- id: capture
|
|
40
|
+
run: echo "result=${{ job.status }}" >> "$GITHUB_OUTPUT"
|
|
41
|
+
outputs:
|
|
42
|
+
result: ${{ steps.capture.outputs.result }}
|
|
43
|
+
2. Use `fromJSON(toJSON(jobs.build)).result` — this works because it forces the
|
|
44
|
+
expression evaluator to serialise and deserialise the jobs context object, which
|
|
45
|
+
happens to populate the result at evaluation time. This is an accidental workaround
|
|
46
|
+
and not guaranteed to remain stable.
|
|
47
|
+
|
|
48
|
+
The root cause is that the `jobs.<job_id>.result` property in the workflow_call outputs
|
|
49
|
+
`value:` expression is evaluated before job conclusions are fully committed to the
|
|
50
|
+
expression context, unlike `jobs.<job_id>.outputs.<name>` which goes through a different
|
|
51
|
+
code path.
|
|
52
|
+
|
|
53
|
+
Source: actions/runner#2495 (open since 2023, multiple upvotes),
|
|
54
|
+
actions/runner#3087 (Cannot access jobs.<id>.result from on.workflow_call.outputs..value).
|
|
55
|
+
fix: |
|
|
56
|
+
Do NOT use `value: ${{ jobs.<name>.result }}` in on.workflow_call.outputs — it will
|
|
57
|
+
always be empty in the caller.
|
|
58
|
+
|
|
59
|
+
**Option 1 (recommended): Capture outcome via a step output.**
|
|
60
|
+
Add a final step that writes `job.status` to GITHUB_OUTPUT, then chain it up:
|
|
61
|
+
step output → job output → workflow_call output
|
|
62
|
+
|
|
63
|
+
**Option 2: Use fromJSON(toJSON(jobs.<name>)).result workaround.**
|
|
64
|
+
The fromJSON/toJSON wrapper forces the jobs context to be serialised at evaluation
|
|
65
|
+
time, which makes .result available. This is a workaround — prefer option 1.
|
|
66
|
+
|
|
67
|
+
**Option 3: Control caller behavior via job success/failure instead of reading result.**
|
|
68
|
+
If you only need to run downstream caller jobs conditionally based on the reusable
|
|
69
|
+
workflow succeeding or failing, use `if: needs.<job>.result == 'success'` — the
|
|
70
|
+
`needs.<job>.result` in the CALLER is always correct and does NOT need to go through
|
|
71
|
+
workflow_call outputs.
|
|
72
|
+
fix_code:
|
|
73
|
+
- language: yaml
|
|
74
|
+
label: 'Broken: value: ${{ jobs.build.result }} is always empty in caller'
|
|
75
|
+
code: |
|
|
76
|
+
# .github/workflows/reusable.yml — BROKEN pattern
|
|
77
|
+
on:
|
|
78
|
+
workflow_call:
|
|
79
|
+
outputs:
|
|
80
|
+
build-result:
|
|
81
|
+
description: 'Job outcome of the build job'
|
|
82
|
+
value: ${{ jobs.build.result }} # always empty string in caller
|
|
83
|
+
|
|
84
|
+
jobs:
|
|
85
|
+
build:
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
steps:
|
|
88
|
+
- run: make build
|
|
89
|
+
- language: yaml
|
|
90
|
+
label: 'Fixed: capture outcome via step output and chain it through job outputs'
|
|
91
|
+
code: |
|
|
92
|
+
# .github/workflows/reusable.yml — CORRECT pattern
|
|
93
|
+
on:
|
|
94
|
+
workflow_call:
|
|
95
|
+
outputs:
|
|
96
|
+
build-result:
|
|
97
|
+
description: 'Job outcome of the build job'
|
|
98
|
+
value: ${{ jobs.build.outputs.result }} # works correctly
|
|
99
|
+
|
|
100
|
+
jobs:
|
|
101
|
+
build:
|
|
102
|
+
runs-on: ubuntu-latest
|
|
103
|
+
outputs:
|
|
104
|
+
result: ${{ steps.capture.outputs.result }}
|
|
105
|
+
steps:
|
|
106
|
+
- run: make build
|
|
107
|
+
|
|
108
|
+
- id: capture
|
|
109
|
+
if: always()
|
|
110
|
+
shell: bash
|
|
111
|
+
run: echo "result=${{ job.status }}" >> "$GITHUB_OUTPUT"
|
|
112
|
+
- language: yaml
|
|
113
|
+
label: 'Alternative workaround: fromJSON(toJSON(jobs.build)).result (fragile, prefer option 1)'
|
|
114
|
+
code: |
|
|
115
|
+
# .github/workflows/reusable.yml — accidental workaround (not recommended)
|
|
116
|
+
on:
|
|
117
|
+
workflow_call:
|
|
118
|
+
outputs:
|
|
119
|
+
build-result:
|
|
120
|
+
value: ${{ fromJSON(toJSON(jobs.build)).result }}
|
|
121
|
+
|
|
122
|
+
jobs:
|
|
123
|
+
build:
|
|
124
|
+
runs-on: ubuntu-latest
|
|
125
|
+
steps:
|
|
126
|
+
- run: make build
|
|
127
|
+
- language: yaml
|
|
128
|
+
label: 'Caller: reading needs.<job>.result directly works and does NOT need workflow_call outputs'
|
|
129
|
+
code: |
|
|
130
|
+
# .github/workflows/caller.yml
|
|
131
|
+
# needs.<job>.result in the CALLER is always correct — no need to pass it through outputs
|
|
132
|
+
jobs:
|
|
133
|
+
call-build:
|
|
134
|
+
uses: ./.github/workflows/reusable.yml
|
|
135
|
+
|
|
136
|
+
deploy:
|
|
137
|
+
needs: call-build
|
|
138
|
+
# This works correctly — reads the reusable workflow conclusion directly
|
|
139
|
+
if: needs.call-build.result == 'success'
|
|
140
|
+
runs-on: ubuntu-latest
|
|
141
|
+
steps:
|
|
142
|
+
- run: ./deploy.sh
|
|
143
|
+
prevention:
|
|
144
|
+
- 'Never use `value: ${{ jobs.<name>.result }}` in workflow_call outputs — capture it as a step output first'
|
|
145
|
+
- 'If you only need to gate downstream caller jobs on reusable workflow success/failure, use `needs.<job>.result` in the caller directly — it works without going through workflow_call outputs'
|
|
146
|
+
- 'Test output values by printing them in the caller with `run: echo "${{ needs.<job>.outputs.<name> }}"` before relying on them in logic'
|
|
147
|
+
- 'The `fromJSON(toJSON(...)).result` workaround works today but is fragile — prefer the step-output capture pattern'
|
|
148
|
+
docs:
|
|
149
|
+
- url: 'https://github.com/actions/runner/issues/2495'
|
|
150
|
+
label: 'actions/runner#2495: Reusable workflow does not output individual job result'
|
|
151
|
+
- url: 'https://github.com/actions/runner/issues/3087'
|
|
152
|
+
label: 'actions/runner#3087: Cannot access jobs.<id>.result from on.workflow_call.outputs..value'
|
|
153
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#onworkflow_calloutputs'
|
|
154
|
+
label: 'GitHub Docs: on.workflow_call.outputs syntax reference'
|
|
155
|
+
- url: 'https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/contexts#jobs-context'
|
|
156
|
+
label: 'GitHub Docs: jobs context (available only in reusable workflows)'
|