Guide

GitHub Actions fundamentals explained

Push a commit, and within two minutes a remote machine installs your dependencies, runs your test suite, builds a container image, and opens a deploy preview — all without you SSH-ing anywhere. That is GitHub Actions: a built-in CI/CD engine tied to your Git repository. Workflows live as YAML files in .github/workflows/, triggered by pushes, pull requests, schedules, or manual buttons. GitHub provisions ephemeral runners (Ubuntu, Windows, or macOS VMs) for each job, executes steps from the Actions marketplace or your own scripts, and tears the machine down when finished. For teams already on GitHub, Actions removes the Jenkins server tax while integrating branch protection, environments, and OIDC cloud deploys. This guide covers workflow anatomy, jobs and dependency graphs, secrets and environments, matrix testing, caching and artifacts, reusable workflows, OIDC patterns, a Harbor Supply pipeline worked example, a tooling decision table, common pitfalls, and a practitioner checklist — alongside our CI/CD pipelines explainer, Docker fundamentals guide, and Playwright E2E testing overview.

Core concepts: workflows, jobs, steps, and runners

GitHub Actions organizes automation into four nested layers:

  • Workflow — one YAML file triggered by an event (e.g. push to main). A repo can have many workflows.
  • Job — a set of steps that run on the same runner. Jobs in one workflow run in parallel by default unless connected with needs:.
  • Step — a single task: run a shell command or invoke a packaged action from the marketplace.
  • Runner — the VM executing the job. GitHub-hosted runners are fresh each run; self-hosted runners persist on your infrastructure.

A minimal workflow looks like this:

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

actions/checkout clones the commit that triggered the workflow. Subsequent steps see the repo at that exact SHA — reproducible builds are the point of continuous integration.

Triggers you will use daily

  • push / pull_request — standard CI on every change; filter by branch or path with paths: to skip docs-only commits.
  • workflow_dispatch — manual “Run workflow” button in the UI; useful for one-off migrations.
  • schedule — cron syntax for nightly security scans or dependency updates.
  • release / workflow_call — publish on GitHub Release tags; invoke reusable workflows from other repos.

Jobs, dependencies, and concurrency

Split pipelines into focused jobs: lint, unit-test, build-image, deploy-staging. Parallel jobs finish faster than one monolithic script. Chain jobs with needs: so deploy waits for tests:

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [ ... ]
  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps: [ ... ]

Use concurrency: groups to cancel superseded runs when a developer pushes three fixes in a row — only the latest commit should consume runner minutes. Set cancel-in-progress: true on feature-branch CI; use false on production deploys so two releases never fight.

Matrix builds

Test across Node 18 and 20, or Ubuntu and macOS, with a single job definition:

strategy:
  matrix:
    node: [18, 20]
    os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}

Matrix jobs multiply runner minutes — a 2×2 grid is four VMs. Use fail-fast: false when you want every combination to finish even if one fails (helpful for flaky browser tests). Pair matrix Node versions with Playwright sharding for large E2E suites.

Actions marketplace and custom actions

An action is a reusable unit — JavaScript, Docker, or composite shell steps — published at owner/repo@version. Pin to a major version tag (@v4) or an immutable SHA for supply-chain safety. Common actions:

  • actions/checkout — clone repo with optional submodules and LFS.
  • actions/setup-node, setup-python, setup-go — install language runtimes with caching hooks.
  • actions/cache — persist ~/.npm, Gradle, or pip directories between runs.
  • actions/upload-artifact / download-artifact — pass build outputs between jobs.
  • docker/build-push-action — build and push images to GHCR or ECR.

Composite actions bundle shell steps your org reuses across repos (e.g. “run lint + test with our standard Node version”). Store them in .github/actions/my-action/action.yml and reference with uses: ./.github/actions/my-action.

Reusable workflows

When multiple repositories need identical deploy logic, extract a workflow to a central repo and call it with workflow_call. Inputs and secrets pass explicitly — no copy-paste YAML drift across forty microservices.

Secrets, variables, and environments

Never commit API keys. Store them in repository secrets (Settings → Secrets) and reference as ${{ secrets.MY_TOKEN }}. GitHub masks secret values in logs, but secrets can still leak through careless echo commands or error messages — treat CI logs as semi-public.

  • Repository secrets — available to all workflows in the repo.
  • Environment secrets — scoped to production or staging; pair with protection rules requiring reviewer approval before deploy jobs run.
  • Organization secrets — shared across repos with selective access.
  • Variables — non-sensitive config (region names, feature flags) exposed as ${{ vars.AWS_REGION }}.

Reference an environment in a job:

deploy:
  environment: production
  steps:
    - run: ./deploy.sh
      env:
        API_KEY: ${{ secrets.PROD_API_KEY }}

OIDC instead of long-lived cloud keys

Modern deploy workflows exchange a short-lived OIDC token from GitHub for AWS, GCP, or Azure credentials — no static AWS_ACCESS_KEY_ID in secrets. Configure an IAM role trust policy on token.actions.githubusercontent.com and use aws-actions/configure-aws-credentials. Tokens expire in minutes; a leaked log line is far less catastrophic than a permanent key.

