A workflow file is executable deployment and administration logic. A small change under .github/workflows/ can alter which code runs, which credentials are available, where artifacts go, or which cloud role a job can assume. That makes a workflow change different from an ordinary source change, even when it arrives in a normal pull request.
On 28 July 2026, GitHub began holding certain potentially malicious workflow runs for approval before they start in public repositories on GitHub.com. The protection is automatic: there is no repository setting to enable, and a collaborator with write access must approve a held run in an authenticated web session. It is a useful last check, but it is not a workflow security program. It applies only when GitHub identifies a run as suspicious, and approval still gives the workflow its normal capabilities.
This guide establishes a review path around that protection. The finished result is a repository where workflow changes are easy to identify, jobs receive only the permissions and secrets they need, and a maintainer can inspect an automatically held run without approving it by reflex. The commands and configuration are documentation-verified examples, not output from a particular repository.
Start with an inventory, not an approval
When a run is held, do not begin at the Approve button. Begin with the change that caused it. Review the pull request’s Files changed view and focus first on .github/workflows/, custom actions, reusable workflows, and scripts invoked by run: blocks.
For a local checkout of the pull request branch, these commands identify workflow files and show their changes against the trusted base branch:
git ls-files '.github/workflows/*' '.github/actions/**'
git diff -- .github/workflows .github/actions
git grep -nE '(^|[[:space:]])(uses:|run:|permissions:|secrets:|id-token:)' -- .github/workflows
The first command finds both top-level workflow definitions and local composite actions. The second keeps the review focused on automation files. The third is a starting point for locating the lines that call third-party actions, execute shell code, set token permissions, pass secrets, or request an OpenID Connect token. It is not a complete security analysis; multiline shell blocks and values assembled in reusable workflows still need to be read in context.
Treat these as separate questions:
- What event can trigger the workflow?
- Which revision is checked out or downloaded?
- Which token permissions, environment secrets, or cloud identities can a job use?
- Can untrusted pull-request text or code reach a shell, an action, or a privileged follow-up job?
- Does the change add an outbound network destination, upload data, or alter a deployment target?
A workflow that only formats source code has a different risk profile from one that publishes packages or deploys infrastructure. The review should make that distinction visible rather than treating every YAML change as equally safe or unsafe.
Make the default token read-only
Set a conservative default at the workflow level, then add permissions only to the job that needs them. GitHub documents that GITHUB_TOKEN permissions can be restricted with the permissions key. A top-level declaration makes accidental write access easier to spot in review.
name: test
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-length-commit-sha>
- run: ./ci/test.sh
Replace the placeholder with the full commit SHA from the action’s canonical repository. A full SHA is immutable in a way that a branch or tag name is not. It does not make a poorly designed action safe, so inspect the action source before approving it and keep its version update in the same review scope.
Do not add contents: write, pull-requests: write, packages: write, or id-token: write at the top level simply because one release job needs it. Give the release job the narrow capability locally:
publish:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-length-commit-sha>
- run: ./ci/publish.sh
This example also separates pull-request testing from publishing. The if: condition is an important boundary: a contributor pull request should not enter a job that can publish simply because the test job passed. Adapt the event, branch, package registry, and action revision to the repository’s release process.
Keep untrusted code out of privileged triggers
GitHub specifically warns about pull_request_target and workflow_run when they are combined with untrusted pull-request code. Those triggers can have access to a more privileged context, including repository secrets or write permissions. A workflow intended only to label a pull request should not check out and execute the contributor’s branch.
Use pull_request for ordinary linting, testing, and building of pull-request code. For a fork, GitHub supplies a read-only token by default, which is a useful boundary. If a privileged follow-up is unavoidable, keep it limited to trusted metadata or validated data rather than a downloaded executable artifact.
For example, a labeling workflow can operate on event metadata without a checkout:
on:
pull_request_target:
types: [opened, synchronize]
permissions:
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@<full-length-commit-sha>
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['needs-review']
})
This is illustrative configuration. Before using it, check that the action revision is approved and that the repository permits automated labels. The important property is the absence of a checkout or run: command that consumes untrusted repository content. If the job needs to build, test, install dependencies, or interpret an artifact from the pull request, move that work to an unprivileged pull_request workflow instead.
Put secrets behind a second decision
Repository secrets are available to every workflow job that is eligible to receive them. Put deployment credentials and production access behind an environment with required reviewers rather than making them routine test inputs. A job that references environment secrets will wait for the environment approval gate before it can access them.
deploy:
needs: test
if: github.ref == 'refs/heads/main'
environment: production
permissions:
contents: read
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-length-commit-sha>
- run: ./ci/deploy.sh
Configure the production environment in repository settings with the required reviewers and any branch restrictions that match the deployment policy. Request id-token: write only when the deployment actually exchanges GitHub’s OIDC token for a short-lived cloud credential. Do not add a long-lived cloud key as a general repository secret to avoid configuring that identity exchange.
An automatic held-run approval and an environment approval are different controls. The first decides whether a suspicious workflow run may start. The second decides whether a specific deployment job may obtain protected secrets. Keeping both decisions separate means a maintainer can inspect a held test run without also granting production access.
Verify the path with a harmless change
Do not create a deliberately malicious workflow to test the hold feature. GitHub decides which runs are held, so a harmless workflow edit may run normally. Instead, verify the parts of the process that the repository controls.
Create a review-only pull request that changes a comment or a non-executing string in a workflow. Confirm that the pull request uses the intended pull_request trigger, the job receives only contents: read, and no environment approval is requested by the test job. Inspect the run’s rendered workflow and logs for unexpected checkouts, network clients, masked-value warnings, or attempts to access protected environments.
Then review a release or deployment workflow without running it against production. Confirm that it has a branch condition, job-scoped permissions, and a named environment. In the repository’s Actions settings, confirm that policy for allowed actions and reusable workflows matches the organization’s requirements. If the repository is public on GitHub.com and GitHub later holds a run, the reviewer should compare the held revision with the reviewed pull-request diff before approving it. GitHub’s held-run protection does not currently apply to GitHub Enterprise Server.
Finally, keep an incident response note for workflow changes. It should identify the repository owner, the person allowed to approve held runs, the cloud or package identities each deployment can use, and the rotation procedure if a workflow appears to have exposed a credential. If a secret reaches a log or an untrusted destination, revoke or rotate it before treating log deletion or masking as a fix.
A held run is a signal to slow down. A narrow token, a protected deployment environment, immutable action references, and a review that follows data and privileges through the workflow give that signal a practical response. The result is not an assumption that GitHub will catch every risky change; it is a workflow design where an approval has a smaller and more understandable consequence.