Happy Dussehra! May good always win. Happy Diwali! Wishing you a bright year ahead. Merry Christmas! Warm wishes from Vizag. Happy New Year! Here’s to a great year ahead.

Archive · DevOps

Continuous Integration with GitHub Actions

Build a CI workflow with caching, a test matrix, least-privilege permissions, concurrency control, container image builds and keyless deployment to AWS using OIDC.

3 min read

  • GitHub Actions
  • actions/checkout v3
  • Node.js 16
  • OIDC
  • Amazon ECR

Continuous integration means every change is built and tested automatically before it can merge, so broken code is found in minutes instead of at release time. GitHub Actions runs workflows defined in YAML inside your repository, on GitHub-hosted or self-hosted runners.

Versions in use on this date. actions/checkout@v3 and actions/setup-node@v3 (both run on the Node 16 action runtime, with built-in dependency caching), Node.js 16 (LTS), and OIDC federation to AWS, which GitHub supports since late 2021. ubuntu-latest currently maps to Ubuntu 20.04; Ubuntu 22.04 runners are in beta.

Pipeline

flowchart TB
  PR[Pull request or push to main] --> W[Workflow starts<br/>concurrency cancels older runs]
  W --> T[Job test<br/>matrix Node 16 and 18]
  W --> L[Job lint and scan]
  T --> BI
  L --> BI[Job build image<br/>needs test and lint]
  BI -->|main branch only| OIDC[Assume AWS role via OIDC<br/>no stored keys]
  OIDC --> ECR[Push image to Amazon ECR]
  ECR --> DEP[Update deployment config<br/>GitOps pull request or environment approval]
  T -->|artifacts| ART[Test reports and coverage]

A complete workflow

.github/workflows/ci.yml:

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

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node: [16, 18]
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm test -- --ci --coverage
      - uses: actions/upload-artifact@v3
        if: always()
        with: { name: coverage-${{ matrix.node }}, path: coverage/ }

  image:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      id-token: write        # required for OIDC
      contents: read
    env:
      REGISTRY: 111111111111.dkr.ecr.ap-south-1.amazonaws.com
      REPO: shop/api
    steps:
      - uses: actions/checkout@v3
      - uses: aws-actions/configure-aws-credentials@v1
        with:
          role-to-assume: arn:aws:iam::111111111111:role/github-ci-shop
          aws-region: ap-south-1
      - uses: aws-actions/amazon-ecr-login@v1
      - run: |
          docker build -t $REGISTRY/$REPO:${{ github.sha }} .
          docker push $REGISTRY/$REPO:${{ github.sha }}

Highlights:

  • permissions at the top sets the GITHUB_TOKEN to read-only. Grant more per job only where needed (for example id-token: write).
  • concurrency cancels older runs for the same branch, saving minutes.
  • matrix runs the tests on several versions at once; fail-fast: false shows all failures.
  • cache: npm in setup-node restores dependencies keyed on the lockfile; use actions/cache for other tools.
  • needs and if control order and when the image is pushed (only from main).

Keyless AWS access with OIDC

Long-lived AWS_ACCESS_KEY_ID secrets in a repository are a standing risk. With OIDC, GitHub presents a signed token for each run and AWS exchanges it for temporary credentials.

  1. In IAM, create an identity provider for https://token.actions.githubusercontent.com (audience sts.amazonaws.com).
  2. Create a role with a trust policy limited to your repository and branch:
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
      "StringLike": { "token.actions.githubusercontent.com:sub": "repo:acme/shop-api:ref:refs/heads/main" }
    }
  }]
}
  1. Give the role only the permissions it needs (push to one ECR repository). The sub condition is critical: without it any repository could assume the role.

Secrets, environments and approvals

  • Store secrets in Settings, Secrets and variables. Secrets are not passed to workflows from forked pull requests.
  • Use Environments (staging, production) with required reviewers and environment-scoped secrets for deployment jobs.
  • Treat third-party actions as code that runs with your permissions: prefer official actions, pin to a version tag, and for sensitive workflows to a full commit SHA. Dependabot can keep them updated (.github/dependabot.yml, ecosystem github-actions).
  • Never print secrets or run workflows with pull_request_target on untrusted code without understanding the risk.

Reuse and speed

  • Reusable workflows (workflow_call) share a standard pipeline across repositories.
  • Cache Docker layers with docker/build-push-action and cache-from: type=gha.
  • Run slow tests in parallel jobs, and split by path with paths: filters to skip unrelated work.
  • Hosted runners are billed by minute beyond the free allowance; self-hosted runners suit private networks but need hardening and patching.

Debugging

  • Read the failing step’s log; re-run with debug logging by setting the secret ACTIONS_STEP_DEBUG to true.
  • Reproduce locally with act (approximate), or run the same commands in a container.
  • Common failures: a missing id-token: write permission (OIDC), cache misses after lockfile changes, and differences between local and runner versions (pin them).