Caching, artifacts, and permissions

Cold npm ci on every push wastes time. actions/cache keys on lockfile hashes restore node_modules when dependencies are unchanged. Language setup actions often include cache: npm as a one-liner. Cache misses still complete — they just reinstall.

Artifacts pass built binaries, coverage reports, or Docker image tarballs from a build job to a deploy job in the same workflow run. Set retention days to avoid unbounded storage costs on busy repos.

Default GITHUB_TOKEN permissions are read-only since 2023. Explicitly set permissions: at workflow or job level — contents: read for CI, contents: write plus packages: write only on release jobs. Principle of least privilege limits damage if a third-party action is compromised.

Worked example: Harbor Supply CI pipeline

Harbor Supply runs a Node.js inventory API and a React admin dashboard in one monorepo. Their ci.yml workflow triggers on every pull request and on pushes to main:

  1. Job lint — checkout, setup Node 20 with npm cache, run ESLint and TypeScript tsc --noEmit. Fails in ~40 seconds.
  2. Job unit-test — parallel with lint; npm test -- --coverage; uploads coverage artifact.
  3. Job e2eneeds: [lint, unit-test]; installs Playwright browsers, runs three spec files against a local dev server started in background; uploads trace on failure.
  4. Job build-image — on main only after e2e passes; builds Docker image, tags with git SHA, pushes to GitHub Container Registry using GITHUB_TOKEN.
  5. Job deploy-stagingenvironment: staging; pulls image by SHA, rolls Kubernetes Deployment; smoke test hits /healthz.
  6. Job deploy-productionenvironment: production with required reviewer; manual gate after staging green for 30 minutes on weekday releases.

Path filters skip the workflow when only docs/ changes — saving ~800 runner minutes per month. Concurrency group ci-${{ github.ref }} cancels stale PR runs. Production deploy uses OIDC to AWS EKS; no static kubeconfig in secrets.

Tooling decision table

QuestionBest choiceWhy
Code already on GitHub, team < 50 engineers?GitHub ActionsZero infra, native PR checks, free tier for public repos.
Self-hosted GitLab with compliance air-gap?GitLab CIRuns entirely inside your VPC; same YAML mental model.
Hundreds of plugins, legacy Jenkinsfiles?JenkinsMature ecosystem; you operate the controller and agents.
Complex monorepo with custom remote cache?Buildkite / CircleCIElastic agent pools, advanced caching, per-minute billing clarity.
Container-native GitOps deploy only?Argo CD + minimal CICI builds image; cluster pulls from Git; see GitOps guide.
Open-source project wanting free macOS builds?GitHub ActionsmacOS runners included; other vendors charge premium.
Need deterministic hermetic builds (Bazel)?Self-hosted or BuildBuddyGitHub cache helps but remote execution may need dedicated infra.
Regulated industry requiring signed attestations?GHA + artifact attestationsNative SLSA provenance generation on supported ecosystems.

Common pitfalls

  • Floating action tags@main can change overnight; pin SHAs for security-critical steps.
  • Secrets in fork PRs — workflows from forks do not receive write secrets; use pull_request_target only with extreme care (RCE risk).
  • Unbounded matrix — 10 OS × 8 Node versions = 80 jobs; costs explode on private repos.
  • Missing permissions: — overly broad default write access before you tighten it.
  • Cache poisoning — cache keys must include lockfile hash; never cache node_modules keyed only on branch name.
  • Deploy on green without smoke tests — CI green does not mean production healthy; add post-deploy checks.
  • Ignoring runner minute budgets — private repos bill per minute; optimize with path filters and concurrency cancel.
  • Storing production DB URLs in repo variables — variables are visible to anyone with repo read access; use environment secrets.
  • One giant workflow file — 800-line YAML becomes unmaintainable; split by concern or use reusable workflows.

Practitioner checklist

  • Add a minimal ci.yml that runs lint and unit tests on every PR.
  • Pin third-party actions to version tags or commit SHAs.
  • Enable branch protection requiring the CI check before merge.
  • Configure concurrency to cancel outdated PR runs.
  • Cache dependencies keyed on lockfile content hash.
  • Split build and deploy into separate jobs with needs:.
  • Create staging and production environments with approval gates on production.
  • Replace long-lived cloud keys with OIDC federation where supported.
  • Set explicit least-privilege permissions: per workflow.
  • Upload test artifacts and Playwright traces on failure for debugging.

Key takeaways

  • GitHub Actions runs CI/CD as YAML workflows triggered by Git events, with ephemeral hosted runners.
  • Jobs parallelize work; needs: chains dependencies; matrix multiplies test coverage across versions and OSes.
  • Secrets and environments gate production deploys; prefer OIDC over static cloud credentials.
  • Caching and artifacts speed pipelines and pass build outputs between jobs.
  • Pin actions, scope permissions, and treat workflow design as production code — it ships what users see.

Related reading