--- name: ci-github-actions description: GitHub Actions CI pipeline conventions — job shape, caching, permissions, and action pinning. Use when asked to "set up CI", "add a GitHub Actions workflow", "fix the pipeline", or when creating/reviewing files under .github/workflows/ for Go or Node/TS projects. --- # CI with GitHub Actions Complete copy-paste workflows for Go and Node/TS live in `references/workflows.md` — read it when writing an actual workflow file. This file is the rules. ## Pipeline shape Default pipeline: **lint → typecheck → test → build**, as parallel jobs (they don't depend on each other's outputs) with `build` optionally `needs:` the rest if you want fail-fast economics. Trigger: ```yaml on: push: branches: [main] pull_request: ``` Don't run `push` on every branch *and* `pull_request` — that double-builds PR branches. ## Concurrency — cancel superseded runs Every CI workflow gets this; without it, ten pushes to a PR queue ten full runs: ```yaml concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true ``` For deploy workflows, keep the group but set `cancel-in-progress: false` (queue, don't kill mid-deploy). ## Permissions — least privilege Top of every workflow: ```yaml permissions: contents: read ``` Grant more only per-job, only what that job proves it needs (`pull-requests: write` for a comment bot, `id-token: write` for OIDC cloud auth, `packages: write` for pushing images). Never leave the org/repo default write-all doing the work implicitly. ## Pin third-party actions to SHA - Official `actions/*` and other heavily-trusted orgs: major tag is acceptable (`actions/checkout@v5`). - **Everything else: pin to a full commit SHA** with a version comment: ```yaml uses: some-org/some-action@3d1a2b... # v2.1.0 ``` Tags are mutable; a compromised action re-tagged at `v2` runs in your CI with your secrets. Dependabot/Renovate can bump SHAs for you — enable it. ## Caching - **Go**: `actions/setup-go@v6` with `cache: true` (default when a `go.sum` exists) — caches module and build cache. Don't hand-roll `actions/cache` for Go. - **Node/npm or yarn**: `actions/setup-node@v5` with `cache: npm` — caches the package cache (correct; don't cache `node_modules`). - **pnpm**: setup-node's `cache: pnpm` caches the pnpm store. Install pnpm first (`pnpm/action-setup`, SHA-pinned), then setup-node with `cache: pnpm`, then `pnpm install --frozen-lockfile`. - **bun**: `oven-sh/setup-bun` (SHA-pinned) has no built-in caching — pair with `actions/cache` on `~/.bun/install/cache`, key `bun-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/bun.lock') }}`, then `bun install --frozen-lockfile`. - Cache keys must include the lockfile hash — built-in caching does this for you. - Never cache build outputs between CI runs as a correctness shortcut; only as a measured optimization (Next.js `.next/cache` is the common legitimate case). ## Matrix — only when genuinely needed A matrix multiplies CI cost. Use one when you actually support multiple targets (a library testing Node 20/22, or linux+windows CLIs). An app deployed to one runtime tests on that one runtime version — pin it to the version production runs. ## Secrets - Secrets come from GitHub **Environments** (`environment: production` on the job), which gate them behind protection rules and reviewers — not from repo-wide secrets sprayed into every job. - CI jobs (lint/test/build) should need zero secrets. If a test needs a secret, question the test. - Never `echo` secrets, never pass them as CLI args (visible in process lists/logs). Prefer OIDC (`id-token: write` + cloud role assumption) over long-lived cloud keys. - `pull_request_target` + checkout of PR head = classic secret-exfiltration hole. Don't use `pull_request_target` unless you know exactly why. ## Monorepo path filters Scope workflows to what changed: ```yaml on: pull_request: paths: - "services/api/**" - ".github/workflows/api.yml" ``` Include the workflow file itself in its own paths. If a filtered check is a required status check, add a no-op fallback workflow with the inverse `paths-ignore` so PRs that don't touch the service aren't blocked forever. ## Artifacts Upload build outputs when a later job or a human needs them: ```yaml - uses: actions/upload-artifact@v5 with: name: server-dist path: dist/ retention-days: 7 ``` Set `retention-days` deliberately (default 90 wastes storage). Pass files between jobs via artifacts, not by rebuilding. Test reports/coverage: upload with `if: always()` so failures still produce the report. ## Checklist for a new workflow - [ ] Triggers: `push` to main + `pull_request`, no double-trigger - [ ] `concurrency` with `cancel-in-progress: true` - [ ] `permissions: contents: read` at top; extras per-job only - [ ] Third-party actions SHA-pinned with version comment - [ ] setup-go / setup-node built-in caching, keyed off lockfile - [ ] Installs use lockfile-strict mode (`npm ci`, `pnpm install --frozen-lockfile`, `bun install --frozen-lockfile`) - [ ] No matrix unless multiple targets are truly supported - [ ] Secrets via environments; none in lint/test/build jobs - [ ] Monorepo: `paths` filters incl. the workflow file - [ ] Artifacts have `retention-days`; reports uploaded `if: always()` - [ ] Runtime versions read from the repo (`go.mod`, `.nvmrc`/`package.json engines`), not hardcoded twice