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.
Enable it
Section titled “Enable it”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.
Private repositories
Section titled “Private repositories”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.
Deploy from a workflow
Section titled “Deploy from a workflow”name: Pageson: 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, anddeploy-pagesprints 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.
Supported deploy-pages inputs
Section titled “Supported deploy-pages inputs”| 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.
How requests are served
Section titled “How requests are served”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.
Compression
Section titled “Compression”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.
Limits
Section titled “Limits”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.
Deployments and rollback
Section titled “Deployments and rollback”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.
Disabling Pages
Section titled “Disabling Pages”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.
Custom domains
Section titled “Custom domains”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. Plainhttp://redirects tohttps://. - 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.