GitHub Actions

CI/CD pipelines defined as YAML in .github/workflows/, triggered by repo events, and run on GitHub-hosted or self-hosted runners.

0/0 checks

Concepts

graph TD
    classDef blue fill:#3498db,stroke:#2980b9,color:#fff
    classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef red fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef orange fill:#e67e22,stroke:#d35400,color:#fff
    classDef purple fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef teal fill:#1abc9c,stroke:#16a085,color:#fff
    classDef dark fill:#2c3e50,stroke:#1a252f,color:#fff
    classDef yellow fill:#f39c12,stroke:#d68910,color:#000
    classDef k8s fill:#326ce5,stroke:#254ea8,color:#fff
    classDef aws fill:#ff9900,stroke:#cc7a00,color:#000
    TRIGGER["Trigger: push, pull_request, schedule, workflow_dispatch"]:::orange --> WORKFLOW["Workflow (.github/workflows/*.yml)"]:::green
    WORKFLOW --> JOB1["Job 1: test (runs-on: ubuntu-latest)"]:::blue
    WORKFLOW --> JOB2["Job 2: build (needs: test)"]:::orange
    WORKFLOW --> JOB3["Job 3: deploy (needs: build)"]:::green

    JOB1 --> STEP1["Step: checkout"]:::blue
    JOB1 --> STEP2["Step: run go test"]:::blue
    JOB1 --> STEP3["Step: upload coverage"]:::blue
  • Workflow — a YAML file in .github/workflows/. One repo can have many workflows.
  • Job — a group of steps that run on the same runner. Jobs run in parallel by default; needs: makes them sequential.
  • Step — a single task: uses (an action) or run (shell command).
  • Runner — the VM that executes jobs. GitHub-hosted (ubuntu-latest, macos-latest) or self-hosted.

By default, do two jobs in the same workflow run in parallel or one after another — and what changes that?


Full CI Workflow

name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  GO_VERSION: "1.23"
  ECR_REPO: 123456789.dkr.ecr.us-east-1.amazonaws.com/my-service

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true   # caches go module download cache automatically

      - name: Run tests
        run: go test -race -coverprofile=coverage.out ./...

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.out

  build:
    runs-on: ubuntu-latest
    needs: test   # only runs if test passes
    outputs:
      image-tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.ECR_REPO }}
          tags: |
            type=sha,prefix=sha-
            type=semver,pattern={{version}}

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions-ecr
          aws-region: us-east-1

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha       # GitHub Actions cache for Docker layers
          cache-to: type=gha,mode=max

  deploy:
    runs-on: ubuntu-latest
    needs: build
    environment: production   # requires manual approval if configured in repo settings
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
          aws-region: us-east-1

      - name: Update ECS service
        run: |
          aws ecs update-service \
            --cluster prod \
            --service my-service \
            --force-new-deployment

The three jobs above are chained with needs:, so a single workflow run walks through them in a fixed order rather than firing all at once. Step through it:

1. Trigger. A push to main/develop, or a pull request against main, starts the workflow. All three jobs are defined, but only test has no needs: — it's the only one ready to start immediately.
2. test runs. Checks out the code, sets up Go, runs go test -race, uploads the coverage artifact. If any step here fails, the job fails and nothing downstream ever starts.
3. build runs (needs: test). Only begins once test succeeds. Extracts image metadata, assumes an AWS role over OIDC, logs into ECR, then builds and pushes the image using the GitHub Actions layer cache.
4. deploy runs (needs: build). Only begins once build succeeds. Because environment: production is set, this can pause for a manual approval if the repo requires one, then assumes a separate, narrower deploy role and forces a new ECS deployment.

The test job fails. Do build and deploy still run?


OIDC to AWS (No Long-Lived Keys)

OIDC lets GitHub Actions workflows assume an AWS IAM role without storing AWS access keys as secrets. GitHub mints a short-lived OIDC token per workflow run; AWS STS validates it and returns temporary credentials.

