CI repeats the local gate on GitHub's machines, adds the device lanes a laptop cannot run every
night, and starts store builds only when a person decides. Three workflows do it (ci.yml: npm run verify on every pull request and push; devices.yml: Android emulator and iOS simulator lanes
nightly; release.yml: an EAS build by hand behind a protected environment), with every action
pinned to a commit SHA and every runner and tool pinned to a version. Two checkers prove it. Verified
on 2026-10-14 against the ubuntu-24.04 and macos-26 runner-image READMEs, actionlint 1.7.12, Expo
SDK 57, Node 26.4.0, Xcode 26.6 and eas-cli 24.10.0; the device jobs were not run on GitHub.
Rules that must hold
- CI runs the local gate, byte for byte:
npm ci, thennpm run verify, on every pull request and every push tomain(nopaths/branchesfilter onpull_request, noif:on the job or the two steps). No hand-written lint, test or build steps, nonpx jestbeside it, only npm scripts that exist; a job that setsAPP_VARIANTsetsEXPO_PUBLIC_APP_VARIANTto the same value. Why: the guardrail compares the scripts, not YAML copies; CI that checks something else passes what the pre-push hook would stop. - Every runner and tool is pinned:
runs-on: ubuntu-24.04/macos-26(never-latest), Node from.nvmrcthroughsetup-nodenode-version-file, JDK 17 where Gradle or Maestro runs, Xcode selected by version inside the repo's build script. Why: the Ubuntu image's npm 10.9 failsengine-strict, no image's Node is the one.nvmrcpins, and a moving label (macos-latest,macos-latest-xlarge) changes the build with no diff to review. - Every action is pinned to a full 40-character commit SHA with a
# vX.Y.Zcomment, at least 7 days old, and Dependabot proposes updates withcooldown: default-days: 7. Why: a tag can be repointed to malicious code; the comment keeps the pin reviewable; the cooldown matches the npm release age. - Least privilege, no untrusted code in privileged context: top-level
permissions: contents: read,pull_request(neverpull_request_targetorworkflow_run),actions/checkoutwithpersist-credentials: false, no${{ }}of contributor-controlled or secret context inside arun:script (pass it throughenv:). Why: these are the routes by which a pull request reaches a write token or runs as code. - Secrets live only in jobs with a protected
environment:(never in the workflow-levelenv:, never passed to a reusable workflow), and those jobs restore no cache. Why: the environment's reviewers and branch rule protect the secret; a cache any branch can write would run next to it. - Every workflow has a concurrency group and every job a
timeout-minutes(at most 180); a release workflow never cancels in progress, and the merge gate cancels only pull request runs. Why: stale runs waste minutes, a hung simulator burns six hours, a cancelled release leaves a half-made build, and a cancelled run onmainleaves a merged commit unverified. - A red step stays red: no
continue-on-error,|| true,|| exit 0,set +eor other||fallback after a gate command, no pipe after one (| tee) without pipefail, noNPM_CONFIG_*override, no weakeningnpm ciflag;LEFTHOOK: '0'is the one switch, set in every workflow that installs. Why: each turns a failing gate green or drops part of the.npmrcpolicy, silently. - Caches are keyed on the file that pins their content and never hold
node_modules. npm downloads onpackage-lock.json, Gradle onpackage-lock.json, the Maestro zip on its installer. Why: a key withouthashFilesnever invalidates, andnpm cideletesnode_modulesanyway. - Every device lane the app has a script for runs in CI on its own schedule: Android
(
build:android:emu,e2e:android) on Linux with KVM, iOS (build:ios:sim,e2e:ios) on macOS, nightly and on demand; a lane not run is a decision with its reason incontracts/ci.json. Why: macOS arm64 runners cannot nest virtualization, and a lane nobody runs proves nothing. - Workflows never touch a device or a licence directly: no
maestro,adb,emulator,avdmanager,xcrun simctl,xcode-selectlines or device actions, nosdkmanager --licenses. Why: the repo's scripts create<project>-<purpose>devices and address them by id; accepting a licence is the developer's legal decision. - Every gate and device job uploads its reports with
if: ${{ always() }}(or!cancelled()). Why: screenshots, junit files and coverage are the evidence, and they die with the runner. - Release lanes start by hand:
workflow_dispatch(or a pushed tag) only, a protected environment, the EAS CLI installed as one exact version with--min-release-age=7 --ignore-scripts(nevernpx eas-cli, nevereas-version: latest),--non-interactive, and eas-build-and-submit's preflight first; noeas submitoreas updateon a push, also not behind an npm script (npm run release:eas,release:ios,release:android). Why: a store build, a submission or an OTA update reaches people, so a person decides. - A workflow is a gate.
quality-gates.jsongatedPathscovers.github/workflows/**and.github/dependabot.yml, so a change carries aGate-Change:trailer; pushing a workflow and starting a release run are the developer's calls. Why: weakening CI is the quietest way to weaken every gate at once.
Workflow
-
Know what the repo has:
ls .github/workflows .github/dependabot.yml .nvmrc, thepackage.jsonscripts, and whether quality-gates is adopted (quality-gates.json,npm run verify). Withoutverify, adopt quality-gates first: CI repeats a gate, it never invents one. Read references/workflow-anatomy.md for what each workflow does and why there are three. -
Copy the templates (line 1 of each names its repo path):
Template Goes to When templates/workflows/ci.yml.github/workflows/ci.ymlalways templates/dependabot.yml.github/dependabot.ymlalways templates/workflows/devices.yml.github/workflows/devices.ymlthe app has a device script; delete a job whose lane the app does not have templates/workflows/release.yml.github/workflows/release.ymlthe app releases through EAS (eas-build-and-submit) templates/contracts/ci.json+ci.schema.jsoncontracts/always ( laneOptOuts: {}until the developer decides otherwise)In an existing workflow, replace ad-hoc steps with the npm scripts instead of adding a fourth file. Another skill's checker reaches CI as an npm script in quality-gates'
verifyExtraSteps, never as a step of its own; one that compares with the target branch needsfetch-depth: 0(workflow-anatomy.md, "Adding a skill's checker to CI"). -
Pin and harden: read references/security-hardening.md; resolve any new action to a commit SHA of a release at least 7 days old (
git ls-remote --tags <repo> '<tag>' '<tag>^{}') and add the# vX.Y.Zcomment. -
Device lanes: read references/device-lanes.md before changing a runner, the Android packages, the JDK step or the cadence. A lane the app will not run in CI gets a
laneOptOutsentry only after the developer agrees, with their reason. -
Release lane: read references/release-lane.md; list the human setup (EAS token, the
productionenvironment with reviewers and branch rule) in the report. The agent never creates tokens, environments or branch rules. -
Gate the files: check that
quality-gates.jsongatedPathsholds.github/workflows/**and.github/dependabot.yml; adding them is a quality-gates gate change (Gate-Change:trailer). -
Run the checks:
node ${CLAUDE_SKILL_DIR}/scripts/check-workflows.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-ci-lanes.mjs .(repo root positional, never--root). Fix everyFAILline (each names the file, line, rule and fix; references/checks.md explains every rule), rerun until both printRESULT: PASS. Then run actionlint from a pinned, checksum-verified binary (checks.md, "actionlint"). -
Ask before pushing: a workflow runs on GitHub's machines under the repository's name. When CI later fails, triage as in examples/red-run-triage.md: same command, same Node, same files, then the uploaded reports; fix the cause, never the workflow's strictness.
When multi-package: the workflows stay at the repository root; npm ci and npm run verify run
there; device scripts run with --workspace apps/<app> (either side of the script name); pass
--app-dir apps/<app> to check-ci-lanes.mjs (a workspace command is looked up in that package, any
other in the root package.json); one device job per shipping app.
Definition of done
-
ci.ymlrunsnpm cithennpm run verifyonpull_requestand on push tomain, with no other gate commands;dependabot.ymlupdatesgithub-actionswith a 7-day cooldown. - Every
uses:is a 40-character SHA with a# vX.Y.Zcomment; no-latestrunner; Node from.nvmrc; JDK 17 in every Gradle or Maestro job. - Each device script in
package.jsonhas a scheduled job on the right runner, or alaneOptOutsentry the developer agreed to. -
release.yml(if present) starts only by hand, in a protected environment, with the pinned EAS CLI and the preflight first. - actionlint (pinned release) prints nothing; the human settings (environment, branch protection,
required
verifycheck) are listed in the report for the developer. - Expected SKIP lines only, each until its owner skill is adopted:
SKIP ... [script-missing] npm script "<build:android:emu|e2e:android|e2e:ios|build:ios:sim>" not yet created (<owner> creates it)andSKIP .github/workflows/release.yml [script-missing] scripts/eas/release-preflight.ts not yet created (eas-build-and-submit creates it), andSKIP quality-gates.json [workflows-gated] not yet created (quality-gates creates it)until quality-gates is adopted. Once the templates are copied both checkers count what they read (check-workflows: N files checked,check-ci-lanes: N workflows checked);NOT APPLICABLEmeans.github/workflows/was never created, so the skill is not applied yet. -
node ${CLAUDE_SKILL_DIR}/scripts/check-workflows.mjs .printsRESULT: PASS -
node ${CLAUDE_SKILL_DIR}/scripts/check-ci-lanes.mjs .printsRESULT: PASS
Anti-patterns
run: npx eslint . && npx tsc && npx jestin the workflow. It drifts fromverifythe day a gate changes, and the guardrail never sees it. Runnpm run verify.uses: actions/checkout@v7. Pin the commit SHA with the release as a comment; let Dependabot move it.runs-on: macos-latestfor the Android emulator. No nested virtualization on Apple silicon runners; the emulator never boots. Linux with KVM.continue-on-error: trueon the E2E job "while it is flaky". The lane is then decoration. Fix the flow or the app; read the uploaded screenshots.npm installin CI. It can rewrite the lockfile and install unreviewed versions;npm ci.NPM_CONFIG_MIN_RELEASE_AGE: '0'to get a fresh package installed. Wait for the release age or add a dated exclude block (dependency-management).echo "${{ github.event.pull_request.title }}"in a script (or"${{ env.TITLE }}"after moving it toenv:). A crafted title runs as code; pass it throughenv:and read"$TITLE".pull_request_target"so forks get the token". That is exactly the attack; usepull_request.yes | sdkmanager --licenses. Accepting a licence is the developer's decision; the hosted images ship with them accepted.expo/expo-github-actionwitheas-version: latest, ornpx eas-cli buildon every push tomain. An unpinned CLI building and spending credits without a decision; start releases by hand with the pinned CLI.- Caching
node_modulesor restoring the npm cache in the release job. Wasted or poisoned; lockfile-keyed download caches in unprivileged jobs only. - Raising
timeout-minutesuntil a hanging lane "passes". Read the logs; the hang is the bug.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/workflow-anatomy.md | The three workflows line by line, the runners and their images, the pinned toolchain, caches, reports, adopting, when multi-package, what CI cannot prove | Workflow step 1 |
| references/security-hardening.md | SHA pins (with the verified pins), finding a SHA, Dependabot cooldown, permissions, pull_request_target, checkout credentials, script injection, environments, cache poisoning, the repository settings a human sets |
Workflow step 3 |
| references/device-lanes.md | Android on Linux with KVM, the SDK packages and licences, iOS on macOS with the pinned Xcode, JDK 17, named devices, flaky lanes, failures | Workflow step 4, and when a device job fails |
| references/release-lane.md | The manual EAS release job, the human setup, what never runs from CI, Expo's EAS Workflows, apps without EAS | Workflow step 5 |
| references/checks.md | Every rule of both checkers with its reason, contracts/ci.json, expected SKIP lines, the YAML subset, actionlint, when multi-package |
Workflow step 7, and when a check fails |
| examples/red-run-triage.md | A red CI run on a Tasks app traced to a case-only rename git did not record on macOS, and what was not done | When CI fails and local passes |
templates/workflows/ci.yml |
The merge gate: npm ci + npm run verify on pull requests and pushes |
Workflow step 2 |
templates/workflows/devices.yml |
Nightly Android (Linux + KVM) and iOS (macOS) build and E2E lanes | Workflow step 2 |
templates/workflows/release.yml |
The manual EAS build behind the production environment |
Workflow step 2 |
templates/dependabot.yml |
Weekly SHA-pin updates for actions with a 7-day cooldown | Workflow step 2 |
templates/contracts/ci.json |
The CI contract: laneOptOuts, the device lanes the developer decided not to run, with reasons |
Workflow steps 2 and 4 |
templates/contracts/ci.schema.json |
The contract's JSON schema (lane names, a reason of at least 20 characters) | When editing the contract |
scripts/check-workflows.mjs |
Pins, runners, permissions, triggers, credentials, injection, secrets, concurrency, timeouts, soft fails, npm config, LEFTHOOK, caches, Dependabot (16 rules) | Workflow step 7 and the definition of done |
scripts/check-ci-lanes.mjs |
The verify gate, installs, ad-hoc commands, Node pin, scripts, device lanes and runners, JDK, raw device commands, licences, reports, release trigger, environment and CLI, variant pairs, gated paths, opt-outs (19 rules) | Workflow step 7 and the definition of done |
scripts/lib/workflow-yaml.mjs |
The zero-dependency YAML reader (lines and comments kept) | Read only to change the parser |
scripts/lib/workflows.mjs |
The workflow model both checkers share (triggers, jobs, steps, shell commands, lanes, owners) | Read only to change a rule |
scripts/selftest.mjs |
Proves both checkers on the templates and every planted bug | After changing a checker, a template or a fixture |
scripts/check-lib.mjs |
Shared script helper, synced from the library (do not edit here) | Never by hand |
assets/shared.json |
Declares the synced files | When adding a shared file |
tests/bases/app/ |
The minimal app the suites start from (scripts, .nvmrc, gated paths, a preflight stub) |
When adding a case |
tests/fixtures/ |
Overlay cases: good/, one bad-* per rule, pass-* for SKIP and NOT APPLICABLE, error-* |
When adding a rule |
Related skills
quality-gates- whatnpm run verifyruns,gatedPaths,LEFTHOOKoutside workflows, the never list.dependency-management-.npmrc, release age, install scripts,check:deps, Node and npm pins.ios-simulator-build,android-emulator-build- the build scripts the device lanes call.e2e-maestro- the E2E scripts, the Maestro installer and flake rules.eas-build-and-submit-eas.json, the release preflight, submitting a build.ota-updates- EAS Update channels; never published from a push.app-variants-and-environment-APP_VARIANTandEXPO_PUBLIC_*values in build jobs.git-commits-and-reporting- theGate-Change:trailer and the report that cites the run.troubleshooting-playbook- tool failures behind a red job.