Last updated 2026-05-07

Authoring a Workflow

A worked example: write a CI workflow from scratch, run it locally, and ship it. By the end, the same YAML works on cloud GitHub Actions and on the local runner.

1. Sketch the workflow

The simplest useful CI: install, lint, type-check, test.

.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
skip-tests:
description: "Skip the test job"
required: false
default: "false"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- run: npm run lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'npm' }
- run: npm ci
- run: npx tsc --noEmit
test:
needs: [lint, typecheck]
if: ${{ inputs.skip-tests != 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: ${{ matrix.node }}, cache: 'npm' }
- run: npm ci
- run: npm test
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-output-node${{ matrix.node }}
path: junit.xml

2. Run it locally

stax runner start # if not already running
stax workflows # confirm "ci" appears
stax run ci # default inputs
stax run ci -i skip-tests=true # workflow_dispatch input

Watch the live event stream:

# Live log of the most recent run
stax log -f
# Or open the dashboard
stax dashboard

3. Iterate

Local-first means: edit ci.yml, hit stax run ci again. No commits required. The runner re-discovers the file on each trigger.

Common iteration patterns

GoalHow
Pin a single matrix combination while debuggingAdd if: matrix.node == '20' to a step.
Skip slow steps during iterationAdd a workflow_dispatch input and gate the step's if:.
Inject test secretsUse stax secrets sync runner for repo-backed values, or stax secret set DB_URL postgres://... for ad hoc local-only values. Reference ${{ secrets.DB_URL }}.
Avoid sandbox setup timestax run ci --no-sandbox while iterating; remove for the final run.

4. Use built-in shims to your advantage

These actions are shimmed locally, so they "just work" without a real GitHub Actions runner image:

  • actions/checkout — already mounted, no-op.
  • actions/setup-{python,java,go,ruby,dotnet,gradle,xcode} — detect system installs.
  • actions/cache — local filesystem cache.
  • actions/upload-artifact / download-artifact — copies under .artifacts/.

See the marketplace actions page for the full shim table.

5. Add expression-driven gates

jobs:
deploy:
needs: [test]
if: github.ref == 'refs/heads/main' && success()
runs-on: ubuntu-latest
steps:
- run: ./scripts/deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

6. Ship the workflow

stax diff "Add CI workflow"
stax sync
stax submit
# Once approved:
stax land

The same YAML now runs on GitHub Actions automatically because that's what the file was always for. Local execution gave you a fast feedback loop without committing half-finished changes.