DevOps

GitHub Actions Advanced: Reusable Workflows, Composite Actions & OIDC 2026

Basic GitHub Actions workflows are straightforward — but large engineering organisations quickly hit the limits of copy-pasted YAML across dozens of repositories. Reusable workflows, composite actions, OIDC keyless authentication, and matrix strategies are the advanced patterns that separate maintainable, secure CI/CD at scale from a tangled mess of duplicated configuration.

Md Sanwar Hossain April 8, 2026 26 min read GitHub Actions
GitHub Actions advanced patterns: reusable workflows, composite actions, OIDC authentication

TL;DR — The Three Advanced Patterns You Must Know

"Reusable workflows eliminate duplicated CI/CD logic across repos. OIDC eliminates long-lived secrets from your workflows entirely. Composite actions bundle multi-step logic into reusable units without spinning up containers or running Node. Together, they enable enterprise-grade CI/CD that is DRY, secure, and maintainable."

Table of Contents

  1. Reusable Workflows (workflow_call)
  2. Composite Actions: Bundle Steps as Reusable Units
  3. Custom Actions: JavaScript and Docker
  4. OIDC Keyless Authentication: AWS, GCP & Azure
  5. Matrix Strategies: Parallel Multi-Dimension Testing
  6. Environments, Protection Rules & Secrets
  7. Caching: Maven, npm, Go, and Custom Caches
  8. Artifacts: Sharing Build Outputs Between Jobs
  9. Concurrency Control and Cancel-in-Progress
  10. Workflow Security Hardening
  11. Best Practices & Enterprise Patterns

1. Reusable Workflows (workflow_call)

Reusable workflows let you define a workflow in one repository and call it from many others. They are triggered by the workflow_call event and accept inputs, secrets, and can return outputs. This is the primary mechanism for standardising CI/CD across multiple repositories in an organization.

Defining a Reusable Workflow (the "called" workflow)

# .github/workflows/deploy-java-service.yml
# In: org/shared-workflows repository

on:
  workflow_call:
    inputs:
      service-name:
        required: true
        type: string
        description: "Name of the Java service to deploy"
      environment:
        required: true
        type: string
        description: "Target environment: staging or production"
      java-version:
        required: false
        type: string
        default: "21"
    secrets:
      ECR_ROLE_ARN:
        required: true
        description: "AWS IAM role ARN for ECR push"
    outputs:
      image-tag:
        description: "Docker image tag that was deployed"
        value: ${{ jobs.deploy.outputs.image-tag }}

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ inputs.java-version }}
          distribution: temurin
          cache: maven
      - run: mvn verify -DskipITs=false

  deploy:
    needs: build-and-test
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      image-tag: ${{ steps.build-image.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.ECR_ROLE_ARN }}
          aws-region: ap-southeast-1
      - name: Build and push Docker image
        id: build-image
        run: |
          TAG="${{ inputs.service-name }}-$(git rev-parse --short HEAD)"
          docker build -t $ECR_URI:$TAG .
          docker push $ECR_URI:$TAG
          echo "tag=$TAG" >> $GITHUB_OUTPUT

Calling a Reusable Workflow (the "caller" workflow)

# .github/workflows/ci.yml
# In: org/order-service repository

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

jobs:
  deploy-staging:
    uses: org/shared-workflows/.github/workflows/deploy-java-service.yml@main
    with:
      service-name: order-service
      environment: staging
      java-version: "21"
    secrets:
      ECR_ROLE_ARN: ${{ secrets.ECR_ROLE_ARN }}

  deploy-production:
    needs: deploy-staging
    if: github.ref == 'refs/heads/main'
    uses: org/shared-workflows/.github/workflows/deploy-java-service.yml@main
    with:
      service-name: order-service
      environment: production
    secrets: inherit   # pass all caller secrets to the called workflow

Reusable Workflow Rules and Limitations

GitHub Actions advanced patterns: reusable workflows, composite actions, OIDC, matrix, environments
GitHub Actions advanced patterns overview. Source: mdsanwarhossain.me

2. Composite Actions: Bundle Steps as Reusable Units

Composite actions bundle multiple steps into a single reusable unit that can be used as a step in any workflow. Unlike reusable workflows, composite actions don't spin up a new runner — they run in the calling job's runner environment. This makes them faster and simpler for reusable setup logic.

