Skip to content

Workflow Linting with actionlint

Workflow Linting with actionlint

This document describes the actionlint pre-push hook and CI gate added to prevent workflow configuration errors from reaching CI.

Background

Broken workflow YAML is a high-impact failure mode: when .github/workflows/ci.yml or a related file is misconfigured, all CI stops until the error is found and fixed. A 5-hour CI incident prompted the introduction of two complementary safety nets that catch these errors as early as possible in the development cycle.

Safety Net 1 — Pre-Push Hook (.pre-commit-config.yaml)

The repo root contains a pre-commit configuration that runs actionlint as a pre-push hook:

repos:
- repo: https://github.com/rhysd/actionlint
rev: v1.7.7
hooks:
- id: actionlint

Setup

Install the pre-commit framework (once per machine):

Terminal window
pip install pre-commit
# or
brew install pre-commit

Then install the hooks from the repo root:

Terminal window
pre-commit install

The default_install_hook_types: [pre-push] key in .pre-commit-config.yaml tells pre-commit to install the pre-push hook automatically — no --hook-type pre-push flag required. The stages: [pre-push] entry on the actionlint hook ensures it only fires on git push, not on every commit.

To run it manually against all workflow files at any time:

Terminal window
pre-commit run actionlint --all-files

Safety Net 2 — lint-workflows CI Gate (.github/workflows/lint-workflows.yml)

A standalone workflow that runs actionlint in CI on every push and pull request that touches .github/workflows/** or .github/actions/**:

name: Lint Workflows
on:
push:
paths:
- '.github/workflows/**'
- '.github/actions/**'
pull_request:
paths:
- '.github/workflows/**'
- '.github/actions/**'
jobs:
actionlint:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@... # v6.0.2
- uses: rhysd/actionlint@... # v1.7.7
with:
args: >-
-ignore SC2086
-ignore SC2016
-ignore SC2129
-ignore SC2088
-ignore "string should not be empty"
-ignore "unknown permission scope"

The -ignore args suppress pre-existing findings that are known false positives or shellcheck info/style codes — the gate only fails on genuine structural errors:

  • SC2086, SC2016, SC2129, SC2088 — shellcheck info/style warnings in pre-existing run: steps
  • string should not be empty — actionlint v1.7.7 incorrectly flags the empty-string entry (- '') used as the default choice in workflow_dispatch type: choice inputs; this is valid GitHub Actions syntax
  • unknown permission scope — the models: read GitHub Models permission was introduced after actionlint v1.7.7; this ignore can be removed once actionlint is upgraded to a version that includes it

Custom runner labels (e.g. macos-15-intel) are declared in .github/actionlint.yaml — see Actionlint config.

This workflow is registered as a required status check in branch protection, meaning any PR that introduces broken workflow YAML will be blocked from merging into main.

Why a Separate Workflow?

lint-workflows is intentionally a standalone workflow file rather than a job inside ci.yml. This means:

  • It runs even when the rest of ci.yml is broken (which is precisely the situation it needs to catch)
  • It has minimal blast radius — a failure here only blocks workflow-touching PRs, not all PRs
  • It completes in under 30 seconds with no external dependencies

What actionlint Catches

actionlint performs static analysis on GitHub Actions workflow YAML and detects:

CategoryExamples
Expression syntax errorsMalformed ${{ }} expressions, wrong context variables
Invalid needs: referencesReferencing a job that doesn’t exist
Action version mismatchesUsing a uses: ref that doesn’t resolve
Shell script errorsVia shellcheck integration on run: steps
Deprecated runner labelsubuntu-18.04, windows-2019, etc.
Secret/input name typossecrets.CLOUDFLARE_API_TOKEN vs secrets.CF_API_TOKEN
Missing required with: inputsCalling an action without its required inputs

Actionlint config

.github/actionlint.yaml configures actionlint’s static analysis rules:

self-hosted-runner:
labels:
- macos-15-intel

self-hosted-runner.labels — any runner label that isn’t in GitHub’s published list (e.g. a self-hosted or Intel-slice runner) must be declared here to prevent false-positive [runner-label] errors. When a new custom runner is added to the repo’s workflows, add its label to this file at the same time.

Note: The -ignore regex patterns for suppressing known false positives (shellcheck codes and models permission) are passed as command-line args in lint-workflows.yml, not in this config file. The ignore: key is not supported in .github/actionlint.yaml.

Versions

Both the pre-push hook and the CI action use actionlint v1.7.7:

  • .pre-commit-config.yaml: rev: v1.7.7
  • lint-workflows.yml: rhysd/actionlint@03d0035246f3e81f36aed592ffb4bebf33a03106 # v1.7.7

When upgrading actionlint, update both files together so local and CI behavior stay in sync.