Publishing Any Generator to GitHub Pages With Actions

GitHub Pages is free, fast enough for most documentation and project sites, and lives next to the code. Since GitHub added deployment from Actions artifacts, it publishes the output of any generator, not just Jekyll. Organisations with several sites end up with a different workflow file per repository — copied from each generator's documentation, each with its own quirks for caching, base paths and Node versions. Those files drift, and a fix in one never reaches the others.

This guide builds one reusable workflow that publishes any generator's output to GitHub Pages, and calls it from five repositories using Hugo, Astro, Eleventy, Jekyll and VitePress. It handles base paths for project sites, caching per toolchain and a custom 404 page. It belongs to GitHub Actions for Automated SSG Builds; the single-site version is in Deploying to GitHub Pages with Actions.

Prerequisites

  • Repositories with Pages enabled and the source set to "GitHub Actions" (Settings → Pages).
  • A shared repository for workflows (here acme/workflows), readable by the site repositories.
  • Each generator able to read its base URL from configuration or an environment variable.

The Shape of Every Pages Deploy

Steps shared by every generator's GitHub Pages deploy Five steps. Configure Pages, which outputs the base path and URL. Set up the toolchain, which differs per generator. Build with the base path passed in, which differs per generator. Upload the output directory as a Pages artifact. Deploy the artifact to the github-pages environment. The same five steps for every generator configure- pages base path + URL Toolchain setup varies per generator Build varies per generator upload-pages- artifact output dir deploy-pages github-pages env
Only the second and third steps vary; the reusable workflow takes them as inputs.

Step 1: The Reusable Workflow

# acme/workflows/.github/workflows/pages.yml
name: pages
on:
  workflow_call:
    inputs:
      toolchain: { type: string, required: true }     # node | hugo | ruby
      node-version: { type: string, default: '20' }
      hugo-version: { type: string, default: '0.139.0' }
      build: { type: string, required: true }         # e.g. "npm run build"
      output: { type: string, required: true }        # e.g. "dist"
permissions: { contents: read, pages: write, id-token: write }
concurrency: { group: pages, cancel-in-progress: false }
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { submodules: recursive, fetch-depth: 0 }
      - id: pages
        uses: actions/configure-pages@v5
      - if: inputs.toolchain == 'node'
        uses: actions/setup-node@v4
        with: { node-version: '${{ inputs.node-version }}', cache: npm }
      - if: inputs.toolchain == 'node'
        run: npm ci
      - if: inputs.toolchain == 'hugo'
        run: |
          curl -sL "https://github.com/gohugoio/hugo/releases/download/v${{ inputs.hugo-version }}/hugo_extended_${{ inputs.hugo-version }}_linux-amd64.tar.gz" | tar xz -C /usr/local/bin hugo
      - if: inputs.toolchain == 'ruby'
        uses: ruby/setup-ruby@v1
        with: { ruby-version: .ruby-version, bundler-cache: true }
      - run: ${{ inputs.build }}
        env:
          BASE_PATH: ${{ steps.pages.outputs.base_path }}
          SITE_URL: ${{ steps.pages.outputs.base_url }}
      - uses: actions/upload-pages-artifact@v3
        with: { path: '${{ inputs.output }}' }
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: { name: github-pages, url: '${{ steps.deploy.outputs.page_url }}' }
    steps:
      - id: deploy
        uses: actions/deploy-pages@v4

configure-pages outputs base_path (/repo for a project site, empty with a custom domain) and base_url. The build step receives them as environment variables. The pages concurrency group with cancel-in-progress: false lets an in-progress deploy finish instead of cancelling it halfway.

Step 2: Call It From Each Site

Each site's workflow is a few lines:

# Astro site: .github/workflows/deploy.yml
on: { push: { branches: [main] }, workflow_dispatch: }
jobs:
  pages:
    uses: acme/workflows/.github/workflows/pages.yml@v2
    with: { toolchain: node, build: 'npm run build', output: dist }
GeneratortoolchainbuildoutputBase path read via
Astronodenpm run builddistbase: process.env.BASE_PATH in config
VitePressnodenpm run docs:builddocs/.vitepress/distbase: process.env.BASE_PATH + '/'
Eleventynodenpx @11ty/eleventy --pathprefix=$BASE_PATH_site--pathprefix + HtmlBasePlugin
Hugohugohugo --gc --minify --baseURL "$SITE_URL/"public--baseURL flag
Jekyllrubybundle exec jekyll build --baseurl "$BASE_PATH"_site--baseurl flag

The workflow is referenced by a tag (@v2), so changes to the shared workflow roll out when each site updates its reference — Renovate or Dependabot opens those pull requests.

Step 3: Base Paths That Work Everywhere

Base path bugs are the most common GitHub Pages failure: the site builds and deploys, then every asset 404s because links point at / instead of /repo/. Two checks catch them. Each generator's config reads the variable, as in the table, so links are generated with the prefix. And a post-build step greps the output for root-relative links that lack the prefix:

[ -n "$BASE_PATH" ] && ! grep -rEho '(href|src)="/[^/"][^"]*"' "${OUTPUT}" | grep -v "=\"$BASE_PATH/" | head -5 | grep . \
  || echo "base path ok"

