Publishing to GitHub Pages

The build is green, the deploy is green, and the live site is unstyled with every link one level off. Nothing failed: a project site is served from https://user.github.io/repo/, not from /, and any absolute /css/styles.css you wrote points at a directory GitHub never made.

Poops writes relative path prefixes by default โ€” ./, ../ โ€” precisely so a build works wherever it lands, subdirectory included. Use {{ relativePathPrefix }} for every asset and link and you can stop reading after the workflow. Hardcode a leading slash anywhere, or need absolute URLs in sitemap.xml and og: tags, and the fix is one flag: --base-url /repo.

Turn Pages on first

Settings โ†’ Pages โ†’ Build and deployment โ†’ Source: GitHub Actions. The workflow below uploads an artifact and asks Pages to publish it; while the source is still Deploy from a branch, there is nothing on the other end of that request.

The workflow

.github/workflows/pages.yml:

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      - run: npm ci

      # The repo name is the subdirectory the site is served from.
      - run: npx poops --build --base-url /${{ github.event.repository.name }}

      - uses: actions/upload-pages-artifact@v4
        with:
          path: dist

      - id: deployment
        uses: actions/deploy-pages@v4

That is the shape this site deploys with โ€” pages.yml in the Poops repo is the same file with example/dist as the artifact path, because the docs site lives inside the repo that builds it. Point path: at whatever your markup.out is.

Three lines carry the weight:

Line Why it is there
permissions: pages: write, id-token: write deploy-pages authenticates with an OIDC token minted per run. Without id-token: write the deploy step fails; no secret is stored anywhere either way.
concurrency: group: pages Two pushes in a minute otherwise race for the same Pages deployment. The later one wins, and which one that is depends on timing.
npm ci Installs exactly the lockfile. npm install may resolve a newer minor of a dependency than the one you tested โ€” on a static site that shows up as a layout that moved, months later.

Tip

cache: npm on setup-node needs a lockfile in the repo. No lockfile, no cache โ€” and no npm ci either; that step wants one too.

When you need --base-url, and when you do not

Where the site lives Flag Why
user.github.io/repo/ (project site) --base-url /repo {{ relativePathPrefix }} resolves to /repo/ everywhere instead of a per-page ../. Required the moment anything is absolute.
user.github.io (user site, repo user.github.io) none The site is the root. A base URL of /user.github.io would be wrong.
A custom domain none Same reason โ€” the site is at the root of that domain.

The flag overrides markup.options.baseURL from the config, which is the point: one config file, different deploy paths per environment. Leaving both unset keeps prefixes relative, which also works from file:// โ€” open dist/index.html in a browser and the site still works.

Absolute URLs โ€” sitemap.xml, canonical links, og:image, JSON-LD โ€” come from site.url instead, so set that to the deployed address:

"markup": {
  "options": {
    "site": { "url": "https://user.github.io/repo" }
  }
}

A custom domain

Two things, and the order does not matter:

  1. Point the DNS at GitHub, and set the domain under Settings โ†’ Pages โ†’ Custom domain.
  2. Keep a CNAME file in the published output. Pages reads it from the artifact root, so put it in your markup input directory or copy it in:
"copy": [{ "in": "src/CNAME", "out": "dist" }]

Then drop --base-url from the workflow and set site.url to the domain. A stale --base-url on a custom domain is the same 404 as before, in the other direction.

What still bites

Symptom Cause Fix
Posts reshuffle between deploys A post with no date in front matter falls back to file mtime, and git clone on a runner sets mtime to checkout time Put a real date in front matter โ€” the build already warns about this
Assets 404 only in production An absolute /css/... in a layout {{ relativePathPrefix }}css/..., or --base-url
Deploy publishes an empty site path: in upload-pages-artifact does not match markup.out They are two separate strings; keep them in step
Nothing deploys, no error poops -b exits 0 when it has nothing to compile โ€” a wrong path is a green build Assert an artifact exists: test -f dist/index.html

The last one is worth a step of its own in any workflow you care about:

      - name: Verify build output
        run: test -f dist/index.html && test -f dist/sitemap.xml

The other route: deploy from a branch

Committing dist to a gh-pages branch still works, and it is what you fall back to when the site is built somewhere other than Actions.

Actions artifact gh-pages branch
What is stored nothing โ€” the artifact is transient every build, forever, in git history
Jekyll runs on it no, the artifact is served as uploaded yes โ€” branch sources are built with Jekyll unless a .nojekyll file sits at the root
Needs Pages source set to GitHub Actions a push token and a branch
Rollback re-run an older workflow git revert

Jekyll on a branch deploy eats any directory starting with an underscore, which is exactly what a Poops _layouts or _partials directory is called if it ever reaches the output. Add .nojekyll and the problem disappears; on the Actions route it never appears.