Skip to content

Run CI locally before you push

Updated

You can run CI on your machine in two ways: replay the workflow with a tool such as act, or run the same commands before you push. Both find failures sooner. Neither saves CI any work, unless CI can see that your run passed.

ToolRunsNeedsDoes CI run it again?
actYour GitHub Actions workflowsDockerYes
gitlab-ci-localYour .gitlab-ci.yml jobsDocker or a shellYes
A pre-push hookThe commands you chooseGitYes
hushrun runThe commands you choosehushrunNo, for a pass on the same files

Replay GitHub Actions with act

act reads .github/workflows/ and runs each job in a container, which helps most when you're changing a workflow and don't want to push to try it:

brew install act
act -l                 # list the jobs
act pull_request       # run what a pull request would start
act -j test            # run one job

It isn't the same as running on GitHub:

  • It needs Docker, and it's a container where GitHub uses a virtual machine, so some software behaves differently.
  • Its default images lack some of GitHub's tools. Larger images come closer.
  • macOS and Windows jobs have no container. On a matching machine, act -P macos-latest=-self-hosted runs them on the host.
  • Events are partial. A workflow that reads the pull request's details may need an event file, passed with -e.
  • GITHUB_TOKEN isn't provided. Pass it yourself: act -s GITHUB_TOKEN="$(gh auth token)".
  • Artifacts need a path, set with --artifact-server-path.

Replay GitLab CI

gitlab-ci-local does the same for .gitlab-ci.yml, with Docker or in your shell:

npm install -g gitlab-ci-local
gitlab-ci-local --list     # list the jobs
gitlab-ci-local test       # run one job

GitLab's own gitlab-runner exec was deprecated and removed in GitLab Runner 17.0, so older guides that use it are out of date.

Run checks in a pre-push hook

A pre-push hook runs before git push and stops it when it exits with an error:

.git/hooks/pre-push
#!/bin/sh
npm test
chmod +x .git/hooks/pre-push
  • Git doesn't clone hooks. To share one, commit it to a directory and run git config core.hooksPath .githooks in each clone, or use a tool such as Husky.
  • A long suite holds up the push, so keep the hook to fast checks.
  • git push --no-verify skips it. Treat a hook as a convenience, and keep the checks that matter in CI.

Coding agents need this more than people do, since they push often. Make Claude Code run tests before it pushes covers that case.

Make the local run count

Everything above repeats work. Your laptop runs the tests, then CI runs them again on the same files. To run them once, CI has to see the pass.

hushrun run records a pass for the exact files, check name, command and directory. Wrap a script once:

package.json
"scripts": {
  "test": "hushrun run -- vitest run"
}

The first run on this code runs the check, and any machine that shares results skips it afterwards, CI included:

$ hushrun run e2e -- playwright test --project site
hushrun: e2e running: no pass yet for this exact code
…
hushrun: e2e passed in 16s; it won't run again on this code
$ hushrun run e2e -- playwright test --project site
hushrun: e2e skipped: this exact code passed 1 min ago (saves 16s)

hushrun ready lists what CI would still run on your commit, and the commands that run it here. Whether CI should trust a laptop's result is your call: turn on CI reuses only CI's results and it won't.

hushrun runs your commands, not your workflow, so it doesn't test the YAML. npx hushrun init sets it up in about three minutes: see the Quickstart.

Which one to use

  • You're changing a workflow file: act or gitlab-ci-local.
  • You want failures before the push: a pre-push hook.
  • You don't want CI to repeat what passed: hushrun run.

They combine: a hook that runs wrapped checks costs nothing on code that already passed.

Markdown