Deploy to GitHub Pages
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 shape of output/ is easier to automate once it's familiar.
Assumptions
- 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. - Working with GitHub Actions YAML at the "copy, commit, inspect the run log" level feels approachable.
For a working setup, see examples/SubPathDeployableExample. The .github/workflows/deploy.yml, host-config siblings (staticwebapp.config.json, netlify.toml, nginx.conf, web.config), and the BuildHost helper are the teaching surface; the rest of the example is outside scope here.
Steps
- 1
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. - 2
Drop in the canonical workflow
Commit the YAML below to
.github/workflows/deploy.ymlat the repo root. It pinsactions/setup-dotnet@v4to .NET 11, derives the base URL from${{ github.event.repository.name }}so the same file works on forks and renames, runsdotnet run -- build "$BASE_URL", writes.nojekyll, and handsoutput/toactions/upload-pages-artifact@v3andactions/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 a custom domain (root `/`), replace the `BASE_URL` # calculation with an empty string and strip the `/` from the build arg. 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: 11.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 - 3
Point the
--projectpath at your siteThe template targets
examples/SubPathDeployableExample; edit the--projectargument and anyworking-directoryreferences so thedotnet runstep points at the correct csproj. For repos that host multiple buildable projects, addactions/cache@v4over~/.nuget/packagesif NuGet restore takes more than a minute —--configuration Releaseis already set. - 4
Keep
.nojekyllin the artifactGitHub Pages runs content through Jekyll by default, which silently strips any path starting with an underscore — that removes Pennington's
_content/copy folder and SPA_spa-data/payloads. Thetouch output/.nojekyllstep in the workflow disables Jekyll processing; leave it in place. - 5
Match the build
baseUrlto the Pages URLProject Pages sites serve at
https://<user>.github.io/<repo>/, so the workflow passes/<repo>as the first positionalbuildargument andBaseUrlHtmlRewriterprefixes every internalhref,src, andactionon the way out. For sites at an org-level root or a custom apex domain, replace theBASE_URLenv with an empty string and drop the argument entirely. Sub-path wiring is covered in Host under a sub-path (base URL). - 6
(Optional) Fail CI on a bad
BuildReportRunOrBuildAsyncalready sets a non-zero exit code on errors, so the workflow fails fast on broken pages. For stricter semantics — failing the main-branch build on broken xrefs while letting warnings pass on feature branches — wrap the call and write the report to stdout.if (args.Length > 0 && args[0].Equals("build", StringComparison.OrdinalIgnoreCase)) { await app.StartAsync(); var generator = app.Services.GetRequiredService<OutputGenerationService>(); var report = await generator.GenerateAsync(); await app.StopAsync(); PrintBuildReport(report); } else { await app.RunAsync(); }report.WriteTo(Console.Out); if (report.HasErrors) { Environment.ExitCode = 1; }
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>/">. - 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 —
BuildReport,BuildDiagnostic, andBrokenLinksemantics for the CI step above.