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:
- Point the DNS at GitHub, and set the domain under Settings โ Pages โ Custom domain.
- Keep a
CNAMEfile in the published output. Pages reads it from the artifact root, so put it in your markup input directory orcopyit 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.