name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
install:
uses: ./.github/workflows/_install.yml
test:
needs: install
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Restore node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node }}-
- run: npm ci
- run: npm test -- --ci --reporters=default
build:
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
name: install
on:
workflow_call:
jobs:
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Cache node_modules
id: cache
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-20-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-20-
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: npm ci --prefer-offline --no-audit
- name: Lint
run: npm run lint
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --prefer-offline --no-audit
FROM node:20-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
This set of workflow files shows how a real CI pipeline for a Node service is wired so that dependency installation is cached, tests run in a parallel matrix, and the Docker image only gets built after tests pass. The three tabs collaborate: a reusable install/cache workflow, a test matrix that consumes it, and a build job gated on the test results.
In ci.yml, the top-level on block restricts runs to pushes and PRs against main, and the concurrency group cancels superseded runs for the same ref so a rapid series of pushes does not burn runners on stale commits. The install job calls the reusable _install.yml via uses, keeping the caching logic in one place instead of duplicating it across every job. The test job then needs install and runs a strategy.matrix over several Node versions with fail-fast: false, which means one failing version still lets the others report — useful for spotting a single-version regression rather than aborting the whole grid.
The _install.yml reusable workflow is where the caching actually happens. actions/setup-node is given cache: npm, and a separate actions/cache step keys the node_modules directory on the OS plus a hash of package-lock.json via hashFiles. The restore-keys fallback lets a partial cache hit (same OS, changed lockfile) still seed most dependencies, so npm ci mostly relinks rather than downloading everything. The if: steps.cache.outputs.cache-hit != 'true' guard skips the install entirely on an exact hit, which is the main time saving. Note the trade-off: caching node_modules is faster but riskier than caching only the npm download cache, so the lockfile hash in the key is what keeps it correct.
The build job in ci.yml declares needs: [test], so Docker never builds against code that failed tests. It uses docker/build-push-action with push: false on PRs and layer caching through cache-from/cache-to type gha, which reuses image layers across runs. A developer reaches for this layout when CI time matters: cache to cut install cost, matrix to widen coverage, and a gated build to avoid publishing broken artifacts.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
Share this code
Here's the card — post it anywhere.