Skip to content

ci: publish a documentation preview for each pull request - #368

Merged
jat255 merged 14 commits into
jat255/3w6z-python-docs-sitefrom
jat255/qp8j-docs-previews
Sep 13, 2026
Merged

jat255 merged 14 commits into
jat255/3w6z-python-docs-sitefrom
jat255/qp8j-docs-previews

Conversation

@jat255

@jat255 jat255 commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

This PR adds a documentation preview for each pull request. See the comments below for examples. It publishes the landing page, the R site, and the Python site as one tree under pr-<number>/ on the gh-pages branch, and then the action posts a comment with a link to the deployed preview (see below). There's also an "auto-cleanup" action that runs when a PR is merged (which hasn't been tested yet, but we can adjust as needed).

The PR also adds scripts/preview-docs.sh which can be used to build and serve the docs for both packages locally.

The other change (which probably should have gone on #366) is to give all three sites one shared favicon.

Stacked on #366.

Implementation notes (agent-written)

The rationale for each piece is in a comment at the top of the file, so this only says where to look.

Publishing. pkgdown.yaml and quartodoc.yaml gain a preview deploy alongside the existing one. noindex-preview.py adds a robots meta tag to every page, because a robots.txt under posit-dev.github.io is never read. link-r-dev.sh redirects r/ to r/dev/, which is where pkgdown puts a development version. It runs for previews only, and the file says why moving the tree instead would break site search.

Cleanup. preview-cleanup.yml removes a preview when its pull request closes, and a weekly schedule collects whatever the close event missed. The schedule cannot run until this merges, so the manual run defaults to a dry run.

Forks. A fork gets no preview, because building one would serve unreviewed HTML from posit-dev.github.io. preview-fork-notice.yml says so in a comment. It uses pull_request_target because a fork's token cannot write one; it checks out no code and runs none.

Favicon. favicon/ at the repository root is the source, and sync-shared.sh copies it into the two packages and the landing page. Destinations marked :bare skip the generated README, because their contents go onto a website as they stand.

Tests. .github/scripts/ belongs to no package, so scripts-check.yaml runs test_noindex_preview.py on it.

Verification. scripts/preview-docs.sh built both sites and served them; the landing page, r/dev/, and py/ all resolved, and their links to each other worked. scripts/sync-shared.sh regenerates every destination with no drift.

R changes

Favicon assets are now copied by scripts/sync-shared.sh into pkg-r/pkgdown/favicon/, which pkgdown picks up when it builds the site.

One little wrinkle/oddity is the interaction with pkgdown's automatic development mode. Because the deployed site expects the R site at /r, but pkgdown writes into /r/dev, the link-r-dev.sh script was necessary to provide a shim that writes a redirect into r/index.html only when that file does not exist (this is true for a development version and false for a released one). If the R package ever builds a preview from a released version, the redirect correctly does not appear. I'm not overly familiar with the benefits of pkgdown's dev mode, but I'm wondering if it's causing more trouble than value here.

Both site workflows already built on pull_request and skipped only the deploy
step, so a preview is that same deploy with target-folder pr-<number> and the
condition inverted. Each posts a sticky comment with the link. Fork pull
requests are excluded: their token is read-only, and the alternative means
serving unreviewed fork HTML from posit-dev.github.io.

Preview folders are collected on pull request close and, more importantly, by
a weekly job that lists pr-* on gh-pages and keeps only the folders whose pull
request is still open. Event-driven cleanup is best-effort, so the schedule is
what makes the branch converge from any state. It asks for the open pull
requests once rather than per folder: a per-folder query has to decide what a
failed query means, and treating failure as "not open" would delete every live
preview the first time the API rate-limited us.

The deploy concurrency group is now shared by every run that pushes to
gh-pages, including previews, which previously could not collide because pull
requests did not deploy.

robots.txt keeps previews out of search results, since the repository is
public.
The site workflows skip the preview deploy for forks, and a fork's token is
read-only, so they cannot explain the skip themselves. pull_request_target
runs with the base repository's token and can. The job checks out nothing and
holds only pull-requests: write, so it neither reads nor runs fork code.
Review caught two defects in the preview workflows.

Serializing every gh-pages push through one concurrency group was wrong.
Actions keeps only one pending run per group, so a third arrival cancels the
queued one outright: a pull request touching both packages, landing alongside
a push to main, would lose one site's preview with no failure to show for it.
Groups are per workflow and per pull request again, and the races they were
meant to prevent are handled where they happen, by the deploy action's rebase.
That needs force: false, since the default force-push would drop whichever
deploy landed first. The cleanup job rebases and retries its own push for the
same reason.

robots.txt did nothing. This is a project site under posit-dev.github.io, and
crawlers read robots.txt only at the origin root, which this repository does
not own. Preview pages now carry a noindex meta tag instead, added by a script
both workflows run before the preview deploy. Shipping a robots.txt that looks
like protection and is never read is worse than shipping nothing, so it is
gone.
The head start tag is optional in HTML, so reporting a page that lacks one and
moving on left it deployed and indexable. The script synthesizes a head now,
after the html tag where there is one and at the top of the file otherwise.

Failing the run instead, as the review suggested, would let a single vendored
fragment block every preview, and a fragment is usually not a page anyone
would index. Neither generator omits the tag today: all 70 pages across the
two built sites carry one.
A document may omit both <html> and <head> but still open with a doctype, and
a doctype only counts if nothing precedes it. Prepending the synthesized head
demoted it and put the browser in quirks mode, so the preview would render
unlike the page it previews. The head now goes after the doctype when there is
no <html> tag to follow, and only a file with neither gets it prepended.
Three review rounds found three defects in this script, all in the branch that
handles a page with no head, and none of them reachable from either generator:
all 70 pages across the two built sites carry a head. The branch was untested
because a .github/scripts helper has no package to be tested by.

Removed the fallback that prepended a head to anything at all. A file with no
head, no html tag, and no doctype is a fragment, not a page, so nothing
indexes it alone and giving it a head only corrupts whatever embeds it.
Skipping it removes the ordering question the last two defects were about. The
doctype pattern now also allows a leading byte order mark, which is the
remaining way a doctype could have been demoted.

The transformation is a pure function now, with tests covering an existing
head, an uppercase head with attributes, a page that already declares robots,
head synthesis after an html tag, doctype-first in the upper, lower, and byte
order mark forms, fragments, and idempotency. A small workflow runs them,
since the package suites are scoped to their own directories.
The doctype alternative was not anchored, so any file quoting a doctype
anywhere passed as a document and was rewritten. It is anchored now, and the
byte order mark is written as an escape rather than as the literal character,
which was invisible in the source.

Review did not mention the related defect, which the same regex caused: with
both alternatives in one pattern, the leftmost match won, so a page with a
doctype and an html tag but no head had its head inserted between the two,
outside html. The html tag is now tried first, and the doctype only when there
is no html tag to follow. Both cases have tests.

__pycache__/ was ignored only under pkg-py, so running the new test left a
bytecode cache that a directory-wide git add would pick up. The root file's
own convention is that a pattern with no separator belongs at the root and
applies at any depth.
The sites are published as one tree, and their links to each other are
relative, so opening a built file directly resolves none of them.
scripts/preview-docs.sh assembles the same tree a deploy publishes and serves
it, so a local look and a pull request preview show the same thing. It runs
each package's real build commands rather than its own, so the output matches
what CI produces.

A missing toolchain skips that site with a line naming what to install, and
the script says which site is absent, because the alternative is clicking a
cross-language link and wondering whether the build broke. Requiring both
toolchains would make it useless to anyone set up for one language.

The R site needs commons installed, as the workflow does through
setup-r-dependencies, so the script checks for it up front. Without the check
a missing install surfaces as an R stack trace several minutes into a build.
Naming the missing package left the reader to work out where to type the fix,
and the answer differs between the shell and the R console. Each skip now
prints a reason and a command to paste. The commons one installs pak if it has
to and runs from the repository root, so it works from whatever directory the
reader happened to be in.

The R site's real blocker on a fresh machine is magick, an Imports with a
macOS binary on CRAN, so nothing outside R needs installing. Letting
local_install_deps read DESCRIPTION covers it without naming it here, where it
would go stale.
One long wrapped line between two other messages is not something anyone
copies. Each skip now prints its reason, then its commands one per line,
indented with nothing in front of them so the block can be selected and pasted
whole. No box drawing: the border characters come along with the copy.

The commands take an absolute path rather than opening with cd, so pasting
them does not move the reader's shell, and the pak line appears only when pak
is actually missing instead of as a conditional to evaluate by eye.
pkgdown's automatic development mode decides the destination from the version
in DESCRIPTION: a released version lands at the root of destination, a
development version in dev/ below it. DESCRIPTION is at 0.1.0.9000, so a build
from this branch produces only docs/r/dev/, and both the preview comment and
the local script pointed at docs/r/, which serves a directory listing. The
deployed site hides this, because v0.1.0 was released and left an index there.

Both now resolve the entry point from what the build produced. The local
script prints the address of each site after building rather than leaving the
reader to infer it from the landing page, whose r/ link is correct for the
deployed site and wrong for a development build.
A build from a branch is always a development version, so pkgdown writes it to
r/dev/ and leaves r/ empty, and the landing page's link to r/ lands on nothing.

Moving dev/ up would fix the link and break the site: the build writes its own
absolute address into search.json, sitemap.xml, and 15 pages, so a moved tree
keeps directing the browser to r/dev/, and site search stops resolving. A
redirect at r/index.html leaves the build untouched and costs one file.

It is written for local previews and pull request previews only. The published
site keeps the released documentation at r/, and replacing that index with a
redirect to the development docs would be a regression for every reader. The
production deploy step and the preview steps are mutually exclusive on the
event, so the two cannot cross.
pkgdown passes --mathml once per topic and rmarkdown passes --mathjax once
per vignette; pandoc 3.11 deprecated both spellings, and neither flag is
reachable from here. Filter those lines out of the R build's stderr, keeping
stdout and the exit status untouched.
The icon set pkgdown generated was never committed, so only a local R build
had a favicon. Move it to favicon/ at the repository root, sync it into the
pkgdown directory, the Quarto site, and the landing page, and give the
Python site and the landing page the same five link tags pkgdown writes.

The manifest named its icons with absolute paths, which resolve above a
site published under a prefix, and carried no name.

Destinations marked :bare skip the sync script's generated README, since
their contents are copied onto a website as they stand.
@jat255
jat255 added this pull request to stack #367 September 13, 2026 22:16
@github-actions

Copy link
Copy Markdown
Contributor

Python site preview: https://posit-dev.github.io/commons/pr-368/py/

Built from the latest commit on this branch. The landing page's R link resolves inside the preview only if this pull request also rebuilt the R site.

@github-actions

Copy link
Copy Markdown
Contributor

R site preview: https://posit-dev.github.io/commons/pr-368/r/

Built from the latest commit on this branch. Use the link above rather than the landing page's, which points at the released site. The landing page's Python link resolves only if this pull request also rebuilt the Python site.

@jat255 jat255 added documentation Improvements or additions to documentation r Affects the R implementation py Affects the Python implementation labels Sep 13, 2026
@jat255
jat255 merged commit 1ed83a4 into main Sep 13, 2026
27 of 28 checks passed
@jat255

jat255 commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

For posterity of what happened here, I accidentally merged this branch into the PR #366 branch prematurely, and then Github auto-closed this PR, which I couldn't re-open. #370 takes its place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation py Affects the Python implementation r Affects the R implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant