Summary
safe-outputs.steps: cannot be used to supply credentials to the safe-outputs App-token mint, for two distinct reasons:
- In the
safe_outputs job, safe-outputs.steps: is appended after the Generate GitHub App token step, so a custom step's outputs can't feed github-app.client-id / github-app.private-key.
safe-outputs.steps: is not injected into the conclusion job at all, even though conclusion mints the same App token — so there's no way to supply that job's mint from a custom step.
The schema describes safe-outputs.steps: as applying to all safe-output jobs and running before the safe-output code, which makes the current behavior at least under-specified and (for #2) contradictory. Filed against v0.74.8; source inspection of main (v0.78.1) shows the same code paths unchanged.
⚠️ Evidence is compile-time / structural. Everything below is derived from the generated .lock.yml and from reading the compiler source — we did not execute a live run of a workflow using safe-outputs.steps:. Runtime consequences (e.g. "the mint receives empty inputs and fails") are inferred, not observed, and flagged as such.
Background — what we were trying to do
We need App credentials fetched from an external secret manager at runtime (OIDC → cloud KV → PEM), not stored as repo secrets. This is the scenario in #33592. The mint step itself is fine with github-app.private-key: ${{ steps.X.outputs.pem }} — we just need step X to run before the mint, in every job that mints.
safe-outputs.steps: looked like the intended hook (it was added in #18460 / requested in #18362 for Vault-style token sources), so we tried it first. It doesn't work for pre-mint credential supply, for the two reasons above.
Documented alternative that doesn't cover this case
safe-outputs.needs: (docs: Safe Outputs Dependencies) is the documented mechanism for secret-manager-backed App credentials — a custom job exposes outputs.app_private_key, and github-app.private-key reads ${{ needs.<job>.outputs.app_private_key }}.
This works for secrets-backed values, but routing the private key through needs.<job>.outputs.* means the PEM crosses a job boundary. GitHub Actions runner v2.308+ drops masked values from jobs.<id>.outputs.* (actions/runner#2293), so a masked PEM blanks out in the consumer job. That makes needs: unsuitable for the OIDC/secret-manager flow, which is exactly the gap #33592 is open about. Hence the need for an in-job pre-mint step, which is what safe-outputs.steps: appeared to offer.
Finding 1 — safe-outputs.steps: runs after the mint in safe_outputs
The placement is deliberate, not a stray bug — but it means safe-outputs.steps: can't feed the mint. The mint is spliced in ahead of the shared checkout steps on purpose:
// pkg/workflow/compiler_safe_outputs_job.go:470-471
// Note: App token step must be inserted BEFORE shared checkout steps
// because those steps reference steps.safe-outputs-app-token.outputs.token
SafeOutputs.Steps is concatenated after both the mint and the shared checkout steps, so it always lands downstream of the mint.
The schema, however, says:
pkg/parser/schemas/main_workflow_schema.json — "Custom steps to inject into all safe-output jobs. These steps run after checking out the repository and setting up the action, and before any safe-output code executes."
"Before any safe-output code executes" is ambiguous about whether the App-token mint counts as "safe-output code." The originating PR #18460 documents a step order that doesn't mention the mint at all, and the #18362 closing-comment example feeds a handler (github-token: ${{ steps.vault.outputs.token }}), not the mint. So this may be working as the author intended for handler-side setup — but there is then no field that injects before the mint, which is the use case #18362's issue body actually asked for.
Finding 2 — safe-outputs.steps: is never injected into conclusion
This one is a clear schema-vs-implementation contradiction. The schema promises "all safe-output jobs," but buildConclusionJob never reads data.SafeOutputs.Steps:
pkg/workflow/notify_comment.go buildConclusionJob(...) mints the same App token (steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, ...)...), ~line 65) using the same SafeOutputs.GitHubApp config…
- …but nowhere in that function is
SafeOutputs.Steps referenced, so the custom steps are silently dropped for this job.
PR #18460 only touched compiler_safe_outputs_job.go; the conclusion path was never wired. The PR also shipped with no tests asserting step ordering vs. the mint, or asserting injection into conclusion (pkg/workflow/compiler_safe_outputs_steps_test.go has no such assertions).
Reproduction (compile-time)
---
on:
workflow_dispatch:
permissions:
contents: read
issues: read
id-token: write
engine: copilot
safe-outputs:
id-token: write
github-app:
client-id: ${{ steps.custom-fetch.outputs.client-id }}
private-key: ${{ steps.custom-fetch.outputs.pem }}
owner: 'some-org'
repositories: ['some-repo']
steps:
- name: Fetch credentials from custom source
id: custom-fetch
run: |
echo "client-id=Iv23xxxxxxxxxx" >> "$GITHUB_OUTPUT"
printf '%s\n' 'pem<<EOF' '-----BEGIN RSA PRIVATE KEY-----' 'fake' '-----END RSA PRIVATE KEY-----' 'EOF' >> "$GITHUB_OUTPUT"
add-comment: {}
---
# Repro
Test.
gh aw compile repro, then inspect the generated repro.lock.yml.
safe_outputs: job — actual step order:
| Step # |
Name |
Notes |
| 1 |
Setup Scripts |
|
| 2 |
Download agent output artifact |
|
| 3 |
Setup agent output environment variable |
|
| 4 |
Generate GitHub App token |
references ${{ steps.custom-fetch.outputs.client-id/pem }} |
| 5 |
Configure GH_HOST for enterprise compatibility |
|
| 6 |
Fetch credentials from custom source |
the safe-outputs.steps: entry — injected after the mint |
| 7 |
Process Safe Outputs |
|
| 8 |
Invalidate GitHub App token |
|
At runtime, the step-4 expressions evaluate before step 6 has run, so they resolve to empty strings; actions/create-github-app-token declares private-key as required: true, so the mint would then fail. (Inferred from the lock-file structure and the action's input contract; not observed in a live run.)
conclusion: job: contains Generate GitHub App token but no Fetch credentials from custom source step — the entire safe-outputs.steps: array is absent from this job.
Workaround (works today)
jobs.safe_outputs.pre-steps: and jobs.conclusion.pre-steps: are injected before the mint in both built-in jobs, so this works:
jobs:
safe_outputs:
permissions:
id-token: write
pre-steps:
- name: Checkout repository # built-in jobs don't auto-checkout before pre-steps
uses: actions/checkout@v5
- name: Fetch credentials from custom source
id: custom-fetch
run: |
# ...
conclusion:
permissions:
id-token: write
pre-steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Fetch credentials from custom source
id: custom-fetch
run: |
# ...
This is supported by applyBuiltinJobPreSteps (pkg/workflow/compiler_jobs.go), which iterates every job in the manager — but the pre-steps ADR (docs/adr/27138-*.md) and glossary only enumerate activation / pre_activation as supported targets, so using it on safe_outputs / conclusion is undocumented for these jobs.
Possible remediations (in rough order of preference)
- Formalize
jobs.safe_outputs.pre-steps: / jobs.conclusion.pre-steps: as the supported pre-mint hook — the compiler already injects them before the mint; this is mostly an ADR + glossary + docs change (plus confirming conclusion honors it). Lowest-risk, matches the working workaround.
- Add a dedicated
safe-outputs.pre-token-steps: (or similar) field that injects before the mint in both safe_outputs and conclusion. Names the intent, preserves existing safe-outputs.steps: semantics.
- Reorder
safe-outputs.steps: to run before the mint and mirror it into conclusion — not recommended as-is: it's a behavior change that could silently break users whose injected steps consume steps.safe-outputs-app-token.outputs.token (valid under today's post-mint ordering). Would need to be opt-in.
Independently of which option is chosen, the conclusion omission in Finding 2 is a straightforward bug given the schema's "all safe-output jobs" wording.
Environment
gh-aw v0.74.8 (repro); main @ v0.78.1 inspected — same code paths
actions/create-github-app-token v3.2.0
- Linux runner (ubuntu-latest)
Related
Summary
safe-outputs.steps:cannot be used to supply credentials to the safe-outputs App-token mint, for two distinct reasons:safe_outputsjob,safe-outputs.steps:is appended after theGenerate GitHub App tokenstep, so a custom step's outputs can't feedgithub-app.client-id/github-app.private-key.safe-outputs.steps:is not injected into theconclusionjob at all, even thoughconclusionmints the same App token — so there's no way to supply that job's mint from a custom step.The schema describes
safe-outputs.steps:as applying to all safe-output jobs and running before the safe-output code, which makes the current behavior at least under-specified and (for #2) contradictory. Filed against v0.74.8; source inspection ofmain(v0.78.1) shows the same code paths unchanged.Background — what we were trying to do
We need App credentials fetched from an external secret manager at runtime (OIDC → cloud KV → PEM), not stored as repo secrets. This is the scenario in #33592. The mint step itself is fine with
github-app.private-key: ${{ steps.X.outputs.pem }}— we just need stepXto run before the mint, in every job that mints.safe-outputs.steps:looked like the intended hook (it was added in #18460 / requested in #18362 for Vault-style token sources), so we tried it first. It doesn't work for pre-mint credential supply, for the two reasons above.Documented alternative that doesn't cover this case
safe-outputs.needs:(docs: Safe Outputs Dependencies) is the documented mechanism for secret-manager-backed App credentials — a custom job exposesoutputs.app_private_key, andgithub-app.private-keyreads${{ needs.<job>.outputs.app_private_key }}.This works for secrets-backed values, but routing the private key through
needs.<job>.outputs.*means the PEM crosses a job boundary. GitHub Actions runner v2.308+ drops masked values fromjobs.<id>.outputs.*(actions/runner#2293), so a masked PEM blanks out in the consumer job. That makesneeds:unsuitable for the OIDC/secret-manager flow, which is exactly the gap #33592 is open about. Hence the need for an in-job pre-mint step, which is whatsafe-outputs.steps:appeared to offer.Finding 1 —
safe-outputs.steps:runs after the mint insafe_outputsThe placement is deliberate, not a stray bug — but it means
safe-outputs.steps:can't feed the mint. The mint is spliced in ahead of the shared checkout steps on purpose:SafeOutputs.Stepsis concatenated after both the mint and the shared checkout steps, so it always lands downstream of the mint.The schema, however, says:
"Before any safe-output code executes" is ambiguous about whether the App-token mint counts as "safe-output code." The originating PR #18460 documents a step order that doesn't mention the mint at all, and the #18362 closing-comment example feeds a handler (
github-token: ${{ steps.vault.outputs.token }}), not the mint. So this may be working as the author intended for handler-side setup — but there is then no field that injects before the mint, which is the use case #18362's issue body actually asked for.Finding 2 —
safe-outputs.steps:is never injected intoconclusionThis one is a clear schema-vs-implementation contradiction. The schema promises "all safe-output jobs," but
buildConclusionJobnever readsdata.SafeOutputs.Steps:pkg/workflow/notify_comment.gobuildConclusionJob(...)mints the same App token (steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, ...)...), ~line 65) using the sameSafeOutputs.GitHubAppconfig…SafeOutputs.Stepsreferenced, so the custom steps are silently dropped for this job.PR #18460 only touched
compiler_safe_outputs_job.go; the conclusion path was never wired. The PR also shipped with no tests asserting step ordering vs. the mint, or asserting injection intoconclusion(pkg/workflow/compiler_safe_outputs_steps_test.gohas no such assertions).Reproduction (compile-time)
gh aw compile repro, then inspect the generatedrepro.lock.yml.safe_outputs:job — actual step order:Setup ScriptsDownload agent output artifactSetup agent output environment variableGenerate GitHub App token${{ steps.custom-fetch.outputs.client-id/pem }}Configure GH_HOST for enterprise compatibilityFetch credentials from custom sourcesafe-outputs.steps:entry — injected after the mintProcess Safe OutputsInvalidate GitHub App tokenAt runtime, the step-4 expressions evaluate before step 6 has run, so they resolve to empty strings;
actions/create-github-app-tokendeclaresprivate-keyasrequired: true, so the mint would then fail. (Inferred from the lock-file structure and the action's input contract; not observed in a live run.)conclusion:job: containsGenerate GitHub App tokenbut noFetch credentials from custom sourcestep — the entiresafe-outputs.steps:array is absent from this job.Workaround (works today)
jobs.safe_outputs.pre-steps:andjobs.conclusion.pre-steps:are injected before the mint in both built-in jobs, so this works:This is supported by
applyBuiltinJobPreSteps(pkg/workflow/compiler_jobs.go), which iterates every job in the manager — but the pre-steps ADR (docs/adr/27138-*.md) and glossary only enumerateactivation/pre_activationas supported targets, so using it onsafe_outputs/conclusionis undocumented for these jobs.Possible remediations (in rough order of preference)
jobs.safe_outputs.pre-steps:/jobs.conclusion.pre-steps:as the supported pre-mint hook — the compiler already injects them before the mint; this is mostly an ADR + glossary + docs change (plus confirmingconclusionhonors it). Lowest-risk, matches the working workaround.safe-outputs.pre-token-steps:(or similar) field that injects before the mint in bothsafe_outputsandconclusion. Names the intent, preserves existingsafe-outputs.steps:semantics.safe-outputs.steps:to run before the mint and mirror it intoconclusion— not recommended as-is: it's a behavior change that could silently break users whose injected steps consumesteps.safe-outputs-app-token.outputs.token(valid under today's post-mint ordering). Would need to be opt-in.Independently of which option is chosen, the
conclusionomission in Finding 2 is a straightforward bug given the schema's "all safe-output jobs" wording.Environment
gh-awv0.74.8 (repro);main@ v0.78.1 inspected — same code pathsactions/create-github-app-tokenv3.2.0Related
pre-stepssafe-outputs.needs:safe-outputs.stepssafe-outputs.stepsfor injecting custom steps into safe-output jobs #18460 — PR that addedsafe-outputs.steps:safe-outputsjob #18684 / Auto-detect OIDC/vault actions in safe-outputs steps and add id-token:write permission #18701 — id-token permissions + OIDC/vault action auto-detection forsafe-outputs.steps