Skip to content
AstroPaper
Go back

GitHub Actions — Complete Deep-Dive Engineering Guide

Updated:
Edit page

GitHub Actions — Complete Deep-Dive Engineering Guide

For a frontend engineer (React / Next.js / Astro / TypeScript) moving toward DevOps, CI/CD, automation, release engineering, and platform engineering.


Table of Contents

Open Table of Contents

1. Big Picture

1.1 What GitHub Actions Are

GitHub Actions is an event-driven automation engine built directly into GitHub. It executes arbitrary code — your code, community-maintained actions, or shell scripts — in response to events that happen inside or around a repository.

You already know this pattern. When you run npm run dev, Vite watches files and re-renders on change. GitHub Actions does the same thing, but the “watcher” is GitHub itself, the “files” are Git events (pushes, PRs, tags, schedules, manual triggers), and the “render” is a pipeline of jobs that lint, test, build, deploy, notify, scan, release, or do anything a shell can do.

It is not just CI. It is a general-purpose workflow automation platform that happens to be excellent at CI/CD.

1.2 How GitHub Actions Differ From Things You Already Know

Tool / PatternWhat it doesWhere it runsKey difference from GitHub Actions
Local scripts (./deploy.sh)One-off automationYour laptopNot shared, not auditable, not triggered by Git events, not reproducible across machines
npm scripts (npm run build)Project commandsWherever you run themSingle-process, single-machine; Actions orchestrates many jobs across isolated VMs
Husky (Git hooks)Pre-commit / pre-push checksDeveloper machineRuns before push on your machine; Actions runs after push on GitHub’s machines; Husky can be skipped with --no-verify
CI/CD tools (Jenkins, CircleCI)Automated pipelinesTheir own infrastructureActions is natively integrated into GitHub — no separate platform to manage, no webhook glue
Cron jobsTime-based schedulingA server you maintainActions supports schedule triggers, but also reacts to every Git event; no server to maintain
Traditional deployment pipelinesStage-gated release flowsCentralized infraActions is decentralized (per-repo YAML), composable (reusable workflows), and version-controlled alongside code

The React/Next.js analogy:

Think of your local npm run devnpm run buildnpm run start cycle. Now imagine GitHub automatically runs that cycle every time anyone on your team pushes code, but on a fresh clean machine, with results posted to the PR, and the build output deployed to a URL — all without you touching a terminal. That is GitHub Actions.

1.3 Core Concepts

Workflow

A YAML file in .github/workflows/ that defines an automation pipeline. One repository can have many workflows. Each workflow responds to one or more events.

Analogy: A workflow is like a next.config.js — it declares how the system should behave, and the platform interprets it.

Event

Something that triggers a workflow. Examples: push, pull_request, release, schedule, workflow_dispatch (manual button), workflow_call (called by another workflow).

Analogy: Events are like DOM events. A push is like an onClick — when it fires, registered handlers (workflows) run.

Job

A unit of work that runs on a single runner. Jobs within a workflow run in parallel by default, unless you declare dependencies with needs.

Analogy: Jobs are like independent React components — they render (execute) independently, but can pass data to each other.

Step

A single command or action within a job. Steps run sequentially, top to bottom.

Analogy: Steps are like lines inside a useEffect — they execute in order, sharing the same context (the runner’s filesystem and environment).

Runner

The machine (VM) that executes a job. GitHub provides hosted runners (Ubuntu, Windows, macOS) or you can use self-hosted runners.

Analogy: A runner is like a fresh npx create-next-app environment — clean, isolated, and disposable. Every job starts on a blank machine.

Action

A reusable, parameterized unit of workflow logic. Published to the GitHub Marketplace or defined locally. Called with uses:.

Analogy: An action is like an npm package for your pipeline — someone else wrote and tested it, you import it and pass props (inputs).

Matrix

A strategy that fans a single job definition into many parallel runs by varying parameters (Node versions, OS, config flags).

Analogy: Like rendering a component list with .map() — same template, different data per instance.

Artifact

A file or directory produced by a workflow run that you want to persist — build outputs, test reports, binaries. Uploaded with actions/upload-artifact, downloaded with actions/download-artifact.

Analogy: Like the out/ or dist/ directory from next build, but stored on GitHub for later jobs or manual download.

Cache

Stored dependency data (like node_modules or the pnpm store) that is restored on subsequent runs to skip expensive installs. Keyed by a hash of the lockfile.

Analogy: Like the .next/cache folder that makes rebuilds faster — but across CI runs.

Environment

A named deployment target (e.g., staging, production) with configurable protection rules — required reviewers, wait timers, branch restrictions, scoped secrets.

Analogy: Like Vercel’s “Preview” vs. “Production” distinction, but you define the rules yourself.

Secret

An encrypted value stored at the repository, environment, or organization level. Accessed via ${{ secrets.NAME }}. Masked in logs automatically.

Analogy: Like environment variables in Vercel or Cloudflare dashboards, but scoped to your pipeline.

1.4 The Lifecycle: git push → deployment

