Skip to content

Publishing a site with Pages

Pages serves a static site built by your CI workflow. It uses the same two actions as GitHub Pages, actions/upload-pages-artifact and actions/deploy-pages, so a GitHub Pages workflow runs once you remove actions/configure-pages (see below).

Sites live on their own domain, neosource.page, apart from the app:

Repository Site URL
neosource.dev/<owner>/<repo> https://<owner>.neosource.page/<repo>/
neosource.dev/<owner>/<owner>.neosource.page https://<owner>.neosource.page/

The repository named <owner>.neosource.page is the owner’s root site, served at /. Each owner gets one hostname, shared by all of that owner’s repositories, the way <owner>.github.io is on GitHub.

The owner name has to be a valid DNS label: letters, digits and -, at most 63 characters. An owner whose name contains . or _ cannot enable Pages.

Open the repository’s Settings → Pages and press Enable Pages. You need admin access on the repository.

Source branch is the only setting. Leave it blank and Pages deploys from the repository’s default branch. Name a branch and only runs on that branch can deploy. You can change it later from the same screen.

A private or internal repository publishes only with the public-site consent: tick Publish a public site from this private repository when you enable Pages, or press Consent and publish later. The consent reads:

This site is public although the repository is private. Everything you deploy, the repository’s name and its owner become visible at <owner>.neosource.page/<repo>/ and on any custom domain.

Without it, the site answers 404 exactly like a repository that does not exist, and a published repository made private stops serving until someone consents. Revoke consent stops serving at once. Setting and revoking it are recorded with who did it.

Under consent only CI publishes: a deploy must come from a CI job of a trusted run whose trigger names the source branch itself (a push of it, or a manual dispatch naming it). An account deploy, a scheduled run, a dispatch that names no branch and a run of another branch are refused, and so is rolling back to a deployment that was not published that way. A live deployment that breaks this rule is not served.

.neosource/workflows/pages.yml
name: Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build # writes the site to dist/
- uses: actions/upload-pages-artifact@v3
with:
path: dist
- uses: actions/deploy-pages@v4
id: deployment
- run: echo "Live at ${{ steps.deployment.outputs.page_url }}"

upload-pages-artifact packs the directory into a github-pages artifact. deploy-pages asks the server to publish that artifact, waits until the deployment is live, and sets the page_url output. Upload and deploy can also run in separate jobs (needs: the upload job), as on GitHub.

The server decides whether a run may deploy:

  • The run must be trusted. A pull request run from a fork is refused, so a fork cannot publish to your site.
  • The run must be on the source branch: its ref is refs/heads/<source branch>. A pull request run or a run on any other branch gets a 403, and deploy-pages prints the reason.
  • A job can deploy only its own run’s artifact.

The server’s refusal replaces GitHub’s github-pages environment protection. Keys like permissions: { pages: write, id-token: write } and environment: github-pages are accepted and ignored with a warning, so you can keep them in a workflow that also runs on GitHub.

Input Notes
artifact_name The artifact to deploy. Default github-pages.
timeout Milliseconds to wait for the deployment to go live. Default 600000.
reporting_interval Milliseconds between status checks. Default 5000.
token, error_count Accepted and ignored. The job’s own token is the credential.
preview preview: true fails. Preview deployments do not exist.

Remove actions/configure-pages: it has no neosource implementation, and the upstream action fails here. If your build used its base_path or base_url outputs, set the base yourself: /<repo>/ for a repository site, / for the root site.

Paths resolve like GitHub Pages:

Request Serves
/ or /docs/ index.html in that directory.
/about about, else about.html, else a 301 to /about/ if about/index.html exists.
/<repo> A 301 to /<repo>/ when that repository has a live site.
anything missing Your site’s 404.html with status 404, or a plain 404 if there is none.

Only GET and HEAD are answered. Every file carries a strong ETag (the SHA-256 of the file, or of the compressed copy when one of yours is served), so a revalidation with If-None-Match gets a 304. HTML and JSON are cached for 60 seconds (Cache-Control: public, max-age=60), since a new deployment changes them under the same URL. Every other file is cached for an hour. Responses carry X-Content-Type-Options: nosniff, the Content-Type comes from the file extension, and Pages sets no cookies.

