CI systems are often debugged by pushing commits, waiting for remote runners, reading logs, and pushing again. That loop is slow, noisy, and especially painful when the bug is in workflow YAML, action inputs, missing environment variables, or container assumptions rather than in application code. Local workflow emulation shortens the feedback loop by executing the same workflow definitions on a developer machine before the remote CI service sees them.
The key idea is to treat the workflow file as an executable graph. A local runner reads workflow definitions, selects jobs triggered by an event, resolves job dependencies, prepares runner images, maps the repository into a container, injects CI-like environment variables, and executes each step through Docker. The result is not a perfect copy of the hosted CI platform, but it is accurate enough to catch many syntax, dependency, and script failures before they reach a pull request.
This pattern is useful beyond one tool. Any team with YAML-driven CI can apply the same architecture: parse the workflow declaratively, build an execution plan, emulate event payloads and runner context, isolate jobs in containers, and make incompatibilities explicit. The discipline is to use local emulation for fast validation while keeping remote CI as the source of truth for platform-specific behavior.
A workflow is more than a list of shell commands. Jobs have triggers, dependency edges, matrix expansions, conditional expressions, environment scopes, default shells, and action references. A local emulator first turns YAML into an executable graph so it can list runnable jobs, run a specific job, or skip jobs whose event trigger does not match.
Hosted runners are virtual machines with preinstalled tools; local emulators usually use Docker containers. Parity is therefore a managed approximation. The chosen container image must contain the expected shell, Node runtime for JavaScript actions, language toolchains, and system packages, or the local run will fail for reasons that remote CI would not.
Local CI emulation should catch deterministic workflow and script defects quickly. It should not be treated as a replacement for remote CI because hosted runners expose platform behavior, identity services, cache semantics, OIDC endpoints, permission scopes, and cancellation behavior that a Docker-based emulator may not fully reproduce.
The emulator scans the workflow directory, filters workflows by event, expands jobs, and resolves needs dependencies. The output is an execution plan: which jobs run, in what order, which image each job needs, which repository path is mounted, and which environment variables or event fields are available to expressions.
type Workflow struct {
On []string
Jobs map[string]Job
}
type Job struct {
Needs []string
RunsOn string
Steps []Step
}
func plan(wfs []Workflow, event string, selectedJob string) ([]Job, error) {
graph := NewDAG()
for _, wf := range wfs {
if !wf.Triggers(event) {
continue
}
for name, job := range wf.Jobs {
if selectedJob != "" && name != selectedJob {
continue
}
graph.Add(name, job.Needs)
}
}
return graph.TopologicalJobs()
}
For each planned job, the emulator pulls or builds a runner image and starts a container with the repository mounted into the expected workspace path. It then runs shell steps directly and resolves action steps into either local action code, Docker actions, composite actions, or JavaScript action entrypoints. This makes the workflow executable without provisioning a remote runner.
# Run all push-triggered jobs in the default workflow directory.
act push
# List jobs available for a pull_request event.
act -l pull_request
# Execute one job and provide a fuller event payload.
act pull_request -j test -e event.json
# Map a runner label to a specific local image.
act -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
Many workflows depend on fields from github.event, such as pull request branches, tags, issue numbers, or release metadata. A local emulator can infer basic repository state, but complex workflows need an explicit event JSON file. Supplying the payload makes expression evaluation more realistic and prevents false confidence from missing fields.
Local emulation is most valuable when editing workflow structure: trigger names, job dependencies, action versions, environment variables, and shell commands. Developers can run the job repeatedly without polluting commit history or waiting for hosted runner queues.
Teams often duplicate commands between Makefiles, package scripts, and CI YAML. A local runner allows the CI workflow to become the canonical task definition. The same lint, test, and build jobs can run locally and remotely, reducing drift between development and CI environments.
Authors of custom actions can validate that an action receives the expected inputs, has the right runtime dependencies, and interacts correctly with the workspace. Composite actions benefit especially because their behavior is mostly shell orchestration and file layout, both of which are practical to exercise locally.
Do not rely on an implicit default image for every job. Map runner labels to images that match the workload: a slim image for basic shell checks, a language-specific image for application tests, and a fuller image when the workflow expects hosted-runner tools.
# .actrc
--container-architecture linux/amd64
-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
-s GITHUB_TOKEN=ghp_example_local_token
# Developer command
act pull_request -j unit -e .github/events/pull_request.json
Store sanitized event payloads under a test fixtures directory. Keep one fixture for common push events, one for pull requests, and additional fixtures for release or schedule workflows. This makes local CI tests reproducible and reviewable.
OIDC token exchange, deployment-environment protection rules, permission boundaries, cache behavior, and hosted-runner images should still be validated remotely. Treat local emulation as the first gate, not the only gate.