DEV Community

Cover image for How to test a GitHub Actions workflow before you push it
Kay
Kay

Posted on Originally published at latchkey.dev

How to test a GitHub Actions workflow before you push it

To test a GitHub Actions workflow before push, check it at three levels: lint the file statically with actionlint, replay its steps locally with act, and run the real jobs somewhere clean. For workflow files on Latchkey CI, latchkey validate covers the static level and latchkey preflight runs the jobs on your uncommitted tree without painting a check.

Three preflight levels: static lint, local replay, real run, with what each catches and misses
Each level is cheaper and less faithful than the one below it. Run them top to bottom and stop at the first that answers your question.

Workflow files are the one part of a repository you usually test by pushing. The loop is commit, push, wait, read a red X about a key you misspelled, and push again. For a coding agent that edits CI config, each turn of that loop is a commit on the branch and a run of the pipeline.

Three levels of check shorten it, and they answer different questions. Each gets its command below, along with what it cannot see.

Level 1: lint the file with actionlint

actionlint is a static checker for workflow files. It catches unexpected or missing keys, type errors in ${{ }} expressions, wrong with: inputs and step outputs, bad needs: dependencies, glob and cron syntax, runner labels, and script injection from untrusted inputs, and it runs shellcheck and pyflakes on run: scripts when those commands are installed. With no argument, it finds all workflow files in the current repository and checks them. It runs nothing, so it needs no Docker and no network.

For an agent, two things make it useful. Its exit status separates the outcomes: 0 for no problem, 1 for problems found, 2 for a bad option and 3 for a fatal error. And -format '{{json .}}' prints the errors as JSON instead of prose. The example in its README shows the kind of line it prints:

brew install actionlint            # or: go install github.com/rhysd/actionlint/cmd/actionlint@latest
actionlint
actionlint -format '{{json .}}' .github/workflows/ci.yml

# quoted from the actionlint README:
# test.yaml:3:5: unexpected key "branch" for "push" section. expected one of "branches", "branches-ignore", "paths", "paths-ignore", "tags", "tags-ignore", "types", "workflows" [syntax-check]
Enter fullscreen mode Exit fullscreen mode

Checks, install commands and exit statuses from the rhysd/actionlint README, docs/install.md and docs/usage.md; latest release v1.7.12. Read 2026-11-12.

Level 2: replay the steps with act

act runs each step of your workflow in a Docker container on your machine, which proves the steps execute in order with the conditions you meant. Its trade-offs, chiefly that its images are not GitHub's runner images, are covered in route 2 of run CI from Claude Code and not repeated here.

One flag sits between level 1 and level 2: act -n (--dryrun) disables container creation and validates only workflow correctness, so it checks how act plans the jobs without running any step.

Level 3 on GitHub: a scratch branch

The only complete test of a GitHub Actions workflow is a run on GitHub, with its secrets, permissions and runner image. You can keep it off your feature branch: add a push trigger for a scratch branch pattern to the workflow under test, commit, push that commit to a scratch branch, and watch the run it starts. workflow_dispatch is no shortcut for a brand-new file: GitHub's manual-run guide says that to trigger the event, the workflow must be in the default branch.

Find the run by the commit you pushed, not by the branch. Right after a push, gh run list --branch can return nothing yet, or the previous run on that branch, so the loop below waits until a run for this SHA exists. This level costs a commit, a run and its minutes, which is the cost the first two levels exist to avoid.

```.github/workflows/ci.yml and Terminal

.github/workflows/ci.yml, the trigger the scratch branch relies on

on:
pull_request:
push:
branches: ['wip/**']

Terminal

sha=$(git rev-parse HEAD)
git push origin HEAD:refs/heads/wip/ci-check
until run_id=$(gh run list --commit "$sha" --event push --limit 1 \
--json databaseId --jq '.[0].databaseId // empty') && [ -n "$run_id" ]; do
sleep 5
done
gh run watch "$run_id" --exit-status --compact
gh run view "$run_id" --log-failed




> The default-branch rule is from Manually running a workflow on docs.github.com; the `--commit` and `--event` filters from `gh run list --help` in gh 2.102.0. Read 2026-11-12.

## On Latchkey CI: latchkey validate

If a pipeline runs on Latchkey CI, its workflow file lives under `.latchkey/workflows/` and Latchkey compiles it before a run. `latchkey validate <file>` does that compile without starting anything. A refused file prints its first refusal as `<file>:<line>: <sentence> (<code>)`, the same sentence the Latchkey check would show for a push. A file that compiles prints each job it compiles to, one per matrix leg, then the most one run of them can cost at list rates: each job's timeout plus a minute, once. That figure is a ceiling, not a forecast.

It checks the file alone; secrets and an environment's branches are checked when a run starts. It needs a key with `jobs:read`, starts nothing and costs nothing, and `--output json` gives the same answer as the MCP tool `validate_workflow`.



```Terminal
latchkey validate .latchkey/workflows/ci.yml
latchkey validate .latchkey/workflows/ci.yml --output json
Enter fullscreen mode Exit fullscreen mode

