CI that keeps up

How do I run GitHub Actions locally?

To run a GitHub Actions workflow before you push, move the workflow’s steps to a place that your laptop can also run. The workflow file then only calls those steps. A portable pipeline meets four requirements: the steps have one definition, the environment is the same everywhere, the services are healthy before the tests start, and the CI file stays thin. Dagger meets all four in one place. You run dagger check on your laptop, and the workflow runs the same command in GitHub Actions or in another hosted CI.

The requirements for a portable pipeline

A workflow that runs the same way before and after you push meets four requirements:

  1. One definition of the steps. The commands that test the project live in one place, and your laptop and the workflow both call it. If the commands live only in YAML, you can test them only when you push.
  2. The same environment. The tests use the same Python version and the same Postgres and Redis images in both places. In GitHub Actions, service containers are Docker containers that GitHub starts for one job and removes when the job completes.
  3. Healthy services before the tests. The tests start only after Postgres and Redis accept connections. GitHub’s PostgreSQL example does this with --health-cmd pg_isready in the service options.
  4. A thin CI file. The workflow installs a tool and runs one command. A thin file does not change when the tests change, or when you move to another CI system.

A typical Python job does not meet the first and fourth requirements. The images, the health checks, and the connection URLs exist only in the workflow file:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
    env:
      DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
      REDIS_URL: redis://localhost:6379/0
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10
      - run: uv run pytest

What act and depot ci run give you

Two tools run a workflow before you push. Both run the workflow YAML itself, so the steps still have only one runnable form: the YAML.

  • act reads .github/workflows/ and runs the jobs in local Docker containers. Its default runner images are “intentionally incomplete”, so a job can behave differently than on a GitHub-hosted runner. act also ignores some keys, such as timeout-minutes and continue-on-error.
  • depot ci run submits a workflow from .depot/workflows/ to Depot CI. It uploads your uncommitted changes as a patch and applies the patch after actions/checkout. The job runs in a Depot sandbox, not on your laptop. Untracked files are not in the patch, so run git add on new files first. A job that calls a reusable workflow runs without your changes.

Both tools give you a result without a commit. The steps still exist only in workflow YAML, which only GitHub Actions, act, or Depot CI can run.

Make the workflow portable by hand

Move the services into a Compose file, and the steps into commands that any shell can run. The workflow then calls the same commands. This Compose file keeps the images and health checks from the workflow:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 5s
      retries: 10
  redis:
    image: redis:7
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      retries: 10

Add pytest to the project’s dev dependencies. Then run the same commands on your laptop and in the workflow’s run steps:

docker compose up --wait
export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
export REDIS_URL=redis://localhost:6379/0
uv run pytest

docker compose up --wait waits for the services to be running or healthy, so it meets the third requirement. The image tags are now in one file, so the services meet the first requirement.

Laptop and workflow test environments differ

The hand-built version meets the requirements for the services, but not for the test environment. The gaps grow with the project:

  • The Python environment differs. On a laptop, uv run pytest uses the local Python and the local system libraries. In the workflow, it uses what setup-uv and the runner image provide. A test can pass in one place and fail in the other.
  • The connection URLs live in two places. The export lines and the workflow env block must change together.
  • The glue is your code. Each new service, test suite, or lint step adds lines to a script and to the workflow. You can test the workflow form of each change only when you push.

Meet the requirements with Dagger

Dagger runs each step in a container that the project defines, so the steps and their environment are the same on a laptop and in CI. A Dagger module is a package of functions that build and test a project. A check is a function that dagger check runs, and that passes or fails. The official Pytest module finds the Python projects and test files, and runs pytest as a check.

Initialize the workspace. Then install the Pytest module and the Dang SDK:

dagger init
dagger module install dagger.io/python/pytest
dagger module install dagger.io/sdk/dang

dagger init creates dagger.toml, the workspace configuration for everyone who uses the repository. Dagger also writes dagger.lock, which pins each module and image to an exact version. Commit both files.

The tests need Postgres and Redis. The Pytest module’s base setting takes the container that runs the tests, with Python and uv. A small module supplies that container, with the services bound to it. Dang is Dagger’s native language for modules. Create the project’s dev module:

dagger module init dang -y

The command creates .dagger/modules/shop-dev, named after the project directory, and installs it as the workspace main module. In the snippets, shop-dev and ShopDev stand in for the names that dagger module init generated. Replace .dagger/modules/shop-dev/main.dang with this code:

