@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.
Files changed (32) hide show
  1. package/errors/caching-artifacts/caching-artifacts-073.yml +100 -0
  2. package/errors/caching-artifacts/caching-artifacts-074.yml +117 -0
  3. package/errors/concurrency-timing/concurrency-timing-059.yml +146 -0
  4. package/errors/concurrency-timing/concurrency-timing-060.yml +144 -0
  5. package/errors/known-unsolved/known-unsolved-071.yml +122 -0
  6. package/errors/known-unsolved/known-unsolved-072.yml +143 -0
  7. package/errors/known-unsolved/known-unsolved-073.yml +172 -0
  8. package/errors/permissions-auth/permissions-auth-071.yml +144 -0
  9. package/errors/permissions-auth/permissions-auth-072.yml +112 -0
  10. package/errors/permissions-auth/permissions-auth-073.yml +127 -0
  11. package/errors/permissions-auth/permissions-auth-074.yml +106 -0
  12. package/errors/permissions-auth/permissions-auth-075.yml +137 -0
  13. package/errors/runner-environment/runner-environment-227.yml +106 -0
  14. package/errors/runner-environment/runner-environment-228.yml +117 -0
  15. package/errors/runner-environment/runner-environment-229.yml +119 -0
  16. package/errors/runner-environment/runner-environment-230.yml +129 -0
  17. package/errors/runner-environment/runner-environment-231.yml +90 -0
  18. package/errors/runner-environment/runner-environment-232.yml +131 -0
  19. package/errors/runner-environment/runner-environment-233.yml +90 -0
  20. package/errors/runner-environment/runner-environment-234.yml +114 -0
  21. package/errors/runner-environment/runner-environment-235.yml +151 -0
  22. package/errors/silent-failures/silent-failures-112.yml +97 -0
  23. package/errors/silent-failures/silent-failures-113.yml +110 -0
  24. package/errors/silent-failures/silent-failures-114.yml +116 -0
  25. package/errors/silent-failures/silent-failures-115.yml +130 -0
  26. package/errors/silent-failures/silent-failures-116.yml +117 -0
  27. package/errors/silent-failures/silent-failures-117.yml +137 -0
  28. package/errors/silent-failures/silent-failures-118.yml +156 -0
  29. package/errors/triggers/triggers-072.yml +150 -0
  30. package/errors/yaml-syntax/yaml-syntax-075.yml +128 -0
  31. package/errors/yaml-syntax/yaml-syntax-076.yml +107 -0
  32. 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)'