Reusable Workflows for SSG Builds
Organisations rarely have one static site. They have the product docs, the marketing site, the engineering blog, an API reference, a status page, three microsites for events — each built with a static generator, each with a GitHub Actions workflow that someone copied from the previous one and adjusted. Two years later there are fourteen slightly different pipelines: some cache dependencies and some do not, some run link checks, one still deploys with a long-lived API token, and a security fix to the deploy step has to be made fourteen times.
GitHub Actions reusable workflows turn that into one pipeline definition, versioned and called by each site with a few inputs. This guide builds a shared build-test-deploy workflow for static sites, packages smaller pieces as composite actions, versions it with tags, and migrates fourteen sites onto it. It is part of GitHub Actions for Automated SSG Builds.
Prerequisites
- A GitHub organization with several static site repositories.
- A central repository for shared CI (here
example/ssg-ci), with access granted to the organization's repositories under Settings → Actions → Access. - A common deploy target, or a small set of them (here Cloudflare Workers static assets and GitHub Pages).
What the Fourteen Pipelines Had in Common
Diffing the workflows showed the same shape everywhere, with drift in the details:
| Step | Sites doing it | Variations |
|---|---|---|
| Checkout, setup Node or Hugo | 14 | 5 Node versions, 3 Hugo versions |
| Dependency caching | 9 | 4 different cache keys |
| Build | 14 | 4 generators |
| Link check | 6 | 3 different tools |
| HTML validation | 2 | — |
| Preview deploy per PR | 8 | 3 hosts |
| Production deploy | 14 | 5 used long-lived tokens |
A shared workflow could standardize the common path and expose the real differences — generator, build command, output directory, deploy target — as inputs.
Step 1: The Reusable Workflow
# example/ssg-ci/.github/workflows/ssg.yml
name: SSG build and deploy
on:
workflow_call:
inputs:
node-version: { type: string, default: '22' }
build-command: { type: string, default: 'npm run build' }
output-dir: { type: string, default: 'dist' }
deploy-target: { type: string, required: true } # 'workers' | 'gh-pages'
worker-name: { type: string, default: '' }
run-link-check: { type: boolean, default: true }
secrets:
CLOUDFLARE_API_TOKEN: { required: false }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: example/ssg-ci/actions/setup-node-cached@v3
with: { node-version: '${{ inputs.node-version }}' }
- run: ${{ inputs.build-command }}
- if: inputs.run-link-check
uses: example/ssg-ci/actions/check-output@v3
with: { dir: '${{ inputs.output-dir }}' }
- uses: actions/upload-artifact@v4
with: { name: site, path: '${{ inputs.output-dir }}' }
deploy:
needs: build
runs-on: ubuntu-latest
permissions: { contents: read, deployments: write, pull-requests: write, pages: write, id-token: write }
steps:
- uses: actions/download-artifact@v4
with: { name: site, path: site }
- if: inputs.deploy-target == 'workers'
uses: example/ssg-ci/actions/deploy-workers@v3
with: { dir: site, worker-name: '${{ inputs.worker-name }}', preview: '${{ github.event_name == ''pull_request'' }}' }
env: { CLOUDFLARE_API_TOKEN: '${{ secrets.CLOUDFLARE_API_TOKEN }}' }
Each site's workflow becomes a few lines:
# docs repo: .github/workflows/site.yml
on: { push: { branches: [main] }, pull_request: {} }
jobs:
site:
uses: example/ssg-ci/.github/workflows/ssg.yml@v3
with: { deploy-target: workers, worker-name: docs-site, build-command: 'npm run build' }
secrets: { CLOUDFLARE_API_TOKEN: '${{ secrets.CLOUDFLARE_API_TOKEN }}' }
Step 2: Composite Actions for the Pieces
Steps that several workflows share — and that some sites want to use outside the full pipeline — are composite actions in the same repository:
# example/ssg-ci/actions/setup-node-cached/action.yml
name: Setup Node with cache
inputs:
node-version: { default: '22' }
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with: { node-version: '${{ inputs.node-version }}', cache: npm }
- run: npm ci --prefer-offline --no-audit --fund=false
shell: bash
check-output bundles the link check and HTML validation, so the six sites that had link checks and the eight that did not all got both, with the same tools and settings. The practices are those in Checking Links in Pull Requests and Validating HTML Output in CI.
Step 3: Version and Upgrade Deliberately
Callers reference the shared workflow by tag (@v3). Changes are released as new tags with a changelog; breaking changes to inputs bump the major version. Renovate, configured with the github-actions manager, opens pull requests in each site repository when a new tag appears, so each site upgrades with a preview build proving the new pipeline works for it.
For security-sensitive consumers, pinning by commit SHA instead of tag prevents a moved tag from changing what runs; the trade-offs are in Pinning Actions by Commit SHA. The shared repository itself pins every third-party action it uses by SHA, so callers inherit that protection.
Step 4: Secrets and Permissions
Reusable workflows receive only the secrets passed to them. Passing one named secret (CLOUDFLARE_API_TOKEN) instead of secrets: inherit keeps each site's other secrets out of the shared pipeline. Permissions are declared per job in the shared workflow, at the minimum each job needs; the caller's GITHUB_TOKEN is scoped to them. The migration was also the occasion to replace the five long-lived deploy tokens with OIDC-based short-lived credentials where the target supported it, following Securing Deploy Credentials with GitHub OIDC.
Migrating Fourteen Sites
The migration took two weeks. The shared workflow was developed against three representative sites (Astro on Workers, Hugo on GitHub Pages, Eleventy on Workers) until it covered their needs with inputs; then each remaining site got a pull request replacing its workflow with the eight-line caller. Preview builds proved each one. Two sites needed an extra input — a Hugo version and a custom pre-build step — which became optional inputs rather than special cases.
Owning the Shared Pipeline
A shared workflow needs an owner, or it becomes the thing nobody dares to change. The platform team owns ssg-ci, with a short CODEOWNERS file, a changelog, and a test matrix: every pull request to the shared repository runs the workflow against three fixture sites — a minimal Astro, Hugo and Eleventy project checked into the repository — so a change that breaks one generator fails before it is tagged. Site teams can propose changes, and the most common request, an additional optional input, is accepted when at least two sites need it. Requests that only one site needs are handled as a pre-build script in that site's repository, which keeps the shared interface small.
Documentation matters as much as code here. The repository's README lists every input with its default and an example caller for each generator, and a short "how to debug a failing run" section explains how to reproduce the shared workflow locally with act or by copying it into the site repository temporarily. Questions from site teams dropped noticeably once that section existed; before it, a failing shared step felt like a black box.
Finally, watch for drift in the other direction. Sites occasionally copy a step out of the shared workflow to customise it, and the copy then misses future fixes. A monthly script lists workflow files in every site repository that are longer than the standard caller, which surfaces those forks early.
Measured Impact
| Measure | 14 separate workflows | Shared workflow |
|---|---|---|
| Lines of workflow YAML across repositories | 2,380 | 112 (callers) + 310 (shared) |
| Sites with link checks and HTML validation | 6 and 2 | 14 and 14 |
| Sites with dependency caching | 9 | 14 |
| Median build-and-deploy time | 3 min 40 s | 2 min 10 s |
| Long-lived deploy tokens | 5 | 0 |
| Time to roll out a pipeline fix to all sites | ~2 days | 1 tag + 14 automated PRs (~1 hour) |
Pitfalls & Rollback
- Calling by branch.
@mainchanges under every site at once; call by tag or SHA. secrets: inheriteverywhere. Pass only what the workflow needs.- Too many inputs. A shared workflow with forty inputs is fourteen workflows in disguise; keep special cases in the site repository as a pre-build step.
- Forgetting access settings. Private shared repositories must allow access from the calling repositories.
- Rollback: each site's caller can pin the previous tag, or restore its old workflow file from Git history.
Conclusion
When an organisation runs many static sites, their pipelines should be one versioned definition, not many copies. A reusable workflow for the build-test-deploy path, composite actions for the pieces, explicit secrets and permissions, and deliberate upgrades by tag gave fourteen sites the same checks and caching, removed every long-lived deploy token, and turned a pipeline fix from a two-day chore into an hour of automated pull requests.
FAQ
What is a reusable workflow in GitHub Actions?
A workflow file with an on workflow_call trigger that other workflows can call like a function, passing inputs and secrets. The called workflow runs its jobs in the caller's context, so one pipeline definition can serve many repositories.
When should I use a composite action instead?
Composite actions package a sequence of steps that run inside a caller's job, which suits small reusable pieces such as setting up Node with caching or running a link check. Reusable workflows package whole jobs, with their own runners, permissions and dependencies, which suits complete build and deploy pipelines.
How should shared workflows be versioned?
Call them by a tag or commit SHA rather than a branch, for example uses org/ci/.github/workflows/ssg.yml@v3. Publish changes as new tags and let each site upgrade deliberately, ideally through automated dependency pull requests.
Can a reusable workflow access the caller's secrets?
Only secrets passed explicitly, or all of them with secrets inherit when caller and callee are in the same organization. Passing only the secrets a workflow needs keeps the blast radius small.
Related
- Parent: GitHub Actions for Automated SSG Builds — building static sites in Actions.
- Matrix Builds for Multi-Site Monorepos — many sites in one repository instead.
- Concurrency Groups to Cancel Stale Builds — a setting worth sharing.
- Pinning Actions by Commit SHA — versioning third-party actions.
- Testing Static Sites in CI — the checks the shared workflow runs.
Filed underHow-tosGitHub Actions