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:

StepSites doing itVariations
Checkout, setup Node or Hugo145 Node versions, 3 Hugo versions
Dependency caching94 different cache keys
Build144 generators
Link check63 different tools
HTML validation2—
Preview deploy per PR83 hosts
Production deploy145 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.

Number of sites performing each pipeline step before consolidation Horizontal bars of how many of the 14 sites performed each step. Build and production deploy 14. Dependency caching 9. Preview deploys 8. Link checks 6. HTML validation 2. Five of the fourteen production deploys used long-lived API tokens. How consistently each step was done across 14 sites Build + production deploy 14 Dependency caching 9 Preview deploy per PR 8 Link check 6 HTML validation 2
From a review of each repository's workflow files; variations within each step not shown.

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 }}' }
Call sequence from a site repository to the shared workflow The site repository's workflow calls the shared ssg.yml at tag v3 with inputs and one secret. The shared workflow's build job uses composite actions for Node setup with caching and output checks, builds and uploads the artifact. The deploy job downloads it and deploys with the composite deploy action, as a preview for pull requests or to production on main. A site calling the shared workflow Site repo site.yml (8 lines) ssg.yml@v3 reusable workflow Composite actions setup, checks, deploy uses ssg.yml@v3 with inputs + secret setup-node-cached check-output (links, HTML) deploy-workers (preview or prod) status checks and preview comment
The site repository contains only inputs; all pipeline logic lives in the versioned shared repository.

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.

When to use a reusable workflow versus a composite action A matrix comparing the two. Reusable workflows package whole jobs with their own runners, permissions, secrets and job dependencies, suit complete pipelines, and are called at the job level. Composite actions package steps inside the caller's job, share the caller's runner and permissions, suit small building blocks, and are called at the step level. Reusable workflow or composite action? Aspect Reusable workflow Composite action Unit of reuse whole jobs steps in a job Own runner and permissions yes uses caller's Secrets passed explicitly caller's environment Best for complete pipelines small building blocks
The shared repository uses one reusable workflow built from five composite actions.

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

Measure14 separate workflowsShared workflow
Lines of workflow YAML across repositories2,380112 (callers) + 310 (shared)
Sites with link checks and HTML validation6 and 214 and 14
Sites with dependency caching914
Median build-and-deploy time3 min 40 s2 min 10 s
Long-lived deploy tokens50
Time to roll out a pipeline fix to all sites~2 days1 tag + 14 automated PRs (~1 hour)

Pitfalls & Rollback

  • Calling by branch. @main changes under every site at once; call by tag or SHA.
  • secrets: inherit everywhere. 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.

Filed underHow-tosGitHub Actions