luaguides

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