Catch broken and insecure GitHub Actions workflows before they run.
jactionlint is a static checker for GitHub Actions. It reads your workflows, composite actions and dependabot.yml and reports two kinds of problems: the mistakes that make a run fail or misbehave, and the patterns that let an outsider run code in your repository or steal its token. It started as a fork of actionlint and covers its checks, and its security rules follow the audits of zizmor.
- Correctness. Unknown keys and events, type errors in
${{ }}expressions, wrong inputs of actions and reusable workflows, and brokenneeds:, cron and glob patterns. shellcheck and pyflakes check the scripts ofrun:steps. - Security. Script injection, excessive token permissions, dangerous triggers such as
pull_request_target, cache poisoning, credentials left in the checkout and AI agent actions that outsiders can steer. - Policy. Actions used by a tag instead of a commit, and workflows without
permissions:, a job timeout or a concurrency group. - Fixes.
jactionlint --fixrepairs what has a mechanical fix, and--diffshows the change first. - Gradual adoption. Profiles choose how much is checked, and a baseline fails the build only on new findings.
- Local runs.
jactionlint run ciruns the jobs of a workflow on your machine, every step in an OS sandbox, with a local actions cache and no container engine unless the workflow asks for one. See Run workflows locally. - No network unless you ask. The online checks, which ask GitHub about archived repositories, impostor commits and published advisories, run only with
--online.
Try it without installing anything in the playground, which runs jactionlint in your browser.
Install jactionlint with mise and run it at the root of a repository:
mise use -g jactionlint
jactionlintWith no arguments, jactionlint checks the workflows, composite actions, dependabot.yml and Actions lock file of the repository. Pass file names to check only those, or - to read standard input. It exits with status 1 when a finding of level error is left.
Other ways to install it:
- Homebrew:
brew tap jdx/jactionlint https://github.com/jdx/jactionlint, thenbrew install --cask jdx/jactionlint/jactionlint. - PyPI:
uvx jactionlintruns it without installing it, andpipx install jactionlintoruv tool install jactionlintinstalls it. - Docker: the image
ghcr.io/jdx/jactionlintalso holds shellcheck and pyflakes. Run the image shows the command. - Go:
go install github.com/jdx/jactionlint/v2/cmd/jactionlint@latest. - Release archives: download the archive for your platform from the releases and verify it.
Packages named actionlint, as in Homebrew core, install the original project, not this fork. Installation covers every method.
This workflow has a typo in a trigger, untrusted input in a script and an undefined matrix property. It also uses an action by tag and sets no permissions, timeout or concurrency group. Save it as test.yaml:
on:
push:
branch: main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "${{ github.event.head_commit.message }}"
- run: echo "${{ matrix.os }}"jactionlint test.yaml prints each finding with its position, a message, the line it points at and, in brackets, the ID of its rule:
test.yaml:1:1: workflow has no "concurrency:", so every run of it executes at the same time even when a newer run supersedes the older ones. add a top-level "concurrency:" with a "group:" and "cancel-in-progress: true" [concurrency-limits]
|
1 | on:
| ^~~
test.yaml:3:5: unexpected key "branch" for "push" section. expected one of "branches", "branches-ignore", "paths", "paths-ignore", "tags", "tags-ignore", "types", "workflows" [workflow-syntax]
|
3 | branch: main
| ^~~~~~~
test.yaml:5:3: "timeout-minutes" is not set at this job. Set it to avoid wasting runner minutes when the job hangs. Set "default-minutes" in the "missing-timeout" rule options of the configuration to turn on the --fix fix [missing-timeout]
|
5 | test:
| ^~~~~
test.yaml:5:3: neither the workflow nor this job sets "permissions:" so the GITHUB_TOKEN gets the default permissions of the repository. set "permissions:" at workflow-level or job-level (use "permissions: {}" for no permissions) because the "missing-permissions" rule is enabled [missing-permissions]
|
5 | test:
| ^~~~~
test.yaml:8:15: action "actions/checkout@v4" must be pinned to a full-length commit SHA like "{owner}/{repo}@{sha}" because the "unpinned-uses" rule is enabled [unpinned-uses]
|
8 | - uses: actions/checkout@v4
| ^~~~~~~~~~~~~~~~~~~
test.yaml:8:15: actions/checkout leaves the GITHUB_TOKEN in the git config of the workspace, where a later step such as an artifact upload can publish it. set "persist-credentials: false" under "with:" unless a later step needs to push [artipacked]
|
8 | - uses: actions/checkout@v4
| ^~~~~~~~~~~~~~~~~~~
test.yaml:9:24: "github.event.head_commit.message" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [template-injection]
|
9 | - run: echo "${{ github.event.head_commit.message }}"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
test.yaml:10:24: property "os" is not defined in object type {} [undefined-property]
|
10 | - run: echo "${{ matrix.os }}"
| ^~~~~~~~~
jactionlint --explain template-injection prints what a rule checks, why it matters and how to fix it. With --profile correctness, jactionlint reports only workflow-syntax, template-injection and undefined-property. The other five findings come from the security and policy rules of the default profile, which applies when you configure nothing.
jactionlint --diff test.yaml previews the safe fixes, and jactionlint --fix test.yaml applies them. Here they stop actions/checkout from persisting the token, give the token read-only permissions and pass the untrusted message through an environment variable:
--- a/test.yaml
+++ b/test.yaml
@@ -1,10 +1,17 @@
on:
push:
branch: main
+
+permissions:
+ contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - run: echo "${{ github.event.head_commit.message }}"
+ with:
+ persist-credentials: false
+ - run: echo "${HEAD_COMMIT_MESSAGE}"
+ env:
+ HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
- run: echo "${{ matrix.os }}"The jactionlint action installs jactionlint, checks the repository and annotates the pull request with the findings. This workflow passes the default profile itself:
name: jactionlint
on:
push:
branches: [main]
pull_request:
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
jactionlint:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false
- uses: jdx/jactionlint@9e87e8f34edfc00b849a27807e26eff9d00b936d # v2.5.0Set advanced-security: true under with: to upload the findings to code scanning instead, which needs security-events: write. Run jactionlint in CI also covers mise, which verifies the download, the Docker image and problem matchers. Git hooks and editors covers hk and pre-commit.
The default profile can fail the first run on a repository that never followed it. Choose how much is checked with --profile, or with profile: in .github/jactionlint.yaml:
| Profile | What it checks |
|---|---|
correctness |
The checks of actionlint and the bug detectors of jactionlint. |
default |
correctness, plus the security and policy rules worth failing a build on. It applies when you configure nothing. |
pedantic |
default, plus the noisier and opinionated rules. |
Fix what the correctness profile reports first, because those findings are mistakes, not debt. Then record what the default profile still finds, and let the build fail only on new findings:
jactionlint --profile correctness # fix these first
jactionlint --baseline-write # record the findings that are left
jactionlint --baseline # fail only on new findingsSilence a finding that you accept with a comment such as # jactionlint ignore=unpinned-uses on the line above it. jactionlint --init-config writes a config file to start from. See Profiles, Adopt new checks with a baseline and Ignore findings.
- From actionlint. jactionlint reads the config file of actionlint.
--profile correctnesskeeps the checks that you have, and the default profile adds the security and policy rules when you are ready for them. Coming from actionlint lists the renamed options and the differences. - From zizmor. Each audit of zizmor 1.30.1 has a rule, under the same ID in every case but one, and
# zizmor: ignore[...]comments keep working. jactionlint and zizmor maps the audits, the personas and the settings ofzizmor.yml.
jactionlint.jdx.dev has the full documentation, and the release archives hold the same pages. These are the ones to start with:
- Installation and Run jactionlint: install it, run it and read a finding.
- Checks and Rules: what each rule finds, with an example and the fix.
- Fix findings automatically: safe and unsafe fixes, and why a finding has none.
- Configuration:
.github/jactionlint.yaml, profiles and rule levels. - Go API: use jactionlint from a Go program.
The site documents the main branch. jactionlint --help lists what your installed version supports.
To report a bug, a false positive or an idea for a check, open an issue with a minimal workflow that shows it. CONTRIBUTING.md explains how to build and test jactionlint and how to propose a change.
jactionlint is distributed under the MIT license. It is a fork of actionlint by rhysd, and its security rules follow the audits of zizmor. NOTICE.md names every project that it builds on.