Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Every pull request publishes the page it changes

Last edited: 2026-08-26 · #112, #114, #142, #143, #144, #171

A preview link was the one thing a pull request never handed you, and getting one turned out to be mostly a matter of not deleting a build CI had already paid for. Two questions had to be answered before that was safe, and each was answered wrong the first time: what the published site is made of, and who is allowed to write it.

Nothing to clean up, because nothing accumulates

The first answer arrived as a bill: three weeks in, the branch carrying the site had reached 103 MB, its build had crept from 28 s to 494 s, the live site was serving a layout two merges old, and a preview whose pull request merged four days earlier was still published. Four bugs, one cause — the published site was versioned state. Write it instead as a function of what is open right now and that entire class of failure stops being reachable:

A closed pull request’s preview is never removed; it simply stops being an input. That one sentence covers the stranded preview, the .nojekyll files orphaned in closed pull requests’ directories, and the branch that only ever grew — all three were incremental-cleanup bugs, and there is no increment left to clean. The store is GitHub’s artifact retention rather than git.

The name on an artifact is not a claim about who wrote it

The second answer arrived as a contributor, and this page asserted the wrong one for a month. The first fork pull request (#164) got no preview, and the guard that arranged that was doing nothing of the sort. For a pull_request event GitHub runs the workflow file from the merge commit — for a fork, a commit the contributor controls. The guard lived in that file. So did the line naming the artifact. And assembly chose the site root by asking for the newest artifact called site-main. Each link is defensible alone; chained, they meant a fork could have published the root of the site, with nothing in the way but a run-approval prompt that stops appearing after a contributor’s first merged pull request.

So the guard was deleted rather than fixed, and forks now publish like anyone else. What makes that safe is a rule that reads nothing the branch wrote — provenance, not name:

AdmittedOnly when
site-mainits run’s head_repository_id equals repository_id, and its branch is main
preview-pr-Nits run’s head SHA is pull request N’s current head
the trigger itselfthe completed run was .github/workflows/deploy.yml

Every field there comes from the API, never from the artifact. The middle row is what lets a fork publish at all: it cannot claim another pull request’s directory without its branch tip being that commit, at which point it has claimed nothing.

What changed from the plan

What the plan got right is the part that mattered: the fail-loud path fired for real on the first pull request, before any site-main artifact existed, printing No usable 'site-main' artifact. The live site is unchanged; recover with: gh workflow run deploy.yml — and the previous site kept serving throughout, exactly as an atomic deployment should.

Why not pull_request_target for the build?

It is the obvious way to hand a fork’s build a write token, and the wrong one here: the build runs myst build --execute, which executes every notebook in the branch. pull_request_target would pair arbitrary contributor code with a token that can write to the repository. workflow_run inverts it — the untrusted half keeps a read-only token and passes an artifact across, and the privileged half never sees the branch at all.

Why not Cloudflare Pages or Netlify?

Both hand out preview URLs with no workflow YAML at all. The price is a vendor account and an API token in the repository’s secrets — for a package heading into JOSS review, one more dependency a reviewer cannot see the far side of. GitHub Pages already hosts the site; a preview should not add a second host. The same stance is why assembly uses nothing but gh.

Why not just download the build artifact?

That was the smallest possible change back when previews were added — delete one if: and the artifact survives. Artifacts are now the transport, but the conclusion is unchanged: a 52 MB zip you download, unpack and serve locally is not a link, and a review gate only works when reading the page is the path of least resistance.

How --strict broke the build on a valid DOI

Docs rules have three enforcers, each because the one above it is blind to something: check_docs.py catches what the build never mentions (a missing target, a dead .ipynb link, a drifted kernel name); myst build --strict catches what the build reports and then exits 0 on anyway; the preview catches whether the page actually reads right. Turning the middle one on went red on a valid DOI — Could not find DOI "https://doi.org/10.1002/mrm.25568" from doi.org — because mystmd resolves every doi.org link over the network at build time. myst build --doi-bib freezes that metadata into docs/myst.doi.bib, which only takes effect once listed in myst.yml’s bibliography, since declaring that key makes mystmd load only the files named there. The lesson generalises past DOIs: a strict gate converts every latent network dependency into a flaky failure, on whichever pull request happens to be open at the time. Note --strict exits non-zero on errors only, never warnings — so the quoted-source pins in the Architecture Contract stay load-bearing.

The .nojekyll trap, and the ref pattern, now historical

A branch-served Pages site runs Jekyll, which silently drops every _-prefixed path — all of the site’s CSS and JS. Neither deploy action wrote a .nojekyll, so the build did, for the root only. Writing one into each preview directory looked harmless and was not: the removal deploys an empty folder, and the action adds --exclude .nojekyll to its delete-rsync whenever the source lacks one, so exactly that file outlived every closed pull request. A site served from a workflow artifact is published as-is, so both the file and its footgun are gone.

Separately, a pull request was once refused with Branch "refs/pull/142/merge" is not allowed to deploy to github-pages. The rule admitting it must be spelled refs/pull/*/merge, because policies are matched against GITHUB_REF with fnmatch semantics, where a wildcard never crosses a /. That entry is now removable: with publishing moved to main’s workflow, no deployment ever comes from a merge ref again.