GitHub Actions Fundamentals — Workflows, Jobs, Triggers, and Artefacts

Continuous Integration and Continuous Deployment (CI/CD) transforms software delivery from a manual, error-prone process into an automated, reliable pipeline. Every code push triggers automated tests, security scans, image builds, and deployments — catching bugs before they reach production, enforcing quality standards, and enabling multiple deployments per day with confidence. GitHub Actions is the native CI/CD platform for GitHub repositories, providing free compute for public repos and generous allowances for private ones, with a YAML-based workflow syntax that lives alongside your code.

GitHub Actions Core Concepts

Concept Definition Example
Workflow An automated process defined in a YAML file in .github/workflows/ ci.yml, deploy.yml
Trigger (on) Event that starts a workflow push, pull_request, schedule, workflow_dispatch
Job A group of steps that run on the same runner (virtual machine) test, build, deploy
Step An individual task within a job — a shell command or action run: npm test, uses: actions/checkout@v4
Runner The virtual machine that executes a job ubuntu-latest, macos-latest, windows-latest
Action Reusable, composable unit of automation from the GitHub Marketplace actions/checkout, docker/build-push-action
Secret Encrypted variable stored in repo/org settings — injected at runtime secrets.JWT_SECRET, secrets.DOCKERHUB_TOKEN
Environment Named deployment target with protection rules and secrets staging, production
Matrix Run a job multiple times with different parameter combinations Test on Node 18, 20, 22 simultaneously
Artifact File produced by a job and uploaded for use by later jobs Test coverage reports, build outputs
Note: Workflow files live in .github/workflows/ at the root of your repository. Each YAML file defines one workflow. You can have multiple workflows that trigger on different events — a ci.yml that runs on every push to any branch, a deploy-staging.yml that runs on pushes to develop, and a deploy-production.yml that runs on releases. Workflows can also call other workflows as reusable components with uses: ./.github/workflows/reusable.yml.
Tip: Pin action versions to a full commit SHA rather than a mutable tag like @v4. A tag can be moved to point to malicious code — this is a supply chain attack vector. Using actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (the SHA for v4.1.1) ensures you always run exactly the code you audited, even if the v4 tag is later moved. Many security-conscious organisations require SHA pinning in CI/CD workflows.
Warning: Never print secrets to workflow logs. run: echo ${{ secrets.JWT_SECRET }} exposes the secret in the Actions log (which may be accessible to collaborators). GitHub automatically redacts known secret values from logs, but multi-character patterns may still leak partial information. Keep secrets in GitHub Secrets, access them only through the secrets context, and never assign them to environment variables in log-visible steps.

Complete CI Workflow — Pull Request Quality Gate

# .github/workflows/ci.yml
# Runs on every push and pull request — the quality gate before merging

name: CI

on:
    push:
        branches: [main, develop, 'feature/**']
    pull_request:
        branches: [main, develop]

# Cancel in-progress runs for the same branch/PR (saves CI minutes)
concurrency:
    group: ci-${{ github.ref }}
    cancel-in-progress: true

env:
    NODE_VERSION:  '20'
    REGISTRY:      ghcr.io
    IMAGE_NAME:    ${{ github.repository }}

jobs:

    # ── Job 1: Lint and type check ────────────────────────────────────────
    lint:
        name:    Lint & Type Check
        runs-on: ubuntu-latest
        steps:
            - name: Checkout
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: ${{ env.NODE_VERSION }}
                  cache: npm
                  cache-dependency-path: api/package-lock.json

            - name: Install API dependencies
              run: npm ci
              working-directory: api

            - name: Lint API
              run: npm run lint
              working-directory: api

            - name: Type check API
              run: npm run typecheck
              working-directory: api

            - name: Setup Node.js for Angular
              uses: actions/setup-node@v4
              with:
                  node-version: ${{ env.NODE_VERSION }}
                  cache: npm
                  cache-dependency-path: client/package-lock.json

            - name: Install Angular dependencies
              run: npm ci
              working-directory: client

            - name: Lint Angular
              run: npm run lint
              working-directory: client

    # ── Job 2: Test API ───────────────────────────────────────────────────
    test-api:
        name:    Test API
        runs-on: ubuntu-latest
        # Run lint first — skip tests if linting fails
        needs:   lint
        steps:
            - name: Checkout
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: ${{ env.NODE_VERSION }}
                  cache: npm
                  cache-dependency-path: api/package-lock.json

            - name: Install dependencies
              run: npm ci
              working-directory: api

            - name: Run unit tests with coverage
              run: npm run test:coverage
              working-directory: api
              env:
                  JWT_SECRET:      test-secret-for-ci
                  REFRESH_SECRET:  test-refresh-secret
                  NODE_ENV:        test

            - name: Upload coverage report
              uses: actions/upload-artifact@v4
              with:
                  name: api-coverage
                  path: api/coverage/
                  retention-days: 7

            - name: Check coverage thresholds
              run: |
                  COVERAGE=$(cat api/coverage/coverage-summary.json | \
                      node -e "const d=require('/dev/stdin'); \
                      console.log(d.total.lines.pct)")
                  echo "Line coverage: ${COVERAGE}%"
                  if (( $(echo "$COVERAGE < 80" | bc -l) )); then
                      echo "Coverage ${COVERAGE}% is below 80% threshold"
                      exit 1
                  fi

    # ── Job 3: Test Angular ────────────────────────────────────────────────
    test-angular:
        name:    Test Angular
        runs-on: ubuntu-latest
        needs:   lint
        steps:
            - name: Checkout
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: ${{ env.NODE_VERSION }}
                  cache: npm
                  cache-dependency-path: client/package-lock.json

            - name: Install dependencies
              run: npm ci
              working-directory: client

            - name: Run Angular tests (headless)
              run: npm run test:ci
              working-directory: client
              # test:ci = ng test --watch=false --browsers=ChromeHeadless

    # ── Job 4: Security audit ─────────────────────────────────────────────
    security:
        name:    Security Audit
        runs-on: ubuntu-latest
        steps:
            - name: Checkout
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: ${{ env.NODE_VERSION }}

            - name: Audit API dependencies
              run: npm audit --audit-level=high
              working-directory: api

            - name: Audit Angular dependencies
              run: npm audit --audit-level=high
              working-directory: client

    # ── Job 5: Build check (Angular production build) ────────────────────
    build-check:
        name:    Build Check
        runs-on: ubuntu-latest
        needs:   [test-api, test-angular]
        steps:
            - name: Checkout
              uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: ${{ env.NODE_VERSION }}
                  cache: npm
                  cache-dependency-path: client/package-lock.json

            - name: Install Angular dependencies
              run: npm ci
              working-directory: client

            - name: Production build
              run: npm run build -- --configuration production
              working-directory: client

            - name: Upload build artifacts
              uses: actions/upload-artifact@v4
              with:
                  name: angular-build
                  path: client/dist/
                  retention-days: 1

