Last updated 2026-05-07

Runner Architecture

The runner is small (~10k lines TypeScript) and intentionally flat-structured. Three subsystems: the engine, the action runtime, and the storage layer.

Module diagram

platform/runner/src/
├── index.ts Entry point — Express boot, store wiring, sandbox cleanup
├── server.ts REST routes + SSE multiplexer
├── types.ts Shared TypeScript types
├── engine/
│ ├── executor.ts WorkflowExecutor — full run lifecycle
│ ├── scheduler.ts JobScheduler — needs / fail-fast / max-parallel
│ ├── parser.ts YAML loading + workflow discovery
│ ├── expression.ts ${{ }} parser (literals, ops, functions, contexts)
│ ├── context.ts Builds ExpressionContext + GITHUB_* env
│ ├── job-runner.ts Single job (services, steps, timeout)
│ ├── step-runner.ts Single step (shell vs uses, output parsing)
│ ├── matrix.ts Cartesian + include/exclude
│ ├── sandbox.ts worktree | copy-on-write | rsync-copy | none
│ └── reusable.ts workflow_call resolution (local + remote)
├── actions/
│ ├── resolver.ts Marketplace cache + shim dispatch
│ ├── loader.ts action.yml parsing
│ ├── composite.ts Composite step recursion
│ ├── node-runner.ts Node.js action lifecycle (pre/main/post)
│ ├── docker-runner.ts Docker build/pull + run
│ ├── commands.ts ::set-output, ::error, ::add-mask, …
│ ├── shim-framework.ts Declarative shim → composite YAML compiler
│ └── shim-definitions.ts All language-setup shims
├── artifacts/store.ts In-memory metadata + filesystem under .artifacts/
└── secrets/
├── store.ts Per-workspace secrets, optional AES-256-GCM
├── backend.ts SecretsBackend interface
├── r2-backend.ts Cloudflare R2
└── github-backend.ts gh CLI bridge

Execution flow

POST /api/runs {workflow: "ci"}
WorkflowExecutor.triggerRun()
├─ findWorkflow() # discover & parse .github/workflows/ci.yml
├─ git ref/sha for trigger context
├─ create WorkflowRun (status: queued)
└─ return immediately # 201 Created
▼ (async)
WorkflowExecutor.executeRun()
├─ createSandbox() # worktree | copy | none
├─ topologicalSort() # job dependency order
└─ prepareSchedulableJobs() # expand matrices
JobScheduler.run() # scheduling loop
├─ for each ready job:
│ ├─ check needs: dependencies met?
│ ├─ check fail-fast: matrix sibling failed?
│ ├─ check max-parallel: under the cap?
│ └─ launch via onLaunchJob()
runJob()
├─ evaluateCondition() # job-level if:
├─ startServices() # docker compose up (if services declared)
├─ for each step:
│ ├─ buildExpressionContext()
│ ├─ evaluateCondition() # step-level if:
│ └─ runStep()
│ ├─ shell: spawn bash/sh/pwsh/python
│ └─ uses: resolveActionPath() → loadAction()
│ ├─ composite: recursively run sub-steps
│ ├─ node20: spawn node with @actions/core env
│ └─ docker: docker build/pull + docker run
├─ parse GITHUB_OUTPUT, GITHUB_ENV, GITHUB_PATH
├─ parse workflow commands (::set-output, ::error, …)
└─ resolve job outputs
sandbox.cleanup() # remove sandbox / worktree
emit('run.completed') # SSE

Key invariants

  • SSE is fan-out. Every state change goes through a single event bus; the SSE endpoint is just a subscriber. The CLI's stax log -f, the dashboard, and any external tool get the same firehose.
  • Sandboxes are throwaway. The runner never writes to your workspace during a run; everything happens in /tmp/runner-sandboxes/<run-id>/.
  • Secrets never leave their layer. The expression evaluator can read them; logs run through a single masking pass that replaces every secret value with ***.
  • Action cache is content-addressable per ref. Once owner/repo@v1.2 has been fetched, future calls reuse the cached clone for the configured TTL.

Extending the runner

Adding an API endpoint

router.get('/my-endpoint', (req, res) => {
// executor, workspacePath, secretsStore are in closure
res.json({ data: 'hello' });
});

Adding an expression function

Add a case to evaluateFunction() in src/engine/expression.ts:

case 'myfunction': {
const arg = String(args[0] ?? '');
return arg.toUpperCase();
}

Function names are matched case-insensitively.

Adding a custom shim

See Marketplace → Adding your own shim.