# .github/actions/setup-java-env/action.yml
# A composite action for Java environment setup

name: "Setup Java Environment"
description: "Set up JDK, Maven cache, and tool versions"
inputs:
  java-version:
    description: "JDK version to install"
    required: false
    default: "21"
  maven-args:
    description: "Extra Maven arguments"
    required: false
    default: ""
outputs:
  java-home:
    description: "Path to Java home directory"
    value: ${{ steps.setup-java.outputs.path }}

runs:
  using: "composite"
  steps:
    - name: Set up JDK
      id: setup-java
      uses: actions/setup-java@v4
      with:
        java-version: ${{ inputs.java-version }}
        distribution: temurin
        cache: maven

    - name: Print Java version
      shell: bash
      run: java -version

    - name: Configure Maven settings
      shell: bash
      run: |
        mkdir -p ~/.m2
        cat > ~/.m2/settings.xml <<EOF
        <settings>
          <mirrors>
            <mirror>
              <id>central</id>
              <url>https://repo.maven.apache.org/maven2</url>
              <mirrorOf>*</mirrorOf>
            </mirror>
          </mirrors>
        </settings>
        EOF

Using the Composite Action in a Workflow

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

      # Use composite action from same repo:
      - uses: ./.github/actions/setup-java-env
        with:
          java-version: "21"

      # Use composite action from another repo (pinned to SHA):
      - uses: org/shared-actions/.github/actions/setup-java-env@abc1234
        with:
          java-version: "21"

      - run: mvn verify

Composite Actions vs Reusable Workflows: Decision Guide

Use Case Composite Action Reusable Workflow
Environment setup (JDK, Python, Node) ✅ Ideal Overkill
Full build-test-deploy pipeline Not suitable ✅ Ideal
Shared scanning / linting step ✅ Good fit Can work but heavier
Environment protection (approvals) Not available ✅ Via environment:
Parallel matrix jobs Not available ✅ With strategy.matrix

3. Custom Actions: JavaScript and Docker

When composite actions aren't powerful enough — you need to call APIs, parse complex outputs, or run platform-independent logic — custom JavaScript or Docker container actions are the answer.

JavaScript Action Structure

# action.yml
name: "Notify Slack on Deploy"
description: "Posts deploy status to Slack"
inputs:
  slack-token:
    required: true
  channel:
    required: true
  message:
    required: true
  status:
    required: false
    default: "success"
outputs:
  message-ts:
    description: "Slack message timestamp for threading"
runs:
  using: "node20"
  main: "dist/index.js"   # must be bundled — no node_modules at runtime

# src/index.js (simplified)
const core = require("@actions/core");
const { WebClient } = require("@slack/web-api");

async function run() {
  const token = core.getInput("slack-token", { required: true });
  const channel = core.getInput("channel");
  const message = core.getInput("message");
  const status = core.getInput("status");

  const icon = status === "success" ? ":white_check_mark:" : ":x:";
  const client = new WebClient(token);

  const result = await client.chat.postMessage({
    channel,
    text: `${icon} ${message}`,
    blocks: [{ type: "section", text: { type: "mrkdwn", text: `${icon} *${message}*` } }]
  });

  core.setOutput("message-ts", result.ts);
}

run().catch(core.setFailed);

Docker Container Action

# action.yml for Docker action
name: "Run Integration Tests in Container"
runs:
  using: "docker"
  image: "Dockerfile"   # or a public Docker image
  args:
    - ${{ inputs.test-suite }}

# Dockerfile
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

# entrypoint.sh
#!/bin/bash
echo "Running test suite: $1"
mvn test -Dtest="$1" -pl integration-tests

4. OIDC Keyless Authentication: AWS, GCP & Azure

OpenID Connect (OIDC) is the most significant GitHub Actions security improvement in recent years. Instead of storing long-lived cloud credentials as repository secrets, GitHub issues a short-lived JWT token per workflow run, which can be exchanged for temporary cloud credentials. The token expires automatically, is scoped to the exact repository and branch, and leaves no static secrets to rotate or leak.

OIDC Flow Diagram

OIDC with AWS: Setup and Usage

