ยท hands on
Are You Linting Your GitHub Actions?
One invalid line in a workflow file disabled my GitHub Action and failed every single push. Learn how to catch broken workflow files at commit time with JSON Schema validation straight from npm.
I woke up to a failed GitHub Actions run in my trading-signals repository that made no sense at first glance. There was no red test and no failing job. In fact, there were no jobs at all. The run carried the name of the workflow file itself and a single hint. "This run likely failed because of a workflow file issue."
Here is what happened and how one npm package stops it from ever being committed.
Contents
A Failed Run with Zero Jobs
When a test breaks, you click into the failing step and read the stack trace. This run had nothing to click into, because GitHub had refused to parse the workflow file and no job ever started. The only concrete detail was this error message.
Invalid workflow file: .github/workflows/claude.yml#L1
(Line: 27, Col: 7): Unexpected value 'workflows'The workflow in question triggers Claude Code whenever someone mentions @claude in an issue or pull request. Line 27 sits in its permissions block, which looked perfectly reasonable.
permissions:
contents: write
pull-requests: write
issues: read
id-token: write
actions: read
workflows: writeLine 27 is the bug. The action pushes commits that may modify workflow files, so surely it needs workflows: write. That reasoning is intuitive and wrong. There is no permission scope called workflows.
Linting GitHub Actions
GitHub Actions workflow files are described by a JSON Schema that is maintained on SchemaStore. It is the same spec file that powers autocompletion in VS Code's YAML extension. The schema knows exactly which permission keys exist.
"permissions-event": {
"type": "object",
"additionalProperties": false,
"properties": {
"actions": {"$ref": "#/definitions/permissions-level"},
"contents": {"$ref": "#/definitions/permissions-level"},
"issues": {"$ref": "#/definitions/permissions-level"}
}
}The additionalProperties: false line does the heavy lifting. Any key outside the allowed set becomes a validation error. This is the same excess property check you already rely on in TypeScript object literals, applied to YAML.
Two tools can catch this class of mistake before GitHub does.
actionlint
actionlint goes deeper than schema validation. It also type-checks ${{ }} expressions and runs ShellCheck on run: blocks. I decided against it because it is a Go binary installed via Homebrew or Docker, so it lives outside package.json and every machine has to install it separately.
action-validator
The action-validator package wraps the SchemaStore schema in a command-line tool. It is a Rust validator compiled to WebAssembly and shipped via npm, so it becomes a regular devDependency that is versioned in package.json and locked in package-lock.json. There is no brew install step that every contributor and CI machine has to remember, and WebAssembly means there are no platform-specific binaries to worry about across macOS, Linux, and CI. In a Node.js project, a tool that arrives with npm install beats a more thorough one that needs its own setup.
npm i -D @action-validator/cli @action-validator/coreThe CLI declares @action-validator/core as a peer dependency, so install both explicitly to pin the pairing in your own manifest. Running the validator against my broken file names the exact bug, including the path to it.
npx action-validator .github/workflows/claude.yml{
"code": "properties",
"detail": "Additional property 'workflows' is not allowed",
"path": "/jobs/claude/permissions"
}Validate at Commit Time with Lefthook
Knowing the tool exists is not enough, because nobody remembers to run a validator by hand. I use lefthook for Git hooks, and the whole integration is three lines.
pre-commit:
commands:
oxc:
run: npx oxlint --fix {staged_files}
action-validator:
glob: '.github/workflows/*.{yml,yaml}'
run: npx action-validator {staged_files}The glob filter means the hook only fires when a workflow file is actually staged, so the vast majority of commits pay zero overhead. When a workflow file is staged, npx resolves the binary from local node_modules, which keeps the check fast and working offline. With this in place, the original bug dies at commit time instead of disabling a workflow in production.