Responses are compressed automatically: a text-like file (not an image other than SVG) is compressed on the fly for any client that accepts br, gzip or zstd. On-the-fly compression favours speed over size: brotli runs at quality 4, not the maximum of 11.

To serve smaller files, ship your own compressed copies. If the artifact holds X.br and/or X.gz next to X, each copy is checked when the deployment is unpacked. A copy is used when it:

  • is smaller than X,
  • is at most 32 MiB, and
  • decodes byte for byte to X.

A client that accepts br gets X.br. One that accepts only gzip gets X.gz. Any other client gets X. These responses carry Vary: Accept-Encoding. A copy that fails a check is never served for X. Either way, X.br and X.gz stay ordinary files at their own paths, and they count toward the deployment’s size.

Compressing once at build time lets you use the slowest, best settings. This script uses only Node’s built-in zlib, and Node is on PATH on ubuntu-latest:

// precompress.mjs — run as `node precompress.mjs dist` before the upload step.
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { brotliCompressSync, gzipSync, constants } from "node:zlib";
const TEXT = /\.(html|css|js|mjs|json|svg|txt|xml|map|wasm)$/;
for (const entry of await readdir(process.argv[2], {
recursive: true,
withFileTypes: true,
})) {
if (!entry.isFile() || !TEXT.test(entry.name)) continue;
const path = join(entry.parentPath, entry.name);
const raw = await readFile(path);
const br = brotliCompressSync(raw, {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
});
const gz = gzipSync(raw, { level: 9 });
if (br.length < raw.length) await writeFile(`${path}.br`, br);
if (gz.length < raw.length) await writeFile(`${path}.gz`, gz);
}

GitHub Pages ignores these copies and serves gzip only, so they are safe to keep in a workflow that publishes to both.

A deployment is refused if it holds more than 50,000 files or more than 1 GiB in total. Precompressed copies count toward both. A path may be at most 1,024 bytes. Symlinks, hard links and other special files are refused. upload-pages-artifact follows symlinks when it packs the directory, so they only matter if you build the artifact yourself. The limits are checked during the unpack, before anything goes live, so a refused deployment never replaces the live site.

Settings → Pages lists the ten most recent deployments, with their status: Active, Inactive, Deploying or Failed, plus the error for a failed one. A deployment still unpacking after 30 minutes is shown as failed.

To roll back, press Roll back on an inactive deployment. It goes live within seconds, without a rebuild. The next deployment from CI replaces it.

Disable Pages in the same screen removes the site and all of its deployments, and the site answers 404 at once. Enabling Pages again starts with no deployments, so you have to deploy again.

If the operators take a site down for abuse, it answers 451 Unavailable For Legal Reasons on every path. While it is down, the site cannot be deployed to, rolled back or disabled. To report a site, use the link on neosource.page.

A site can be served at the root of one domain you own, such as docs.example.com. For now it must be a subdomain: a bare registrable domain (example.com, or alice.github.io under a shared suffix) is refused, as are hosts under neosource.page and the hosts neosource itself answers.

In Settings → Pages → Custom domain, enter the domain. neosource then asks for two DNS records:

Type Name Value
CNAME docs.example.com <owner>.neosource.page
TXT _neosource-pages.docs.example.com neosource-pages-verification=<token>

The TXT record proves you control the domain; the CNAME points it at neosource. Every address the name resolves to must be one of neosource’s, so a CNAME to somewhere else, or an extra A record, fails the check.

The domain then moves through these states:

  • Awaiting DNS (pending): neosource looks for both records, at first every minute and then less often. Check now looks at once. A claim that is not verified within 7 days is removed. Several sites may claim the same domain; the first one to verify it wins, and the other claims are removed.
  • Issuing certificate (verified): the records are verified and a certificate is being issued.
  • Active: the site is served at https://docs.example.com/, and requests to <owner>.neosource.page/<repo>/ redirect there. Plain http:// redirects to https://.
  • Failing: neosource re-checks both records every 6 hours. When either is gone the domain is failing; it keeps serving while its certificate lasts, and is removed after 48 hours of continuous failure.

Removing the domain stops serving it at once. A private repository’s site needs the public-site consent on its custom domain too.