A local GitHub Actions runner built with MoonBit. Run and debug GitHub Actions workflows locally with a gh-compatible CLI.
actrun keeps its release contract as close as possible to existing GitHub Actions semantics. Workflow YAML and action metadata stay on a GitHub-compatible surface, while WASM support is treated as a self-hosted runner optimization. See docs/public-api.md for the contract boundary and ADR 0001 for the rationale.
Install
# npx (no install required) npx @mizchi/actrun workflow run .github/workflows/ci.yml # curl (Linux / macOS) curl -fsSL https://raw.githubusercontent.com/mizchi/actrun/main/install.sh | sh # Docker docker run --rm -v "$PWD":/workspace -w /workspace ghcr.io/mizchi/actrun workflow run .github/workflows/ci.yml # npm global install npm install -g @mizchi/actrun # Nix (run without installing) nix run github:mizchi/actrun -- workflow run .github/workflows/ci.yml # Nix (install into profile) nix profile install github:mizchi/actrun # moon install moon install mizchi/actrun/cmd/actrun # Build from source git clone https://github.com/mizchi/actrun.git && cd actrun moon build src/cmd/actrun --target native
Nix
Run directly
nix run github:mizchi/actrun -- workflow run .github/workflows/ci.yml
Build from source
nix build github:mizchi/actrun ./result/bin/actrun workflow run .github/workflows/ci.yml
Development shell
Or without direnv:
Adding the overlay to your flake.nix
{ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; actrun.url = "github:mizchi/actrun"; }; outputs = { nixpkgs, actrun, ... }: let system = "aarch64-darwin"; # or "x86_64-linux" pkgs = import nixpkgs { inherit system; overlays = [ actrun.overlays.default ]; }; in { packages.${system}.default = pkgs.actrun; devShells.${system}.default = pkgs.mkShell { packages = [ pkgs.actrun ]; }; }; }
Without flakes
# default.nix provides a ready-to-use derivation let actrun = import (builtins.fetchTarball "https://github.com/mizchi/actrun/archive/main.tar.gz") { }; in actrun
Quick Start
# Run a workflow locally actrun workflow run .github/workflows/ci.yml # Show execution plan without running actrun workflow run .github/workflows/ci.yml --dry-run # Emit flow cache plan JSON for an external orchestrator actrun workflow run .github/workflows/ci.yml \ --dry-run \ --json \ --flow-cache-store /tmp/flow-cache.json \ --flow-signature build=sig-build # Skip actions not needed locally (e.g. setup tools already installed) actrun workflow run .github/workflows/ci.yml \ --skip-action actions/checkout \ --skip-action extractions/setup-just # Run in isolated worktree actrun workflow run .github/workflows/ci.yml \ --workspace-mode worktree # Generate config file actrun init # View results actrun run view run-1 actrun run logs run-1 --task build/test
Configuration
actrun init generates an actrun.toml in the current directory:
# Workspace mode: local, worktree, tmp, docker workspace_mode = "local" # Skip actions not needed locally local_skip_actions = ["actions/checkout"] # Trust all third-party actions without prompt trust_actions = true # Nix integration: "auto" (force), "off" (disable), or empty (auto-detect) nix_mode = "" # Additional nix packages nix_packages = ["python312", "jq"] # Container runtime: docker, podman, container, lima, nerdctl container_runtime = "docker" # Include uncommitted changes in worktree/tmp workspace # include_dirty = true # Default local GitHub context when `--event` is omitted # [local_context] # repository = "owner/repo" # ref_name = "main" # before_rev = "HEAD^" # after_rev = "HEAD" # actor = "your-name" # Override actions with local commands # [override."actions/setup-node"] # run = "echo 'using local node' && node --version" # Affected file patterns per workflow # [affected."ci.yml"] # patterns = ["src/**", "package.json"]
When --event is omitted, actrun auto-detects github.repository, github.ref_name, github.sha, and github.actor from the local git repository when possible. Use [local_context] only when you need to pin or override those values. See Local GitHub Context for precedence and examples.
CLI flags always override actrun.toml settings. See Cheatsheet for quick reference and Advanced Workflow for details.
Flow Cache
Use --flow-cache-store <path> together with one or more --flow-signature <job-or-task>=<fingerprint> flags to exchange task cache state with the embedded bitflow planner.
--dry-run --json includes a flow_cache.plan payload with per-task hit/miss information. A normal run writes successful task fingerprints back to the same store and persists both the plan and writeback result under run.json as flow_cache.plan and flow_cache.writeback.
CLI Reference
Workflow Commands
actrun workflow list # List workflows in .github/workflows/ actrun workflow run <workflow.yml> # Run a workflow locally
Run Commands
actrun run list # List past runs actrun run view <run-id> # View run summary actrun run view <run-id> --json # View run as JSON actrun run watch <run-id> # Watch until completion actrun run logs <run-id> # View all logs actrun run logs <run-id> --task <id> # View specific task log actrun run download <run-id> # Download all artifacts
Analysis Commands
# Lint: type check expressions and detect dead code actrun lint # Lint all .github/workflows/*.yml actrun lint .github/workflows/ci.yml # Lint a specific file actrun lint --ignore W001 # Suppress a rule (repeatable) # Visualize: render workflow job dependency graph actrun viz .github/workflows/ci.yml # ASCII art (terminal) actrun viz .github/workflows/ci.yml --mermaid # Mermaid text (for Markdown) actrun viz .github/workflows/ci.yml --detail # Mermaid with step subgraphs actrun viz .github/workflows/ci.yml --svg # SVG image actrun viz .github/workflows/ci.yml --svg --theme github-light
Lint Diagnostics
| Rule | Severity | Description |
|---|---|---|
undefined-context |
error | Undefined context (e.g. foobar.x) |
wrong-arity |
error | Wrong function arity (e.g. contains('one')) |
unknown-function |
error | Unknown function (e.g. myFunc()) |
unknown-property |
warning | Unknown property (e.g. github.nonexistent) |
type-mismatch |
warning | Comparing incompatible types |
unreachable-step |
warning | Unreachable step (if: false) |
future-step-ref |
error | Reference to future step |
undefined-step-ref |
error | Reference to undefined step |
undefined-needs |
error | Undefined needs job reference |
circular-needs |
error | Circular needs dependency |
unused-outputs |
warning | Unused job outputs |
duplicate-step-id |
error | Duplicate step IDs in same job |
missing-runs-on |
error | Missing runs-on |
empty-job |
error | Empty job (no steps) |
uses-and-run |
error | Step has both uses and run |
empty-matrix |
warning | Matrix with empty rows |
invalid-uses |
error | Invalid uses syntax |
invalid-glob |
warning | Invalid glob pattern in trigger filter |
redundant-condition |
warning | Always-true/false condition |
script-injection |
warning | Script injection risk (untrusted input in run:) |
permissive-permissions |
warning | Overly permissive permissions |
deprecated-command |
warning | Deprecated workflow command (::set-output etc.) |
missing-prt-permissions |
warning | pull_request_target without explicit permissions |
if-always |
warning | Bare always() — prefer success() || failure() |
dangerous-checkout-in-prt |
error | Checkout PR head in pull_request_target |
secrets-to-third-party |
warning | Secrets passed via env to third-party action |
missing-timeout |
warning | No timeout-minutes (opt-in: --strict) |
mutable-action-ref |
warning | Tag ref instead of SHA pin (opt-in: --online) |
action-not-found |
error | Action ref not found on GitHub (opt-in: --online) |
Configure lint behavior in actrun.toml:
[lint] preset = "default" # default, strict, oss ignore_rules = ["unknown-property", "unused-outputs"]
| Preset | Includes |
|---|---|
default |
All rules except missing-timeout and online checks |
strict |
default + missing-timeout |
oss |
strict + mutable-action-ref / action-not-found (network) |
Visualization Example
$ actrun viz .github/workflows/release.yml
┌───────┐ ┌────────┐
│ build │ │ docker │
└───────┘ └────────┘
└┐
│
┌─────────┐
│ release │
└─────────┘
Artifact & Cache Commands
actrun artifact list <run-id> # List artifacts actrun artifact download <run-id> --name <name> # Download artifact actrun cache list # List cache entries actrun cache prune --key <key> # Delete cache entry
Workflow Run Flags
| Flag | Description |
|---|---|
--dry-run |
Show execution plan without running |
--skip-action <pattern> |
Skip actions matching pattern (repeatable) |
--workspace-mode <mode> |
worktree (default), local, tmp, docker |
--repo <path> |
Run from a git repository |
--event <path> |
Push event JSON file |
--repository <owner/repo> |
GitHub repository name |
--ref <ref> |
Git ref name |
--run-root <path> |
Run record storage root |
--nix |
Force nix wrapping for run steps |
--no-nix |
Disable nix wrapping even if flake.nix/shell.nix exists |
--nix-packages <pkgs> |
Ad-hoc nix packages (space-separated) |
--container-runtime <name> |
Container runtime: docker, podman, container, lima, nerdctl |
--wasm-runner <kind> |
Wasm runner kind: wasmtime, deno, v8 |
--affected [base] |
Only run if files matching patterns changed (see below) |
--retry |
Re-run only failed jobs from the latest run |
--include-dirty |
Include uncommitted changes in worktree/tmp workspace |
--json |
JSON output for read commands and --dry-run |
Affected Runs
Skip workflows when no relevant files have changed. Patterns are resolved in order:
actrun.toml[affected."<workflow>"]patternson:push:pathsfrom the workflow file (automatic fallback)
# Compare against last successful run (default) actrun ci.yml --affected # Compare against a specific rev actrun ci.yml --affected HEAD~3 actrun ci.yml --affected abc1234 # Preview what would happen (shows plan even if skipped) actrun ci.yml --affected HEAD~1 --dry-run
Configure patterns in actrun.toml:
[affected."ci.yml"] patterns = ["src/**", "package.json"] [affected.".github/workflows/lint.yml"] patterns = ["src/**", "*.config.*"]
If actrun.toml has no patterns, on:push:paths from the workflow is used automatically:
on: push: paths: ["src/**", "*.toml"] # actrun --affected uses these
Workspace Modes
| Mode | Description |
|---|---|
local |
Run in-place in the current directory |
worktree |
Create an isolated git worktree for execution (default) |
tmp |
Clone to a temp directory via git clone |
docker |
Run in a Docker container |
Container Runtime
actrun supports multiple container runtimes for job container:, services:, and docker:// actions.
| Runtime | Binary | Notes |
|---|---|---|
docker |
docker |
Default |
podman |
podman |
Docker-compatible CLI |
container |
container |
Apple container runtime (macOS) |
nerdctl |
nerdctl |
containerd CLI |
lima |
lima nerdctl |
Lima VM with nerdctl (wrapper script auto-generated) |
# CLI flag actrun workflow run ci.yml --container-runtime podman # actrun.toml container_runtime = "podman" # Environment variable (also works) ACTRUN_CONTAINER_RUNTIME=podman actrun workflow run ci.yml
Supported GitHub Actions
Builtin Actions (deterministic emulation)
| Action | Supported Inputs |
|---|---|
actions/checkout@* |
path, ref, fetch-depth, clean, sparse-checkout, submodules, lfs, fetch-tags, persist-credentials, set-safe-directory, show-progress |
actions/upload-artifact@* |
name, path, if-no-files-found, overwrite, include-hidden-files |
actions/download-artifact@* |
name, path, pattern, merge-multiple |
actions/cache@* |
key, path, restore-keys, lookup-only, fail-on-cache-miss |
actions/cache/save@* |
key, path |
actions/cache/restore@* |
key, path, restore-keys, lookup-only, fail-on-cache-miss |
actions/setup-node@* |
node-version, node-version-file, cache, registry-url, always-auth, scope |
Remote Actions (fetch + execute)
- GitHub repo
nodeactions withpre/main/postlifecycle - GitHub repo
dockeractions withpre-entrypoint/entrypoint/post-entrypointlifecycle - Composite actions (local and remote)
docker://imagedirect execution
Self-Hosted WASM Optimization
- A self-hosted runner may prefer a sibling
*.wasmfile next to a standardnode*actionruns.main - The same action still runs on GitHub Actions through the normal JS fallback path
- The runtime family is selected with
--wasm-runner/ACTRUN_WASM_RUNNER
Protocol extensions such as wasm://... and runs-on: wasi are experimental / internal and are not part of the release contract. See docs/public-api.md for details.
Local-Only Execution Flag
actrun sets ACTRUN_LOCAL=true in the execution environment. Use this in if: conditions to skip steps locally or run steps only locally:
steps: # Skipped when running locally (runs on GitHub Actions) - uses: actions/checkout@v5 if: ${{ !env.ACTRUN_LOCAL }} # Runs only locally (skipped on GitHub Actions) - run: echo "local debug info" if: ${{ env.ACTRUN_LOCAL }}
On GitHub Actions, ACTRUN_LOCAL is not set, so !env.ACTRUN_LOCAL evaluates to true and all steps run normally.
Action Overrides
Replace specific uses: action steps with custom run: commands via actrun.toml. This is useful when you have tools installed locally and want to skip the action's setup logic.
[override."actions/setup-node"] run = "echo 'using local node' && node --version"
When a workflow step matches uses: actions/setup-node@*, actrun replaces it with the specified run: command before execution.
Combine with local_skip_actions for full control:
local_skip_actions = ["actions/checkout"] [override."actions/setup-node"] run = "echo 'using local node'" [override."actions/setup-python"] run = "python3 --version"
Secrets & Variables
# Provide secrets via environment variables ACTRUN_SECRET_MY_TOKEN=xxx actrun workflow run ci.yml # Provide variables ACTRUN_VAR_MY_VAR=value actrun workflow run ci.yml
Secrets are automatically masked in stdout, stderr, logs, and run store. The ::add-mask:: workflow command is also supported.
Environment Variables
| Variable | Description |
|---|---|
ACTRUN_SECRET_<NAME> |
${{ secrets.<name> }} |
ACTRUN_VAR_<NAME> |
${{ vars.<name> }} |
ACTRUN_NODE_BIN |
Node.js binary path |
ACTRUN_DOCKER_BIN |
Docker binary path |
ACTRUN_WASM_RUNNER |
Wasm runner kind: wasmtime, deno, v8 |
ACTRUN_WASM_BIN |
Wasm runtime binary (default: wasmtime) |
ACTRUN_GIT_BIN |
Git binary path |
ACTRUN_GITHUB_BASE_URL |
GitHub API base URL |
ACTRUN_ARTIFACT_ROOT |
Artifact storage root |
ACTRUN_CACHE_ROOT |
Cache storage root |
ACTRUN_GITHUB_ACTION_CACHE_ROOT |
Remote action cache root |
ACTRUN_ACTION_REGISTRY_ROOT |
Custom registry root |
ACTRUN_NIX |
Set to false to disable nix wrapping |
ACTRUN_WASM_RUNNER を指定した場合、default bin は wasmtime / deno / ACTRUN_NODE_BIN (v8) に切り替わります。ACTRUN_WASM_BIN を併用すると、runner kind は固定したまま実行 binary だけ上書きできます。
Nix Integration
actrun automatically detects flake.nix or shell.nix in the workspace root and wraps run: steps in the corresponding nix environment. This lets workflows written for ubuntu-latest run locally with nix-managed toolchains.
Auto-detection
| Condition | Wrapping |
|---|---|
flake.nix exists |
nix develop --command <shell> <script> |
shell.nix exists |
nix-shell --run '<shell> <script>' |
| Neither exists | No wrapping (host environment) |
Detection requires nix to be installed. If nix is not found, wrapping is silently skipped.
Examples
# Auto-detect flake.nix / shell.nix actrun workflow run .github/workflows/ci.yml # Disable nix wrapping actrun workflow run .github/workflows/ci.yml --no-nix # Ad-hoc packages without flake.nix actrun workflow run .github/workflows/ci.yml --nix-packages "python312 jq" # Disable via environment variable ACTRUN_NIX=false actrun workflow run .github/workflows/ci.yml
Typical flake.nix for Rust
{ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; outputs = { self, nixpkgs }: let systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forAllSystems = nixpkgs.lib.genAttrs systems; in { devShells = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; in { default = pkgs.mkShell { packages = [ pkgs.rustc pkgs.cargo ]; }; }); }; }
Typical flake.nix for Python + uv
{ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; outputs = { self, nixpkgs }: let systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forAllSystems = nixpkgs.lib.genAttrs systems; in { devShells = forAllSystems (system: let pkgs = nixpkgs.legacyPackages.${system}; in { default = pkgs.mkShell { packages = [ pkgs.python312 pkgs.uv ]; }; }); }; }
Notes
- Only
run:steps are wrapped.uses:action steps are not affected. - Job
container:steps skip nix wrapping (container has its own environment). nix develop/nix-shellis invoked per step, so the nix environment is consistent across steps.
Workflow Features
- Push trigger filter (
branches,paths) strategy.matrix(axes, include, exclude, fail-fast, max-parallel)- Job/step
ifconditions (success(),always(),failure(),cancelled()) needsdependencies with output/result propagation- Reusable workflows (
workflow_call) with inputs, outputs, secrets,secrets: inherit, nested expansion - Job
containerandserviceswith Docker networking - Expression functions:
contains,startsWith,endsWith,fromJSON,toJSON,hashFiles - File commands:
GITHUB_ENV,GITHUB_PATH,GITHUB_OUTPUT,GITHUB_STEP_SUMMARY - Shell support:
bash,sh,pwsh, custom templates ({0}) step.continue-on-error,steps.*.outcome/steps.*.conclusion
Performance
Benchmark on Apple Silicon (M-series):
| Mode | Startup | CPU (Node.js) | Write 1k files |
|---|---|---|---|
local |
~0.13s | 644ms | 52ms |
nix-packages |
~0.70s | 629ms | 47ms |
apple-container |
~0.93s | 502ms | 14ms |
- local — lowest overhead, best for fast iteration
- nix-packages — +0.6s startup for
nix develop; execution speed identical to local - apple-container — +0.9s startup; many-file I/O is 3-4x faster (ext4 vs APFS metadata)
See docs/perf.md for full benchmark details.
# Try it yourself
nix run github:mizchi/actrun -- workflow run .github/workflows/ci.ymlDevelopment
just # check + test just fmt # format code just check # type check just test # run tests just e2e # run E2E scenarios just release-check # fmt + info + check + test + e2e
Live Compatibility Testing
# One-shot: dispatch, wait, download, compare just gha-compat-live compat-checkout-artifact.yml # Step by step just gha-compat-dispatch compat-checkout-artifact.yml just gha-compat-download <run-id> just gha-compat-compare compat-checkout-artifact.yml _build/gha-compat/<run-id>
Architecture
| File | Purpose |
|---|---|
src/lib.mbt |
Contract types |
src/parser.mbt |
Workflow YAML parser |
src/trigger.mbt |
Push trigger matcher |
src/lowering.mbt |
Bitflow IR lowering, action/reusable workflow expansion |
src/executor.mbt |
Native host executor |
src/runtime.mbt |
Git workspace materialization |
src/lint/ |
Expression parser, type checker, dead code detection, workflow visualization |
src/cmd/actrun/main.mbt |
CLI entry point |
testdata/ |
Compatibility fixtures |
Prior Art
- actionlint — Static checker for GitHub Actions workflow files.
actrun lintis inspired by its rule design and type system.
License
Apache-2.0