Workflow Authoring (GitHub Actions)
Workflow Authoring (GitHub Actions)
Section titled “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.
Why a convention, not a fix
Section titled “Why a convention, not a fix”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.
The runner seam
Section titled “The runner seam”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 --applyprojects the manifest declaration into the variable throughrunner.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.
Pinning a job that cannot run on the farm
Section titled “Pinning a job that cannot run on the farm”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 imageThe 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).
Toolchain: assert, don’t assume
Section titled “Toolchain: assert, don’t assume”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 installor 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_ENVIRONMENTtells the two apart when a fallback is legitimate:ci.ymlinstalls tmux withapt-getonly when it equalsgithub-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.
Secrets stay GitHub-held
Section titled “Secrets stay GitHub-held”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 prodmake 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.0To 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: readfor 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’senv:and read"$NAME".
The gate — make check-workflow-runs-on
Section titled “The gate — make check-workflow-runs-on”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.
make check-workflow-runs-on # PASS … 18 job(s) in 6 workflow(s) — 18 on the CI locus seammake 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.
Checklist for a new workflow
Section titled “Checklist for a new workflow”- Every job:
runs-on: ${{ vars.CI_RUNNER_LABEL || 'ubuntu-latest' }}, or# pinned: <reason>directly above a runner that has no farm form. - A header comment naming the seam and why it is there (copy from
ci.yml). - Every binary the steps need: provided by a
setup-*action or asserted in a first step that names the runbook. - Every
secrets.*reference: an entry insecrets-inventory.jsonwhoseconsumers.workflowslists this file, set on the step that uses it — never in a job- or workflow-levelenv:. - 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 runsmake check. - Third-party actions on a commit SHA with the version in a comment, a
permissions:block, and no event or matrix value inrun:text. make check-workflow-runs-on check-workflow-secrets check-workflow-hygienebefore the commit.
See also
Section titled “See also”- CI Locus — the
_meta.cideclaration 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 checktargets. - Credential Inventory — where every workflow secret’s home and rotation path is declared (#2237).