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
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 }
| Generator | toolchain | build | output | Base path read via |
|---|---|---|---|---|
| Astro | node | npm run build | dist | base: process.env.BASE_PATH in config |
| VitePress | node | npm run docs:build | docs/.vitepress/dist | base: process.env.BASE_PATH + '/' |
| Eleventy | node | npx @11ty/eleventy --pathprefix=$BASE_PATH | _site | --pathprefix + HtmlBasePlugin |
| Hugo | hugo | hugo --gc --minify --baseURL "$SITE_URL/" | public | --baseURL flag |
| Jekyll | ruby | bundle 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.
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:
| Measure | Before | After | Source |
|---|---|---|---|
| Workflow files to maintain | 5 (61–140 lines each) | 1 shared (58 lines) + 5 × 6 lines | Repositories |
| Sites with caching configured | 2 of 5 | 5 of 5 | Workflows |
| Median deploy time (5 sites) | 2 min 50 s | 1 min 40 s | Actions |
| Base-path incidents in 6 months | 3 | 0 | Incident log |
| Time to roll out an Actions version bump | ~5 PRs by hand | 1 release + 5 bot PRs | — |
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: falsefor 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.
Related
- Parent: GitHub Actions for Automated SSG Builds — CI for static sites.
- Deploying to GitHub Pages with Actions — the single-site version.
- Reusable Workflows for SSG Builds — reusable workflows in general.
- Custom Domains and HTTPS for Jekyll on GitHub Pages — custom domains and base paths.
- Pull Request Previews for GitHub Pages Sites — previews, which Pages lacks natively.
Filed underHow-tosGitHub ActionsGitHub Pages