┌─────────────────────────────────────────────────────────────────────────┐
│ DEVELOPER                                                               │
│                                                                         │
│  git add . → git commit → git push origin main                         │
│                                                                         │
└────────────────────────────┬────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ GITHUB EVENT SYSTEM                                                     │
│                                                                         │
│  Event emitted: push (ref: refs/heads/main)                            │
│  GitHub scans .github/workflows/*.yml for matching triggers            │
│                                                                         │
└────────────────────────────┬────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ WORKFLOW EXECUTION                                                      │
│                                                                         │
│  ci.yml matched on: push.branches: [main]                              │
│                                                                         │
│  ┌──────────┐   ┌──────────┐                                           │
│  │ Job: lint │   │ Job: test │  ← parallel by default                  │
│  └─────┬────┘   └─────┬────┘                                           │
│        │               │                                                │
│        └───────┬───────┘                                                │
│                ▼                                                        │
│        ┌──────────────┐                                                 │
│        │  Job: build   │  ← needs: [lint, test]                        │
│        └──────┬───────┘                                                 │
│               ▼                                                        │
│        ┌──────────────┐                                                 │
│        │  Job: deploy  │  ← needs: build, environment: production      │
│        └──────────────┘                                                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ DEPLOYMENT TARGET                                                       │
│                                                                         │
│  Vercel / Cloudflare Pages / AWS S3 / Docker registry / etc.           │
│  Status check reported back to GitHub                                  │
│  Deployment URL linked in PR or commit                                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

1.5 Comparison With Other CI/CD Systems

DimensionGitHub ActionsGitLab CI/CDJenkinsCircleCIAzure DevOpsVercel deployment
Config formatYAML per repo.gitlab-ci.ymlJenkinsfile (Groovy) or UI.circleci/config.ymlazure-pipelines.ymlZero-config / vercel.json
Where it runsGitHub-hosted VMs or self-hostedGitLab runnersSelf-hosted (always)CircleCI cloud or self-hostedAzure-hosted or self-hostedVercel’s edge infra
Native SCMGitHubGitLabAny (via plugins)GitHub, BitbucketAzure Repos, GitHubGitHub, GitLab, Bitbucket
Marketplace20,000+ actionsTemplates1,800+ pluginsOrbsExtensionsN/A
Pricing modelFree minutes + per-minute overageFree tier + per-minuteFree (self-host cost)Free tier + per-minuteFree tier + per-minuteFree tier + per-seat
Best forGitHub-centric teams of any sizeGitLab-centric teamsHeavy customization / legacyFast CI for product teamsMicrosoft-centric enterpriseFrontend-first apps
Biggest weaknessYAML complexity at scaleEcosystem sizeMaintenance burdenSeparate platformHeavier UXNot general-purpose
Learning curveModerateModerateHighModerateModerate-HighVery Low

Key insight for a frontend engineer: Vercel gives you the best DX for deploying Next.js — zero config, instant previews, automatic production. GitHub Actions gives you the most control — you decide every step, can add security scanning, multi-platform testing, release gating, and deployment to any target. Most real teams use both: Vercel for deployment, Actions for everything else (lint, test, release, automation).

1.6 Mental Model Diagram

┌────────────────────────────────────────────────────┐
│                  YOUR REPOSITORY                    │
│                                                    │
│  src/  package.json  .github/workflows/ci.yml      │
│                                                    │
└────────────────────────┬───────────────────────────┘
                         │ git push / PR / tag / schedule / manual

┌────────────────────────────────────────────────────┐
│              GITHUB EVENT SYSTEM                    │
│                                                    │
│  Matches event → selects workflow(s)               │
│                                                    │
└────────────────────────┬───────────────────────────┘

           ┌─────────────┼─────────────┐
           ▼             ▼             ▼
      ┌─────────┐  ┌─────────┐  ┌─────────┐
      │ Job A   │  │ Job B   │  │ Job C   │   ← each on its own runner
      │ (lint)  │  │ (test)  │  │ (types) │
      └────┬────┘  └────┬────┘  └────┬────┘
           │             │             │
           └──────┬──────┘─────────────┘

            ┌───────────┐
            │  Job D    │   ← needs: [A, B, C]
            │  (build)  │
            └─────┬─────┘
                  │ upload artifact

            ┌───────────┐
            │  Job E    │   ← needs: D, environment: production
            │  (deploy) │
            └───────────┘


         Deploy to target

2. Learning Roadmap by Skill Level

Level 1 — Newbie

Goal: Create your first working workflow and understand every part of it.

What a workflow file is

A workflow file is a YAML document that lives in .github/workflows/. It tells GitHub: “When this event happens, run these jobs on these machines.”

You already manage config files — next.config.js, tsconfig.json, tailwind.config.ts. A workflow file is another config file, but for your automation pipeline instead of your app.

YAML basics you need

# Scalars (strings, numbers, booleans)
name: CI
timeout-minutes: 10

# Mappings (key-value pairs — like JS objects)
env:
  NODE_ENV: production
  CI: true

# Sequences (lists — like JS arrays)
branches:
  - main
  - develop

# Multi-line strings
run: |
  echo "Line 1"
  echo "Line 2"

# Inline flow syntax
branches: [main, develop]

Critical YAML rules:

Where workflows live

your-repo/
└── .github/
    └── workflows/
        ├── ci.yml          ← runs on push and PR
        ├── deploy.yml      ← runs on merge to main
        └── nightly.yml     ← runs on schedule

GitHub only reads workflow files from .github/workflows/. The filenames can be anything ending in .yml or .yaml.

First workflow — the complete “Hello World”

# .github/workflows/hello.yml
name: Hello World

on:
  push:
    branches: [main]

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - name: Say hello
        run: echo "Hello from GitHub Actions!"

      - name: Show environment
        run: |
          echo "Runner OS: ${{ runner.os }}"
          echo "Event: ${{ github.event_name }}"
          echo "Branch: ${{ github.ref }}"
          echo "SHA: ${{ github.sha }}"

First real workflow — lint + test + build

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

      - name: Test
        run: npm test

      - name: Build
        run: npm run build

Why npm ci instead of npm install?

npm ci does a clean install from the lockfile — faster, deterministic, and will fail if package-lock.json is out of sync. This is what you always want in CI. npm install can modify the lockfile, introducing drift.

How to debug failures

  1. Read the failing step’s logs. Click the red X → click the failing job → expand the failing step. The error message is usually right there.
  2. Check the YAML syntax. GitHub shows a parse error banner if your YAML is malformed. Use a local YAML linter or VS Code’s YAML extension.
  3. Reproduce locally. Run the exact same commands on your machine. If it works locally but fails in CI, the difference is usually: Node version, missing dependency, environment variable, or file path.
  4. Add debug output.
    - name: Debug info
      run: |
        node --version
        npm --version
        ls -la
        cat package.json
        echo "Event: ${{ toJSON(github.event) }}"
  5. Enable debug logging. Re-run the workflow with “Enable debug logging” checked, or set the repository secret ACTIONS_STEP_DEBUG to true.
  6. Check the event payload. Use ${{ toJSON(github.event) }} to see exactly what data GitHub sent.

Common mistakes at this level

MistakeWhat happensFix
Tabs in YAMLParse errorUse spaces (2-space indent recommended)
Missing actions/checkoutCommands fail — no source codeAlways checkout first
Using npm installLockfile may change, non-deterministicUse npm ci
Wrong Node versionPackage incompatibilitiesMatch your local version
Typo in branch nameWorkflow never triggersCheck on.push.branches carefully
Running in wrong directorycommand not found or missing filesUse working-directory: if needed
Assuming env vars existUndefined variables, empty stringsExplicitly set env: or use secrets

5 small practice exercises

  1. Print your toolchain: Create a workflow that prints the Node.js version, npm version, and Git version on every push.
  2. Lint on PR: Create a workflow that runs npm run lint only on pull requests.
  3. Multi-step build: Create a workflow with separate steps for install, lint, test, and build.
  4. Break it intentionally: Introduce a lint error, push it, and study the failure logs. Then fix it.
  5. Branch filtering: Create a workflow that runs on pushes to main and develop but not other branches.

Level 1 success criteria


Level 2 — Junior

Goal: Build workflows a team actually uses — multi-job, cached, conditional, with deployments.

Multiple jobs and dependencies

name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  lint:
    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

  typecheck:
    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 typecheck

  test:
    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 test -- --ci

  build:
    runs-on: ubuntu-latest
    needs: [lint, typecheck, test] # ← waits for all three
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7

Why separate jobs? Faster feedback. If lint fails in 20 seconds, you know immediately — even if tests take 5 minutes. Each job runs on its own runner in parallel unless constrained by needs.

Trade-off: Each job does a fresh checkout and install. This costs time. Caching mitigates it. For very small projects, a single job may be faster end-to-end.

Matrix builds

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false # ← don't cancel others if one fails
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        node-version: [18, 20, 22]
        exclude:
          - os: macos-latest
            node-version: 18 # ← skip this combination
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

When to use a matrix: When you genuinely need cross-platform or cross-version confidence. For most frontend apps, a single OS and Node version is enough. Save matrices for libraries, packages, and tools.

Caching — npm, pnpm, and yarn

The actions/setup-node action has built-in caching:

# npm
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm # ← caches based on package-lock.json hash

# pnpm
- uses: pnpm/action-setup@v4
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: pnpm # ← caches based on pnpm-lock.yaml hash

# yarn
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: yarn # ← caches based on yarn.lock hash

What gets cached? Not node_modules itself — the package manager’s global cache directory. On cache hit, npm ci / pnpm install still runs but reads from local cache instead of the network.

Advanced: manual caching with actions/cache:

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      .next/cache
    key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
      ${{ runner.os }}-nextjs-

Environment variables and secrets

# Workflow-level env
env:
  CI: true
  NODE_ENV: production

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      DEPLOY_TARGET: staging # ← job-level env
    steps:
      - name: Deploy
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }} # ← step-level, from secrets
          DEPLOY_URL: ${{ vars.DEPLOY_URL }} # ← from repository variables
        run: |
          echo "Deploying to $DEPLOY_TARGET"
          ./deploy.sh

Secret rules:

Conditional execution

# Run only on main branch
- name: Deploy
  if: github.ref == 'refs/heads/main'
  run: npm run deploy

# Run only on PRs
- name: Preview
  if: github.event_name == 'pull_request'
  run: npm run build:preview

# Run only when a specific label is present
- name: Full test
  if: contains(github.event.pull_request.labels.*.name, 'full-test')
  run: npm run test:e2e

# Run even if a previous step failed
- name: Cleanup
  if: always()
  run: ./cleanup.sh

# Run only if previous steps succeeded (default behavior)
- name: Deploy
  if: success()
  run: npm run deploy

# Run only if a previous step failed
- name: Notify failure
  if: failure()
  run: curl -X POST $SLACK_WEBHOOK -d '{"text":"CI failed!"}'

Pull request workflows

PR workflows are your primary quality gate:

name: PR Checks

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  validate:
    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
      - run: npm run typecheck
      - run: npm test -- --ci
      - run: npm run build

  # Post build size as a PR comment
  bundle-size:
    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 build
      - uses: andresz1/size-limit-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}

Deployment basics

deploy:
  runs-on: ubuntu-latest
  needs: build
  if: github.ref == 'refs/heads/main'
  environment:
    name: production
    url: https://myapp.com
  steps:
    - uses: actions/download-artifact@v4
      with:
        name: build-output
    - name: Deploy to hosting
      env:
        DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
      run: |
        npx wrangler pages deploy dist/ --project-name=my-site

5 mini project ideas

  1. React CI with bundle size tracking: Lint, test, build, and post bundle size diff as a PR comment.
  2. Next.js preview deployment: On PR, build and deploy a preview to Vercel or Cloudflare Pages.
  3. Astro static site deployment: Build and deploy Astro output to GitHub Pages on merge to main.
  4. Matrix test across Node versions: Test a shared utility library across Node 18, 20, and 22.
  5. Automated dependency updates: Configure Dependabot + auto-merge for patch updates that pass CI.

Common mistakes and anti-patterns

Anti-patternWhy it is badBetter approach
One giant workflow for everythingHard to read, slow feedback, hard to maintainSplit into focused workflows (ci.yml, deploy.yml, release.yml)
Duplicating YAML across reposDrift, inconsistency, maintenance painUse reusable workflows or composite actions
Not caching dependenciesSlow installs on every runUse built-in cache in actions/setup-node
Deploying from every branchBroken previews, wasted resourcesDeploy only from main or tagged releases
Hardcoding secrets in YAMLSecurity disasterUse secrets context
Ignoring needs graphJobs run in wrong order or waste timeDesign explicit dependency chains
Using npm installNon-deterministic, modifies lockfileUse npm ci
Not requiring CI checks for mergeBroken code lands in mainSet up branch protection with required status checks

Level 2 success criteria


Level 3 — Senior

Goal: Build production-ready CI/CD — fast, secure, maintainable, with deployment safety.

Branch strategy integration

Map workflows to your Git strategy:

Feature branch  → PR workflow: lint, test, typecheck, build, preview deploy
Main branch     → CI + deploy to staging (automatic)
Release tag     → Deploy to production (with approval)
Hotfix branch   → Fast-track CI + deploy to production
on:
  pull_request: # ← feature branches
  push:
    branches: [main] # ← staging deploy trigger
    tags: ['v*'] # ← production deploy trigger

Monorepo workflows with path filters

name: Frontend CI

on:
  push:
    paths:
      - 'packages/web/**'
      - 'packages/shared/**'
      - 'package.json'
      - 'pnpm-lock.yaml'
  pull_request:
    paths:
      - 'packages/web/**'
      - 'packages/shared/**'

jobs:
  web-ci:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: packages/web
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint
      - run: pnpm run test
      - run: pnpm run build

Advanced change detection: For complex monorepos, path filters are too coarse. Use a change detection step:

- uses: dorny/paths-filter@v3
  id: changes
  with:
    filters: |
      web:
        - 'packages/web/**'
        - 'packages/shared/**'
      api:
        - 'packages/api/**'
        - 'packages/shared/**'

- name: Run web tests
  if: steps.changes.outputs.web == 'true'
  run: pnpm --filter web test

Reusable workflows

Define once, call from many repos:

# .github/workflows/reusable-ci.yml (in a shared repo)
name: Reusable CI

on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '20'
      working-directory:
        type: string
        default: '.'
      package-manager:
        type: string
        default: 'npm'
    secrets:
      deploy-token:
        required: false

jobs:
  ci:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: ${{ inputs.package-manager }}
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build
# .github/workflows/ci.yml (in a consuming repo)
name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  ci:
    uses: my-org/shared-workflows/.github/workflows/reusable-ci.yml@main
    with:
      node-version: '20'
      package-manager: npm
    secrets:
      deploy-token: ${{ secrets.DEPLOY_TOKEN }}

Composite actions

Reusable step-level logic (not full jobs):

# .github/actions/setup-project/action.yml
name: Setup Project
description: Checkout, install Node, install dependencies

inputs:
  node-version:
    description: Node.js version
    default: '20'

runs:
  using: composite
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm
    - run: npm ci
      shell: bash
# Usage in a workflow
steps:
  - uses: ./.github/actions/setup-project
    with:
      node-version: '20'
  - run: npm run lint
  - run: npm test

Reusable workflow vs composite action — decision table

QuestionReusable workflowComposite action
Can define multiple jobs?YesNo
Can define triggers?Yes (workflow_call)No
Can be called cross-repo?YesYes (with checkout or reference)
Can share step-level logic?IndirectlyYes — this is the purpose
Can define environment:?YesNo
Can use secrets:?Yes (declared)No (pass via inputs)
Best forFull pipeline patternsShared setup / utility steps

Self-hosted runners

Use them when you need:

Runner management rules:

Secure secret management

PracticeWhyPriority
Use OIDC for cloud accessShort-lived tokens, no static keysHigh
Scope secrets to environmentsProduction creds only available in production jobsHigh
Use environment protection rulesRequire approval before production deployHigh
Audit secret accessKnow who can read whatMedium
Rotate secrets regularlyLimit blast radiusMedium
Never echo secretsEven masked, transformed values can leakCritical
Pin third-party actionsPrevents supply chain injectionHigh

OIDC example — deploying to AWS without static keys:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789:role/github-actions
      aws-region: us-east-1
  - run: aws s3 sync dist/ s3://my-bucket/

Deployment strategies

StrategyHow it worksWhen to useRiskCost
Direct deployReplace current version immediatelySmall apps, static sitesInstant rollback neededLow
RollingReplace instances graduallyStateless servicesSlow convergenceLow
Blue-greenRun two environments, switch trafficCritical apps needing instant rollbackDouble infrastructure during deployMedium
CanaryRoute small % of traffic to new versionRisk-sensitive releasesComplex routing setupMedium
Feature flagsDeploy code dark, enable via flagProduct experimentationFlag governance overheadLow

Release automation

name: Release

on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          generate_release_notes: true
          files: dist/**

Semantic release automation:

- uses: cycjimmy/semantic-release-action@v4
  with:
    branches: |
      ['main']
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Rollback strategy

Rule: Define rollback before you define deploy. If you cannot answer “how do I undo this?”, you are not ready to ship.

Approaches:

  1. Re-deploy previous artifact: Keep artifacts with retention-days. On failure, re-run the deploy job pointing at the last good artifact.
  2. Git revert + re-deploy: Create a revert commit, push, and let the pipeline deploy the reverted code.
  3. Traffic switch: With blue-green or canary, route traffic back to the old version instantly.
  4. Feature flag disable: If the new code is behind a flag, turn it off.
# Manual rollback workflow
name: Rollback

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to rollback to (e.g., v1.2.3)'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.inputs.version }}
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - run: npm run deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

CI optimization

TechniqueImpactEffort
Cache dependencies30-60% faster installsLow
Parallel jobsFaster overall pipelineLow
Path filtersSkip unchanged packagesMedium
Skip CI on docs changesSave minutesLow
Smaller Docker base imagesFaster pullsMedium
Incremental builds (.next/cache)Faster buildsMedium
Fan out with matrix only where neededRight-sized coverageLow
Combine small stepsReduce overheadLow

Notifications and incident handling

# Slack notification on failure
- name: Notify Slack
  if: failure()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "text": "❌ CI failed on ${{ github.ref }} — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }

5 production-grade project examples

  1. Next.js monorepo CI/CD: Path-filtered CI, parallel lint/test/build, artifact-based deploy to Vercel with preview environments for PRs and production deployment on tag.
  2. Astro static site with Cloudflare Pages: Build on PR for preview, deploy to production on main, with cache warming and bundle size tracking.
  3. Automated release pipeline: Semantic versioning from commit messages, auto-generated changelog, GitHub Release creation, and deployment trigger.
  4. Docker image build and publish: Multi-platform Docker build with caching, vulnerability scanning, image signing, and push to GHCR (GitHub Container Registry).
  5. Organization-wide shared CI: Reusable workflows for lint/test/build/deploy consumed by 20+ repos, with centralized updates and version pinning.

Level 3 success criteria


Level 4 — Expert

Goal: Design and operate a CI/CD platform at organization scale.

At this level you are not writing one workflow — you are designing the system that all workflows run inside. You think about governance, cost, reliability, security, and developer experience across hundreds of repositories.

Building an internal CI/CD platform

Your platform provides:

Organization-wide reusable workflows

Structure:

my-org/ci-workflows/
├── .github/workflows/
│   ├── frontend-ci.yml          ← called by all frontend repos
│   ├── backend-ci.yml           ← called by all backend repos
│   ├── docker-build.yml         ← called by all containerized services
│   ├── deploy-preview.yml       ← shared preview deploy logic
│   ├── deploy-production.yml    ← shared production deploy with approval
│   ├── release.yml              ← shared release automation
│   └── security-scan.yml        ← shared security scanning
└── README.md

Versioning strategy: Use tagged releases of the shared workflow repo. Consuming repos pin to a version:

uses: my-org/ci-workflows/.github/workflows/frontend-ci.yml@v2.1.0

This prevents breaking changes from propagating instantly.

Governance and policy enforcement

PolicyImplementation
All repos must run CI before mergeBranch protection rules with required status checks
Workflow files cannot be modified without reviewCODEOWNERS file protecting .github/
Third-party actions must be auditedOrganization-level allow list for actions
Production secrets only available with approvalEnvironment protection rules
All deployments must be traceableRequire deployment environments with history
Security scans must run on all PRsOrganization-level required workflow

Multi-repository automation

Use workflow_dispatch with API calls to trigger workflows across repos:

# Trigger a deploy in another repo
- name: Trigger deploy
  uses: peter-evans/repository-dispatch@v3
  with:
    token: ${{ secrets.CROSS_REPO_TOKEN }}
    repository: my-org/production-deploy
    event-type: deploy-frontend
    client-payload: '{"version": "${{ github.sha }}", "repo": "${{ github.repository }}"}'

Multi-environment promotion pipeline

PR → preview (automatic)
     ↓ merge
main → staging (automatic)
     ↓ manual approval
staging → production (protected environment)
     ↓ monitor
production → rollback (manual dispatch)
deploy-staging:
  needs: build
  if: github.ref == 'refs/heads/main'
  environment:
    name: staging
    url: https://staging.myapp.com
  # ...

deploy-production:
  needs: deploy-staging
  environment:
    name: production
    url: https://myapp.com
  # ← requires manual approval via environment protection rules
  # ...

Advanced self-hosted runner architecture

┌─────────────────────────────────────────────────┐
│ RUNNER FLEET                                     │
│                                                 │
│  Pool: general-purpose (auto-scaled)            │
│    ├── ubuntu-latest × N (ephemeral)            │
│    └── scale-to-zero when idle                  │
│                                                 │
│  Pool: docker-builds (large instances)          │
│    ├── 8 CPU / 32 GB RAM                        │
│    └── Docker layer cache on persistent volume  │
│                                                 │
│  Pool: trusted (for production deploys)         │
│    ├── Network access to production             │
│    ├── Strict access controls                   │
│    └── Separate from CI pool                    │
│                                                 │
└─────────────────────────────────────────────────┘

Key architectural decisions:

Security hardening

ThreatMitigation
Compromised third-party actionPin actions to commit SHA, audit on update
Secret exfiltration via logsMask secrets, avoid echoing transformed values
PR from fork running dangerous codeUse pull_request_target carefully, limit permissions
Runner compromiseEphemeral runners, least-privilege, network segmentation
Supply chain attackSign artifacts, verify provenance, scan dependencies
Credential theftOIDC for cloud access, short-lived tokens, environment scoping
Workflow injectionValidate inputs, avoid ${{ github.event.*.body }} in run:

Action pinning — SHA vs. tag:

# Tag — convenient, but mutable:
uses: actions/checkout@v4

# SHA — immutable, auditable:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

For critical production workflows, SHA pinning with Dependabot updates is the gold standard.

Supply chain security

OIDC and cloud credentials

Problem: Static cloud access keys (AWS, GCP, Azure) stored as secrets are long-lived and risky.

Solution: OIDC (OpenID Connect) federated identity — GitHub mints a short-lived JWT, your cloud provider exchanges it for temporary credentials.

permissions:
  id-token: write
  contents: read

steps:
  # AWS
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789:role/deploy
      aws-region: us-east-1

  # GCP
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: projects/123/locations/global/workloadIdentityPools/gh/providers/gh
      service_account: deploy@my-project.iam.gserviceaccount.com

  # Azure
  - uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Disaster recovery for CI/CD systems

RiskMitigation
GitHub outageDocument manual deploy procedure, keep deployment scripts runnable locally
Lost workflow definitionsVersion control (they are in the repo), backup org-level configs
Runner fleet failureAutoscaling with health checks, fallback to GitHub-hosted runners
Secret rotation failureDocumented rotation procedure, monitoring for expiring credentials
Broken shared workflowVersion pinning, canary rollout of shared workflow updates

Architecture review checklist

Use this when reviewing any production workflow:

What expert engineers care about that juniors miss

Expert concernWhy it mattersJunior blind spot
Drift between workflow intent and runtimeWorkflows silently change behavior”It passed, ship it”
Secret sprawlUnused secrets accumulate, increasing risk”Just add another secret”
AuditabilityWho deployed what, when, and whyNo deployment logs
Recovery time objectivesHow fast can we fix a bad deploy?No rollback plan
Reproducible buildsSame code should produce same artifactNode version drift, unpinned deps
Policy as codeGovernance should be automated, not manual”Trust the team”
Supply chain integrityEvery action/dependency is an attack surface”It’s from the marketplace, it’s fine”
Cost per deploymentCI minutes add up at scale”It’s free”
Fleet managementRunners need ops like any infrastructure”GitHub hosts it”
Organizational consistencyTeams should not reinvent basic CI”Each team owns their workflow”

10 advanced engineering discussion topics

  1. Runner economics: At what scale do self-hosted runners become cheaper than GitHub-hosted? Include maintenance, networking, security, and ops cost.
  2. Workflow as product: Should the platform team treat reusable workflows as internal products with SLOs, changelogs, and migration guides?
  3. Multi-tenancy: How do you isolate CI for different teams in a single GitHub organization while sharing common infrastructure?
  4. Pipeline observability: What metrics should you track? Propose a dashboard for CI health across 100+ repos.
  5. Action supply chain: Design a policy for evaluating, approving, and monitoring third-party actions.
  6. Migration strategy: How would you migrate 50 repos from Jenkins to GitHub Actions without disrupting teams?
  7. Secrets at scale: Design a secrets management architecture for an org with 200 repos, 10 environments, and 5 cloud providers.
  8. Monorepo CI scaling: At 500 packages, path filters break down. What architectural patterns handle this?
  9. Incident response: A compromised action was used in production workflows. Design the response playbook.
  10. Cost allocation: How do you attribute CI costs to teams, projects, or products for budget planning?

3. Setup Guide

Step 1: Create the workflows directory

mkdir -p .github/workflows

Step 2: Use clear naming conventions

FilePurpose
ci.ymlMain CI pipeline (lint, test, typecheck, build)
deploy-preview.ymlPreview deployment on PRs
deploy-production.ymlProduction deployment on merge/tag
release.ymlAutomated release and changelog
nightly.ymlScheduled nightly builds or checks
security.ymlSecurity scanning workflow
dependabot-merge.ymlAuto-merge safe dependency updates

Convention: Name files by intent (what they do), not by implementation (how they do it).

Step 3: Basic workflow structure

Every workflow has three parts:

# 1. NAME — human-readable label
name: CI

# 2. TRIGGERS — when does this run?
on:
  push:
    branches: [main]
  pull_request:

# 3. JOBS — what does this do?
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Hello"

Step 4: Complete example workflows

Lint workflow

name: Lint

on:
  pull_request:

jobs:
  lint:
    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

Test workflow

name: Test

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    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 test -- --ci --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/

Build workflow

name: Build

on:
  push:
    branches: [main]

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 build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 14

Deploy workflow

name: Deploy

on:
  push:
    branches: [main]

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 build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: echo "Deploy dist/ to production"

Step 5: Framework-specific examples

React app with Vite

name: React CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  ci:
    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
      - run: npm run typecheck
      - run: npm test -- --ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: react-build
          path: dist/

Next.js app

name: Next.js CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - uses: actions/cache@v4
        with:
          path: .next/cache
          key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
          restore-keys: |
            ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
            ${{ runner.os }}-nextjs-
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test
      - run: npm run build

  deploy-preview:
    needs: ci
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy preview to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}

  deploy-production:
    needs: ci
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Vercel (production)
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'

Astro static site

name: Astro CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  ci:
    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
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: astro-site
          path: dist/

  deploy:
    needs: ci
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: production
    permissions:
      pages: write
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: astro-site
          path: dist/
      - uses: actions/configure-pages@v5
      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist/
      - uses: actions/deploy-pages@v4

pnpm setup (shared pattern)

steps:
  - uses: actions/checkout@v4
  - uses: pnpm/action-setup@v4
    with:
      version: 9
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: pnpm
  - run: pnpm install --frozen-lockfile

yarn setup (shared pattern)

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: yarn
  - run: yarn install --frozen-lockfile

Step 6: Caching strategy

What to cacheHowKey strategy
npm global cacheactions/setup-node with cache: npmBased on package-lock.json hash
pnpm storeactions/setup-node with cache: pnpmBased on pnpm-lock.yaml hash
Next.js build cacheactions/cache targeting .next/cacheBased on lockfile + source hash
Playwright browsersactions/cache targeting ~/.cache/ms-playwrightBased on Playwright version
Docker layersDocker buildx cacheBased on Dockerfile + source hash

Cache key design:

key: ${{ runner.os }}-${{ inputs.package-manager }}-${{ hashFiles('**/lockfile') }}
restore-keys: |
  ${{ runner.os }}-${{ inputs.package-manager }}-

