Deploy to GitHub Pages
Ship a Pennington site to GitHub Pages with a ready-to-copy Actions workflow, base-URL injection, and the `.nojekyll` marker.
This guide covers deploying a working Pennington site committed to a GitHub repo, so Pages builds and deploys it automatically on every push to main. When the site still only runs under dotnet run, complete Build a static site first — the directory structure of output/ is easier to automate once it's familiar.
Before you begin
- A Pennington site that builds locally with
dotnet run --project <your-project> -- build(see Build a static site if not). - The repo is pushed to GitHub and Pages is enabled under Settings → Pages → Build and deployment → Source: GitHub Actions.
- The site will serve under a repository sub-path like
https://<user>.github.io/<repo>/. Root-domain deployments are called out in Step 5.
For a working setup, see examples/SubPathDeployableExample — the .github/workflows/deploy.yml and BuildHost helper are the relevant siblings.
Steps
Enable GitHub Pages with the Actions source
In the repo settings, switch Pages → Build and deployment → Source to GitHub Actions so the deploy workflow is authorized to publish. Also confirm the three workflow permissions the deploy action needs — contents: read, pages: write, id-token: write — are not blocked at the organization level. The workflow declares them explicitly, but an org-wide deny overrides that.
Add the deploy workflow
Commit the YAML below to .github/workflows/deploy.yml at the repo root. It pins actions/setup-dotnet@v4 to .NET 10, derives the base URL from ${{ github.event.repository.name }} so the same file works on forks and renames, runs dotnet run -- build "$BASE_URL", writes .nojekyll, and hands output/ to actions/upload-pages-artifact@v3 and actions/deploy-pages@v4.
# Canonical GitHub Pages workflow for a Pennington static site.
#
# Assumes the site is served under a repository sub-path — the typical
# project-Pages URL is `https://<user>.github.io/<repo>/`, which requires
# a matching `baseUrl` argument at build time so internal anchors, CSS,
# JS, and data URLs all resolve under `/<repo>/`.
#
# The workflow:
# 1. Derives the base URL from `${{ github.event.repository.name }}` so
# the same file works on any fork or renamed repo.
# 2. Runs `dotnet run --project … -- build /<repo>` to emit `output/`.
# 3. Drops a `.nojekyll` marker so GitHub Pages serves `_content/*`
# folders verbatim (Jekyll would silently strip underscore paths).
# 4. Uploads `output/` as a Pages artifact and deploys it.
#
# If your site sits at an org root or a custom domain (served from `/`),
# set `BASE_URL` to an empty string. `build "$BASE_URL"` then passes an
# empty argument and the base-URL rewriter leaves internal links untouched.
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
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
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Build static site
env:
BASE_URL: /${{ github.event.repository.name }}
run: |
dotnet run \
--project examples/SubPathDeployableExample \
--configuration Release \
-- build "$BASE_URL"
- name: Disable Jekyll processing
run: touch output/.nojekyll
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: output
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Note
The touch output/.nojekyll step is load-bearing: without it GitHub Pages runs the artifact through Jekyll, which strips any path starting with an underscore — including Pennington's _content/ static-web-asset folder. The marker disables Jekyll so _content/* ships verbatim.
Point the --project path at your site
The template targets examples/SubPathDeployableExample; edit the --project argument and any working-directory references so the dotnet run step points at the correct csproj.
Match the build baseUrl to the Pages URL
Project Pages sites serve at https://<user>.github.io/<repo>/, so the workflow passes /<repo> as the first positional build argument and BaseUrlHtmlRewriter prefixes every internal href, src, and action on the way out. For sites at an org-level root (https://<org>.github.io/) or a custom apex domain, the site serves from /: set BASE_URL to an empty string so build "$BASE_URL" passes an empty argument and the rewriter leaves links untouched. The workflow's header comment marks the same two lines to change. Sub-path wiring is covered in Host under a sub-path (base URL).
Customize the exit semantics
RunOrBuildAsync already sets a non-zero exit code on errors, so the workflow above fails fast on broken pages. When you need stricter or more selective behavior — failing the main-branch build on broken xrefs while letting warnings pass on feature branches — skip the RunOrBuildAsync extension, run the generator yourself, and inspect the BuildReport before setting the exit code. The BuildHost helper in the example does exactly that:
public static void PrintBuildReport(BuildReport report)
{
report.WriteTo(Console.Out);
if (report.HasErrors)
{
Environment.ExitCode = 1;
}
}
report.HasErrors covers broken xrefs and failed pages; branch on report.Diagnostics for finer-grained rules. Call BuildHost.RunOrBuildAsync from Program.cs in place of the default extension to route the build through it.
Verify
- Push to
main; the Deploy to GitHub Pages workflow runs thebuildanddeployjobs in sequence and turns green. - Visit
https://<user>.github.io/<repo>/— the landing page loads, navigation links resolve under/<repo>/, and view-source shows<body data-base-url="/<repo>">(the rewriter trims the trailing slash). - Open the build job log — expect the
BuildReportsummary line with zero failed pages and zero broken links; any non-zero count fails the job.
Related
- Recipe: Build a static site — what
build [baseUrl] [outputDirectory]produces before you automate it. - Recipe: Host under a sub-path (base URL) — how
BaseUrlHtmlRewriterhandles the/<repo>/prefix for non-GitHub-Pages hosts. - Recipe: Adapt the deploy workflow for other hosts — Azure Static Web Apps, Cloudflare Pages, and Netlify deltas against this workflow.
- Reference: CLI and build arguments — the
build [baseUrl] [outputDirectory]surface this workflow drives. - Reference: Build report fields — the
BuildReportsurface (HasErrors,FailedPages,Diagnostics) the CI step above checks.