# 1. Create OIDC Identity Provider in AWS IAM (one time):
# Provider URL: https://token.actions.githubusercontent.com
# Audience: sts.amazonaws.com

# 2. Create an IAM role with trust policy:
# (terraform example)
resource "aws_iam_role" "github_actions_deploy" {
  name = "github-actions-deploy-order-service"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          # Restrict to specific repo and branch:
          "token.actions.githubusercontent.com:sub" = "repo:org/order-service:ref:refs/heads/main"
        }
      }
    }]
  })
}

# 3. Use in GitHub Actions workflow (NO secret needed!):
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write    # REQUIRED for OIDC token
      contents: read
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy-order-service
          aws-region: ap-southeast-1
          role-session-name: github-actions-${{ github.run_id }}
      - run: aws s3 ls   # uses temporary credentials automatically

OIDC with GCP: Workload Identity Federation

jobs:
  deploy-gcp:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - id: auth
        name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/123/locations/global/workloadIdentityPools/pool/providers/github
          service_account: deploy@my-project.iam.gserviceaccount.com
      - uses: google-github-actions/setup-gcloud@v2
      - run: gcloud run deploy order-service --image gcr.io/my-project/order-service

5. Matrix Strategies: Parallel Multi-Dimension Testing

Matrix strategies let a single job definition spawn multiple parallel jobs with different variable combinations — perfect for cross-platform testing, multi-version support, and shard-based test parallelism.

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        java: ["17", "21"]
        # Exclude specific combinations:
        exclude:
          - os: macos-latest
            java: "17"
        # Add extra variables to specific combinations:
        include:
          - os: ubuntu-latest
            java: "21"
            run-integration: true
      fail-fast: false    # don't cancel other jobs if one fails
      max-parallel: 4     # limit concurrent runners
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: temurin
      - run: mvn test
      - if: ${{ matrix.run-integration }}
        run: mvn verify -P integration-tests

Dynamic Matrix: Generating Matrix from Script

jobs:
  # Job 1: Generate the matrix dynamically
  setup-matrix:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.generate.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: generate
        run: |
          # Discover changed services dynamically
          SERVICES=$(git diff --name-only HEAD~1 HEAD | \
            grep '^services/' | cut -d/ -f2 | sort -u | \
            jq -R -s -c 'split("\n") | map(select(length > 0))')
          echo "matrix={\"service\":$SERVICES}" >> $GITHUB_OUTPUT

  # Job 2: Deploy only changed services
  deploy:
    needs: setup-matrix
    if: ${{ needs.setup-matrix.outputs.matrix != '{"service":[]}' }}
    strategy:
      matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ matrix.service }}"

6. Environments, Protection Rules & Secrets

Environments add governance controls to deployment workflows. You can require human approval before deploying to production, restrict deployments to specific branches, and scope secrets to individual environments instead of the whole repository.

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://api.myservice.com   # shown in GitHub UI after deploy
    steps:
      # This job pauses here until required reviewers approve in GitHub UI
      - uses: actions/checkout@v4
      - run: ./deploy.sh ${{ secrets.PROD_DB_URL }}
      # secrets.PROD_DB_URL is scoped to "production" environment only
      # Not accessible to staging jobs or other workflows

Environment Protection Rule Best Practices

7. Caching: Maven, npm, Go, and Custom Caches

Effective caching can cut GitHub Actions build times by 60-80%. The actions/cache action stores directories between runs and restores them based on a cache key.

# Maven cache (also available as built-in via setup-java cache: maven)
- uses: actions/cache@v4
  with:
    path: ~/.m2/repository
    key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
    restore-keys: |
      maven-${{ runner.os }}-

# npm cache:
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: npm-${{ runner.os }}-

# Go module cache:
- uses: actions/cache@v4
  with:
    path: ~/go/pkg/mod
    key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}

# Custom Docker layer cache:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha     # GitHub Actions cache for Docker layers
    cache-to: type=gha,mode=max

# Cache key best practices:
# 1. Include OS in key for cross-platform jobs
# 2. Hash the lockfile (pom.xml, package-lock.json) as the key
# 3. Use restore-keys as fallback to avoid total cache miss
# 4. Cache size limit: 10 GB per repo; evicted after 7 days of no use

