GitHub Actions Reusable Patterns Reference Guide
Source: Synthesis of 14 open source projects across 326 workflow files: Checkov, dep-scan, Gitleaks, Grype, Insomnia, Minimal, Nextcloud Server, oCIS, OpenTofu, Threat Dragon, Traefik, Wazuh, ZAP, and agent-scan.
Overview
GitHub Actions has become the lingua franca of CI/CD. Every project in our sample uses it, but no two use it the same way. This guide extracts the most practical, battle-tested patterns from 14 production codebases — from the minimal (Gitleaks, 3 workflows) to the enterprise (Wazuh, 137 workflows).
Each pattern includes concrete examples and guidance on when to adopt it. The goal is not to be exhaustive — GitHub’s docs do that — but to show how mature projects actually compose these features.
Key Findings
1. Reusable Workflows (workflow_call)
Reusable workflows let you define CI logic once and call it from multiple workflows. We found 13 definitions and 24 call sites across the sample.
When to use: You have identical CI logic triggered by different events (e.g., security scans on both PRs and pushes, but the PR path requires manual approval).
Best example — Checkov’s security scan split:
Checkov defines security-shared.yml with on: workflow_call containing bandit, trufflehog, and checkov secret scans. Two callers invoke it differently:
# .github/workflows/security.yml (PR path — requires approval)
jobs:
start-security-scan:
environment: scan-security # manual approval gate
runs-on: ubuntu-latest
steps:
- run: echo "Security scan approved"
security:
needs: start-security-scan
uses: ./.github/workflows/security-shared.yml
secrets: inherit
# .github/workflows/build.yml (push path — automatic)
jobs:
security:
uses: ./.github/workflows/security-shared.yml
secrets: inheritThis is elegant: one security definition, two different approval workflows.
Cross-organization reusable workflows:
Grype calls workflows from a separate anchore/workflows repository, pinned by SHA:
jobs:
version-available:
uses: anchore/workflows/.github/workflows/check-version-available.yaml@8b2b1caf
with:
version: ${{ github.event.inputs.version }}
check-gate:
uses: anchore/workflows/.github/workflows/check-gate.yaml@8b2b1caf
with:
checks: ["Acceptance tests (Linux)", "Acceptance tests (Mac)", ...]This is the gold standard for organizations with multiple repos — extract shared logic into a dedicated workflows repo and pin by SHA.
Wazuh’s parameterized builders:
Wazuh defines reusable workflows with heavy parameterization for package building:
on:
workflow_call:
inputs:
docker_image_tag:
type: string
architecture:
type: string
system:
type: string
revision:
type: string
is_stage:
type: boolean
debug:
type: boolean
checksum:
type: boolean
id:
type: string8 inputs per workflow call — the tradeoff is flexibility vs. complexity. This makes sense for Wazuh’s multi-platform, multi-architecture build matrix but would be overkill for simpler projects.
oCIS’s organizational pattern:
oCIS calls organizational reusable workflows for non-core concerns:
jobs:
sync:
uses: owncloud/reusable-workflows/.github/workflows/translation-sync.yml@main
with:
mode: make
reviewers: kobergj,mmattelPattern: Extract cross-cutting concerns (translation sync, release publishing, security scanning) into reusable workflows. Keep them in a shared repo pinned by SHA, not tag.
2. Composite Actions
Composite actions bundle multiple steps into a single reusable action defined in .github/actions/<name>/action.yml. We found 29 composite actions across the sample — 28 from Wazuh and 1 from Grype.
When to use: You repeat the same 3-5 setup steps across multiple workflows in the same repo (install dependencies, configure tools, etc.).
Best example — Grype’s bootstrap action:
# .github/actions/bootstrap/action.yaml
name: "Bootstrap"
description: "Bootstrap all tools and dependencies"
inputs:
go-version:
default: "1.26.2"
python-version:
default: "3.11"
go-dependencies:
default: "true"
cache-key-prefix:
default: "1ac8281053"
compute-fingerprints:
default: "true"
tools:
default: "true"
bootstrap-apt-packages:
default: "libxml2-utils"
cache-test-fixtures:
default: "false"
runs:
using: "composite"
steps:
- uses: actions/setup-go@v6
if: inputs.go-version != ''
with:
go-version: ${{ inputs.go-version }}
check-latest: true
- uses: actions/setup-python@v6
with:
python-version: ${{ inputs.python-version }}
- name: Restore tool cache
uses: actions/cache@v5
if: inputs.tools == 'true'
with:
path: ${{ github.workspace }}/.tool
key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-tool-${{ hashFiles('.binny.yaml') }}
- name: Install go dependencies
if: inputs.go-dependencies == 'true'
shell: bash
run: make ci-bootstrap-go
# ... more stepsThis single action replaces 8 identical setup steps across every Grype workflow. Every workflow now starts with:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/bootstrap
with:
cache-test-fixtures: "true"Wazuh’s domain-specific actions:
Wazuh has 28 composite actions for C/C++ build operations: compile, compile_and_test, coverage, coverage_cpp, clang_format, build_test_flags, doxygen, etc. These wrap multi-step CMake + gcc + gcov operations into single action calls.
Key Wazuh examples:
check_files(8 steps): Validates installed files, ownership, permissions, and sizes after package installationbuild_external_deps(6 steps): Installs all C/C++ external dependencies for compilationcoverage(6 steps): Runs gcov, filters results, generates reports with threshold enforcement
Pattern: Create a composite action when you copy-paste 3+ steps across workflows. Grype's bootstrap pattern — configurable setup with sensible defaults — is the template to follow.
3. Concurrency Control
Concurrency prevents duplicate workflow runs from wasting resources. We found 112 instances across the sample.
The dominant pattern — PR-scoped with fallback:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: trueThis was used 37 times (33% of all concurrency configs). The || github.run_id fallback ensures that pushes to branches (without PR numbers) each get their own group instead of canceling each other.
Three concurrency strategies, ranked by frequency:
- PR-scoped with fallback (37x) —
${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}— Cancels superseded PR runs, never cancels branch pushes - Branch-scoped (13x) —
${{ github.workflow }}-${{ github.ref }}— Cancels any superseded run on the same branch - Head-branch scoped with fallback (11x) —
${{ github.workflow }}-${{ github.head_ref || github.run_id }}— Similar to PR-scoped but uses the source branch name
Static groups for critical workflows:
# Grype — only one release at a time
concurrency:
group: release
# Checkov — only one build pipeline
concurrency:
group: build
cancel-in-progress: truecancel-in-progress behavior:
truein 103 cases (92%) — standard for PRs and non-release workflowsfalsein 5 cases — used for GitHub Pages deploys and long-running publish workflows where interruption causes issues
Pattern: Always set concurrency on PR-triggered workflows. Use the PR-number-with-fallback pattern unless you have a reason not to. For release workflows, use a static group name to prevent parallel releases.
4. Path-Based Triggers
Path triggers skip workflows when irrelevant files change. We found 67 instances across the sample.
When to use: Your repo has components that change independently and each has its own test suite.
Traefik’s targeted testing:
on:
pull_request:
paths:
- .github/workflows/test-gateway-api-conformance.yaml
- pkg/provider/kubernetes/gateway/**
- integration/fixtures/gateway-api-conformance/**
- integration/gateway_api_conformance_test.go
- integration/integration_test.goEach of Traefik’s test workflows watches a specific set of source paths. A change to pkg/provider/kubernetes/knative/** triggers the Knative conformance tests but not the Gateway API tests.
Wazuh’s component-level granularity:
Wazuh has the most sophisticated path triggers in the sample. Each component gets its own workflow with precise paths:
# vulnerability-scanner unit tests — only when scanner code changes
on:
pull_request:
paths:
- .github/workflows/5_testunit_vulnerability-scanner.yml
- src/wazuh_modules/vulnerability_scanner/**
- src/shared_modules/utils/**
# API RBAC analysis — only when RBAC defaults change
on:
pull_request:
paths:
- .github/workflows/5_codeanalysis_api-rbac-db-version.yml
- framework/wazuh/rbac/default/**
- framework/wazuh/rbac/orm.pySelf-referencing pattern:
Every project that uses path triggers includes the workflow file itself in the paths list. This ensures workflow changes always trigger a validation run:
paths:
- .github/workflows/this-workflow.yml # always include self
- src/matching-code/**Pattern: Include the workflow file itself in path triggers so workflow modifications are always tested. For monorepos, use component-level path granularity to keep CI fast.
5. Environment Protection Rules
Environments add manual approval gates and branch restrictions. We found 8 instances — concentrated in Checkov (4) and OpenTofu (1).
When to use: Release workflows, security-sensitive operations, or anything that needs human approval before execution.
Checkov’s dual-environment approach:
# PR security scans — require manual approval
jobs:
start-security-scan:
environment: scan-security # humans must approve
runs-on: ubuntu-latest
steps:
- run: echo "approved"
# Version bumps — restricted to release branch
jobs:
bump-version:
environment: release
runs-on: [self-hosted, public, linux, x64]OpenTofu’s GPG environment:
jobs:
release:
environment: gpg # controls access to GPG signing keys
runs-on: larger-runners
steps:
- name: Import GPG key
# GPG key only available within the gpg environment
- name: Run GoReleaser
- name: Remove GPG key # cleanup immediately after useThe gpg environment restricts access to the GPG private key used for signing release artifacts. The key is imported, used by GoReleaser, and removed — never persisted on disk.
Pattern: Use environments for release gates, secret scoping, and approval workflows. Don't overuse them — most CI jobs don't need protection rules.
6. Matrix Strategies
Matrix strategies run jobs across combinations of OS, language version, architecture, etc. We found 33 advanced matrix configurations (with include/exclude).
dep-scan’s cross-platform Python matrix:
strategy:
matrix:
os: [ubuntu-latest, macos-26, windows-latest]
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']This generates 15 combinations (3 OS × 5 Python versions). dep-scan also uses separate jobs with smaller matrices for specific test types:
# Docker tests — only Ubuntu + all Python versions
matrix:
os: [ubuntu-latest]
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
# Mac/Win tests — only latest Python
matrix:
os: [macos-26, windows-latest]
python-version: ['3.12']dep-scan’s rich matrix for Docker image builds:
strategy:
matrix:
image:
- id: default-amd64
arch: amd64
dockerfile: Dockerfile
- id: default-arm64
arch: arm64
runner-suffix: -arm
dockerfile: Dockerfile
- id: al9-amd64
arch: amd64
dockerfile: Dockerfile.al9
- id: al9-arm64
arch: arm64
runner-suffix: -arm
dockerfile: Dockerfile.al9Matrix entries are full objects — each defines id, arch, dockerfile, and runner-suffix. This is a powerful pattern for building multiple image variants from a single workflow.
Insomnia’s include-only matrix for release artifacts:
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
- os: windows-2022
- os: ubuntu-24.04No axes — just an explicit list of runners. This is simpler than defining an OS axis with only these three values, and it’s clearer about intent.
fail-fast behavior:
falsein most release and publish workflows — don’t cancel other platforms if one failstrue(or omitted) in test workflows — stop wasting resources if a fundamental issue is found
Pattern: Use include-only matrices for heterogeneous job configs (different architectures, different Dockerfiles). Use axis-based matrices for homogeneous testing (OS × version). Always set
fail-fast: falseon release workflows.
7. Artifact Flow Patterns
Artifacts pass data between jobs. We found 272 artifact upload/download operations across the sample.
Cross-job test results (dep-scan):
dep-scan uses a named artifact pattern where platform-specific test results flow from build jobs to downstream consumers:
# Build job — upload with matrix-parameterized name
- uses: actions/upload-artifact@v4
with:
name: containertests_${{ matrix.os }}_python${{ matrix.python-version }}
path: test-results/
# Consumer job — download specific artifact
- uses: actions/download-artifact@v4
with:
name: containertests_ubuntu-latest_python3.11Build→publish artifact handoff (Insomnia):
# Build job
- uses: actions/upload-artifact@v4
with:
name: ${{ runner.os }}-${{ runner.arch }}-artifacts
path: dist/
# Publish job (in separate workflow, triggered by workflow_run)
- uses: actions/download-artifact@v4
with:
pattern: "*-artifacts"
merge-multiple: trueSLSA provenance with artifacts (Insomnia):
Insomnia combines artifact uploads with SLSA provenance generation:
jobs:
artifact-provenance:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects-as-file: ${{ matrix.binary_artifacts_subject_as_file }}
upload-assets: true
provenance-name: ${{ matrix.product }}-provenance.intoto.jsonl
inso-image-provenance:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
image: kong/inso
digest: ${{ needs.publish.outputs.INSO_DOCKER_IMAGE_DIGEST }}Two SLSA generators — one for binary artifacts, one for container images — producing cryptographic provenance attestations.
Pattern: Use descriptive, parameterized artifact names (include OS, arch, version) to avoid collisions in matrix builds. For release pipelines, combine artifact uploads with SLSA provenance for supply chain security.
8. Permission Scoping
The principle of least privilege applied to GITHUB_TOKEN. We found 144 workflows with explicit permissions.
The dominant pattern — read-only by default:
permissions:
contents: readThis appeared in 90 of 144 scoped workflows (63%). Most CI only needs to read code — write permissions are dangerous because any compromised step could push commits or create releases.
Write permissions only where needed:
contents: write(6x) — version bumps, release creation, git pushpull-requests: write(4x) — PR comments, auto-approve, feedbackid-token: write(1x) — OIDC token for cloud provider authpages: write(1x) — GitHub Pages deployment
Checkov’s pages deployment:
permissions:
contents: read
pages: write
id-token: write # for deployment authenticationOpenTofu’s release permissions:
permissions:
contents: write # create release
id-token: write # OIDC for signing
packages: write # push to GHCRPattern: Always set top-level
permissions: contents: readon every workflow. Only elevate specific scopes on jobs that need them. Never usepermissions: write-all.
9. Conditional Step Execution
Conditional steps (if: on steps) are the most common advanced pattern — 565 instances across the sample. Here are the most useful categories:
Cache hit/miss branching:
# Grype
- name: Load test image cache
if: steps.install-test-image-cache.outputs.cache-hit == 'true'
- name: (cache-miss) Create test image cache
if: steps.install-test-image-cache.outputs.cache-hit != 'true'Change detection gating:
# Checkov
- name: Setup dependencies if they changed
if: steps.changed_files.outputs.files_changed == 'true'
# Wazuh — per-component gating
- name: Request pkg_deb_manager_builder_amd64 update
if: steps.changes.outputs.pkg_deb_manager_builder_amd64 == 'true'Release channel routing (Insomnia):
- name: App version (stable, patch latest stable)
if: github.event.inputs.channel == 'stable' && !github.event.inputs.version
- name: App version (alpha/beta, with a specific version)
if: github.event.inputs.channel != 'stable' && github.event.inputs.version
- name: Upload .deb to pulp and/or cloudsmith (stable only)
if: ${{ !contains(github.event.inputs.version, 'alpha') && !contains(github.event.inputs.version, 'beta') }}Always-run cleanup:
# Upload traces even if tests failed
- name: Upload smoke test traces
if: ${{ !cancelled() }}
# Generate vulnerability report regardless of build outcome
- name: Generate vulnerability report
if: always()Fork protection:
# Nextcloud — skip expensive operations on forks
- name: Disabled on forks
if: ${{ fromJSON(steps.get-repository.outputs.result) != github.repository }}Pattern: Use
if: always()for cleanup and reporting steps. Useif: !cancelled()for steps that should run on success or failure but not cancellation. Gate expensive steps with change detection outputs.
10. Self-Hosted Runners
Self-hosted runners are used exclusively by Checkov (17 instances). Every Checkov job runs on:
runs-on: [self-hosted, public, linux, x64]With one exception for ARM builds:
runs-on: [self-hosted, public, linux, arm64]Checkov (owned by Palo Alto Networks / Bridgecrew) uses self-hosted runners for:
- Proprietary test suites that need internal network access
- Larger resource pools than GitHub-hosted runners provide
- Consistent environments for security scanning tools
The public label is notable — it means these runners accept workflows from public fork PRs, which is a security tradeoff. GitHub-hosted runners are the safer default for public repos.
Only one project in our 14-repo sample uses self-hosted runners. For most open source projects, GitHub-hosted runners are sufficient and more secure.
Assessment
What works well:
- Concurrency control is near-universal (112/326 workflows) and follows consistent patterns — the ecosystem has converged on best practices
- Permission scoping is widespread (144/326) and mostly follows least-privilege
- Composite actions (Grype’s bootstrap) dramatically reduce workflow boilerplate
- Path-based triggers are used strategically in monorepos (Wazuh, Traefik) to keep CI fast
Concerns:
- Composite actions are underused — only Grype and Wazuh have them, despite every project having repeated setup steps
- Wazuh’s 28 composite actions show the pattern at scale but also show diminishing returns — some actions have a single step and add indirection without reducing complexity
- Cross-org reusable workflows (Grype/Anchore) require SHA pinning discipline — a tag reference could be silently updated
- SLSA provenance integration (Insomnia) is sophisticated but rare — only one project does it
Recommendations for teams adopting these patterns:
- Start with Grype’s bootstrap composite action pattern — extract setup into a configurable action
- Add concurrency control to all PR-triggered workflows using the PR-number-with-fallback pattern
- Scope permissions to
contents: readunless you have a specific need for more - Use path triggers for any repo with >5 workflow files to reduce CI spend
- Consider reusable workflows (Checkov pattern) for security scans that need different approval paths
- Adopt SLSA provenance for release artifacts if supply chain security matters for your project