:octocat: Static checker for GitHub Actions workflow files
See the codeCatch 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.
${{ }} expressions, wrong inputs of actions and reusable workflows, and broken needs:, cron and glob patterns. shellcheck and pyflakes check the scripts of run: steps.pull_request_target, cache poisoning, credentials left in the checkout and AI agent actions that outsiders can steer.permissions:, a job timeout or a concurrency group.jactionlint --fix repairs what has a mechanical fix, and --diff shows the change first.--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
jactionlint
With 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:
brew tap jdx/jactionlint https://github.com/jdx/jactionlint, then brew install --cask jdx/jactionlint/jactionlint.uvx jactionlint runs it without installing it, and pipx install jactionlint or uv tool install jactionlint installs it.ghcr.io/jdx/jactionlint also holds shellcheck and pyflakes. Run the image shows the command.go install github.com/jdx/jactionlint/v2/cmd/jactionlint@latest.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@1027f0a131ab98bc7e191d995bfa15dbed5bbf8f # v2.2.0
Set 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 findings
Silence 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.
--profile correctness keeps 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.# zizmor: ignore[...] comments keep working. jactionlint and zizmor maps the audits, the personas and the settings of zizmor.yml.jactionlint.jdx.dev has the full documentation, and the release archives hold the same pages. These are the ones to start with:
.github/jactionlint.yaml, profiles and rule levels.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.
2,860 followers · starred Oct 2026
8,275 followers · starred Oct 2026
234 followers · starred Oct 2026
170 followers · starred Oct 2026
:octocat: Static checker for GitHub Actions workflow files
See the codeCatch 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.
${{ }} expressions, wrong inputs of actions and reusable workflows, and broken needs:, cron and glob patterns. shellcheck and pyflakes check the scripts of run: steps.pull_request_target, cache poisoning, credentials left in the checkout and AI agent actions that outsiders can steer.permissions:, a job timeout or a concurrency group.jactionlint --fix repairs what has a mechanical fix, and --diff shows the change first.--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
jactionlint
With 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:
brew tap jdx/jactionlint https://github.com/jdx/jactionlint, then brew install --cask jdx/jactionlint/jactionlint.uvx jactionlint runs it without installing it, and pipx install jactionlint or uv tool install jactionlint installs it.ghcr.io/jdx/jactionlint also holds shellcheck and pyflakes. Run the image shows the command.go install github.com/jdx/jactionlint/v2/cmd/jactionlint@latest.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@1027f0a131ab98bc7e191d995bfa15dbed5bbf8f # v2.2.0
Set 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 findings
Silence 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.
--profile correctness keeps 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.# zizmor: ignore[...] comments keep working. jactionlint and zizmor maps the audits, the personas and the settings of zizmor.yml.jactionlint.jdx.dev has the full documentation, and the release archives hold the same pages. These are the ones to start with:
.github/jactionlint.yaml, profiles and rule levels.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.
2,860 followers · starred Oct 2026
8,275 followers · starred Oct 2026
234 followers · starred Oct 2026
170 followers · starred Oct 2026