Every new repo needs CI, and I kept rewriting the same YAML from memory and forgetting something each time. So here's the version I now copy everywhere, with notes on why each setting is there.

The workflow

Save as .github/workflows/ci.yml:

name: CI (Node.js) on: push: branches: [main] pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run lint --if-present - run: npm test --if-present - run: npm run build --if-present 

Four settings most templates skip

1. Cancel superseded runs

concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true 

Push three fixes to a PR in a row and only the latest run finishes. Fewer wasted minutes and a faster signal. (For deploy workflows, set cancel-in-progress: false so a deploy is never cut off halfway.)

2. Least-privilege token

permissions: contents: read 

CI only needs to read your code. Saying so explicitly limits what a compromised dependency or action could do with GITHUB_TOKEN.

3. Built-in dependency caching

cache: npm on actions/setup-node caches downloads keyed on package-lock.json, with no extra actions/cache step. Pair it with npm ci for installs that exactly match your lockfile.

4. --if-present

npm run lint --if-present skips the step if the script doesn't exist, so the same file works on day one of a project and on day 500.

Bonus: lint your workflows

bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) ./actionlint 

actionlint catches bad expressions, typos in action inputs and (with shellcheck installed) bugs in your run: scripts before you push.

Get it

What's the one thing you always add to CI? Let me know in the comments.