MVP GitHub Actions-compatible push CI runner with bitflow lowering, native execution, and bit workspace materialization.
Dependencies
# 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 nativenix run github:mizchi/actrun -- workflow run .github/workflows/ci.ymlnix build github:mizchi/actrun
./result/bin/actrun workflow run .github/workflows/ci.ymlecho "use flake" > .envrc
direnv allownix develop{
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 ];
};
};
}# default.nix provides a ready-to-use derivation
let
actrun = import (builtins.fetchTarball "https://github.com/mizchi/actrun/archive/main.tar.gz") { };
in
actrun# 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# 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"]actrun workflow list # List workflows in .github/workflows/
actrun workflow run <workflow.yml> # Run a workflow locallyactrun 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# 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| 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) |
[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) |
$ actrun viz .github/workflows/release.yml
┌───────┐ ┌────────┐
│ build │ │ docker │
└───────┘ └────────┘
└┐
│
┌─────────┐
│ release │
└─────────┘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| 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 |
# 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[affected."ci.yml"]
patterns = ["src/**", "package.json"]
[affected.".github/workflows/lint.yml"]
patterns = ["src/**", "*.config.*"]on:
push:
paths: ["src/**", "*.toml"] # actrun --affected uses these| 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 |
| 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| 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 |
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 }}[override."actions/setup-node"]
run = "echo 'using local node' && node --version"local_skip_actions = ["actions/checkout"]
[override."actions/setup-node"]
run = "echo 'using local node'"
[override."actions/setup-python"]
run = "python3 --version"# 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| 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 |
| Condition | Wrapping |
|---|---|
| flake.nix exists | nix develop --command <shell> <script> |
| shell.nix exists | nix-shell --run '<shell> <script>' |
| Neither exists | No wrapping (host environment) |
# 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{
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 ]; }; });
};
}{
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 ]; }; });
};
}| 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 |
# Try it yourself
nix run github:mizchi/actrun -- workflow run .github/workflows/ci.ymljust # 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# 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>| 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 |
///|
test {
let workflow = new_workflow(
"ci",
[
new_job("build", [
new_run_step("install", "pnpm install"),
new_run_step("test", "pnpm test"),
]),
new_job("lint", [new_run_step("lint", "pnpm lint")], needs=["build"]),
],
trigger=new_push_trigger(branches=["main"], paths=["src/*"]),
defaults=new_run_defaults(shell=Some("bash")),
)
let event = new_push_event("main", ["src/lib.mbt"])
inspect(matches_push_trigger(workflow.trigger, event), content="true")
let src =
#|on: push
#|jobs:
#| build:
#| runs-on: ubuntu-latest
#| steps:
#| - run: pnpm test
let parsed = parse_workflow_yaml(src)
let lowered = lower_push_workflow(parsed.workflow.unwrap())
@debug.debug_inspect(lowered.errors, content="[]")
@debug.debug_inspect(lowered.ir.tasks.length(), content="2")
}pub enum ActionRef {
GitHubRepo(String, String, String, String?)
LocalPath(String)
DockerImage(String)
Registry(String, String, String)
}pub struct BitWorkspace {
repo_root : String
git_dir : String
workspace_root : String
commit_sha : String
refname : String
remote_url : String
}pub struct ExecutionPlan {
tasks : Array[TaskPlan]
job_outputs : Map[String, Map[String, String]]
job_if_conditions : Map[String, String]
job_needs : Map[String, Array[String]]
job_need_targets : Map[String, Map[String, Array[String]]]
job_virtual_targets : Map[String, Array[String]]
job_virtual_output_targets : Map[String, Map[String, Array[String]]]
job_matrix_groups : Map[String, String]
job_matrix_fail_fast : Map[String, Bool]
job_containers : Map[String, JobContainerSpec]
job_services : Map[String, Map[String, JobContainerSpec]]
composite_output_mappings : Map[String, Map[String, String]]
}pub struct JobContainerCredentialsSpec {
username : String
password : String
}pub struct JobContainerSpec {
image : String
credentials : JobContainerCredentialsSpec?
env : Map[String, String]
ports : Array[String]
volumes : Array[String]
options : String?
}pub struct JobSpec {
id : String
name : String
if_condition : String
needs : Array[String]
outputs : Map[String, String]
permissions : PermissionsSpec?
concurrency : ConcurrencySpec?
runs_on : Array[String]
env : Map[String, String]
defaults : RunDefaults
steps : Array[StepSpec]
matrix : JobMatrixSpec?
reusable_workflow : String?
reusable_workflow_with : Map[String, String]
reusable_workflow_secrets : Map[String, String]
reusable_workflow_inherit_secrets : Bool
services : Map[String, JobContainerSpec]
container : JobContainerSpec?
container_image : String?
timeout_minutes : Int
environment : String
}pub struct PushEvent {
ref_name : String
before_sha : String
after_sha : String
changed_paths : Array[String]
repository : String
actor : String
}pub struct ResolvedAction {
uses : String
action_ref : ActionRef
kind : String
backend : String
capabilities : BackendCapabilities
action_path : String?
entrypoint : String?
image : String?
args : Array[String]
}pub struct ShellEnv {
vars : Map[String, String]
cwd : String
stdout_buf : Array[String]
stderr_buf : Array[String]
exit_code : Int
exit_called : Bool
write_file : (String, String, Bool) -> Bool
read_file : (String) -> String?
file_exists : (String) -> Bool
is_dir : (String) -> Bool
mkdir : (String) -> Bool
remove : (String) -> Bool
}pub struct TaskPlan {
id : String
kind : String
job_id : String
step_id : String
name : String
script : String
shell : String
working_directory : String
if_condition : String
runs_on : Array[String]
env : Map[String, String]
with_values : Map[String, String]
action : ResolvedAction?
action_scope : String?
requires_action_started : Bool
continue_on_error : String
timeout_minutes : Int
}pub struct TaskRunReport {
id : String
kind : String
status : String
code : Int
duration_ms : UInt64
shell : String
script : String
cwd : String
stdout : String
stderr : String
summary : String
}pub struct WasmDceReport {
removable_functions : Int
removable_bytes : Int
ok : Bool
error : String
}pub struct WasmModuleInfo {
total_bytes : Int
function_count : Int
import_count : Int
export_count : Int
valid : Bool
error : String
}pub struct WasmOptimizeResult {
original_size : Int
optimized_size : Int
optimized_bytes : Bytes
ok : Bool
error : String
}pub struct WasmSandbox {
tempdir : String
env_path : String
output_path : String
path_path : String
summary_path : String
state_path : String
}pub struct WorkflowCallInputSpec {
description : String
required : Bool
default_value : String?
input_type : String
}pub struct WorkflowCallOutputSpec {
description : String
value : String
}pub struct WorkflowCallSpec {
inputs : Map[String, WorkflowCallInputSpec]
outputs : Map[String, WorkflowCallOutputSpec]
secrets : Map[String, WorkflowCallSecretSpec]
}pub struct WorkflowRunReport {
ok : Bool
state : String
order : Array[String]
steps : Array[WorkflowStepReport]
issues : Array[String]
task_reports : Array[TaskRunReport]
}pub struct WorkflowSpec {
name : String
run_name : String
trigger : PushTrigger
pull_request_trigger : PushTrigger?
workflow_call : Bool
workflow_call_spec : WorkflowCallSpec?
permissions : PermissionsSpec?
concurrency : ConcurrencySpec?
env : Map[String, String]
defaults : RunDefaults
jobs : Array[JobSpec]
}pub struct WorkflowStepReport {
id : String
status : String
required : Bool
duration_ms : UInt64
message : String
}fn build_sandboxed_wasmtime_args(sandbox : WasmSandbox, env : Map[String, String], module_path : String) -> Array[String]fn compute_bit_changed_paths(rfs : &RepoFileSystem, repo_root : String, event : PushEvent) -> BitChangedPathsResultasync fn dump_workflow_context_json(workflow_name : String, plan : ExecutionPlan, workspace_root : String, push_event : PushEvent, dispatch_inputs? : Map[String, String], event_name? : String) -> Stringasync fn execute_lowered_native(lowered : LoweringResult, workspace_root? : String, push_event? : PushEvent?, event_name? : String, dispatch_inputs? : Map[String, String], nix_mode? : String, nix_packages? : Array[String], sandbox_mode? : String, sandbox_writable? : Array[String], allow_destructive_checkout_clean? : Bool) -> WorkflowRunReportfn lower_push_workflow_in_workspace(workflow : WorkflowSpec, workspace_root : String) -> LoweringResultfn mask_report_secrets(report : WorkflowRunReport, secret_values : Array[String]) -> WorkflowRunReportfn materialize_bit_push_workspace(fs : &FileSystem, rfs : &RepoFileSystem, repo_root : String, event : PushEvent, workspace_root : String, remote_url? : String) -> BitWorkspaceResultfn materialize_bit_push_workspace_native(repo_root : String, event : PushEvent, workspace_root : String, remote_url? : String) -> BitWorkspaceResultfn new_job(id : String, steps : Array[StepSpec], name? : String, if_condition? : String, needs? : Array[String], outputs? : Map[String, String], permissions? : PermissionsSpec?, concurrency? : ConcurrencySpec?, runs_on? : Array[String], env? : Map[String, String], defaults? : RunDefaults, matrix? : JobMatrixSpec?, reusable_workflow? : String?, reusable_workflow_with? : Map[String, String], reusable_workflow_secrets? : Map[String, String], reusable_workflow_inherit_secrets? : Bool, container? : JobContainerSpec?, container_image? : String?, services? : Map[String, JobContainerSpec], timeout_minutes? : Int, environment? : String) -> JobSpecfn new_job_container_credentials_spec(username : String, password : String) -> JobContainerCredentialsSpecfn new_job_container_spec(image : String, credentials? : JobContainerCredentialsSpec?, env? : Map[String, String], ports? : Array[String], volumes? : Array[String], options? : String?) -> JobContainerSpecfn new_job_matrix(rows : Array[Map[String, String]], fail_fast? : Bool, max_parallel? : Int?) -> JobMatrixSpecfn new_local_action(steps : Array[StepSpec], name? : String, inputs? : Map[String, String], outputs? : Map[String, String]) -> LocalActionSpecfn new_task_plan(id : String, kind : String, job_id : String, step_id : String, name : String, script : String, shell : String, working_directory : String, if_condition : String, runs_on : Array[String], env : Map[String, String], with_values? : Map[String, String], action? : ResolvedAction?, action_scope? : String?, requires_action_started? : Bool, continue_on_error? : String, timeout_minutes? : Int) -> TaskPlanfn new_workflow(name : String, jobs : Array[JobSpec], run_name? : String, trigger? : PushTrigger, pull_request_trigger? : PushTrigger?, workflow_call? : Bool, workflow_call_spec? : WorkflowCallSpec?, permissions? : PermissionsSpec?, concurrency? : ConcurrencySpec?, env? : Map[String, String], defaults? : RunDefaults) -> WorkflowSpecfn new_workflow_call_input_spec(description? : String, required? : Bool, default_value? : String?, input_type? : String) -> WorkflowCallInputSpecfn new_workflow_call_spec(inputs? : Map[String, WorkflowCallInputSpec], outputs? : Map[String, WorkflowCallOutputSpec], secrets? : Map[String, WorkflowCallSecretSpec]) -> WorkflowCallSpecfn override_workflow_steps(workflow : WorkflowSpec, overrides : Map[String, String]) -> WorkflowSpecasync fn prefetch_workflow_github_actions_native(workflow : WorkflowSpec, workspace_root : String, git_bin? : String?, github_base_url? : String?) -> GitHubActionPrefetchResultfn registry_module_path(name : String, version : String) -> Stringfn store_outputs_path(run_id : String, step_id : String) -> StringMVP GitHub Actions-compatible push CI runner with bitflow lowering, native execution, and bit workspace materialization.
Dependencies