---
title: Deploy to GitHub Pages
description: "Ship a Pennington site to GitHub Pages with a ready-to-copy Actions workflow, base-URL injection, and the `.nojekyll` marker."
canonical_url: https://usepennington.net/how-to/deployment/github-pages/
sidecar_url: https://usepennington.net/how-to/deployment/github-pages.md
content_hash: sha256:9c43db34354661c79780d82e18f941866dcda95d2a5f8443fa915fb59f16f0bd
tokens: 2038
uid: how-to.deployment.github-pages
reading_time_minutes: 4
---

Guides
# 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](https://usepennington.net/how-to/deployment/static-build.md) 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](https://usepennington.net/how-to/deployment/static-build.md) 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](https://github.com/usepennington/pennington/tree/main/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`.

 
```yaml:symbol
# 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)](https://usepennington.net/how-to/deployment/base-url.md).

 
## 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:

 
```csharp:symbol
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 the `build` and `deploy` jobs 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 `BuildReport` summary line with zero failed pages and zero broken links; any non-zero count fails the job.
 
 
## Related

 
 - Recipe: [Build a static site](https://usepennington.net/how-to/deployment/static-build.md) — what `build [baseUrl] [outputDirectory]` produces before you automate it.
 - Recipe: [Host under a sub-path (base URL)](https://usepennington.net/how-to/deployment/base-url.md) — how `BaseUrlHtmlRewriter` handles the `/<repo>/` prefix for non-GitHub-Pages hosts.
 - Recipe: [Adapt the deploy workflow for other hosts](https://usepennington.net/how-to/deployment/adapt-for-other-hosts.md) — Azure Static Web Apps, Cloudflare Pages, and Netlify deltas against this workflow.
 - Reference: [CLI and build arguments](https://usepennington.net/reference/host/cli.md) — the `build [baseUrl] [outputDirectory]` surface this workflow drives.
 - Reference: [Build report fields](https://usepennington.net/reference/api/build-report.md) — the `BuildReport` surface (`HasErrors`, `FailedPages`, `Diagnostics`) the CI step above checks.
 
 
[Previous
                
                Build a static site](https://usepennington.net/how-to/deployment/static-build.md)[Next
                    
                Adapt the deploy workflow for other hosts](https://usepennington.net/how-to/deployment/adapt-for-other-hosts.md)