"""
Test services for the shop project: Postgres and Redis, the same as in CI.
"""
type ShopDev {
  """
  Postgres, with the image and password that CI uses.
  """
  postgres: Service! {
    container
      .from("postgres:16")
      .withEnvVariable("POSTGRES_PASSWORD", "postgres")
      .withExposedPort(5432)
      .asService
  }

  """
  Redis, with the image that CI uses.
  """
  redis: Service! {
    container
      .from("redis:7")
      .withExposedPort(6379)
      .asService
  }

  """
  The pytest container: Python and uv, with Postgres and Redis bound to it.
  """
  testBase: Container! {
    container
      .from("ghcr.io/astral-sh/uv:python3.12-trixie-slim")
      .withServiceBinding("postgres", postgres)
      .withServiceBinding("redis", redis)
      .withEnvVariable("DATABASE_URL", "postgresql://postgres:postgres@postgres:5432/postgres")
      .withEnvVariable("REDIS_URL", "redis://redis:6379/0")
  }
}

Give the Pytest module this container as its base:

dagger module settings pytest base test-base

The command writes a module reference to dagger.toml:

[modules.pytest.settings]
base = "dag://shop-dev/test-base"

One definition of the steps

dagger.toml and the dev module hold the whole test pipeline. List the checks:

dagger check -l
MODULE    PYTEST-PROJECT  PYTEST-TEST-FILE  CHECK  DESCRIPTION
dang-sdk                                    stale  staleness check: re-generate modules and clients managed by the Dang SDK
pytest    *               *                 test   Run this test file.
# Use --all to expand collections and list each item.

Run every check on your laptop before you push:

dagger check
CHECKS  ✔ 2 passed
✔ dag://?check=dang-sdk/generate/stale 0.7s OK
✔ dag://?check=test&pytest-project=. 0.6s OK

A failure names the test, and prints the command that runs the same selection again:

    ✘ tests/test_services.py › test_redis FAIL
          ... 9 earlier log lines ...
          E         - b'x'
          E         ?   ^
          E         + b'v'
          E         ?   ^

          tests/test_services.py:12: AssertionError

    ✘ 1 failed
    ✔ 1 passed
✔ dag://?check=dang-sdk/generate/stale 3.6s OK

RUN LOCALLY
  dagger check "dag://?check=test&pytest-project=."

To run one test file, select it with --pytest-test-file=tests/test_services.py. Dagger reads the files in the working tree, so a new test file runs without git add.

The same environment

The tests run in ghcr.io/astral-sh/uv:python3.12-trixie-slim on every machine, not in the host’s Python. The services use postgres:16 and redis:7, the same images as the original workflow.

Healthy services before the tests

withServiceBinding makes each service reachable from the test container by its name, such as postgres. Dagger health-checks a service before any client can connect, so the module needs no health-check flags. To use the same services with an app on your laptop, list them, and start one with dagger start postgres. Dagger tunnels the service’s ports to your machine.

dagger start -l
MODULE    SERVICE   DESCRIPTION
shop-dev  postgres  Postgres, with the image and password that CI uses.
shop-dev  redis     Redis, with the image that CI uses.

A thin CI file

The workflow installs Dagger and runs dagger check. It holds no images, services, or test commands. Replace the job in .github/workflows/ci.yml:

name: ci
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dagger/dagger-for-github@v8.4.1
        with:
          version: "1.0.0-beta.21"
          verb: check

The dagger-for-github action installs the Dagger version that you pin. Use the version that dagger version prints on your laptop.

If you move to Depot CI, depot ci migrate copies this workflow to .depot/workflows/ and remaps runs-on to a Depot label. The test steps stay in the Dagger module, so the migration does not touch them.

Run the checks with no workflow file

Cloud Checks runs dagger check on Dagger’s Cloud Engines for every push and pull request to a GitHub repository, with no CI workflow file. The results show as GitHub check statuses. From the repository root, connect the repository to Dagger Cloud:

dagger cloud checks on

The checks that run after each push are the same checks that dagger check runs on your laptop.

Bottom line: A workflow whose steps exist only in YAML cannot run on your laptop the same way it runs in CI. Tools that imitate the runner, or run the workflow remotely, still depend on that YAML. Put the steps, the test container, and the Postgres and Redis services in Dagger, and run dagger check before you push. GitHub Actions, Depot CI, or Cloud Checks then run the same command.