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 bridgeExecution 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 / worktreeemit('run.completed') # SSEKey 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.2has 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.