A Claude Code plugin that lints GitHub Actions workflow files the moment you
edit them. On every Write or Edit of a file under .github/workflows/
(*.yml or *.yaml) it runs actionlint
and surfaces any findings back to Claude as advisory context.
It ships no rules of its own and no binary. It runs the actionlint already on
your PATH.
- Lint on edit. actionlint runs on every edit of a workflow file. It is non-mutating; it only reports.
- Advisory, never blocking. The hook always exits
0. Findings are reported viaadditionalContext; they never reject the edit. Make a commit hook or CI your hard gate. - Scoped to workflows. Only files matching
.github/workflows/*.ymland.github/workflows/*.yamlare linted. Other YAML is left alone. The registration carries the matchingiffilters (Edit(**/.github/workflows/*.yml)and the.yamltwin), so a Write/Edit of any other file never starts a hook process for it. - Gitignored paths are out of scope. A workflow file the repository gitignores
is not reported. Set
actionlint_lint_gitignoredtotrueto lint gitignored workflow files too. A tracked file that matches an ignore pattern stays in scope. - External run-block linters disabled (
-shellcheck= -pyflakes=). actionlint's embedded-bash ShellCheck andshell: pythonpyflakes integrations are turned off. Each spawns a subprocess perrun:block. ShellCheck deadlocks on large blocks under the Windows subprocess IPC path in actionlint 1.7.x, and either adds latency unsuited to an edit-time hook. Native workflow diagnostics are unaffected; run the full integrations in CI. - Graceful degrade. When
actionlint(orjq) is not onPATHthe hook skips and says so, to both Claude (additionalContext) and you (systemMessage), never a silent no-op. A missingactionlintnotice fires once per session, and every agent in the session shares it. A missingjqnotice fires once per session and agent. Both renew every eighth skip; theactionlintrenewal keeps the install route. A missingnodeis the exception: the hook does not launch, so it cannot say anything itself. The transcript shows a hook error notice, and lint does not run.
- Bash. The hook is a Bash script. On native Windows, install Git for Windows so Claude Code can run it under Git Bash.
- Node.js on
PATH. Every hook row launches throughnode hooks/exec-bash.mjs, which finds Bash and runs the script. Absent: the hook does not launch and lint does not run./actionlint:setup checkreports it. Install Node.js. - jq on
PATH. Parses the hook payload. Absent: the hook skips with a visible notice, once per session and agent, renewed every eighth skip. Install jq. - actionlint on
PATH. The linter itself. Absent: workflow lint skips with a visible notice, once per session (all agents share it), renewed every eighth skip with the install route kept. See the actionlint install guide. A SessionStart probe reports a missingactionlintonce per session, fromprerequisites.json, and the PostToolUse notice names the same install route. The two share one latch, so the probe's notice counts as the first and the first PostToolUse notice stays silent until the renewal./actionlint:checkreports whether the binary resolves and installs nothing.
Per docs/conventions/hook-budget/README.md,
this hook is always-on for every Write and Edit of a .yml or .yaml file under
.github/workflows/ (every PostToolUse handler in hooks/hooks.json carries one of the two if rows,
which keep every other file from spawning it, and the suite pins the whole handler set to the
script's own workflow filter), so its cost on a clean workflow is the figure that counts.
Measured on Linux x86_64 under bash 5.2 in a container with HOOK_TELEMETRY_SINK and
CLAUDE_PROJECT_DIR unset, twelve interleaved trials against an interleaved bash -c : floor
S of about 4 ms, with a kernel census from strace -f -e trace=clone,clone3,fork,vfork,execve
(2026-09-07, 0.8.43). It predates two later spawns on this path: the gitignore check
(hook::file_is_gitignored, one git check-ignore, 0.9.0) and the node launcher (0.9.1). The
figures below exclude both:
| Event | Fires | Wall | Spawn-equivalents | Kernel census |
|---|---|---|---|---|
PostToolUse Write, clean .github/workflows/*.yml |
1 | 34 ms | 7.9 | 14 process creations (one trial in nine reached 15; actionlint's own threads vary), 5 execs: actionlint, git, two jq, the hook's own bash |
PostToolUse Write, any other file |
0 | none | 0 | no process; the if rows drop the handler before a spawn |
At a 4 ms floor the spawn-equivalent column mostly measures actionlint's own run time rather
than spawns, so the census column is the number that transfers to the Windows 11 Git Bash
reference host the convention calls binding; that host's figure for this plugin has not been
taken. The residual is actionlint itself plus the payload parse (jq) and the root resolver
(git).
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install actionlint@<marketplace>Then verify prerequisites with /actionlint:setup check.
actionlint auto-discovers its own .github/actionlint.yaml config from your
repository when present. Three userConfig options tune the hook itself:
actionlint_enabled(boolean, defaulttrue). Kill switch for the actionlint-check hook.actionlint_lint_gitignored(boolean, defaultfalse). Settrueto lint a workflow file the repository gitignores. Off by default.stdin_read_timeout(number, default2, minimum1). Idle bound in seconds on reading the hook payload from stdin. Any byte arriving resets it, so a large or slowly-delivered payload is never cut off while it is still coming; it fires only once the pipe has gone silent for that long, and this hook then fails open (skips). On a shell whoseread -taccepts fractional values the bound is read in four slices, so the stall is detected within a quarter of the configured interval; where fractional timeouts are unavailable (Bash 3.2, the macOS system shell) the bound is read as one window and a producer that sends bytes then goes silent can take up to two intervals. A producer that keeps emitting is bounded by Claude Code's own hook timeout, not by this value. If this shell'sread -twill not accept the setting, or the setting is0, the hook falls back to the default.
Configure interactively with /plugin configure actionlint@<marketplace> or headless at
install time:
claude plugin install actionlint@<marketplace> --config actionlint_enabled=falseGenerated from this plugin's .claude-plugin/plugin.json. Every option Claude Code
will prompt for when the plugin is enabled, with the environment variable each hook
reads it from.
| Option | Type | Default | Environment variable | Description |
|---|---|---|---|---|
actionlint_enabled |
boolean | true |
CLAUDE_PLUGIN_OPTION_ACTIONLINT_ENABLED |
Lint GitHub Actions workflow files on edit via actionlint |
actionlint_lint_gitignored |
boolean | false |
CLAUDE_PLUGIN_OPTION_ACTIONLINT_LINT_GITIGNORED |
By default the hook leaves a file the repository gitignores alone: it is not reported. Set true to lint gitignored workflow files too. A tracked file that matches an ignore pattern is always in scope. |
stdin_read_timeout |
number min 1 |
2 |
CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT |
Idle bound on reading the hook payload from stdin: how long the pipe may go silent before the hook gives up and fails open |
Three supported routes, in the order most people want them:
-
Interactively. Claude Code prompts for declared options when you enable the plugin. To change them later:
/plugin configure actionlint@<marketplace>. -
Headless. Repeat
--configfor each option. Replace<marketplace>with the marketplace you installed this plugin from:claude plugin install actionlint@<marketplace> -s <scope> --config actionlint_enabled=<value>
The same command reconfigures a plugin that is already installed: it prints
already installedand still writes the value. The short-circuit message is about the install, not the config write. Do notclaude plugin uninstallto reconfigure: uninstalling drops this plugin's whole storedpluginConfigsentry, resetting every option in the table above to its default.-sdefaults touser, so pass the scopeclaude plugin listreports for this plugin. The verified-version record lives in the plugin-reconfiguration convention.The value is stored immediately; the session you are in does not change. Hooks are handed their
CLAUDE_PLUGIN_OPTION_*when the session starts, so start a fresh Claude Code session before expecting new behavior. A check run in the old session still reports the old value, and that is not a failed write. -
By hand, in settings. Add the value under
pluginConfigsin your user settings (~/.claude/settings.json):{ "pluginConfigs": { "actionlint@<marketplace>": { "options": { "actionlint_enabled": <value> } } } }Plugin option values are read from user,
--settings, and managed settings only, not from a project's.claude/settings.json. To vary behavior per repository, enable or disable the plugin in that project'senabledPluginsinstead of setting an option there.
Do not set the CLAUDE_PLUGIN_OPTION_* variables yourself. They are how Claude Code
hands a configured value to a hook process; the value comes from the routes above.
- User configuration: the
userConfigschema and theCLAUDE_PLUGIN_OPTION_<KEY>export - Plugin install options: the
--configflag's reference entry - Plugins and skills settings:
enabledPlugins,extraKnownMarketplaces,pluginConfigs - Settings files and who they affect: user vs project vs local precedence
- Manage installed plugins: enabling, disabling,
/plugin list
MIT (SPDX-License-Identifier: MIT).