GitHub Actions
CI/CD pipelines defined as YAML in .github/workflows/, triggered by repo events, and run on GitHub-hosted or self-hosted runners.
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) orrun(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?
needs: to a job makes it wait for the referenced job(s) to finish first, turning that pair (or chain) sequential — that's exactly how test → build → deploy is wired in the full CI workflow below.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:
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.
go test -race, uploads the coverage artifact. If any step here fails, the job fails and nothing downstream ever starts.
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.
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?
build declares needs: test, so it only starts if test succeeds — and deploy declares needs: build, so it never gets a chance to run either. A failure anywhere in the chain stops everything downstream of it.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:
configure-aws-credentials asks GitHub's own OIDC provider for a token scoped to this specific workflow run.
aud, sub) before deciding whether to trust it.
Why doesn't OIDC require storing any AWS access keys in GitHub secrets?
AssumeRoleWithWebIdentity, after validating it against the role's trust policy — for temporary credentials. There's no long-lived access key that ever needs to exist as a stored secret in the first place.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?
fail-fast: false means one matrix job failing doesn't touch the others. Without it — fail-fast: true is the default — a single failure cancels every other still-running job in the matrix immediately.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?
on: workflow_call. That's what lets another workflow reference it with uses: ./.github/workflows/reusable-deploy.yml — it doesn't have (or need) its own push/pull_request trigger, and the caller supplies its inputs and secrets directly.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 }}