From latchkey help validate in @latchkeydev/cli 0.16.0 and latchkey.dev/documentation/workflow-files, read 2026-11-12. Neither command reads .github/workflows; latchkey migrate plan proposes the Latchkey CI files for a GitHub Actions pipeline.

latchkey preflight: the jobs, on your working tree

latchkey preflight, added in CLI 0.16.0, runs the jobs of one workflow file as Latchkey compiles it, on your working tree as it is now, before you commit. The file is read from disk, the repository is packed whole from its top level, and the change is measured against the merge base of HEAD and the default branch origin/HEAD names, as the clone last fetched it.

It is deliberately quiet. A preflight paints no check, opens no pull request, starts no self-heal, saves no cache entry, takes no turn in a concurrency group, and is handed no secret, no identity token and no clone credential. Before any runner starts, every job is judged, and a job that needs something a preflight cannot give is not run, with a reason.

Exit 0 means no job failed, none was left not run, and a job ran. Exit 195 means every job it ran passed but it left at least one not run, which is the signal to read the reasons rather than call it green. Exit 1 is a failure, 193 a batch already at its budget. The CLI's own guidance is not to preflight before every pull request, since the pull request's run checks the change; use it when the workflow file is what changed.

latchkey preflight --workflow .latchkey/workflows/ci.yml \
  --repository acme/api --batch pre-pr --batch-budget 3
case $? in
  0)   echo "every job ran and passed" ;;
  195) echo "passed, but some jobs were not run: read the reasons" ;;
  193) echo "batch budget already spent" ;;
  *)   echo "preflight did not pass" ;;
esac
Enter fullscreen mode Exit fullscreen mode
Not-run reason Why the preflight skips the job
reads_secret A step reads a secret, and a preflight is handed none
reads_identity_token A step requests an identity token
environment_held The job names an environment
advisory_hold The job runs only once its pipeline runs live
uses_artifacts A step uploads or downloads an artifact
needs_git_remote A script names git fetch, git push, git clone or similar
needs_not_run The job needs a job that is not run

From latchkey help preflight in @latchkeydev/cli 0.16.0 and the 0.16.0 entry, released 2026-11-11, in latchkey.dev/documentation/latchkey-cli-changelog. Read 2026-11-12.

Help output, not a recorded preflight

No recorded run backs this page; content/repro-evidence.mjs and content/heal-evidence.mjs have nothing for it. The actionlint and act behavior is from their READMEs, docs and source, the gh flags from --help in gh 2.102.0, and the Latchkey commands from latchkey help validate and latchkey help preflight in CLI 0.16.0, a day after that release. The actionlint error line is the example from its README, quoted as printed, not output captured here.

FAQ

How do I validate a GitHub Actions workflow file locally?

Run actionlint in the repository root. It checks every file under .github/workflows for syntax, expression types, action inputs, needs: and more, and exits 1 when it finds a problem. It never executes the workflow.

How do I run actionlint as a pre-commit hook?

Add https://github.com/rhysd/actionlint to .pre-commit-config.yaml with rev: v1.7.12 and the hook id actionlint, which installs the binary with the Go toolchain. actionlint-docker pulls its Docker image instead, and actionlint-system uses a copy you installed yourself. Running workflows locally before a push, rather than linting them, is covered on run CI from Claude Code.

How do I pass secrets to act?

With -s NAME=value for one secret, or -s NAME alone, which takes the value from an environment variable of that name or asks for it without echoing. For several, act reads a .secrets file by default, and --secret-file points it elsewhere. Keep that file out of git, and prefer test credentials, since the steps run on your machine.

References

Top comments (0)