How It Works

Step 1 — Triggers Define When Workflows Run

The on: section defines which GitHub events start the workflow. push: branches: [main, develop] runs on direct pushes to those branches. pull_request: branches: [main] runs when a PR is opened or updated targeting main. workflow_dispatch adds a manual trigger button in the GitHub UI. schedule: - cron: '0 2 * * *' runs nightly at 2am — useful for security scans and dependency updates.

Step 2 — needs Creates a Dependency Graph Between Jobs

Jobs run in parallel by default. needs: lint makes a job wait for lint to succeed before starting. needs: [test-api, test-angular] waits for both. This creates a DAG (directed acyclic graph) of jobs — lint first, then tests in parallel, then build only if both tests pass. If any dependency fails, downstream jobs are skipped (not just failed). This is more efficient than serial jobs and cheaper than running all checks in one mega-job.

Step 3 — concurrency Cancels Superseded Runs

concurrency: group: ci-${{ github.ref }} groups all runs for the same branch under one concurrency key. When a new push to feature/auth starts a CI run while a previous one is still running, the previous run is cancelled. This prevents CI queues from backing up and avoids wasting compute minutes on runs whose results are already obsolete. The group key using github.ref ensures only runs for the same branch cancel each other.

Step 4 — actions/setup-node cache Speeds Up Dependency Installation

The cache: npm option on actions/setup-node caches the npm cache directory between runs. On cache hit, npm ci can resolve packages from the cache rather than downloading from the registry — turning a 60-second install into a 10-second restore. The cache key is derived from the package-lock.json hash — so the cache is invalidated whenever dependencies change.

Step 5 — Artifacts Pass Data Between Jobs

upload-artifact saves files from one job; download-artifact retrieves them in a later job. The build job uploads the Angular dist/ folder, which the deploy job downloads and pushes to the server — without rebuilding. Coverage reports are uploaded for the PR comment bot to display. Artifacts are stored for a defined retention period and are accessible in the GitHub Actions UI for download and inspection.

Common Mistakes

Mistake 1 — Running all steps in one giant job

❌ Wrong — everything sequential, no parallelism, slow feedback:

jobs:
    everything:
        steps:
            - run: npm run lint
            - run: npm test         # waits for lint even if unrelated
            - run: npm run build    # waits for both even if only checking build

✅ Correct — parallel jobs with dependency graph:

jobs:
    lint: ...
    test:   { needs: lint }        # parallel once lint passes
    build:  { needs: test }        # only after tests pass

Mistake 2 — Hardcoding secrets in workflow YAML

❌ Wrong — secret visible in git history forever:

env:
    JWT_SECRET: my-super-secret-key   # IN THE REPO — visible to everyone!

✅ Correct — reference from GitHub Secrets:

env:
    JWT_SECRET: ${{ secrets.JWT_SECRET }}   # encrypted in GitHub, never in code

Mistake 3 — Not caching dependencies

❌ Wrong — npm ci downloads all packages from network on every run:

- uses: actions/setup-node@v4
  with:
      node-version: '20'
      # Missing: cache: npm

✅ Correct — enable npm caching:

- uses: actions/setup-node@v4
  with:
      node-version: '20'
      cache: npm
      cache-dependency-path: api/package-lock.json

Quick Reference

Task YAML Syntax
Trigger on push to main on: push: branches: [main]
Trigger on PR on: pull_request: branches: [main]
Manual trigger on: workflow_dispatch:
Scheduled run on: schedule: - cron: '0 2 * * 1'
Job depends on other needs: [lint, test]
Cancel superseded runs concurrency: group: ${{ github.ref }}, cancel-in-progress: true
Use a secret ${{ secrets.MY_SECRET }}
Upload artifact uses: actions/upload-artifact@v4, with: name: x, path: ./dist
Download artifact uses: actions/download-artifact@v4, with: name: x
Run in subdirectory working-directory: api

🧠 Test Yourself

A CI workflow has three jobs: lint, test (needs: lint), and build (needs: test). The lint job fails. What happens to test and build?