Setting Up CI for Lua with GitHub Actions
GitHub Actions makes it straightforward to run your Lua test suite on every push and pull request. You can test across multiple Lua versions, generate coverage reports, and gate merges on code quality — all without running anything locally. This tutorial walks through building a complete CI pipeline for a Lua project.
Prerequisites
Before writing any workflow code, make sure your project is set up to run tests locally. You need a rockspec file (or at least a busted dependency) and a working test suite. If you are new to testing in Lua, start with the busted framework tutorial to get your tests running locally first.
Your project structure should look something like this:
my-project/
├── .github/
│ └── workflows/
│ └── ci.yml
├── src/
│ └── mymodule.lua
├── tests/
│ └── mymodule_spec.lua
└── *.rockspec
Once your tests pass with busted on your machine, you are ready to automate.
Setting Up the Lua Environment
The community action ljmf00/setup-lua is the standard way to install Lua (and LuaRocks) in a GitHub Actions job. It supports all major Lua versions including 5.1, 5.3, 5.4, LuaJIT 2.0 and 2.1, and the OpenResty LuaJIT fork.
- uses: ljmf00/setup-lua@v1
with:
luaVersion: "5.4"
The luaVersion string accepts exact versions like "5.4.4" or short aliases like "5.4" that resolve to the latest stable release for that minor branch. LuaJIT variants work too — use "luajit-2.1" for the standard LuaJIT or "luajit-openresty" for the OpenResty fork.
Testing Across Multiple Lua Versions
Real Lua projects often need to support several Lua versions. A matrix strategy in your workflow runs the same job across every version you care about:
jobs:
test:
name: Test on Lua ${{ matrix.luaVersion }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
luaVersion: ["5.1", "5.3", "5.4"]
steps:
- uses: actions/checkout@v4
- uses: ljmf00/setup-lua@v1
with:
luaVersion: ${{ matrix.luaVersion }}
- name: Install dependencies
run: |
luarocks install busted
luarocks install luacov
- name: Run tests
run: busted
fail-fast: false is important here. Without it, GitHub cancels the remaining jobs as soon as one version fails. That hides failures — you want to see whether all versions pass or fail independently.
Caching LuaRocks Dependencies
Installing LuaRocks packages on every CI run is slow. GitHub Actions actions/cache can cache your luarocks directory between runs:
- name: Cache luarocks
uses: actions/cache@v4
with:
path: ~/.luarocks
key: luarocks-${{ runner.os }}-${{ matrix.luaVersion }}-${{ hashFiles('*.rockspec') }}
restore-keys: |
luarocks-${{ runner.os }}-${{ matrix.luaVersion }}-
The key includes a hash of your rockspec files, so the cache invalidates whenever your dependencies change. The restore-keys fallback means a partial match still helps — if only the Lua version matches, it restores whatever cache exists for that version.
Adding Code Coverage
The busted --coverage flag runs lua-cov under the hood to measure which lines your tests exercise. Configure lua-cov with a .luacov file in your project root:
-- .luacov
return {
stats = true,
include = { "src" },
exclude = { "tests", "busted" },
reporter = "default"
}
Then generate an LCOV-formatted report, which GitHub Actions can consume:
- name: Generate LCOV report
run: |
mkdir -p coverage
luacov -o coverage/lcov.info
The zgosalvez/github-actions-report-lcov action reads that LCOV file and posts coverage annotations directly onto your PR files. It also fails the step if coverage drops below a threshold you set:
- name: Report code coverage
uses: zgosalvez/github-actions-report-lcov@v4
with:
coverage-files: coverage/lcov.info
minimum-coverage: 80
github-token: ${{ secrets.GITHUB_TOKEN }}
update-comment: true
Setting minimum-coverage: 80 means any PR that drops coverage below 80% will fail this check, blocking a merge if your branch protection rules enforce it. The update-comment: true option keeps a single coverage comment updated on the PR rather than flooding it with new comments on every push.
You also want to upload the raw coverage artifacts in case you need to dig into a failing CI run locally:
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-lua-${{ matrix.luaVersion }}
path: coverage/
retention-days: 7
The if: always() ensures the artifact uploads even when tests fail — critical for debugging a red build.
A Complete CI Workflow
Here is the full pipeline wired together with triggers on push and pull requests to the main branch:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test on Lua ${{ matrix.luaVersion }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
luaVersion: ["5.1", "5.3", "5.4"]
steps:
- uses: actions/checkout@v4
- name: Cache luarocks
uses: actions/cache@v4
with:
path: ~/.luarocks
key: luarocks-${{ runner.os }}-${{ matrix.luaVersion }}-${{ hashFiles('*.rockspec') }}
restore-keys: |
luarocks-${{ runner.os }}-${{ matrix.luaVersion }}-
- name: Setup Lua
uses: ljmf00/setup-lua@v1
with:
luaVersion: ${{ matrix.luaVersion }}
- name: Install dependencies
run: |
luarocks install busted
luarocks install luacov
- name: Run tests
run: busted --coverage
- name: Generate LCOV report
run: |
mkdir -p coverage
luacov -o coverage/lcov.info
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-lua-${{ matrix.luaVersion }}
path: coverage/
retention-days: 7
- name: Report coverage
uses: zgosalvez/github-actions-report-lcov@v4
with:
coverage-files: coverage/lcov.info
minimum-coverage: 80
github-token: ${{ secrets.GITHUB_TOKEN }}
update-comment: true
This workflow runs on every push to main and on every PR. It tests on three Lua versions, caches dependencies for speed, generates coverage, uploads artifacts, and posts a coverage summary to your PR. Set the minimum-coverage value to match your project’s standards.
OpenResty and Custom Lua Builds
If your project uses OpenResty instead of plain Lua, you have two options. The simplest is to run your tests inside the official OpenResty Docker container:
jobs:
test-openresty:
runs-on: ubuntu-latest
container: openresty/openresty:latest
steps:
- uses: actions/checkout@v4
- name: Install busted
run: |
luarocks install luarocks
luarocks install busted
- name: Run tests
run: busted
The other option is to use the OpenResty LuaJIT variant directly via the setup action:
- uses: ljmf00/setup-lua@v1
with:
luaVersion: "luajit-openresty"
Adding a Lint Step
A CI pipeline is also a good place to run a linter. Add luacheck before your test step to catch unused variables and other static issues:
- name: Lint Lua
run: |
luarocks install luacheck
luacheck src tests
If luacheck finds issues, the step fails and the workflow stops before running tests.
Limiting Workflow Triggers
You can narrow the trigger to only run when Lua files or the workflow itself change:
on:
push:
paths: ["**.lua", ".github/workflows/*.yml"]
pull_request:
paths: ["**.lua", ".github/workflows/*.yml"]
This prevents CI from running on documentation changes, README edits, or other files that do not affect your code.
Conclusion
GitHub Actions handles the repetitive work of testing Lua projects across versions and enforcing quality gates. The key pieces are ljmf00/setup-lua for the runtime, a matrix strategy for multi-version coverage, actions/cache to speed up dependency installation, and zgosalvez/github-actions-report-lcov to surface coverage directly in your PR comments.
Start with the simple three-version matrix and add the coverage and linting steps once the basic pipeline is green. That gives you confidence that every change compiles, passes tests, and maintains the coverage standard you set.
See Also
- Testing with the Busted Framework — set up and write effective tests with busted
- Measuring Test Coverage — understand lua-cov configuration and coverage reports
- Mocks and Stubs in Lua — isolate tests with test doubles and fake dependencies