CI/CD (GitHub Actions) Fundamentals

Anatomy of a workflow

name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm test

on declares the events that trigger the workflow — here, every push to main and every pull request. jobs defines one or more independent units of work; test is this workflow's only job. runs-on picks the runner (the VM the job executes in). steps are the sequential actions within a job — uses runs a reusable, packaged action (checkout clones the repo, setup-node installs a specific Node version); run executes a shell command directly.

Jobs run in parallel, in isolated environments, by default

graph TD trigger["push event"] --> lint["Job: lint<br/>(fresh VM)"] trigger --> test["Job: test<br/>(fresh VM)"] trigger --> build["Job: build<br/>(fresh VM)"]

Unless a needs: dependency is declared, every job in a workflow starts simultaneously, each in its own fresh virtual machine (or container) — there is no shared filesystem, no shared process state, and no implicit ordering between jobs. This is a deliberate design, not a limitation to work around by cramming everything into one job: it's what lets independent checks (lint, unit tests, integration tests) run concurrently instead of one after another, directly cutting total pipeline time.

jobs:
  build:
    runs-on: ubuntu-latest
    steps: [...]
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps: [...]

needs: build makes deploy wait for build to succeed first — this is how a real dependency (don't deploy code that hasn't passed its build) gets expressed explicitly, rather than relying on jobs happening to run in file order (they don't).

Steps within a job DO share state — the one exception

Steps within the same job run sequentially on the same runner and do share the filesystem (a file created in step 1 is visible in step 2) — the isolation boundary is between jobs, not between steps within one job. This asymmetry is the direct reason artifacts exist as a distinct mechanism — they're specifically for passing files between jobs, a problem that doesn't exist between steps in the same job.

Common pitfall

Assuming a later job in the same workflow can see files a previous job created (a build output, a generated config) without explicitly uploading and downloading them via artifacts is one of the most common first-time GitHub Actions mistakes — each job is a genuinely separate machine with an empty filesystem at the start, and nothing from a prior job's workspace is automatically present, regardless of how naturally sequential the jobs feel when reading the YAML top to bottom.