Sites with custom domains have an empty base path, and the check is skipped.

Base path and URL for GitHub Pages hosting cases A matrix of three hosting cases. A user or organisation site at username.github.io has an empty base path. A project site at username.github.io slash repo has a base path of slash repo. A project site with a custom domain such as docs.example.com has an empty base path and the custom domain as base URL. Base path per hosting case Case Base path Base URL User/org site (empty) https://acme.github.io Project site /repo https://acme.github.io/repo Project + custom domain (empty) https://docs.example.com
configure-pages works these out from the repository settings, so the workflow never hard-codes them.

Step 4: 404 Pages

Every generator here can produce 404.html at the output root, and Pages serves it for missing paths with a 404 status. On project sites without a custom domain, the 404 page is served at the missing URL itself (say /repo/old/page), so relative links in it break; its links and assets must be absolute with the base path. Astro, VitePress and Hugo do this automatically when the base is configured; Eleventy needs the HTML base plugin; Jekyll uses relative_url as usual.

Generator-Specific Notes

A few details came up per generator. Hugo needs fetch-depth: 0 in checkout if the site uses .GitInfo or enableGitInfo for last-modified dates, which the shared workflow sets for all sites; it costs a second or two on small repositories. Jekyll reads .ruby-version through setup-ruby with bundler-cache: true, which caches gems keyed on the lockfile. VitePress expects its base to end with a slash, so the config appends one to BASE_PATH. Eleventy needs the HTML base plugin enabled for --pathprefix to rewrite URLs in the output; without it, only URLs passed through the url filter get the prefix. Astro reads base from config and applies it to every generated link and asset; links hard-coded in Markdown as /docs/... are not rewritten, which the post-build grep catches.

Sites that need extra steps — a search indexer after the build, a link check, a performance budget — run them as part of the build input command (npm run build && npx pagefind --site dist), which keeps the shared workflow simple. For anything heavier, a site can call the shared workflow from its own job after its own test job, using needs:, so tests gate the deploy without the shared file knowing about them.

Measured Impact

Five sites moved from individually maintained workflows to the shared one:

MeasureBeforeAfterSource
Workflow files to maintain5 (61–140 lines each)1 shared (58 lines) + 5 × 6 linesRepositories
Sites with caching configured2 of 55 of 5Workflows
Median deploy time (5 sites)2 min 50 s1 min 40 sActions
Base-path incidents in 6 months30Incident log
Time to roll out an Actions version bump~5 PRs by hand1 release + 5 bot PRs—
Median deploy time per site before and after the shared workflow Paired bars in seconds. The Astro site went from 190 to 105. VitePress from 150 to 80. Eleventy from 170 to 95. Hugo from 140 to 70. Jekyll from 200 to 150. Deploy time per site Own workflow Shared workflow Astro 190 s 105 s VitePress 150 s 80 s Eleventy 170 s 95 s Hugo 140 s 70 s Jekyll 200 s 150 s
Median of ten deploys per site; most of the gain came from caching that only some old workflows had.

Most of the deploy-time gain came from caching that only two of the five old workflows had configured.

Pitfalls & Rollback

  • Hard-coded base paths. Read them from configure-pages.
  • Cancelling in-progress deploys. Use cancel-in-progress: false for the Pages group.
  • Referencing the shared workflow by branch. Use tags so changes roll out deliberately.
  • Forgetting the source setting. Pages must be set to deploy from GitHub Actions.
  • Rollback: each site can pin an earlier tag of the shared workflow, or switch back to its own file; the Pages artifact model is the same.

Conclusion

Every GitHub Pages deploy is the same five steps, with only the toolchain and build command varying by generator. One reusable workflow with those as inputs, base paths taken from configure-pages, and a check for unprefixed links now publishes five sites on five generators, faster than their old hand-written workflows and without the base-path incidents that used to follow each new site.

FAQ

Do I need a different workflow for each generator on GitHub Pages?

No. The deploy half is identical for every generator: upload a directory as a Pages artifact and deploy it. Only the setup and build commands differ. A reusable workflow that takes the setup and build command as inputs serves every generator, so an organisation with many sites maintains one deploy path.

How do I handle the base path for project sites on GitHub Pages?

Project sites are served under slash repository-name unless a custom domain is set. The configure-pages action outputs the correct base path and base URL; pass them to the generator, for example as Hugo's baseURL, Astro's base, or an environment variable that the config reads, so links and assets include the prefix.

Does GitHub Pages need a .nojekyll file when deploying with Actions?

No. When deploying an artifact with the deploy-pages action, GitHub Pages serves the files as they are and does not run Jekyll, so folders starting with an underscore, such as underscore astro, are served correctly. The .nojekyll file was needed with the old branch-based publishing.

How do I get a custom 404 page on GitHub Pages?

Put a file named 404.html at the root of the artifact. GitHub Pages serves it with a 404 status for missing paths. On project sites without a custom domain, links inside the 404 page must include the base path, because the page is served at whatever URL was requested.

Filed underHow-tosGitHub ActionsGitHub Pages