Skip to content

safe-outputs.steps can't supply the App-token mint: runs after mint in safe_outputs, absent from conclusion #36547

@Yoyokrazy

Description

@Yoyokrazy

Summary

safe-outputs.steps: cannot be used to supply credentials to the safe-outputs App-token mint, for two distinct reasons:

  1. 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.
  2. 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)

  1. 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.
  2. 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.
  3. Reorder safe-outputs.steps: to run before the mint and mirror it into conclusionnot 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions