# CI Github Actions

> 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.

- Skill: `gnomeria/ci-github-actions` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add gnomeria/ci-github-actions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/gnomeria/ci-github-actions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: gnomeria (https://skillmd.com/u/gnomeria)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/gnomeria/ci-github-actions

---


# 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

