Review GitHub Actions workflow files for security posture, correctness, cost efficiency, and safe deployment patterns — without modifying any files.
Reviews .github/workflows/*.yml files for security, cost, and correctness.
Expected output sections:
- Findings — workflow name, file path, line number, and issue description
- Security concerns — unpinned actions, missing permissions, secret exposure
- Cost concerns — redundant jobs, missing caches, over-provisioned runners
- Remediation — concrete YAML patches and
actionlintcommands
/gha-review review all workflows in .github/workflows/ for security, cost, and correctness
Workflow files:
.github/workflows/
ci.yml ← build, lint, test on pull_request and push
deploy.yml ← deploy to staging/production on push to main
release.yml ← create GitHub release on tag push
## Findings
**F1 — Unpinned third-party actions (ci.yml:18, deploy.yml:12, release.yml:9)**
Multiple workflows use actions pinned to a mutable tag instead of a commit SHA:
```yaml
# Current (unsafe):
- uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v6
- uses: docker/build-push-action@v5A compromised or force-pushed tag can silently replace the action code. Pin to the full commit SHA:
# Correct:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897dab680ace5204f72 # v6.5.0
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v5.4.0Use pin-github-action or Renovate to manage SHA pins automatically.
- Severity: High (supply-chain risk)
F2 — No top-level permissions block (ci.yml, deploy.yml, release.yml)
None of the workflows declare a permissions block. GitHub defaults to
contents: read for most events, but GITHUB_TOKEN inherits the repository's
default permission setting, which many organisations leave at read-write.
Least-privilege requires explicit scoping:
# Add at the workflow level:
permissions:
contents: read
# Override per-job where write is needed:
jobs:
deploy:
permissions:
contents: read
id-token: write # for OIDC token exchange
packages: write # for GHCR push- Severity: High (over-privileged token)
F3 — No cache for Go modules (ci.yml:34)
The go test job downloads the full module cache on every run:
- name: Run tests
run: go test ./...With a 47-module dependency graph, cold-cache runs take ~90 seconds of network time. Add:
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true # caches $GOPATH/pkg/mod and ~/.cache/go-buildactions/setup-go@v5 handles cache key derivation from go.sum automatically.
- Severity: Medium (cost/speed)
F4 — No concurrency group on deploy.yml
deploy.yml triggers on every push to main. Rapid successive pushes will
queue multiple deploy jobs targeting the same environment simultaneously,
risking interleaved Helm releases or partial rollouts:
# Add at the workflow level or per-job:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false # false = queue, not cancel, for deploysFor CI jobs (ci.yml), cancel-in-progress: true is appropriate to save
runner minutes. For deploy jobs, queuing (false) is safer than cancellation.
- Severity: High (operational safety)
F5 — Secret referenced in run: step log (ci.yml:61)
- name: Integration test
run: |
echo "Connecting to ${{ secrets.DB_URL }}"
go test ./integration/...${{ secrets.* }} in a run: command will be masked in logs, but the value
is passed as a shell argument and may appear in process listings or set -x
output. Use an environment variable instead:
- name: Integration test
env:
DB_URL: ${{ secrets.DB_URL }}
run: go test ./integration/...- Severity: Medium (secret hygiene)
F6 — actionlint not run in CI
No workflow runs actionlint to statically validate workflow syntax. Errors
in YAML expressions (e.g. wrong context names, missing env: blocks) are only
caught at runtime. Add a workflow lint step:
- name: Lint workflows
uses: rhysd/actionlint@17bd74abb1b69c6cba85b2a98a67a7c8a82ca3db # v1.7.7
with:
shellcheck: trueOr run locally before pushing:
brew install actionlint
actionlint .github/workflows/*.yml- Severity: Medium
| ID | Issue | Severity |
|---|---|---|
| F1 | Unpinned third-party actions (3 workflows) | High |
| F2 | No permissions block — implicit over-privilege |
High |
| F5 | Secret value interpolated in run: step |
Medium |
| ID | Issue | Severity |
|---|---|---|
| F3 | No Go module cache — ~90s extra per CI run | Medium |
| F4 | No deploy concurrency group — wasted parallel runs | High |
Priority order:
-
Pin all actions to commit SHAs (F1) — run
pin-github-actionacross all three workflow files, then commit the result to the repo. -
Add
permissionsblock (F2) — add a workflow-levelpermissions: contents: readblock to all three files; add per-job overrides for GHCR push and OIDC. -
Fix deploy concurrency (F4) — add
concurrency:group withcancel-in-progress: falsetodeploy.yml. -
Add Go module cache (F3) — update
ci.ymlto useactions/setup-go@v5withcache: true. -
Fix secret interpolation (F5) — move
${{ secrets.DB_URL }}toenv:block in the integration test step. -
Add actionlint (F6) — add a workflow validation step or pre-push hook.
Validation commands:
# Install and run actionlint locally:
brew install actionlint
actionlint .github/workflows/*.yml
# Verify SHA pins resolve to the correct tags:
gh api /repos/actions/checkout/git/refs/tags/v4 --jq '.object.sha'
# Dry-run a workflow locally with act:
act pull_request --dry-run
---
## Notes
- `/gha-review` is read-only. No workflow files are modified.
- Always include the `actionlint` validation command in the remediation section
— it catches expression and syntax errors that static review can miss.
- Supply-chain findings (F1) and over-privilege findings (F2) should always
be listed first, regardless of the order they appear in the workflow files.
- For `cancel-in-progress`: use `true` for CI workflows (save minutes) and
`false` for deploy workflows (prevent partial rollouts).