sequenceDiagram
    participant GH as GitHub Actions
    participant GH_OIDC as GitHub OIDC Provider
    participant STS as AWS STS
    participant AWS as AWS Services

    GH->>GH_OIDC: Request OIDC token for this workflow run
    GH_OIDC-->>GH: JWT token (repo, branch, sha, expiry)
    GH->>STS: AssumeRoleWithWebIdentity (token + role ARN)
    STS->>STS: Validate token against GitHub OIDC endpoint
    STS-->>GH: Temporary credentials (15min-1hr)
    GH->>AWS: API calls with temporary credentials

IAM role trust policy:

{
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::123456789: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:my-org/my-repo:*"
      }
    }
  }]
}

Same handshake, one step at a time:

1. Workflow requests a token. configure-aws-credentials asks GitHub's own OIDC provider for a token scoped to this specific workflow run.
2. GitHub mints a short-lived JWT. It embeds claims like the repo, branch/ref, and commit SHA, and expires quickly. No long-lived secret is involved on GitHub's side at all.
3. GitHub calls AssumeRoleWithWebIdentity. It sends that JWT plus the target role ARN to AWS STS.
4. STS validates the token. AWS checks the JWT's signature against GitHub's OIDC endpoint, then evaluates the role's trust policy conditions (aud, sub) before deciding whether to trust it.
5. STS returns temporary credentials. Valid for somewhere between 15 minutes and an hour, scoped to exactly the permissions on the assumed role.
6. The workflow calls AWS with those credentials. No AWS access key or secret key was ever stored in GitHub — only this run's short-lived token existed, and only briefly.

Why doesn't OIDC require storing any AWS access keys in GitHub secrets?


Matrix Builds

Run the same job across multiple combinations:

jobs:
  test:
    strategy:
      matrix:
        go-version: ["1.21", "1.22", "1.23"]
        os: [ubuntu-latest, macos-latest]
      fail-fast: false   # don't cancel other matrix jobs if one fails
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
      - run: go test ./...

This matrix runs 6 jobs (3 Go versions × 2 OSes) with fail-fast: false. If the ubuntu-latest / 1.21 job fails, what happens to the other five?


Caching

Two ways to cache Go's module and build cache, same end result:

Full control over the cache key and paths — useful when you need a custom key strategy, or want to cache something setup-go doesn't know about.

# Cache Go modules (keyed by go.sum hash)
- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
    key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
    restore-keys: |
      ${{ runner.os }}-go-

Same result, zero cache-key bookkeeping — setup-go caches Go's module and build caches for you, keyed on go.sum automatically.

# Or just use setup-go with cache: true (handles it automatically)
- uses: actions/setup-go@v5
  with:
    go-version: "1.23"
    cache: true

Docker layer caching:

- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha          # restore from GH Actions cache
    cache-to: type=gha,mode=max   # save all layers (mode=max)

Reusable Workflows

Define a workflow once, call it from many others — like a function call for CI pipelines.

# .github/workflows/reusable-deploy.yml
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
    secrets:
      DEPLOY_ROLE_ARN:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
          aws-region: us-east-1
      - run: deploy.sh --env ${{ inputs.environment }}
# .github/workflows/deploy-prod.yml — caller
jobs:
  deploy:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
    secrets:
      DEPLOY_ROLE_ARN: ${{ secrets.PROD_DEPLOY_ROLE_ARN }}

What line in reusable-deploy.yml itself makes it callable as a reusable workflow, instead of running on its own push/PR triggers?


Useful Patterns

# Run only on specific file changes
on:
  push:
    paths:
      - 'src/**'
      - 'go.mod'
      - '!docs/**'   # exclude docs changes

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

# Conditional step
- name: Deploy
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  run: deploy.sh

# Set output from a step
- id: version
  run: echo "tag=$(git describe --tags)" >> $GITHUB_OUTPUT

- run: echo "Deploying ${{ steps.version.outputs.tag }}"

# Use GitHub secrets
- run: deploy.sh --token ${{ secrets.DEPLOY_TOKEN }}