
GitHub Actions Workflow Lockfiles Are Coming
.png)
actionlint and OpenSSF Scorecards catch expression injection, deprecated syntax, and misconfigurations in your workflow YAML before they reach production.
Workflow YAML is code. It controls what runs in your CI/CD pipeline, what secrets get exposed, and what artifacts ship to production. Yet most teams review it with a fraction of the rigor they'd apply to a pull request touching application logic. A misspelled key, an unpinned action, or an expression injection vulnerability can sit in a workflow file for months before anyone notices.
Static analysis tools fix this gap. They parse your workflow files, check them against known rules, and surface problems before they hit production. Two tools stand out: actionlint for deep syntax and type checking of workflow files, and OpenSSF Scorecards for scoring your repository's overall CI security posture. Combined with custom policy gates, they form a first line of defense for your pipelines.
actionlint is a static checker for GitHub Actions workflow files written in Go. It has over 3,700 stars on GitHub, and the latest release (v1.7.11, February 2026) keeps pace with GitHub's evolving runner labels, action metadata, and expression syntax. Unlike generic YAML linters, actionlint understands the Actions workflow schema deeply. It doesn't just validate YAML structure; it type-checks expressions, validates action inputs, and catches security issues.
You can install actionlint with Homebrew, Go, or by downloading a prebuilt binary from the GitHub releases page:
# Homebrew
brew install actionlint
# Go install
go install github.com/rhysd/actionlint/cmd/actionlint@latest
# Or download the binary
curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bashRun it from the root of any repository that contains a .github/workflows directory. No arguments needed. actionlint finds the workflow files automatically and reports every error with file, line, and column numbers.
$ actionlint
.github/workflows/ci.yml:10:28: label "linux-latest" is unknown. available labels are "ubuntu-latest", ... [runner-label]
.github/workflows/ci.yml:13:41: "github.event.head_commit.message" is potentially untrusted. avoid using it directly in inline scripts [expression]
.github/workflows/ci.yml:17:11: input "node_version" is not defined in action "actions/setup-node@v4". available inputs are "node-version", ... [action]Three errors from three completely different categories, caught in under a second. That's the power of a domain-specific linter versus a generic YAML validator.
actionlint runs over a dozen rule categories. Here are the ones that catch the most real-world bugs:
Expression injection detection. This is the big one. If you write echo "${{ github.event.pull_request.title }}" in a run: step, an attacker who controls the PR title can inject arbitrary shell commands into your workflow. actionlint flags these untrusted inputs and tells you to pass them through environment variables instead. This single check has probably prevented more real exploits than everything else combined.
Expression type checking. actionlint maintains a type system for GitHub Actions context objects. If you write github.repository.permissions.admin, it knows that github.repository is a string (the repo name), not an object, so you can't dereference .permissions on it. Same for matrix property access: reference matrix.platform when your matrix only defines os, and actionlint flags it immediately.
Action input/output validation. actionlint ships a built-in database of popular action metadata. Write node_version instead of node-version in your actions/setup-node step, and it'll tell you that input doesn't exist. This catches typos that would otherwise silently do nothing.
Runner label validation. Use linux-latest instead of ubuntu-latest? actionlint knows all the valid GitHub-hosted runner labels (including newer ones like macos-26-intel) and flags unknown ones. You can also configure custom labels for self-hosted runners.
Deprecated command detection. Still using ::set-output or ::save-state workflow commands? These have been deprecated since October 2022 in favor of environment files. actionlint catches them so you can migrate before GitHub drops support entirely.
shellcheck and pyflakes integration. If shellcheck is installed on your system, actionlint pipes your run: scripts through it automatically. Same for pyflakes with Python scripts. This means your inline bash gets the same quality checks as standalone scripts.
The most valuable place to run actionlint is in CI itself, linting your own workflows as part of your pipeline. Here's a minimal workflow that does exactly that:
name: Lint Workflows
on:
pull_request:
paths:
- '.github/workflows/**'
push:
branches: [main]
paths:
- '.github/workflows/**'
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install actionlint
run: bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
- name: Run actionlint
run: ./actionlint -colorThe paths filter means this job only runs when workflow files change. No wasted minutes. The checkout action is pinned to a full SHA, which is itself a best practice that actionlint encourages (more on that later).
For a quicker setup, you can also use the Problem Matchers integration. actionlint outputs errors in a format that GitHub Actions can parse natively, so lint errors appear as annotations directly on the PR diff.
Where actionlint zooms in on individual workflow files, OpenSSF Scorecards zooms out. It evaluates your entire repository's security posture across 16+ automated checks, each scored 0-10. The project has over 5,300 stars and is maintained by the Open Source Security Foundation. The latest release is v5.4.0.
Several Scorecard checks directly evaluate your workflow files:
@v4 or :latest.toJSON(github.event) and other untrusted inputs in run steps.The quickest way to check a public project is the web viewer at scorecard.dev. For your own repositories, the Scorecard GitHub Action is the recommended approach:
name: Scorecard Analysis
on:
schedule:
- cron: '30 1 * * 1' # Weekly on Monday
push:
branches: [main]
permissions: read-all
jobs:
analysis:
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarifSetting publish_results: true pushes your scores to the public Scorecard API, which lets you display a badge in your README and makes your scores available through the REST API at api.scorecard.dev. Uploading the SARIF file to GitHub's code scanning means findings appear in the Security tab alongside CodeQL results.
You can also run the CLI locally. Install via Homebrew (brew install scorecard) or Docker (docker pull ghcr.io/ossf/scorecard:latest), set a GITHUB_AUTH_TOKEN environment variable, and point it at any repo:
export GITHUB_AUTH_TOKEN=ghp_your_token
scorecard --repo=github.com/your-org/your-repo --show-detailsactionlint and Scorecards cover a lot of ground, but every organization has its own rules. Maybe you require all workflows to include a specific compliance step. Maybe self-hosted runners are forbidden on public repos. Maybe every action must be pinned to a SHA. These org-specific policies need custom enforcement.
Open Policy Agent (OPA) with Rego is a natural fit. You parse the workflow YAML into JSON, then write Rego rules that evaluate it. Here's a policy that rejects any workflow using unpinned actions (tags instead of SHAs):
package workflow.pinned_actions
import rego.v1
violation contains msg if {
some job_name, job in input.jobs
some step in job.steps
uses := step.uses
not contains(uses, "@sha256:")
not regex.match(`@[0-9a-f]{40}`, uses)
msg := sprintf("Job '%s' uses unpinned action: %s", [job_name, uses])
}And another that blocks self-hosted runners in repos marked as public:
package workflow.no_selfhosted_public
import rego.v1
violation contains msg if {
some job_name, job in input.jobs
labels := job["runs-on"]
is_array(labels)
some label in labels
label == "self-hosted"
msg := sprintf("Job '%s' uses self-hosted runner, forbidden on public repos", [job_name])
}To run these in CI, convert the workflow YAML to JSON with yq, then pipe it to opa eval:
for file in .github/workflows/*.yml; do
yq -o json "$file" | opa eval \
--data policies/ \
--input /dev/stdin \
'data.workflow.pinned_actions.violation' \
--format pretty
doneIf the violation set is non-empty, fail the build. This turns your policy documentation into executable, testable code.
The three tools complement each other. actionlint catches syntax and type errors at the file level. Scorecards evaluates broader security hygiene. OPA policies enforce your org-specific rules. Here's how to combine them into a single pipeline that triggers only when workflow files change:
name: Workflow Lint
on:
pull_request:
paths:
- '.github/workflows/**'
permissions:
contents: read
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
- run: ./actionlint -color
policy-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install tools
run: |
curl -sL https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -o yq && chmod +x yq
curl -sL https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static -o opa && chmod +x opa
- name: Evaluate policies
run: |
violations=0
for file in .github/workflows/*.yml .github/workflows/*.yaml; do
[ -f "$file" ] || continue
result=$(./yq -o json "$file" | ./opa eval --data policies/ --input /dev/stdin 'data.workflow[_].violation' --format json)
count=$(echo "$result" | jq '[.result[].expressions[].value | length] | add // 0')
if [ "$count" -gt 0 ]; then
echo "::error file=$file::$count policy violation(s) found"
violations=$((violations + count))
fi
done
[ "$violations" -eq 0 ] || exit 1The actionlint job runs fast (typically under 5 seconds) and catches the structural errors. The policy-check job handles your organization's custom rules. Run Scorecards on a weekly schedule separately since it calls the GitHub API and is better suited to periodic audits than per-PR checks.
After running these tools across enough repositories, patterns emerge. These are the workflow anti-patterns I see most often, and each one is detectable with the tooling described above.
Using actions/checkout@v4 instead of a SHA pin means a compromised tag could inject malicious code into every workflow that references it. The reviewdog supply chain attack in March 2025 proved this isn't theoretical. Pin to full commit SHAs and add a comment with the version for readability:
# Good: SHA-pinned with version comment
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Bad: mutable tag
- uses: actions/checkout@v4Scorecard's Pinned-Dependencies check flags this. Dependabot can also automate SHA-pin updates.
This is the most dangerous class of workflow vulnerability. Any expression that interpolates user-controlled data directly into a run: block is exploitable. The fix is always the same: pass the value through an environment variable.
# Vulnerable: attacker controls PR title
- run: echo "PR title is ${{ github.event.pull_request.title }}"
# Safe: passed through env var
- run: echo "PR title is $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}actionlint knows which context properties are untrusted (github.event.pull_request.title, github.event.head_commit.message, github.event.issue.body, and others) and flags them when they appear in inline scripts.
If your workflow doesn't set permissions: at the top level, the GITHUB_TOKEN gets default permissions that are far broader than most jobs need. Scorecard's Token-Permissions check catches this. The fix is simple: declare permissions: read-all at the workflow level, then grant specific write permissions only where needed at the job level.
GitHub Actions masks secrets in logs automatically, but only the exact secret value. If your workflow transforms a secret (base64-encoding it, for instance) or passes it through a tool that reformats it, the transformed value won't be masked. actionlint's credentials check flags hardcoded passwords in workflow files, and a custom OPA policy can require that all run: steps that reference secrets also call ::add-mask:: for any derived values.
YAML is forgiving in all the wrong ways. Write branch: main instead of branches: [main] in your push trigger, and the workflow silently ignores the filter, triggering on every push to every branch. actionlint's syntax checker catches this because it knows the expected keys for each workflow section. These are the bugs that waste hours of debugging time before someone finally spots the typo.
If you're starting from scratch, here's a practical order of adoption:
Workflow YAML deserves the same treatment as any other code in your repository. The tooling exists, it's free, and it takes less than an hour to set up. The question isn't whether you should lint your workflows. It's why you haven't started yet.

GitHub Actions Workflow Lockfiles Are Coming

OpenTelemetry for GitHub Actions: Traces, OTLP, and Pipeline Flamegraphs

From Provision to Shutdown: The Lifecycle of a Tenki Runner
Get Tenki
Change 1 line of YAML for faster runners. Install a GitHub App for AI code reviews. No credit card, no contract. Takes about 2 minutes.