8. Artifacts: Sharing Build Outputs Between Jobs

Artifacts persist files between jobs in the same workflow run and can be downloaded manually from the GitHub Actions UI. Use them for test reports, binaries, coverage reports, and deployment manifests.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: mvn package -DskipTests
      - uses: actions/upload-artifact@v4
        with:
          name: order-service-jar
          path: target/order-service-*.jar
          retention-days: 30   # keep for 30 days (default: 90)

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: mvn test
      - uses: actions/upload-artifact@v4
        if: always()    # upload even if tests fail
        with:
          name: test-results
          path: |
            target/surefire-reports/
            target/site/jacoco/

  deploy:
    needs: [build, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: order-service-jar
          path: ./deploy
      - run: ls ./deploy  # jar file available here

9. Concurrency Control and Cancel-in-Progress

Without concurrency controls, every push to a PR triggers a new workflow run, leaving old runs queued or running in parallel. The concurrency key limits runs and optionally cancels in-progress ones.

# Cancel previous in-progress run for the same PR:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# For deployments: queue but don't cancel (use false):
concurrency:
  group: deploy-production
  cancel-in-progress: false  # wait for current deploy to finish

# Per-PR + per-workflow granularity:
concurrency:
  group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
  # Cancel in-progress for PRs, but not for main branch deploys

10. Workflow Security Hardening

GitHub Actions workflows are attack surfaces. Follow these hardening practices to prevent supply chain attacks, secret leakage, and privilege escalation:

# 1. Pin actions to full commit SHA (not tags that can be moved):
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

# 2. Restrict permissions at workflow and job level:
permissions:
  contents: read     # minimal: read-only access to repo
  packages: write    # only grant what this workflow needs

# 3. Use OIDC instead of long-lived secrets (see Section 4)

# 4. Avoid using pull_request_target with untrusted code:
# pull_request_target has write permissions and access to secrets.
# Never checkout and run untrusted PR code in pull_request_target.

# 5. Sanitise inputs in run: steps (injection prevention):
- name: Safe input usage
  run: |
    # DANGEROUS — direct interpolation allows injection:
    echo "Hello ${{ github.event.issue.title }}"
    # SAFE — pass through environment variable:
    TITLE="${{ github.event.issue.title }}"
    echo "Hello ${TITLE}"

# 6. Use GitHub's security features:
# - Require code review for workflow changes
# - Enable "Require approval for all outside collaborators"
# - Enable secret scanning to catch leaked tokens in workflow files

11. Best Practices & Enterprise Patterns

Repository Organisation

  • Create a dedicated shared-workflows repository in your org
  • Version reusable workflows with semantic tags (v1, v2)
  • Keep reusable workflows generic via inputs, not hardcoded
  • Document each workflow's inputs, outputs, and required secrets

Runner Performance

  • Use the built-in cache: input of setup actions
  • Split large test suites across matrix shards
  • Use self-hosted runners for heavy builds (eliminates cold start)
  • Set timeout-minutes on all jobs to prevent runaway builds

Secret Management

  • Always prefer OIDC over static secrets for cloud providers
  • Use environment-scoped secrets for production credentials
  • Enable secret scanning with push protection on all repos
  • Rotate any secret that appears in workflow logs

Observability

  • Use ::group:: and ::endgroup:: to fold log output
  • Emit ::notice::, ::warning::, ::error:: annotations
  • Use GitHub's built-in deployment tracking via environment.url
  • Monitor workflow minutes with GitHub's billing insights

GitHub Actions Mastery Checklist

GitHub Actions Reusable Workflows OIDC CI/CD DevOps GitHub

Leave a Comment

Related Posts

DevOps

CI/CD with GitHub Actions

Build a complete Java microservices pipeline with GitHub Actions from first principles.

Software Dev

GitHub Security: Dependabot, CodeQL & Secret Scanning

Complete supply chain security guide for GitHub repositories in 2026.

DevOps

GitOps with ArgoCD: Kubernetes CD at Scale

Deploy Kubernetes workloads declaratively with ArgoCD and GitOps principles.

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices · AI/LLM Systems

All Posts
Back to Blog
Last updated: April 8, 2026