GitHub Actions Reusable Workflow: A Complete Implementation of Zero-Config Unified CI/CD
GitHub Actions Reusable Workflow: A Complete Implementation of Zero-Config Unified CI/CD
This is the third post in the “Unified CI/CD Pipeline Governance” series. This article provides an in-depth breakdown of how a platform team uses Reusable Workflows to achieve “zero-config onboarding for business repositories” — covering architecture design, JWT/OIDC authentication, multi-environment routing, container builds, and lessons learned. This article draws from a real-world deployment covering 500+ repositories in production.
Part 1: Architecture Overview
The .github Repository as a Platform Boundary
Within a GitHub Organization, the special repository named .github serves two roles: hosting Organization-level default Community Health Files (such as CODE_OF_CONDUCT.md), and storing Reusable Workflow files that all repositories in the Org can call.
The platform team centralizes all CI/CD logic in the .github repository, establishing a clear platform boundary:
1 | OrgA/.github |
All 500+ business repositories call this same set of workflow files. Each business repository only needs to maintain a minimal ci.yml:
1 | # .github/workflows/ci.yml (business repository, ~15 lines) |
Why pull_request_target Is Necessary
The pull_request event cannot access Org Secrets in fork PR scenarios, causing all jobs that require Vault credentials to fail. Switching to pull_request_target runs the workflow in the base repository’s context, granting access to Secrets. However, this introduces security risks — see the lessons learned section for details.
Workflow File Responsibilities
| File | Responsibility |
|---|---|
platform-ci-core.yml | Orchestrator, contains the config job, calls all downstream reusable workflows |
platform-ci-check.yml | pylint, shellcheck, unit tests |
platform-ci-security.yml | SonarQube / Semgrep scanning |
platform-ci-build.yml | buildx build, multi-registry push, image signing |
platform-ci-prepare-release.yml | Calculate the next semantic version number |
platform-ci-release.yml | Create Git tag, create GitHub Release |
platform-ci-deploy.yml | Status reporting, Docker info push, health check |
Part 2: The config Job — The Key Design That Replaces the Groovy Merger
Why a Dedicated config Job Is Needed
GitHub Actions workflow structure is static: values in with: fields must have their types determined at workflow parse time, and if: conditions also have syntax constraints at the job level. The platform cannot dynamically merge configuration at runtime the way Jenkins Groovy can.
The solution is to set up a config job in the orchestrator that reads the business repository’s .ci-config/config.yaml, outputs all derived configuration as outputs, and lets downstream jobs consume them via needs.config.outputs.*.
1 | .ci-config/config.yaml (business repository declaration) |
The Complete Shell Implementation of the config Job
1 | # platform-ci-core.yml (config job excerpt) |
The importance of Job Summary is amplified at 500+ scale: when a business team reports that “CI behavior doesn’t match expectations,” the platform team needs to quickly determine whether the issue is a config.yaml parsing problem or a pipeline logic problem. The config table in the Step Summary turns that diagnosis from “needing to dig through logs” into “just open the PR page.”
Downstream Consumption
1 | build: |
Part 3: Deep Dive into the JWT/OIDC Credential Architecture
The Nature of the job_workflow_ref Claim
The GitHub Actions OIDC token contains a key claim: job_workflow_ref. Its value is the path of the called workflow file, not the name of the calling repository.
When business repository OrgA/my-app (one of 500) calls platform-ci-build.yml:
1 | job_workflow_ref = "OrgA/.github/.github/workflows/platform-ci-build.yml@refs/heads/main" |
Vault’s bound_claims binds on job_workflow_ref — regardless of which business repository triggers it, as long as the platform workflow file is being called, authentication succeeds. 500 repositories, 1 Vault role, 0 static credentials.
The Complete Authentication Flow
1 | Business repo ci.yml (any one of 500) |
Vault Role JSON Configuration
1 | { |
At 500+ repository scale, the security value of this design is especially significant:
- 500 business repositories, none storing any credentials
- Even if any business repository is compromised, the attacker still cannot obtain platform credentials (
job_workflow_refwon’t match) - Every modification to the platform workflow files must go through a code review on the main branch;
@refs/heads/mainis enforced at the Vault layer
Note: The Vault CLI does not support passing map-type parameters (
bound_claims,claim_mappings) viakey=value. You must use JSON heredoc stdin format:
1
2
3 vault write auth/jwt/role/platform-ci - <<'EOF'
{ ...full JSON... }
EOF
Three Key Properties of Batch Tokens
- Non-renewable:
vault token renewhas no effect on batch tokens; they expire when the TTL is reached - Non-queryable: they do not appear in
vault list auth/token/accessors; the thousands of tokens generated daily by 500 repositories leave no queryable trace - 5-minute TTL: sufficient to complete a single
vault-actioncall; even if leaked after expiry, they cannot be used
OIDC Issuer Differences Between GHE and github.com
1 | github.com: https://token.actions.githubusercontent.com |
Vault’s oidc_discovery_url must point to the correct issuer; otherwise Vault cannot fetch the correct JWKS endpoint to verify signatures:
1 | # GHE environment |
permissions: id-token: write Must Be Declared at the Job Level of Each Sub-Workflow
The permissions declared in the orchestrator platform-ci-core.yml are not automatically propagated to reusable workflows called via uses:. Each job that needs to obtain an OIDC token must declare it independently:
1 | # platform-ci-build.yml |
Part 4: Multi-Environment Routing — The Org Variables Approach
Design Motivation
The traditional approach embeds if/else environment checks directly in the workflow, tightly coupling the workflow files to environments. At 500+ repository scale, any environment configuration change requires modifying the platform workflow files and re-testing.
The platform team adopted an Organization Variable injection approach: when each Org is created, a platform script writes environment-specific variables once. The workflow code contains absolutely no environment branching logic, and uses exactly the same code across all three environments (dev/stg/prod).
The Six Org Variables
| Variable | OrgA-Dev | OrgA-Stg | OrgA (prod) |
|---|---|---|---|
VAULT_URL | https://vault.example.com | same | same |
VAULT_NAMESPACE | platform/dev | platform/stg | platform/prod |
VAULT_ROLE | platform-ci | same | same |
VAULT_AUDIENCE | https://vault.example.com | same | same |
VAULT_ENV | dev | stg | prod |
API_URL | https://api.platform-dev.example.com | https://api.platform-stg.example.com | https://api.platform.example.com |
In the secrets: field of vault-action, ${{ vars.VAULT_ENV }} is automatically interpolated:
1 | secrets: | |
In OrgA-Dev → secret/data/platform/dev/registry
In OrgA → secret/data/platform/prod/registry
Not a single line of workflow code changes; all three environments (covering 500+ repositories) route automatically.
The Idempotent apply-org-variables.sh Implementation
1 |
|
Part 5: Engineering Details of Container Builds
Multi-Registry Push Design
image_url_list is a comma-separated list of URLs:
1 | internet type: |
Cross-Registry Copying with docker buildx imagetools create
After the build completes, imagetools create copies the manifest directly at the registry layer without pulling the image to the runner, saving bandwidth and time. At the scale of 500+ repositories with high-frequency builds, the cumulative savings from this optimization are significant:
1 | # Build and push to the primary registry |
Image Signing (Signify)
The platform uses an internal Signify service for image signing with mTLS client certificate authentication. All tags across all registries require signing:
1 | IFS=',' read -ra ALL_URLS <<< "${IMAGE_URL_LIST}" |
Compatibility Issues with upload-artifact@v4 on GHE
Certain versions of GitHub Enterprise Server do not support the new API used by actions/upload-artifact@v4:
1 | Error: GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+ and |
At 500+ repository scale, the blast radius of this kind of compatibility issue is total — you must downgrade to v3:
1 | - uses: actions/upload-artifact@v3 |
Part 6: Per-Repo Secrets — Vault Enterprise Secrets Sync
The Division of Two Credential Types
| Credential Type | How It’s Obtained | Example | Security Level |
|---|---|---|---|
| Platform shared credentials | Fetched from Vault at runtime via JWT/OIDC | Registry credentials, signing certificates | High (cross-Org permissions) |
| Business repository-specific credentials | Pushed as GitHub Secrets by Vault Secrets Sync | Database connection strings, business API keys | Medium (single-repository permissions) |
At 500+ repository scale, per-repo Secrets management requires automation — you cannot manually configure 500 repositories. Vault Enterprise Secrets Sync provides this capability.
Vault Enterprise Secrets Sync Configuration
1 | # 1. Create a GitHub Actions sync destination (Fine-Grained PAT, only needs secrets:write) |
How Automatic Rotation Sync Works
1 | Vault KV secret updated (manually or via dynamic credentials) |
At 500+ repository scale, credential rotation no longer requires notifying each repository owner — the Vault Sync Engine automatically handles the push. The platform team only manages the KV in Vault, and business repository Secrets are automatically synced.
Part 7: Observability at 500+ Scale
When 500+ repositories are running CI simultaneously, observability is not a “nice to have” — it is foundational infrastructure for platform operations.
Runner Capacity Monitoring
1 | # Record runner info at the start of each job |
CI Health Dashboard
The platform team needs to be able to answer:
- Over the past 7 days, which 20 repositories had the highest CI failure rates?
- Average CI duration trend (is there performance regression)?
- Security scan coverage (which repositories haven’t had a CI run in over 30 days)?
This data can be collected via the GitHub API or through custom telemetry emitted during CI runs.
Bulk Compliance Check Script
1 |
|
Part 8: Lessons Learned
1. Two Ways to Handle Null Values in yq
yq outputs the string null by default when a field doesn’t exist, causing downstream jobs to receive the literal string "null". Across 500+ repositories with significant variation in config.yaml writing styles, this type of issue comes up frequently:
1 | # Option A: yq inline default value (recommended for simple scalar fields) |
2. Multi-Line Values in $GITHUB_OUTPUT Must Use Heredoc
1 | # Wrong: newlines are truncated |
3. with: Fields in Reusable Workflows Only Support Strings
1 | inputs: |
4. The Security Trap of pull_request_target + checkout
pull_request_target runs in the base repository’s context, but actions/checkout defaults to checking out the base branch, not the PR’s code.
You must explicitly specify the PR head SHA:
1 | - uses: actions/checkout@v4 |
Security note: the code being checked out belongs to the fork. The Secrets access logic must be isolated in a separate job from the code checkout to prevent malicious scripts in the fork from reading Secrets.
5. Correct Syntax for needs.config.outputs in if: Conditions
1 | build: |
6. Runner Queue Pressure During 500+ Concurrent Triggers
During the morning commit rush, the runner queue can back up with hundreds of jobs. You need to monitor queue_time (the time from when a job enters the queue to when it starts running) and adjust runner count accordingly. Queue waits exceeding 5 minutes significantly degrade the developer experience.
Summary
At 500+ repository scale, the core value of GitHub Actions Reusable Workflows:
- Working around static structure constraints: the
configjob acts as a dynamic configuration middleware layer, replacing Jenkins Groovy’s runtime merge capability - JWT/OIDC zero long-lived credentials: none of the 500 repositories stores any credentials; batch tokens are non-queryable, maximizing the security boundary
- Multi-environment zero-code routing: Organization Variable injection enables three environments to use exactly the same workflow code
- Multi-registry container builds:
imagetools createcopies at the manifest layer, saving bandwidth across thousands of daily builds from 500+ repositories - Layered credential management: platform shared credentials use OIDC; business-specific credentials use Secrets Sync; credential lifecycle for 500 repositories is fully automated
Each business repository ultimately only needs to maintain a 15-line ci.yml and a .ci-config/config.yaml — this “minimal onboarding model” is identical for the 1st repository and the 500th, which is precisely the design goal of a scalable platform.