GitHub Actions Linter
Lint GitHub Actions workflow files for syntax errors, security issues, deprecated actions, and best practices violations.
Commands
All commands use the bundled Python script at scripts/gha_linter.py.
1. Lint a workflow file
python3 scripts/gha_linter.py lint <file-or-directory> [--strict] [--format text|json|markdown]
Runs all lint rules against one or more workflow files. If given a directory, scans for *.yml and *.yaml files recursively.
Flags:
--strict— exit code 1 on any warning (not just errors)--format— output format:text(default),json,markdown
2. Audit for security issues
python3 scripts/gha_linter.py security <file> [--format text|json|markdown]
Focused security audit: shell injection via ${{ }} in run:, hardcoded secrets, overly permissive permissions, untrusted event contexts in expressions.
3. Check for deprecated actions
python3 scripts/gha_linter.py deprecated <file> [--format text|json|markdown]
Detect outdated action versions (e.g., actions/checkout@v2, actions/setup-node@v3 when v4 exists) and suggest upgrades.
4. Validate workflow structure
python3 scripts/gha_linter.py validate <file> [--format text|json|markdown]
Structural validation only: required keys (on, jobs), valid trigger events, valid runs-on labels, job dependency graph (circular deps, missing refs).
Lint Rules (28 total)
Syntax & Structure (8 rules)
- missing-on — Workflow missing
ontrigger - missing-jobs — Workflow missing
jobssection - empty-jobs — Jobs section is empty
- missing-runs-on — Job missing
runs-on - missing-steps — Job missing
steps - empty-steps — Steps list is empty
- invalid-trigger — Unknown trigger event name
- circular-deps — Circular job dependency via
needs
Security (8 rules)
- shell-injection —
${{ }}expression inrun:(potential injection) - hardcoded-secret — Hardcoded password/token/key patterns in workflow
- permissive-permissions —
permissions: write-allor no permissions block - untrusted-context — Dangerous contexts in expressions (
github.event.issue.title,github.event.pull_request.body, etc.) - pull-request-target —
pull_request_targetwith checkout of PR head (known attack vector) - third-party-action — Non-verified third party action without pinned SHA
- env-in-run — Secret used directly in
run:instead of viaenv: - excessive-permissions — Job requests more permissions than needed
Deprecated & Outdated (4 rules)
- deprecated-action — Action version is outdated (v1/v2 when v4 exists)
- deprecated-runner — Using deprecated runner labels (ubuntu-18.04, macos-10.15)
- set-output-deprecated — Using deprecated
::set-output::command - save-state-deprecated — Using deprecated
::save-state::command
Best Practices (8 rules)
- missing-timeout — Job without
timeout-minutes(default 6h is dangerous) - missing-name — Step without
name(harder to debug) - latest-tag — Action pinned to
@mainor@master(unstable) - no-concurrency — Workflow without
concurrency(can waste resources) - hardcoded-runner — Hardcoded runner version instead of
-latest - long-run-command —
run:block exceeds 50 lines (should be a script) - duplicate-step-id — Duplicate
idin steps within same job - missing-if-continue —
continue-on-error: truewithout explanation comment
Output Formats
Text (default)
workflow.yml:12:3 error [shell-injection] Expression ${{ github.event.issue.title }} in run: is vulnerable to injection
workflow.yml:25:5 warning [missing-timeout] Job 'build' has no timeout-minutes (default: 360 min)
workflow.yml:31:7 warning [missing-name] Step at index 2 has no name
3 issues (1 error, 2 warnings)
JSON
{
"file": "workflow.yml",
"issues": [...],
"summary": {"errors": 1, "warnings": 2, "info": 0}
}
Markdown
Summary table with severity, rule, location, and message.
CI Integration
# .github/workflows/lint-actions.yml
name: Lint Workflows
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 scripts/gha_linter.py lint .github/workflows/ --strict
Exit codes: 0 = clean, 1 = errors found (or warnings in --strict mode).