Safeguard
Security Guides•

Separating privileged steps with workflow_run in GitHub Actions

How the two workflow split works: build untrusted code without secrets, then use workflow_run for the privileged step, and how to handle artifacts safely.

Safeguard Security Team
3 min read

Split risky pipelines in two. The first workflow builds or tests untrusted code with no secrets. A second workflow, triggered by workflow_run, holds the privileged step and treats everything from the first as untrusted data. This keeps secrets away from code you have not reviewed.

Why split at all

Some jobs need both untrusted input and a credential. A common case is commenting test results on a fork pull request. The test needs the fork's code. The comment needs a token with write access.

Combining them in one job gives the fork's code access to the credential. Splitting them removes the overlap.

What workflow_run provides

GitHub's documentation says workflow_run fires when another workflow run is requested or completed, whatever its conclusion. The triggered workflow can access secrets and write tokens even if the triggering workflow could not. That is the useful part: it is built for privileged actions after unprivileged runs.

Some limits from the docs worth knowing:

  • Chaining is limited to three workflow levels.
  • The workflow file must exist on the default branch.
  • You should check github.event.workflow_run.conclusion to gate jobs, because the trigger fires on failure too.

The two workflow shape

Workflow one, with no secrets:

name: Test
on: pull_request
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make test
      - uses: actions/upload-artifact@v4
        with:
          name: results
          path: results.txt

Workflow two, privileged:

name: Report
on:
  workflow_run:
    workflows: ["Test"]
    types: [completed]
permissions:
  pull-requests: write
jobs:
  report:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    steps:
      - run: echo "download the artifact and post it as text"

Handle artifacts as hostile

The secure use page says to treat artifacts from other workflows with caution. A fork controls what goes into the artifact.

  • Read it as plain data. Never run it, source it, or pass it to a shell unquoted.
  • Validate the format. If you expect a number, parse a number.
  • Do not check out the fork's code in the privileged workflow.
  • Do not interpolate artifact text into a script. Use an environment variable, as in stopping script injection.

The same warning still applies

The GitHub docs warn that running untrusted code on a workflow_run trigger can lead to cache poisoning and unintended write access to secrets. The split only helps if the second workflow does not run what the first produced.

When not to split

If the job does not need any credential, keep it as a plain pull_request workflow. A split adds moving parts. Use it only where a privileged result is needed.

Safeguard policies and gates can block merges on failed checks, and the free scan at gold.safeguard.sh/scan can review a repository for dependency risk.

Quick check

Does workflow_run run on failed workflows?

Yes. It runs regardless of the triggering workflow's conclusion, so check the conclusion in an if condition.

Can the privileged workflow safely run the artifact?

No. Treat the artifact as untrusted data and never execute it.

Sources

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.