The restore-keys enable partial cache hits — better than no cache, even if not exact.

Step 7: Secret setup

  1. Go to Settings → Secrets and variables → Actions in your repository.
  2. Add repository secrets for project-specific values.
  3. Create environments (staging, production) and add environment-specific secrets there.
  4. For organization-wide secrets, use organization-level settings.

Common secrets:

SecretPurpose
VERCEL_TOKENDeploy to Vercel
CLOUDFLARE_API_TOKENDeploy to Cloudflare
DEPLOY_TOKENGeneral deployment credential
SLACK_WEBHOOKFailure notifications
NPM_TOKENPublish to npm
CODECOV_TOKENCoverage reporting

Step 8: Environment setup

  1. Go to Settings → Environments.
  2. Create environments: preview, staging, production.
  3. Configure protection rules for production:
    • Required reviewers (at least 1-2 people)
    • Wait timer (optional — e.g., 5 minutes)
    • Restrict to specific branches (only main or release/*)
  4. Add environment-specific secrets and variables.

Step 9: Branch protection integration

  1. Go to Settings → Branches → Branch protection rules.
  2. Add a rule for main:
    • Require a pull request before merging
    • Require status checks to pass (select your CI job names)
    • Require branches to be up to date
    • Require review from code owners (optional)
  3. Your CI workflow job names become the required status checks.

Example repository structure

my-project/
├── .github/
│   ├── workflows/
│   │   ├── ci.yml                 ← lint, typecheck, test, build
│   │   ├── deploy-preview.yml     ← preview on PR
│   │   ├── deploy-production.yml  ← deploy on merge to main
│   │   ├── release.yml            ← tag-based release
│   │   └── nightly.yml            ← scheduled checks
│   ├── actions/
│   │   └── setup-project/
│   │       └── action.yml         ← composite action for shared setup
│   ├── CODEOWNERS                 ← protect .github/ changes
│   └── dependabot.yml             ← automated dependency updates
├── src/
├── tests/
├── public/
├── package.json
├── package-lock.json              ← or pnpm-lock.yaml
├── tsconfig.json
└── README.md

4. Cheatsheet

Workflow syntax quick reference

name: string                          # Human-readable name
on: event | [events] | {event: config}  # Triggers
permissions: {scope: level}           # Token permissions
env: {KEY: value}                     # Workflow-level env vars
concurrency:                          # Prevent duplicate runs
  group: string
  cancel-in-progress: boolean
jobs:
  job-id:
    runs-on: runner-label
    needs: [job-ids]                  # Dependencies
    if: expression                    # Condition
    environment: name | {name, url}   # Deployment target
    timeout-minutes: number           # Job timeout
    strategy:
      matrix: {key: [values]}
      fail-fast: boolean
    defaults:
      run:
        working-directory: path
    steps:
      - name: string                  # Step label
        uses: owner/action@ref        # Use an action
        with: {input: value}          # Action inputs
        run: command                   # Shell command
        env: {KEY: value}             # Step-level env vars
        if: expression                # Step condition
        id: step-id                   # Reference this step
        working-directory: path
        shell: bash | pwsh | python
        continue-on-error: boolean

Triggers (events)

TriggerSyntaxUse case
Pushon: pushCI on every commit
Push to branchon: push: branches: [main]CI on specific branches
Push with path filteron: push: paths: ['src/**']Monorepo selective CI
Pull requeston: pull_requestPR validation
PR typeson: pull_request: types: [opened, synchronize]Specific PR events
Manualon: workflow_dispatchManual trigger with inputs
Scheduleon: schedule: - cron: '0 2 * * *'Nightly builds
Tagon: push: tags: ['v*']Release automation
Releaseon: release: types: [published]Post-release actions
Workflow callon: workflow_callReusable workflow
Repository dispatchon: repository_dispatchExternal/cross-repo trigger
Workflow runon: workflow_runChain after another workflow

Expressions

# Context variables
${{ github.ref }}                          # refs/heads/main
${{ github.sha }}                          # full commit SHA
${{ github.event_name }}                   # push, pull_request, etc.
${{ github.actor }}                        # who triggered
${{ github.repository }}                   # owner/repo
${{ github.run_id }}                       # unique run ID
${{ github.run_number }}                   # incrementing run number
${{ runner.os }}                           # Linux, Windows, macOS
${{ matrix.node-version }}                 # current matrix value
${{ secrets.MY_SECRET }}                   # secret value
${{ vars.MY_VAR }}                         # repository/org variable
${{ steps.step-id.outputs.key }}           # step output
${{ needs.job-id.outputs.key }}            # job output
${{ env.MY_VAR }}                          # environment variable

# Functions
${{ hashFiles('**/package-lock.json') }}   # file hash for cache keys
${{ toJSON(github.event) }}                # JSON dump for debugging
${{ contains(github.ref, 'release') }}     # string contains
${{ startsWith(github.ref, 'refs/tags') }} # string starts with
${{ format('Hello {0}', github.actor) }}   # string format
${{ fromJSON(steps.id.outputs.json) }}     # parse JSON

Conditions

# Branch conditions
if: github.ref == 'refs/heads/main'
if: github.ref != 'refs/heads/main'
if: startsWith(github.ref, 'refs/tags/v')

# Event conditions
if: github.event_name == 'pull_request'
if: github.event_name == 'push'
if: github.event_name == 'workflow_dispatch'

# Label conditions
if: contains(github.event.pull_request.labels.*.name, 'deploy')

# Status conditions
if: success()          # all previous steps succeeded (default)
if: failure()          # any previous step failed
if: always()           # run regardless
if: cancelled()        # run was cancelled

# Combining conditions
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
if: github.event_name == 'pull_request' || github.event_name == 'push'

Matrix patterns

# Basic matrix
strategy:
  matrix:
    node: [18, 20, 22]

# Multi-dimensional
strategy:
  matrix:
    node: [18, 20]
    os: [ubuntu-latest, macos-latest]

# With exclusions
strategy:
  matrix:
    node: [18, 20]
    os: [ubuntu-latest, macos-latest]
    exclude:
      - node: 18
        os: macos-latest

# With inclusions (add specific combos)
strategy:
  matrix:
    node: [18, 20]
    include:
      - node: 22
        os: ubuntu-latest
        experimental: true

# Don't cancel others on failure
strategy:
  fail-fast: false
  matrix:
    node: [18, 20, 22]

Cache patterns

# Built-in (simplest)
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm # or pnpm, yarn

# Manual cache
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: ${{ runner.os }}-npm-

# Next.js build cache
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
      nextjs-${{ runner.os }}-

Artifact patterns

# Upload
- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 7
    if-no-files-found: error # fail if nothing to upload

# Download (in a later job)
- uses: actions/download-artifact@v4
  with:
    name: build-output
    path: dist/

# Upload multiple
- uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: |
      coverage/
      test-results/

Concurrency control

# Cancel previous runs on same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# Don't cancel production deploys
concurrency:
  group: deploy-production
  cancel-in-progress: false

Job outputs

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      should-deploy: ${{ steps.check.outputs.deploy }}
    steps:
      - id: check
        run: echo "deploy=true" >> "$GITHUB_OUTPUT"

  deploy:
    needs: detect
    if: needs.detect.outputs.should-deploy == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying..."

Common marketplace actions

ActionVersionPurpose
actions/checkout@v4Clone repository
actions/setup-node@v4Install Node.js + cache
actions/cache@v4Manual cache control
actions/upload-artifact@v4Persist build outputs
actions/download-artifact@v4Retrieve artifacts
actions/upload-pages-artifact@v3GitHub Pages deploy artifact
actions/deploy-pages@v4Deploy to GitHub Pages
actions/configure-pages@v5Configure GitHub Pages
pnpm/action-setup@v4Install pnpm
softprops/action-gh-release@v2Create GitHub Releases
slackapi/slack-github-action@v2Slack notifications
dorny/paths-filter@v3Detect changed paths
peter-evans/create-pull-request@v7Auto-create PRs
github/codeql-action@v3Security analysis
aws-actions/configure-aws-credentials@v4AWS OIDC auth
google-github-actions/auth@v2GCP OIDC auth

Debugging tips

TechniqueHow
Print contextecho "${{ toJSON(github) }}"
Print env varsenv | sort
List filesls -la or find . -type f
Check Node/npmnode --version && npm --version
Enable debug logsRe-run with “Enable debug logging” or set ACTIONS_STEP_DEBUG: true
Inspect event payloadcat $GITHUB_EVENT_PATH | jq .
SSH into runnerUse mxschmitt/action-tmate for interactive debugging
Test locallyUse act (https://github.com/nektos/act) to run workflows locally

Common errors and fixes

Error messageCauseFix
unexpected value 'workflow_call'Wrong event type or indentationCheck on: syntax
No hosted provisionerInvalid runs-on labelUse ubuntu-latest, macos-latest, or windows-latest
Resource not accessible by integrationInsufficient token permissionsAdd permissions: block
Process completed with exit code 1Command failedRead the output above this line
npm ERR! could not determine executable to runWrong npx/package setupCheck package.json scripts
Error: No files were found with the provided pathUpload artifact path wrongVerify build output directory
The workflow is not validYAML syntax errorUse YAML linter, check indentation
Cache not restoringKey mismatchCheck hashFiles() patterns and key format

YAML pitfalls

PitfallExampleFix
on is a booleanon: becomes true:Always quote: 'on': or use full form
Tabs vs spacesMix of indent typesUse only spaces (2-space recommended)
Unquoted special valuesversion: 3.103.1Quote: version: '3.10'
Multi-line gotcharun: echo "hello\nworld"Use run: | for multi-line
Boolean stringsyes, no, true, falseQuote if you mean strings

Performance optimization

OptimizationExpected impactEffort
Enable dependency caching30–60% faster installsTrivial
Parallel jobs for lint/test/build2–3× faster pipelineLow
Path filters for monoreposSkip 50–80% of runsLow
Concurrency cancellationStop redundant runsTrivial
Skip CI on docs changesSave all minutes for non-code PRsLow
Cache .next/cache20–40% faster Next.js buildsLow
Use actions/upload-artifact + download-artifact instead of rebuildingAvoid double buildsMedium
Smaller runner imagesFaster bootMedium

Security best practices

PracticePriority
Minimize permissions: to what is neededCritical
Use OIDC for cloud access instead of static keysHigh
Pin critical actions to commit SHAHigh
Use environment protection for productionHigh
Never echo or transform secretsCritical
Protect .github/ with CODEOWNERSHigh
Audit third-party actions before useMedium
Rotate secrets on a scheduleMedium
Use pull_request (not pull_request_target) unless you need write accessHigh
Set concurrency to prevent abuseMedium

5. Real-World Engineering Mindset

CI for React / Next.js / Astro

Problem: Every PR needs fast, reliable feedback on code quality and correctness.

Strategies:

StrategyDescriptionProsCons
Single joblint → typecheck → test → build in sequenceSimple, minimal YAMLSlower total time, no parallel feedback
Parallel jobslint, typecheck, test run in parallel; build depends on allFaster feedback on each dimensionMore YAML, repeated installs
Parallel + shared setupComposite action for checkout/install; parallel validationBest balanceSlightly more setup

By team size:

Hidden pitfalls:

Cost: Free tier gives 2,000 minutes/month for private repos. A 4-job pipeline running 3 minutes each, 20 times/day = 240 minutes/day = 7,200 minutes/month → you will exceed free tier quickly. Cache everything.

Senior choice: Parallel jobs (lint, typecheck, test), cached installs, build only after all pass. Add concurrency cancellation to avoid wasting minutes on superseded pushes.


Deploying static sites

Problem: Ship built HTML/CSS/JS to a CDN reliably.

Strategies:

StrategyProsCons
GitHub Pages (via actions/deploy-pages)Free, integrated, simpleLimited to static, no server-side
Cloudflare Pages (via cloudflare/pages-action)Edge CDN, fast, generous free tierCloudflare-specific
Vercel (native or via action)Great DX, instant previewsLess control over pipeline
AWS S3 + CloudFrontFull control, scalableMore setup, more ops
Netlify (via action)Good DX, form handlingPlatform lock-in

Senior choice: For simple static sites (Astro, Vite), Cloudflare Pages or GitHub Pages. For Next.js with server-side features, Vercel. For maximum control, S3 + CloudFront with OIDC auth.


Deploying to Vercel

Problem: You want preview deployments on PRs and production deploys on merge.

Strategies:

StrategyProsCons
Let Vercel handle it nativelyZero config, automatic previews, instant rollbackLess control, harder to add custom steps
Use GitHub Actions + Vercel CLIFull pipeline control, add scans/checks before deployMore setup, you manage the integration
Hybrid: Vercel for deploy, Actions for CIBest of both — Actions validates, Vercel deploysTwo systems to understand

Hidden pitfalls:

Senior choice: Hybrid. Let Vercel handle deployment (its DX is superior), use Actions for lint/test/typecheck/security. This is the most common pattern in production Next.js teams.


Deploying to Cloudflare Pages / Workers

Problem: Publish edge content or Worker code with consistent validation.

Strategies:

StrategyProsCons
Cloudflare native Git integrationSimple, automatic previewsLess pipeline customization
Actions + Wrangler CLIFull control, multi-step validationMore YAML to maintain
# Deploy Astro to Cloudflare Pages
- name: Deploy to Cloudflare Pages
  uses: cloudflare/pages-action@v1
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
    projectName: my-site
    directory: dist/

Senior choice: For static sites, Cloudflare’s native integration is excellent. For Workers with complex build steps, use Actions + Wrangler.


Docker build and deployment

Problem: Build container images reproducibly, scan them, and push to a registry.

name: Docker Build

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Strategies:

StrategyProsCons
GitHub-hosted runner + buildx cacheSimple, good cachingSlow for very large images
Self-hosted runner + persistent cacheFastest buildsRunner maintenance
Multi-stage DockerfileSmaller images, faster deploysDockerfile complexity

Senior choice: GitHub-hosted runner with docker/build-push-action and cache-from: type=gha for most teams. Move to self-hosted when build times exceed 10 minutes.


Preview deployments

Problem: Reviewers want to see and interact with changes before merge.

Strategies:

StrategyProsCons
Platform-native (Vercel/Cloudflare)Zero config, automaticPlatform-specific
GitHub Pages per branchFreeOnly static, complex setup
Ephemeral environment per PRFull runtime fidelityExpensive, complex teardown

Senior choice: Use platform-native previews (Vercel, Cloudflare). For full-stack apps, spin up ephemeral environments only for critical PRs.


Monorepo selective builds

Problem: Running all CI for all packages on every change is wasteful.

Strategies:

StrategyProsCons
Path filters (on.push.paths)SimpleMisses shared dependency changes
Change detection (dorny/paths-filter)More precise, can detect transitive depsMore setup
Turborepo/Nx integrationBuild-tool aware, understands dependency graphTool-specific
# Using dorny/paths-filter
- uses: dorny/paths-filter@v3
  id: filter
  with:
    filters: |
      web:
        - 'apps/web/**'
        - 'packages/ui/**'
        - 'packages/utils/**'
      api:
        - 'apps/api/**'
        - 'packages/db/**'

- name: Web CI
  if: steps.filter.outputs.web == 'true'
  run: pnpm --filter web test && pnpm --filter web build

Senior choice: Change detection + Turborepo for large monorepos. Simple path filters for small ones.


Versioning and release automation

Problem: Releases need consistency, traceability, and should not depend on human memory.

Strategies:

StrategyProsCons
Manual tagsSimple, explicitError-prone, easy to forget
Semantic releaseFully automated from commit messagesRequires conventional commit discipline
Release PRs (e.g., release-please)Auditable, reviewable release processExtra step in the flow
Tag + GitHub ReleaseGood for librariesLess structured
# Using Google's release-please
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: node

Senior choice: release-please for apps (structured, auditable). semantic-release for libraries (fully automated). Both require commit message discipline.


Running database migrations

Problem: If deploy succeeds but migration fails, the app may be broken.

Strategies:

StrategyHow it worksRisk
Migrate before deployRun migrations first, then deploy new codeIf deploy fails, migration is already applied
Deploy then migrateDeploy new code, then run migrationsApp may error if it expects new schema
Expand/contractBackward-compatible schema changes in two phasesSafest but requires discipline

Senior choice: Expand/contract migrations. Make schema changes backward-compatible so old and new code can coexist. Deploy code first, then apply schema changes. This is the only safe approach for zero-downtime systems.


Dependabot automation

Problem: Dependabot creates many PRs that need review.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    groups:
      minor-and-patch:
        update-types: [minor, patch]
    open-pull-requests-limit: 10
# .github/workflows/dependabot-merge.yml
name: Dependabot Auto-merge

on:
  pull_request:

permissions:
  contents: write
  pull-requests: write

jobs:
  auto-merge:
    if: github.actor == 'dependabot[bot]'
    runs-on: ubuntu-latest
    steps:
      - uses: dependabot/fetch-metadata@v2
        id: metadata
      - if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
        run: gh pr merge "${{ github.event.pull_request.number }}" --auto --squash
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Senior choice: Group minor/patch updates, auto-merge patches that pass CI, manually review major updates.


Security scanning

name: Security

on:
  pull_request:
  schedule:
    - cron: '0 6 * * 1' # weekly Monday 6 AM

jobs:
  codeql:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript-typescript
      - uses: github/codeql-action/analyze@v3

  dependency-review:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/dependency-review-action@v4

Senior choice: Run CodeQL on PRs and weekly. Add dependency review on PRs. Use OSSF Scorecard for supply chain assessment.


Auto-labeling PRs

# .github/labeler.yml
frontend:
  - changed-files:
      - any-glob-to-any-file: ['src/components/**', 'src/pages/**']

docs:
  - changed-files:
      - any-glob-to-any-file: ['docs/**', '*.md']

ci:
  - changed-files:
      - any-glob-to-any-file: ['.github/**']
name: Label PR

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/labeler@v5

Generating changelogs

Strategies:

StrategyToolProsCons
ManualNoneAccurateTime-consuming, inconsistent
From PR labelsrelease-pleaseAuditableRequires label discipline
From conventional commitssemantic-releaseFully automatedRequires commit message discipline
GitHub auto-generatedBuilt-in release notesZero configLess structured

Senior choice: Conventional commits + automated generation for mature teams. GitHub auto-generated release notes for small teams.


Multi-environment deployment

name: Deploy

on:
  push:
    branches: [main]
    tags: ['v*']

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 && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: build
          path: dist/

  deploy-staging:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.myapp.com
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/
      - run: echo "Deploy to staging"

  deploy-production:
    needs: deploy-staging
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build
          path: dist/
      - run: echo "Deploy to production"

Senior choice: Reusable deploy workflow called with different environment inputs. Build once, deploy many times.


Rollback after failed deployment

Problem: Production is broken after a deploy. How fast can you recover?

Strategies:

StrategyRecovery timeRequirement
Re-deploy previous artifact2–5 minutesArtifact retention
Traffic switch (blue-green)SecondsTwo environments running
Git revert + pipeline5–10 minutesFast CI pipeline
Feature flag disableSecondsFlag infrastructure
# Manual rollback trigger
name: Rollback Production

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Git tag or SHA to rollback to'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.inputs.version }}
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: echo "Rolling back to ${{ github.event.inputs.version }}"

Senior choice: Make rollback a first-class workflow with manual dispatch. Test it regularly. Know your RTO (Recovery Time Objective).


Scheduled workflows

name: Nightly

on:
  schedule:
    - cron: '0 2 * * *' # 2 AM UTC daily

jobs:
  nightly:
    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 test:e2e
      - run: npm audit
      - name: Notify on failure
        if: failure()
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-type: application/json' \
            -d '{"text":"🌙 Nightly build failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'

Cron syntax quick reference:

ScheduleCronNotes
Every day at 2 AM UTC0 2 * * *Common for nightly builds
Every Monday at 6 AM UTC0 6 * * 1Weekly security scans
Every 6 hours0 */6 * * *Frequent checks
First of every month0 0 1 * *Monthly maintenance

Pitfall: Scheduled workflows only run on the default branch. If your workflow is on a feature branch, it won’t trigger on schedule.


Nightly builds

Problem: PR-time CI might not catch issues that appear only with full test suites, integration tests, or across platforms.

Strategies:

StrategyProsCons
Nightly full matrixMaximum coverageExpensive
Nightly smoke testsCheap, fastLower coverage
Nightly release candidateTests the release processNeeds release infrastructure

Senior choice: Run the broadest matrix nightly. Run the focused matrix on PRs. Alert the team on Slack/email if the nightly fails.


6. Brainstorm / Open Questions

Architecture

  1. What should be standardized in shared workflows vs. left to each repo?
  2. When should a workflow be split into multiple workflows?
  3. When should a workflow become a reusable workflow?
  4. When should a reusable workflow become a platform service?
  5. What does the ideal pipeline DAG (directed acyclic graph) look like for your organization?
  6. Which parts of CI should be event-driven and which should be scheduled?
  7. How much conditional logic is acceptable before a workflow becomes unmaintainable?
  8. Should workflows define their own Node version, or should that be centralized?

Scaling

  1. What happens if this workflow takes 30 minutes instead of 3?
  2. How many parallel jobs can your runner fleet actually support at peak?
  3. What workflows become too expensive to run on every PR?
  4. How do you handle bursts when 20 teams push simultaneously?
  5. How do you detect and reduce redundant workflow executions?
  6. What is the right level of workflow reuse across 50 repos? 500 repos?
  7. How do you prevent duplicated YAML logic across repositories?
  8. At what point should you invest in self-hosted runners?

Security

  1. How do you avoid leaking secrets in logs — even transformed or base64-encoded ones?
  2. Which jobs should never have access to production secrets?
  3. Should third-party actions be pinned to commit SHAs or version tags?
  4. Where should OIDC replace static cloud credentials?
  5. How do you review workflow file changes with the same rigor as application code?
  6. What should happen if a workflow file is compromised via a supply chain attack?
  7. How do you segment trusted and untrusted runners?
  8. What is your process for vetting a new action from the marketplace?
  9. How do you detect if a previously-trusted action changes behavior silently?

DX / Maintainability

  1. Can a new engineer understand the pipeline in under 10 minutes?
  2. Which steps are noisy or flaky enough to degrade trust in CI?
  3. Where is caching helpful vs. where does it cause confusion?
  4. How do you surface the real failure instead of a cascade of follow-on errors?
  5. What should be reusable, and what should remain explicit for readability?
  6. How do you keep YAML complexity from becoming tribal knowledge?
  7. What is the best default workflow template for your team?
  8. How do you test workflow changes safely before merging?

Cost

  1. Which jobs cost the most minutes without proportional value?
  2. Can you skip the entire pipeline for docs-only or README changes?
  3. Should you run full matrix tests on every commit, or only on specific triggers?
  4. How much does artifact retention cost over time?
  5. Are self-hosted runners actually cheaper after including maintenance and ops?
  6. Are your caches helping enough to justify their complexity?
  7. What is the cost of a slow pipeline in developer time and context switching?

Reliability

  1. What should happen if deployment succeeds but the database migration fails?
  2. How do you design for re-runs after flaky infrastructure outages?
  3. How do you ensure artifacts are reproducible and traceable?
  4. What should be automatically retried vs. manually investigated?
  5. How do you detect partial failures that look successful at first glance?
  6. What is your rollback RTO (Recovery Time Objective)?
  7. How do you keep scheduled workflows trustworthy over months?

Release Strategy

  1. When should you release on merge vs. on tag?
  2. Should production deployment be gated by manual approval?
  3. How should preview deployments map to release confidence?
  4. What release process works best for a monorepo with multiple products?
  5. When should database migrations be decoupled from app deployment?
  6. What does a safe canary look like for your technology stack?
  7. How do you reconcile fast shipping with controlled, safe releases?
  8. What is the right balance between release automation and human judgment?

7. Practice Questions

Beginner (Level 1)

Q1. Multiple choice: Where do GitHub Actions workflow files live?

AnswerB. .github/workflows/

Q2. True/False: A job runs on a runner.

AnswerTrue. Every job is assigned to a runner (a VM or container) that executes its steps.

Q3. Fill in the blank: The command npm ___ should be used instead of npm install in CI for deterministic builds.

Answercinpm ci does a clean install strictly from the lockfile.

Q4. Single choice: Which step must usually come first in a GitHub Actions job?

AnswerC. actions/checkout@v4 — without checkout, there is no source code on the runner.

Q5. Debugging: Your workflow says command not found: eslint. Name three likely causes.

Answer 1. npm ci or dependency install step is missing.
2. eslint is a devDependency but dependencies were installed with --production.
3. The working directory is wrong — the job is running in a folder without node_modules.

Q6. Scenario: Your YAML file fails to parse. The error says “mapping values are not allowed here.” What do you check first?

AnswerIndentation. This error almost always means inconsistent indentation — a value is at the wrong level, or tabs are mixed with spaces.

Q7. Matching: Match each concept to its meaning.

ConceptMeaning
A. workflow1. A reusable unit of pipeline logic
B. step2. A file or directory persisted from a run
C. action3. A single command or action invocation
D. artifact4. The full automation definition in YAML
AnswerA→4, B→3, C→1, D→2

Q8. True/False: Tabs are acceptable in YAML workflow files.

AnswerFalse. YAML requires spaces for indentation. Tabs cause parse errors.

Q9. Scenario: You push to a branch called feature/login, but your workflow only triggers on push: branches: [main]. Will the workflow run?

AnswerNo. The branch filter only matches main. The push to feature/login does not match.

Q10. Fill in the blank: To trigger a workflow on every pull request, use on: _______.

Answerpull_request

Junior (Level 2)

Q11. Multiple choice: Why use a matrix strategy?

AnswerB. Matrices fan out a job across combinations of parameters.

Q12. True/False: The needs keyword can control the execution order of jobs.

AnswerTrue. needs: [lint, test] means this job waits for both lint and test to complete.

Q13. Fill in the blank: The cache key should usually be tied to the ____.

AnswerLockfile hash — e.g., hashFiles('package-lock.json'). This ensures the cache is invalidated when dependencies change.

Q14. Scenario: A pull request workflow should NOT deploy to production. Write the condition.

Answerif: github.ref == 'refs/heads/main' && github.event_name == 'push' — this ensures the deploy step only runs on pushes to main, not on PRs.

Q15. Debugging: A job passes locally but fails in CI with missing peer dependencies. What should you inspect?

Answer 1. Node version mismatch between local and CI.
2. npm version difference (npm 7+ auto-installs peer deps, older versions don't).
3. Missing lockfile or stale lockfile.
4. Different package manager (local uses pnpm, CI uses npm).

Q16. Matching: Match the package manager to its lockfile and cache mode.

Package managerLockfileSetup-node cache value
A. npm1. yarn.lockX. pnpm
B. pnpm2. pnpm-lock.yamlY. npm
C. yarn3. package-lock.jsonZ. yarn
AnswerA→3,Y | B→2,X | C→1,Z

Q17. Single choice: Which is a common anti-pattern?

AnswerC. Duplicating YAML causes drift and maintenance pain. Use reusable workflows instead.

Q18. Scenario: Your team wants preview deployments for every PR. What trade-offs should be discussed?

Answer 1. Cost: Each preview uses CI minutes and possibly hosting resources.
2. Cleanup: Preview environments need to be torn down when the PR is closed.
3. Security: Preview environments may expose features before they're ready.
4. Parity: Previews may not match production behavior (different env vars, API endpoints).
5. Value: Previews are most valuable for UI changes — skip them for backend-only PRs.

Q19. True/False: actions/setup-node with cache: npm caches the node_modules directory.

AnswerFalse. It caches npm's global cache directory (~/.npm), not node_modules. You still need npm ci to install from the cache into node_modules.

Q20. Fill in the blank: To access a secret named DEPLOY_KEY in a step, use ${{ secrets.________ }}.

AnswerDEPLOY_KEY

Senior / Expert (Level 3–4)

Q21. Multiple choice: What is the biggest advantage of OIDC over long-lived cloud access keys?

AnswerB. OIDC uses short-lived tokens generated per workflow run, eliminating the risk of leaked static credentials.

Q22. True/False: Blue-green deployment always uses less infrastructure than rolling deployment.

AnswerFalse. Blue-green requires two full environments running simultaneously during the switch, which uses more infrastructure than rolling.

Q23. Fill in the blank: A workflow reused across repositories via workflow_call is called a ____ workflow.

AnswerReusable

Q24. Scenario: Your monorepo has 150 packages. Path filters in on.push.paths miss changes to shared packages. What strategy would you use?

AnswerUse a change detection tool that understands the dependency graph — dorny/paths-filter with explicit shared package paths, or integrate with Turborepo/Nx which know the package dependency tree and can determine affected packages transitively.

Q25. Debugging: Deployment succeeded, but the app is broken because a database migration failed halfway. What design mistake likely occurred?

Answer 1. Migration was not wrapped in a transaction.
2. Migration was not backward-compatible (expand/contract pattern was not used).
3. There was no health check after migration to gate the deploy.
4. Rollback strategy for the migration was not defined.

Q26. Single choice: Which is most appropriate for sharing step-level logic (like checkout + install + setup)?

AnswerA. Composite actions encapsulate reusable step logic.

Q27. Multiple choice: Which is a good reason to use self-hosted runners? (Select all)

AnswerB and D. Self-hosted runners require maintenance but provide network access and can be cheaper at high scale.

Q28. Matching: Match each deployment strategy to its description.

StrategyDescription
A. Rolling1. Route a small % of traffic to the new version first
B. Blue-green2. Gradually replace instances one by one
C. Canary3. Maintain two full environments, switch traffic instantly
AnswerA→2, B→3, C→1

Q29. Scenario: An action from the marketplace (cool-deploy@latest) suddenly starts exfiltrating secrets. What policy would have prevented this?

Answer 1. Pin actions to commit SHA (cool-deploy@abc123) instead of mutable tags.
2. Use Dependabot to update action versions with review.
3. Restrict allowed actions at the organization level.
4. Review action source code before adoption.

Q30. Real-world pain: CI costs have doubled after adding matrix tests across 3 OS × 3 Node versions (9 combinations). How would you evaluate whether the extra coverage is worth the cost?

Answer 1. Check if any real bugs were caught by non-primary OS/Node combos.
2. Run the full matrix on nightly, not on every PR.
3. Run only the primary combo (ubuntu + current LTS Node) on PRs.
4. Use fail-fast: true to stop early on failure.
5. Calculate cost: 9 × avg_minutes × runs_per_day × cost_per_minute.

Q31. True/False: Scheduled workflows can trigger on any branch.

AnswerFalse. Scheduled workflows only run on the default branch (usually main).

Q32. Fill in the blank: Production secrets should be stored in a GitHub ____ with protection rules.

AnswerEnvironment

Q33. Design question: How would you design rollback for a system that uses feature flags AND database migrations?

Answer 1. Feature flags: disable the flag immediately — fastest rollback.
2. Database: use expand/contract migrations so old code works with new schema.
3. If a migration cannot be backward-compatible, ensure the flag is turned off before the migration runs.
4. Keep a manual dispatch rollback workflow that redeploys the previous version.
5. Document the rollback decision tree for on-call engineers.

Q34. Debugging: A workflow that depends on an environment secret works in staging but fails in production with “secret not found.” What should you inspect?

Answer 1. The secret exists in the production environment (not just staging).
2. The job references environment: production.
3. The branch is allowed by the environment's branch protection rules.
4. The person/workflow has approval to access the production environment.

Q35. Design question: When should a team move from per-repo YAML files to organization-wide reusable workflows?

Answer When you observe:
1. The same YAML is copy-pasted across 5+ repos.
2. A change to the standard pipeline requires updating every repo.
3. Teams are making inconsistent CI decisions (different Node versions, missing steps).
4. Security or compliance requires standardized scanning and deployment.

8. Personalized Recommendations

Based on your stack: React, Next.js, Astro, TypeScript, static files

Patterns most useful for you

  1. PR validation workflow: lint + typecheck + test + build on every PR. This is your #1 quality gate.
  2. Dependency caching: actions/setup-node with cache: npm (or pnpm). Essential for speed.
  3. Preview deployments: Automatic preview URLs for every PR (via Vercel or Cloudflare).
  4. Production deployment with environment protection: Deploy on merge to main, with approval for production.
  5. Path filters: For monorepos or multi-package setups, skip CI for unrelated changes.
  6. Concurrency cancellation: Cancel in-progress runs when you push again to the same PR.
  7. Dependabot auto-merge: Automatically merge safe patch updates that pass CI.
  8. Release automation: Automated versioning and changelog generation on merge.

What to learn first (priority order)

  1. Basic workflow syntax and structure.
  2. Triggers: push, pull_request, workflow_dispatch.
  3. actions/checkout + actions/setup-node + npm ci.
  4. Multi-job pipelines with needs.
  5. Caching.
  6. Secrets and environments.
  7. Conditional logic with if:.
  8. Artifacts.
  9. Reusable workflows.
  10. OIDC and security hardening.

Which workflows to build first

OrderWorkflowWhy
1stci.yml — lint, typecheck, test, buildYour foundation — everything else depends on this
2nddeploy.yml — deploy to staging/productionClose the loop — code changes reach users
3rdpreview.yml — preview deployment on PRBetter code review — reviewers can see the change
4thdependabot-merge.yml — auto-merge patchesReduce dependency noise
5threlease.yml — automated versioningProfessional release process
6thnightly.yml — scheduled full test suiteCatch issues PR-CI misses
7thsecurity.yml — CodeQL + dependency reviewSecurity posture

Mistakes frontend engineers commonly make in CI/CD

MistakeWhy it happensBetter approach
Treating CI as “just run my local scripts”Mental model from npm run devDesign CI as a production system with isolation, reproducibility, and failure handling
No caching”It works without it”Always cache — the speed difference compounds
Deploying from every branchCopy-paste from a tutorialDeploy only from main or tags
Mixing preview and production deploysNot understanding environmentsUse separate jobs with environment: and conditions
Ignoring rollback”We’ll fix forward”Define rollback before you define deploy
Using npm installHabitnpm ci is always correct in CI
Hardcoding valuesQuick and dirtyUse secrets, variables, and inputs
Not setting up branch protection”We trust the team”Branch protection catches mistakes trust cannot
Ignoring CI speed”It only takes 5 minutes”Slow CI kills iteration speed; optimize early
Not testing the workflow”YAML is just config”Workflow code is production code — test it

How to evolve from simple to production-grade

Phase 1: Single-job CI
  └── lint + test + build in one job

Phase 2: Multi-job CI
  └── parallel lint/typecheck/test → build

Phase 3: Deployment
  └── CI → staging deploy → production deploy

Phase 4: Safety
  └── environment protection, secrets, branch rules

Phase 5: Speed
  └── caching, concurrency, path filters

Phase 6: Automation
  └── Dependabot, release automation, auto-labeling

Phase 7: Reuse
  └── composite actions, reusable workflows

Phase 8: Security
  └── OIDC, action pinning, CodeQL, dependency review

Phase 9: Observability
  └── notifications, failure alerts, cost tracking

Phase 10: Platform
  └── org-wide shared workflows, governance, runner fleet

30-day learning plan

Week 1: Foundations (Days 1–7)

DayTaskDeliverable
1Read this guide sections 1–2 Level 1Mental model of concepts
2Create first ci.yml: print Node version, run lintWorking workflow
3Add test and build stepsComplete single-job CI
4Break the workflow intentionally, study logsDebugging confidence
5Split into parallel jobs (lint, test, build)Multi-job workflow
6Add actions/setup-node with cachingFaster installs
7Add trigger for pull_request and push to mainProper trigger config

Week 2: Team workflows (Days 8–14)

DayTaskDeliverable
8Add typecheck jobFull validation pipeline
9Configure secrets and environment variablesSecure config
10Add a deployment job (Vercel or Cloudflare)Working deployment
11Add environment protection for productionSafe production deploy
12Set up branch protection requiring CIEnforced quality gate
13Add concurrency cancellationNo wasted runs
14Add Dependabot config + auto-merge workflowDependency automation

Week 3: Production readiness (Days 15–21)

DayTaskDeliverable
15Create a preview deployment workflow for PRsPreview URLs on PRs
16Add .next/cache or build cacheFaster builds
17Add release automation (release-please or manual)Versioned releases
18Add Slack/Discord notification on failureTeam awareness
19Create a composite action for project setupReusable setup logic
20Add CodeQL or dependency reviewSecurity scanning
21Review and document your workflowsMaintainable system

Week 4: Advanced patterns (Days 22–30)

DayTaskDeliverable
22Study reusable workflows; convert CI to reusableShareable pipeline
23Add path filters for a monorepo setupSelective CI
24Create a rollback workflow with workflow_dispatchRecovery mechanism
25Study OIDC; replace a static key with OIDCBetter security
26Add a nightly workflow with broader testingExtended coverage
27Study self-hosted runners conceptuallyArchitecture knowledge
28Audit all workflows against the security checklistSecurity hardening
29Review cost — minutes used, cache efficiencyCost awareness
30Write an architecture decision record for your CI/CDDocumentation

Summary, Next Steps, and Advanced Topics

Concise summary

GitHub Actions is an event-driven automation platform integrated into GitHub. For a frontend engineer, the fastest path to mastery is:

  1. Start with a simple CI workflow (lint, test, build).
  2. Evolve to parallel jobs, caching, and deployment with environments.
  3. Mature into reusable workflows, security hardening, and release automation.
  4. Scale toward organization-wide platform engineering with governance.

The key mindset shift: your pipeline is production infrastructure. Treat it with the same rigor as your application code — version it, test it, monitor it, and design for failure.

Next steps

  1. Create your first ci.yml for a real project today.
  2. Add caching and branch protection this week.
  3. Set up a preview deployment workflow for PRs.
  4. Add environment protection for production deployments.
  5. Study reusable workflows and OIDC within the next two weeks.
  6. Build a rollback workflow and test it.

Suggested advanced topics to continue learning

TopicWhy it matters
Reusable workflows at organization scaleConsistency and governance across many repos
OIDC-based cloud authenticationEliminate static cloud credentials
Ephemeral self-hosted runners (ARC)Cost-effective, secure runner infrastructure
Release automation with semantic versioningProfessional, traceable releases
Supply chain security and action pinningProtect against compromised dependencies
Monorepo pipeline orchestrationEfficient CI for large codebases
Canary and blue-green deployment designSafe production releases
CI/CD observability and failure analyticsData-driven pipeline improvement
Artifact provenance and signingBuild integrity and compliance
Internal developer platform designCI/CD as a product for your organization
GitHub Actions + Terraform/PulumiInfrastructure as code in your pipeline
Multi-cloud deployment strategiesDeploy to AWS, GCP, Azure from one pipeline
Performance testing in CICatch regressions before production
E2E testing with Playwright in ActionsFull browser testing in CI
Custom GitHub Actions developmentBuild and publish your own actions

Edit page
Share this post:

Previous Post
Git Internals
Next Post
GitLab CI/CD — Complete Deep-Dive Engineering Guide