Skip to content

Workflow Authoring (GitHub Actions)

How to write a GitHub Actions workflow for a BoB-provisioned repo so that it inherits the fleet’s CI locus by construction — and what make check refuses when it does not (#1729). This page is about Actions workflow files (.github/workflows/*.yml and the templates BoB scaffolds into other repos); it is unrelated to the Workflow tool’s orchestration scripts.

A project’s CI locus — farm (a fleet self-hosted runner) or hosted (GitHub’s image) — is one declared setting, _meta.ci in the project manifest, projected into the repo variable CI_RUNNER_LABEL (CI Locus, #1728). Every workflow in the repo has to read that one variable, or the locus is not a locus: with the farm runner online and the gate green, a workflow hardwired to ubuntu-latest still fails in about three seconds on a billing-blocked repo, with no steps and no runner. That is exactly how the release cut and the docs deploy died for weeks in August 2026 while ci.yml, already on the variable, stayed green (#1782, todo #1648). Moving those two workflows was the fix for that outage; the convention below is what stops the next workflow from re-creating it.

Every job resolves its runner from the repo variable, with hosted as the unset default:

jobs:
build:
runs-on: ${{ vars.CI_RUNNER_LABEL || 'ubuntu-latest' }}

Exactly that expression, on every job. Properties the gate relies on:

  • Unset is hosted. An unset repo variable evaluates to the empty string, so a repo that has never heard of the fleet runs on GitHub’s image with nothing configured. There is no second “off” switch to keep in sync (#1694).
  • One variable, one writer. scripts/fleet/ci-locus.js reconcile --apply projects the manifest declaration into the variable through runner.sh target; nothing else writes it. A workflow never carries a runner label of its own.
  • The default stays ubuntu-latest. A job that needs a different image is a pin (below), never a variant seam such as ${{ vars.CI_RUNNER_LABEL || 'macos-latest' }} — a variant would silently send the job to the farm’s Linux runner whenever the variable is set.

Header comments in the shipped workflows quote the seam and name the requirement; copy that block when you add a workflow so the next reader knows the line is load-bearing.

Some jobs genuinely cannot run on the fleet runner — a hosted-only feature, macOS / Xcode, arm64-mac verification (the fleet runner is Linux). Such a job is pinned explicitly, with the reason on the runs-on line or the line directly above it:

mac-parity:
# pinned: needs Xcode — the fleet runner is Linux, this job has no farm form
runs-on: macos-latest
installer:
runs-on: windows-latest # pinned: the installer test needs a Windows image

The marker is # pinned: followed by a non-empty reason. It is the one accepted way to leave the seam: an implicit hosted runner (a bare runs-on: ubuntu-latest with no marker) is what the gate exists to refuse, because it is indistinguishable from the forgotten-to-convert case that caused the outage. A pinned job is reported by the gate on every run — it is a standing hosted dependency, so it stays visible. A marker two lines up, or above the job name rather than the runs-on line, does not count.

Today no shipped workflow pins anything: every job in ci.yml, auto-release.yml, docs-deploy.yml and the three templates runs on the seam and has been verified green on the farm runner (release cut, docs deploy and the gate — see the runner runbook).

A hosted image ships jq, tmux, python and a system Node; a self-hosted runner carries whatever its host has. A workflow that needs a binary either provisions it in a step (actions/setup-node, actions/setup-python) or asserts it up front so a missing tool fails with a sentence instead of an opaque error deep inside make:

- name: Verify runner toolchain
run: |
command -v jq >/dev/null || {
echo "::error::jq is missing on runner '$RUNNER_NAME' — see runbooks/self-hosted-runner.md"
exit 1
}

Rules of thumb, from the per-workflow audit in the runner runbook:

  • Never brew install or rely on a preinstalled version — setup-* actions work on both loci (they populate the runner’s tool cache on first use), a system package manager does not.
  • RUNNER_ENVIRONMENT tells the two apart when a fallback is legitimate: ci.yml installs tmux with apt-get only when it equals github-hosted, and fails naming the runbook otherwise.
  • JS and composite actions (actions/cache, cloudflare/wrangler-action, sticky-pull-request-comment) run unchanged on a self-hosted Linux runner.

A workflow reads credentials only as ${{ secrets.NAME }}. On the farm those are injected by GitHub per job, masked in logs and never written to the host — the runner holds no copy, and a job must never read a token from the runner’s filesystem or environment (~/.claude, .mcp.json, .dev.vars) instead. Every secret a shipped workflow names has an entry in the credential inventory whose consumers.workflows lists the workflow, so the declared home and the rotation path are findable from the workflow file (#2237). What the runner host itself keeps between jobs (_work, _diag) is the runner-hygiene requirement’s concern (#1732).

A secret goes on the step that uses it, never the job (#3647). A secret in the workflow’s top-level env: or a job’s own env: is in the environment of every step of that job — the npm ci / npm install lifecycle scripts, the tests, the build — so any dependency could read the production deploy token. Give it to the migrate, deploy or publish step that needs it:

- name: Deploy (prod)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: npx wrangler deploy --env prod

make check-workflow-secrets (scripts/checks/check-workflow-secrets.js) holds every shipped workflow to this: it scans the same files as the runner gate below and fails any secrets.* in a workflow- or job-level env:, naming the file, line, job and secret (exit 0 clean · 1 problems · 2 unreadable; --root DIR grades another checkout, --json prints workflow-secrets-check/1). Step env: and with: inputs are not its concern.

Three hygiene rules (#3649). make check-workflow-hygiene (scripts/checks/check-workflow-hygiene.js) holds every shipped workflow to them and names the file and line (exit 0 clean · 1 problems · 2 unreadable; --json prints workflow-hygiene-check/1):

  • A third-party action is pinned to a commit. A tag or a branch is a name its owner can move, and the action runs in a job that holds this repo’s credentials. Write the full SHA with the version beside it. First-party actions (actions/*, github/*) and local ones (./…) may keep a tag.

    uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0

    To move to a newer version, read the commit behind its tag (git ls-remote https://github.com/<owner>/<action> 'refs/tags/v3.16.0^{}' refs/tags/v3.16.0) and change the SHA and the comment together.

  • The workflow says what its token may do. A top-level permissions: block (contents: read for jobs that only check out), or one on every job. A job that needs more — issues: write, security-events: write — says so itself.

  • An event or matrix value never goes into run: text. ${{ github.event.inputs.x }}, ${{ inputs.x }}, ${{ matrix.x }}, ${{ github.head_ref }} and ${{ github.ref_name }} are expanded before the shell reads the line, so the value is code. Put it in the step’s env: and read "$NAME".

scripts/checks/check-workflow-runs-on.js runs in make check. It scans every tracked *.yml / *.yaml that has a jobs: block — the repo’s own .github/workflows/ and every workflow template under templates/, which is where a new workflow enters the fleet — and grades each job’s runs-on (tests/ is excluded; comment lines are skipped, so a seam quoted in a header comment neither passes nor fails a job):

Verdict Meaning Fix
seam exactly the seam expression —
pinned any other runner with # pinned: <reason> inline or directly above none — reported so the hosted dependency stays visible
hardwired any other runner with no marker use the seam, or pin it with a reason
pin-no-reason # pinned: with nothing after it write why the job cannot run on the farm
seam-drift a vars.CI_RUNNER_LABEL expression that is not the canonical seam use the exact seam; a non-farm need is a pin

A block-list runs-on: (- self-hosted / - gpu) is read as its items and graded the same way. A workflow whose jobs only uses: a reusable workflow has no runs-on and is not a problem.

Terminal window
make check-workflow-runs-on # PASS … 18 job(s) in 6 workflow(s) — 18 on the CI locus seam
make check-workflow-runs-on ARGS=--json # workflow-runs-on-check/1: {ok, seam, scanned, jobs, on_seam, pinned[], workflows[], problems[]}

Exit 0 clean · 1 problems (each named with file, line, job, value and the fix) · 2 unreadable. tests/test-fleet-runner.sh additionally pins the six shipped files to the seam by name, and tests/test-workflow-runs-on.js pins the gate’s own behaviour on fixtures.

  1. Every job: runs-on: ${{ vars.CI_RUNNER_LABEL || 'ubuntu-latest' }}, or # pinned: <reason> directly above a runner that has no farm form.
  2. A header comment naming the seam and why it is there (copy from ci.yml).
  3. Every binary the steps need: provided by a setup-* action or asserted in a first step that names the runbook.
  4. Every secrets.* reference: an entry in secrets-inventory.json whose consumers.workflows lists this file, set on the step that uses it — never in a job- or workflow-level env:.
  5. If the workflow is a template BoB scaffolds elsewhere, put it under templates/ — the gate covers it there; a copy in a consuming repo is that repo’s own workflow and is graded when that repo runs make check.
  6. Third-party actions on a commit SHA with the version in a comment, a permissions: block, and no event or matrix value in run: text.
  7. make check-workflow-runs-on check-workflow-secrets check-workflow-hygiene before the commit.
  • CI Locus — the _meta.ci declaration the seam projects, the resolver, reconcile and the drift report (#1728).
  • Self-Hosted Actions Runner — registering the runner, the per-workflow toolchain audit, what was verified green on the farm, and the security constraints.
  • Verification System — the gate among the other make check targets.
  • Credential Inventory — where every workflow secret’s home and rotation path is declared (#2237).