---
title: "Advanced GitHub Actions Patterns: Matrix Builds, OIDC, Composite Actions, and Self-Hosted Runners"
description: "Production-grade GitHub Actions patterns — matrix strategies, conditional execution, environment protection, composite actions, OIDC cloud authentication, path filtering, and self-hosted runner management."
url: https://agent-zone.ai/knowledge/cicd/github-actions-patterns/
section: knowledge
date: 2026-02-22
categories: ["cicd"]
tags: ["github-actions","ci","cd","oidc","matrix","composite-actions"]
skills: ["ci-pipeline-design","github-actions-authoring","cloud-auth-patterns"]
tools: ["github-actions","aws","gcp","azure"]
levels: ["intermediate"]
word_count: 897
formats:
  json: https://agent-zone.ai/knowledge/cicd/github-actions-patterns/index.json
  html: https://agent-zone.ai/knowledge/cicd/github-actions-patterns/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Advanced+GitHub+Actions+Patterns%3A+Matrix+Builds%2C+OIDC%2C+Composite+Actions%2C+and+Self-Hosted+Runners
---


# Advanced GitHub Actions Patterns

Once you understand the basics of GitHub Actions, these patterns solve the real-world problems: testing across multiple environments, authenticating to cloud providers without static secrets, building reusable action components, and scaling runners.

## Matrix Builds

Test across multiple OS versions, language versions, or configurations in parallel:

```yaml
jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        go-version: ['1.22', '1.23']
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ matrix.go-version }}
      - run: go test ./...
```

This creates 4 jobs (2 OS x 2 Go versions) running in parallel. Set `fail-fast: false` so a failure in one combination does not cancel the others -- you want to see all failures at once.

**Include and exclude** for fine-grained control:

```yaml
strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    node: [18, 20, 22]
    exclude:
      - os: windows-latest
        node: 18
    include:
      - os: ubuntu-latest
        node: 22
        experimental: true
```

`exclude` removes specific combinations. `include` adds extra combinations or properties. In this example, the `experimental` property is accessible as `${{ matrix.experimental }}` in steps.

## Conditional Execution

Control when jobs and steps run using `if` expressions:

```yaml
jobs:
  deploy:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: [test, lint]
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to production
        if: success()
        run: ./deploy.sh

      - name: Notify on failure
        if: failure()
        run: curl -X POST "$SLACK_WEBHOOK" -d '{"text":"Deploy failed"}'
```

Useful conditions:
- `success()` -- default, runs if all previous steps succeeded.
- `failure()` -- runs only if a previous step failed.
- `always()` -- runs regardless of status (cleanup steps).
- `cancelled()` -- runs if the workflow was cancelled.
- `contains(github.event.pull_request.labels.*.name, 'deploy')` -- check PR labels.

**Job dependencies** with `needs`:

```yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]

  lint:
    runs-on: ubuntu-latest
    steps: [...]

  deploy:
    needs: [test, lint]       # Waits for both to succeed
    runs-on: ubuntu-latest
    steps: [...]

  notify:
    needs: [deploy]
    if: always()               # Runs even if deploy failed
    runs-on: ubuntu-latest
    steps: [...]
```

## Environment Protection Rules

Environments add approval gates, wait timers, and scoped secrets:

```yaml
jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - run: ./deploy.sh staging

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.com
    steps:
      - run: ./deploy.sh production
```

Configure environment protection rules in GitHub settings:
- **Required reviewers**: one or more people must approve before the job runs.
- **Wait timer**: delay execution by N minutes (useful for canary deployments).
- **Deployment branches**: restrict which branches can deploy to this environment.

Environment-scoped secrets override repository secrets of the same name, so `production` can have a different `DEPLOY_TOKEN` than `staging`.

## Composite Actions

Package multiple steps into a reusable action. Unlike reusable workflows, composite actions run within the calling job -- same runner, same filesystem.

```yaml
# .github/actions/setup-and-test/action.yml
name: 'Setup and Test'
description: 'Install deps, run lint and test'
inputs:
  go-version:
    description: 'Go version'
    required: true
    default: '1.23'
runs:
  using: 'composite'
  steps:
    - uses: actions/setup-go@v5
      with:
        go-version: ${{ inputs.go-version }}
    - name: Install dependencies
      shell: bash
      run: go mod download
    - name: Lint
      shell: bash
      run: golangci-lint run
    - name: Test
      shell: bash
      run: go test -race -coverprofile=coverage.out ./...
```

Use it in a workflow:

```yaml
steps:
  - uses: actions/checkout@v4
  - uses: ./.github/actions/setup-and-test
    with:
      go-version: '1.23'
```

Every `run` step in a composite action must specify `shell`. This is required by GitHub Actions for composite actions and is easy to forget.

## OIDC for Cloud Authentication

OpenID Connect lets GitHub Actions authenticate to cloud providers without storing static credentials. GitHub issues a short-lived JWT, and the cloud provider verifies it.

**AWS:**

```yaml
permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-actions
      aws-region: us-east-1
  - run: aws s3 ls
```

Configure the AWS IAM role trust policy to accept tokens from GitHub:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Federated": "arn:aws:iam::123456789012: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:myorg/myrepo:*"
      }
    }
  }]
}
```

**GCP:**

```yaml
steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/github'
      service_account: 'github-actions@myproject.iam.gserviceaccount.com'
  - uses: google-github-actions/setup-gcloud@v2
  - run: gcloud compute instances list
```

**Azure:**

```yaml
steps:
  - uses: azure/login@v2
    with:
      client-id: ${{ secrets.AZURE_CLIENT_ID }}
      tenant-id: ${{ secrets.AZURE_TENANT_ID }}
      subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
  - run: az webapp list
```

OIDC eliminates the risk of leaked static credentials. The `sub` claim in the condition restricts which repositories and branches can assume the role.

## Path Filtering for Monorepos

Trigger different workflows based on which files changed:

```yaml
on:
  push:
    paths:
      - 'services/api/**'
      - 'shared/lib/**'
      - '.github/workflows/api-ci.yml'
```

For matrix-based monorepo CI, use `dorny/paths-filter`:

```yaml
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'services/api/**'
            web:
              - 'services/web/**'

  api-test:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Testing API"
```

## Self-Hosted Runners

For workloads that need specific hardware, network access to private resources, or faster startup:

```bash
# Download and configure a runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
./config.sh --url https://github.com/myorg/myrepo --token AXXXX
./run.sh
```

**Security considerations:** self-hosted runners persist between jobs. A malicious workflow in a fork can access the runner's filesystem, network, and credentials. Never use self-hosted runners with public repositories that accept PRs from forks.

**Autoscaling with actions-runner-controller (ARC):** deploy runners as Kubernetes pods that scale based on workflow demand:

```yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: ci-runners
spec:
  replicas: 1
  template:
    spec:
      repository: myorg/myrepo
      labels:
        - self-hosted
        - linux
---
apiVersion: actions.summerwind.dev/v1alpha1
kind: HorizontalRunnerAutoscaler
metadata:
  name: ci-runners-autoscaler
spec:
  scaleTargetRef:
    name: ci-runners
  minReplicas: 1
  maxReplicas: 10
  scaleUpTriggers:
    - githubEvent:
        workflowJob: {}
      duration: "30m"
```

ARC spins up runners when jobs queue and scales down when idle. This avoids paying for idle self-hosted infrastructure while keeping job startup fast.

