From 0169ae6d5fe049054f9e087adee45e932887e098 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 10:41:14 -0600 Subject: [PATCH 01/14] ci: publish a documentation preview for each pull request 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- 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. --- .github/workflows/pkgdown.yaml | 36 ++++++++- .github/workflows/preview-cleanup.yml | 112 ++++++++++++++++++++++++++ .github/workflows/quartodoc.yaml | 36 ++++++++- docs/robots.txt | 4 + 4 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/preview-cleanup.yml create mode 100644 docs/robots.txt diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 232fe25d..711561b5 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -20,14 +20,17 @@ jobs: defaults: run: working-directory: pkg-r - # Shared with the sibling site's workflow: both deploy to gh-pages, and - # two pushes to one branch race. + # Every run that can push to gh-pages shares one group, including pull + # request previews. Two pushes to one branch race, and the cleanup + # workflow joins this group so a closing PR's build cannot recreate the + # folder the cleanup just removed. concurrency: - group: gh-pages-${{ github.event_name != 'pull_request' || github.run_id }} + group: gh-pages env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} permissions: contents: write + pull-requests: write steps: - uses: actions/checkout@v6 @@ -52,3 +55,30 @@ jobs: clean: false branch: gh-pages folder: docs + + # A fork's GITHUB_TOKEN is read-only, so the deploy and the comment both + # fail there. Fork previews are not worth the alternative: publishing + # fork-authored HTML to posit-dev.github.io means serving unreviewed + # content from our own origin. + - name: Deploy the pull request preview 🔎 + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 + with: + clean: false + branch: gh-pages + folder: docs + target-folder: pr-${{ github.event.number }} + + - name: Comment the preview link + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: preview-r + message: | + **R site preview:** [https://posit-dev.github.io/commons/pr-${{ github.event.number }}/r/](https://posit-dev.github.io/commons/pr-${{ github.event.number }}/r/) + + Built from the latest commit on this branch. The landing page's Python link resolves inside the preview only if this pull request also rebuilt the Python site. diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml new file mode 100644 index 00000000..107c7d78 --- /dev/null +++ b/.github/workflows/preview-cleanup.yml @@ -0,0 +1,112 @@ +# Removes the pr-/ preview folders that pkgdown.yaml and +# quartodoc.yaml publish to gh-pages. +# +# Closing a pull request handles the ordinary case immediately. The schedule +# is what makes the branch converge: it does not care whether a close event +# ever fired, whether this workflow existed when the pull request was opened, +# or whether an earlier run failed, because it derives the whole answer from +# the branch and the API each time. +# +# workflow_dispatch exists because a schedule only ever runs the copy of this +# file on the default branch, so this job cannot run at all until it merges. +# Its dry run defaults to true so the first manual run reports rather than +# deletes. +name: preview-cleanup.yml + +on: + pull_request: + types: [closed] + schedule: + # Weekly. Stale previews cost nothing but repository size, so this only + # needs to be faster than the rate at which they accumulate. + - cron: "17 4 * * 1" + workflow_dispatch: + inputs: + dry-run: + description: List what would be removed without removing it + type: boolean + default: true + +permissions: read-all + +jobs: + cleanup: + runs-on: ubuntu-latest + # The same group the two site workflows deploy under, so this job queues + # behind a build that is already running rather than deleting a folder + # that build is about to write. If the two still interleave, the schedule + # collects the folder on its next run. + concurrency: + group: gh-pages + permissions: + contents: write + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + ref: gh-pages + # A shallow clone cannot be pushed back. + fetch-depth: 0 + + - name: Remove preview folders + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + EVENT: ${{ github.event_name }} + CLOSED_PR: ${{ github.event.number }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + + if [ "$EVENT" = "pull_request" ]; then + targets="pr-$CLOSED_PR" + else + # Ask once for the open pull requests, rather than asking about + # each folder. Per-folder queries have to decide what a failed + # query means, and the safe answer ("leave it") is the same as + # the answer for an open pull request, so the query earns + # nothing. One list also fails loudly: a rate limit or a bad + # token aborts the run here, where deleting nothing is correct, + # instead of reading as "no pull request is open" and collecting + # every live preview. + open_prs=$(gh api "repos/$REPO/pulls?state=open&per_page=100" --paginate --jq '.[].number') + + targets="" + # `-printf` would be shorter, but it is GNU-only, and a portable + # listing is one that can be exercised outside the runner. + for dir in $(find . -maxdepth 1 -type d -name 'pr-*' | sed 's|^\./||' | sort); do + number="${dir#pr-}" + if ! printf '%s' "$number" | grep -qE '^[0-9]+$'; then + echo "Skipping $dir: not a pull request number" + continue + fi + if printf '%s\n' "$open_prs" | grep -qx "$number"; then + echo "Keeping $dir: pull request is open" + else + echo "Collecting $dir: pull request is not open" + targets="$targets $dir" + fi + done + fi + + removed="" + for dir in $targets; do + [ -d "$dir" ] || continue + removed="$removed $dir" + done + + if [ -z "$removed" ]; then + echo "Nothing to remove." + exit 0 + fi + + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "Dry run. Would remove:$removed" + exit 0 + fi + + git rm -r --quiet $removed + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "Remove preview folders:$removed" + git push origin gh-pages diff --git a/.github/workflows/quartodoc.yaml b/.github/workflows/quartodoc.yaml index 105fab85..e91fa21b 100644 --- a/.github/workflows/quartodoc.yaml +++ b/.github/workflows/quartodoc.yaml @@ -18,12 +18,15 @@ jobs: defaults: run: working-directory: pkg-py - # Shared with the sibling site's workflow: both deploy to gh-pages, and - # two pushes to one branch race. + # Every run that can push to gh-pages shares one group, including pull + # request previews. Two pushes to one branch race, and the cleanup + # workflow joins this group so a closing PR's build cannot recreate the + # folder the cleanup just removed. concurrency: - group: gh-pages-${{ github.event_name != 'pull_request' || github.run_id }} + group: gh-pages permissions: contents: write + pull-requests: write steps: - uses: actions/checkout@v6 @@ -61,3 +64,30 @@ jobs: clean: false branch: gh-pages folder: docs + + # A fork's GITHUB_TOKEN is read-only, so the deploy and the comment both + # fail there. Fork previews are not worth the alternative: publishing + # fork-authored HTML to posit-dev.github.io means serving unreviewed + # content from our own origin. + - name: Deploy the pull request preview 🔎 + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 + with: + clean: false + branch: gh-pages + folder: docs + target-folder: pr-${{ github.event.number }} + + - name: Comment the preview link + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: preview-py + message: | + **Python site preview:** [https://posit-dev.github.io/commons/pr-${{ github.event.number }}/py/](https://posit-dev.github.io/commons/pr-${{ github.event.number }}/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. diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 00000000..1bd49b48 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +# Pull request previews are published under /pr-/ and are +# unreviewed drafts. Keep them out of search results. +User-agent: * +Disallow: /pr- From a4f34e3f6ee80712dcebf5f3dbe72e589a92b806 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 10:42:16 -0600 Subject: [PATCH 02/14] ci: tell fork authors why they get no documentation preview 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. --- .github/workflows/preview-fork-notice.yml | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/preview-fork-notice.yml diff --git a/.github/workflows/preview-fork-notice.yml b/.github/workflows/preview-fork-notice.yml new file mode 100644 index 00000000..85f09ab0 --- /dev/null +++ b/.github/workflows/preview-fork-notice.yml @@ -0,0 +1,35 @@ +# Tells a fork's author why their pull request has no documentation preview. +# +# pkgdown.yaml and quartodoc.yaml skip the preview deploy for forks, and a +# fork's GITHUB_TOKEN is read-only, so those workflows cannot post this +# themselves. pull_request_target runs with the base repository's token, which +# can. This workflow never checks out the pull request, and asks for no +# permission beyond writing the comment, so none of what makes +# pull_request_target dangerous applies: it reads no fork code and runs none. +name: preview-fork-notice.yml + +on: + pull_request_target: + types: [opened, reopened] + +permissions: {} + +jobs: + notice: + if: github.event.pull_request.head.repo.fork == true + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Post the fork preview notice + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + header: preview-fork + message: | + **No documentation preview for this pull request.** + + Previews are published to this repository's own GitHub Pages site, + so building one from a fork would serve unreviewed HTML from + `posit-dev.github.io`. A maintainer who wants to see the rendered + docs can push this branch to the repository, which produces a + preview automatically. From 3d31f083faed003a0e9466f7729d3da27fd8f8a2 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 10:46:00 -0600 Subject: [PATCH 03/14] ci: let gh-pages deploys rebase, and mark previews noindex 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. --- .github/scripts/noindex-preview.py | 48 +++++++++++++++++++++++++++ .github/workflows/pkgdown.yaml | 26 ++++++++++++--- .github/workflows/preview-cleanup.yml | 24 ++++++++++---- .github/workflows/quartodoc.yaml | 26 ++++++++++++--- docs/robots.txt | 4 --- 5 files changed, 108 insertions(+), 20 deletions(-) create mode 100755 .github/scripts/noindex-preview.py delete mode 100644 docs/robots.txt diff --git a/.github/scripts/noindex-preview.py b/.github/scripts/noindex-preview.py new file mode 100755 index 00000000..23f35bde --- /dev/null +++ b/.github/scripts/noindex-preview.py @@ -0,0 +1,48 @@ +"""Mark every page of a built site as noindex, in place. + +Pull request previews live under posit-dev.github.io/commons/pr-/. +Crawlers read robots.txt only at the origin root, and this repository does not +own posit-dev.github.io, so a robots.txt shipped with the site is never +consulted. A meta tag travels with the page instead. + +Usage: noindex-preview.py +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +TAG = '' +HEAD = re.compile(r"]*>", re.IGNORECASE) + + +def main(root: str) -> int: + directory = pathlib.Path(root) + if not directory.is_dir(): + print(f"{root} is not a directory", file=sys.stderr) + return 1 + + marked = skipped = 0 + for path in sorted(directory.rglob("*.html")): + text = path.read_text(encoding="utf-8", errors="surrogateescape") + if 'name="robots"' in text: + skipped += 1 + continue + patched, count = HEAD.subn(lambda m: m.group(0) + TAG, text, count=1) + if count: + path.write_text(patched, encoding="utf-8", errors="surrogateescape") + marked += 1 + else: + # A page with no cannot carry the tag. Report it rather + # than pass silently, since it stays indexable. + print(f"No in {path}", file=sys.stderr) + skipped += 1 + + print(f"Marked {marked} page(s) noindex, skipped {skipped}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else ".")) diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 711561b5..6bde569a 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -20,12 +20,14 @@ jobs: defaults: run: working-directory: pkg-r - # Every run that can push to gh-pages shares one group, including pull - # request previews. Two pushes to one branch race, and the cleanup - # workflow joins this group so a closing PR's build cannot recreate the - # folder the cleanup just removed. + # Per workflow and per pull request, so a run supersedes an older pending + # run of the same site. Serializing every gh-pages push in one group would + # be wrong: Actions keeps only one pending run per group, so a third + # arrival cancels the queued one outright and its preview never publishes. + # Races between the two sites are handled at the push instead, by the + # deploy action's rebase. concurrency: - group: gh-pages + group: pkgdown-${{ github.event.number || github.ref }} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} permissions: @@ -55,11 +57,23 @@ jobs: clean: false branch: gh-pages folder: docs + # Both sites and the preview deploys push to this one branch, so a + # deploy has to rebase onto whatever landed while it was building. + # Forcing would silently drop that other deploy. + force: false + attempt-limit: 10 # A fork's GITHUB_TOKEN is read-only, so the deploy and the comment both # fail there. Fork previews are not worth the alternative: publishing # fork-authored HTML to posit-dev.github.io means serving unreviewed # content from our own origin. + - name: Mark the preview noindex + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: python3 .github/scripts/noindex-preview.py docs + - name: Deploy the pull request preview 🔎 if: > github.event_name == 'pull_request' && @@ -70,6 +84,8 @@ jobs: branch: gh-pages folder: docs target-folder: pr-${{ github.event.number }} + force: false + attempt-limit: 10 - name: Comment the preview link if: > diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml index 107c7d78..7fc5d3db 100644 --- a/.github/workflows/preview-cleanup.yml +++ b/.github/workflows/preview-cleanup.yml @@ -32,12 +32,12 @@ permissions: read-all jobs: cleanup: runs-on: ubuntu-latest - # The same group the two site workflows deploy under, so this job queues - # behind a build that is already running rather than deleting a folder - # that build is about to write. If the two still interleave, the schedule - # collects the folder on its next run. + # Only against other cleanup runs. This job cannot be serialized against + # the deploys: Actions keeps one pending run per group, so sharing theirs + # would cancel a queued preview. It rebases onto a concurrent deploy + # below, and the schedule collects anything the two still interleave. concurrency: - group: gh-pages + group: preview-cleanup permissions: contents: write pull-requests: read @@ -109,4 +109,16 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -m "Remove preview folders:$removed" - git push origin gh-pages + + # A deploy may have landed on gh-pages while this job ran. Rebase + # onto it and try again rather than forcing, which would drop it. + for attempt in 1 2 3 4 5; do + if git push origin gh-pages; then + exit 0 + fi + echo "Push rejected, rebasing (attempt $attempt)" + git pull --rebase origin gh-pages + done + + echo "Could not push after 5 attempts." >&2 + exit 1 diff --git a/.github/workflows/quartodoc.yaml b/.github/workflows/quartodoc.yaml index e91fa21b..5c6f8163 100644 --- a/.github/workflows/quartodoc.yaml +++ b/.github/workflows/quartodoc.yaml @@ -18,12 +18,14 @@ jobs: defaults: run: working-directory: pkg-py - # Every run that can push to gh-pages shares one group, including pull - # request previews. Two pushes to one branch race, and the cleanup - # workflow joins this group so a closing PR's build cannot recreate the - # folder the cleanup just removed. + # Per workflow and per pull request, so a run supersedes an older pending + # run of the same site. Serializing every gh-pages push in one group would + # be wrong: Actions keeps only one pending run per group, so a third + # arrival cancels the queued one outright and its preview never publishes. + # Races between the two sites are handled at the push instead, by the + # deploy action's rebase. concurrency: - group: gh-pages + group: quartodoc-${{ github.event.number || github.ref }} permissions: contents: write pull-requests: write @@ -64,11 +66,23 @@ jobs: clean: false branch: gh-pages folder: docs + # Both sites and the preview deploys push to this one branch, so a + # deploy has to rebase onto whatever landed while it was building. + # Forcing would silently drop that other deploy. + force: false + attempt-limit: 10 # A fork's GITHUB_TOKEN is read-only, so the deploy and the comment both # fail there. Fork previews are not worth the alternative: publishing # fork-authored HTML to posit-dev.github.io means serving unreviewed # content from our own origin. + - name: Mark the preview noindex + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: python3 .github/scripts/noindex-preview.py docs + - name: Deploy the pull request preview 🔎 if: > github.event_name == 'pull_request' && @@ -79,6 +93,8 @@ jobs: branch: gh-pages folder: docs target-folder: pr-${{ github.event.number }} + force: false + attempt-limit: 10 - name: Comment the preview link if: > diff --git a/docs/robots.txt b/docs/robots.txt deleted file mode 100644 index 1bd49b48..00000000 --- a/docs/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -# Pull request previews are published under /pr-/ and are -# unreviewed drafts. Keep them out of search results. -User-agent: * -Disallow: /pr- From fde7626e29196e599f45d5776bf40c73817f9578 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 10:48:20 -0600 Subject: [PATCH 04/14] ci: give a preview page a head when it has none 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. --- .github/scripts/noindex-preview.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/scripts/noindex-preview.py b/.github/scripts/noindex-preview.py index 23f35bde..6295d224 100755 --- a/.github/scripts/noindex-preview.py +++ b/.github/scripts/noindex-preview.py @@ -16,6 +16,7 @@ TAG = '' HEAD = re.compile(r"]*>", re.IGNORECASE) +HTML = re.compile(r"]*>", re.IGNORECASE) def main(root: str) -> int: @@ -31,14 +32,20 @@ def main(root: str) -> int: skipped += 1 continue patched, count = HEAD.subn(lambda m: m.group(0) + TAG, text, count=1) - if count: - path.write_text(patched, encoding="utf-8", errors="surrogateescape") - marked += 1 - else: - # A page with no cannot carry the tag. Report it rather - # than pass silently, since it stays indexable. - print(f"No in {path}", file=sys.stderr) - skipped += 1 + if not count: + # The head start tag is optional in HTML, and neither generator + # omits it today. Synthesize one anyway rather than deploy a page + # that is still indexable. Failing the run instead would let one + # stray fragment block every preview, which is the worse trade + # for a file that is usually not a page at all. + print(f"No in {path}, inserting one", file=sys.stderr) + patched, count = HTML.subn( + lambda m: m.group(0) + f"{TAG}", text, count=1 + ) + if not count: + patched = f"{TAG}" + text + path.write_text(patched, encoding="utf-8", errors="surrogateescape") + marked += 1 print(f"Marked {marked} page(s) noindex, skipped {skipped}.") return 0 From 88848667a5808957892378096fe2b5cb2598cc77 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 10:49:20 -0600 Subject: [PATCH 05/14] ci: keep the doctype first when synthesizing a head A document may omit both and 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 tag to follow, and only a file with neither gets it prepended. --- .github/scripts/noindex-preview.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/scripts/noindex-preview.py b/.github/scripts/noindex-preview.py index 6295d224..0f8d0c4d 100755 --- a/.github/scripts/noindex-preview.py +++ b/.github/scripts/noindex-preview.py @@ -17,6 +17,7 @@ TAG = '' HEAD = re.compile(r"]*>", re.IGNORECASE) HTML = re.compile(r"]*>", re.IGNORECASE) +DOCTYPE = re.compile(r"^\s*]*>", re.IGNORECASE) def main(root: str) -> int: @@ -39,11 +40,18 @@ def main(root: str) -> int: # stray fragment block every preview, which is the worse trade # for a file that is usually not a page at all. print(f"No in {path}, inserting one", file=sys.stderr) - patched, count = HTML.subn( - lambda m: m.group(0) + f"{TAG}", text, count=1 - ) + head = f"{TAG}" + # After where there is one, otherwise after a leading + # doctype. A doctype has to come first in the document: put the + # head above it and the browser drops into quirks mode, which + # would make the preview render unlike the real page. + patched, count = HTML.subn(lambda m: m.group(0) + head, text, count=1) if not count: - patched = f"{TAG}" + text + patched, count = DOCTYPE.subn( + lambda m: m.group(0) + head, text, count=1 + ) + if not count: + patched = head + text path.write_text(patched, encoding="utf-8", errors="surrogateescape") marked += 1 From dbb92f95c0adcdd2178358a6ef2173dafa7266ec Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 10:51:00 -0600 Subject: [PATCH 06/14] ci: simplify the noindex script and put it under test 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. --- .github/scripts/noindex-preview.py | 49 +++++++++--------- .github/scripts/test_noindex_preview.py | 68 +++++++++++++++++++++++++ .github/workflows/scripts-check.yaml | 22 ++++++++ 3 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 .github/scripts/test_noindex_preview.py create mode 100644 .github/workflows/scripts-check.yaml diff --git a/.github/scripts/noindex-preview.py b/.github/scripts/noindex-preview.py index 0f8d0c4d..cc578a4a 100755 --- a/.github/scripts/noindex-preview.py +++ b/.github/scripts/noindex-preview.py @@ -16,8 +16,30 @@ TAG = '' HEAD = re.compile(r"]*>", re.IGNORECASE) -HTML = re.compile(r"]*>", re.IGNORECASE) -DOCTYPE = re.compile(r"^\s*]*>", re.IGNORECASE) +# A byte order mark may precede the doctype, and the doctype only counts as +# one if nothing but that precedes it. Inserting above either drops the +# browser into quirks mode, so the preview would render unlike the real page. +DOCUMENT = re.compile(r"?\s*]*>|]*>", re.IGNORECASE) + + +def mark(text: str) -> str | None: + """Return `text` with the tag added, or None if it needs no change. + + A file with neither a head, an html tag, nor a doctype is a fragment + rather than a page. Nothing indexes it on its own, and giving it a head + would only corrupt whatever embeds it. + """ + if 'name="robots"' in text: + return None + + patched, count = HEAD.subn(lambda m: m.group(0) + TAG, text, count=1) + if count: + return patched + + patched, count = DOCUMENT.subn( + lambda m: m.group(0) + f"{TAG}", text, count=1 + ) + return patched if count else None def main(root: str) -> int: @@ -29,29 +51,10 @@ def main(root: str) -> int: marked = skipped = 0 for path in sorted(directory.rglob("*.html")): text = path.read_text(encoding="utf-8", errors="surrogateescape") - if 'name="robots"' in text: + patched = mark(text) + if patched is None: skipped += 1 continue - patched, count = HEAD.subn(lambda m: m.group(0) + TAG, text, count=1) - if not count: - # The head start tag is optional in HTML, and neither generator - # omits it today. Synthesize one anyway rather than deploy a page - # that is still indexable. Failing the run instead would let one - # stray fragment block every preview, which is the worse trade - # for a file that is usually not a page at all. - print(f"No in {path}, inserting one", file=sys.stderr) - head = f"{TAG}" - # After where there is one, otherwise after a leading - # doctype. A doctype has to come first in the document: put the - # head above it and the browser drops into quirks mode, which - # would make the preview render unlike the real page. - patched, count = HTML.subn(lambda m: m.group(0) + head, text, count=1) - if not count: - patched, count = DOCTYPE.subn( - lambda m: m.group(0) + head, text, count=1 - ) - if not count: - patched = head + text path.write_text(patched, encoding="utf-8", errors="surrogateescape") marked += 1 diff --git a/.github/scripts/test_noindex_preview.py b/.github/scripts/test_noindex_preview.py new file mode 100644 index 00000000..068662ef --- /dev/null +++ b/.github/scripts/test_noindex_preview.py @@ -0,0 +1,68 @@ +"""Tests for noindex-preview.py. Run: python3 test_noindex_preview.py""" + +import importlib.util +import pathlib +import unittest + +spec = importlib.util.spec_from_file_location( + "noindex_preview", pathlib.Path(__file__).parent / "noindex-preview.py" +) +assert spec and spec.loader +noindex = importlib.util.module_from_spec(spec) +spec.loader.exec_module(noindex) + +mark = noindex.mark +TAG = noindex.TAG + + +class MarkTest(unittest.TestCase): + def test_adds_the_tag_inside_an_existing_head(self): + out = mark("\na") + assert out is not None + self.assertIn(f"{TAG}", out) + + def test_matches_head_case_insensitively_and_with_attributes(self): + out = mark('b') + assert out is not None + self.assertIn(f'{TAG}', out) + + def test_leaves_a_page_that_already_declares_robots(self): + page = '' + self.assertIsNone(mark(page)) + + def test_synthesizes_a_head_after_the_html_tag(self): + out = mark('no head') + assert out is not None + self.assertTrue(out.startswith(f'{TAG}')) + + # The next three are the regressions this script actually shipped: a + # doctype has to stay first, or the browser renders the preview in quirks + # mode and it no longer resembles the page it previews. + def test_keeps_a_doctype_first_when_there_is_no_html_tag(self): + out = mark("\nx") + assert out is not None + self.assertTrue(out.startswith("")) + self.assertIn(TAG, out) + + def test_keeps_a_lowercase_doctype_first(self): + out = mark("\nx") + assert out is not None + self.assertTrue(out.startswith("")) + + def test_keeps_a_byte_order_mark_and_doctype_first(self): + out = mark("\nx") + assert out is not None + self.assertTrue(out.startswith("")) + self.assertIn(TAG, out) + + def test_leaves_a_fragment_alone(self): + self.assertIsNone(mark("
bare fragment
\n")) + + def test_is_idempotent(self): + once = mark("") + assert once is not None + self.assertIsNone(mark(once)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/workflows/scripts-check.yaml b/.github/workflows/scripts-check.yaml new file mode 100644 index 00000000..5364d8d2 --- /dev/null +++ b/.github/workflows/scripts-check.yaml @@ -0,0 +1,22 @@ +# The CI helpers under .github/scripts/ have no package to be tested by, so +# they are tested here. +name: scripts-check.yaml + +on: + push: + branches: [main] + paths: [.github/scripts/**, .github/workflows/scripts-check.yaml] + pull_request: + paths: [.github/scripts/**, .github/workflows/scripts-check.yaml] + workflow_dispatch: + +permissions: read-all + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Test noindex-preview.py + run: python3 .github/scripts/test_noindex_preview.py From ca290ee1cc9cd238cc02382c7a7466c89450f85c Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 10:53:45 -0600 Subject: [PATCH 07/14] ci: anchor the doctype match, and ignore bytecode 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. --- .github/scripts/noindex-preview.py | 25 ++++++++++++++++--------- .github/scripts/test_noindex_preview.py | 14 ++++++++++++++ .gitignore | 1 + 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/scripts/noindex-preview.py b/.github/scripts/noindex-preview.py index cc578a4a..1e8b7500 100755 --- a/.github/scripts/noindex-preview.py +++ b/.github/scripts/noindex-preview.py @@ -16,16 +16,18 @@ TAG = '' HEAD = re.compile(r"]*>", re.IGNORECASE) -# A byte order mark may precede the doctype, and the doctype only counts as -# one if nothing but that precedes it. Inserting above either drops the -# browser into quirks mode, so the preview would render unlike the real page. -DOCUMENT = re.compile(r"?\s*]*>|]*>", re.IGNORECASE) +HTML = re.compile(r"]*>", re.IGNORECASE) +# Anchored, and tolerant of a leading byte order mark, because a doctype +# counts as one only when nothing but that precedes it. The anchor is also +# what keeps a fragment that merely quotes a doctype from passing as a +# document. Written as an escape: a literal mark here would be invisible. +DOCTYPE = re.compile("\\A\\ufeff?\\s*]*>", re.IGNORECASE) def mark(text: str) -> str | None: """Return `text` with the tag added, or None if it needs no change. - A file with neither a head, an html tag, nor a doctype is a fragment + A file with no head, no html tag, and no leading doctype is a fragment rather than a page. Nothing indexes it on its own, and giving it a head would only corrupt whatever embeds it. """ @@ -36,10 +38,15 @@ def mark(text: str) -> str | None: if count: return patched - patched, count = DOCUMENT.subn( - lambda m: m.group(0) + f"{TAG}", text, count=1 - ) - return patched if count else None + # The html tag is tried before the doctype because a head belongs inside + # html. Taking whichever appears first in the text would put the head + # between the two on every page that has both. + head = f"{TAG}" + for pattern in (HTML, DOCTYPE): + patched, count = pattern.subn(lambda m: m.group(0) + head, text, count=1) + if count: + return patched + return None def main(root: str) -> int: diff --git a/.github/scripts/test_noindex_preview.py b/.github/scripts/test_noindex_preview.py index 068662ef..5fafe222 100644 --- a/.github/scripts/test_noindex_preview.py +++ b/.github/scripts/test_noindex_preview.py @@ -58,6 +58,20 @@ def test_keeps_a_byte_order_mark_and_doctype_first(self): def test_leaves_a_fragment_alone(self): self.assertIsNone(mark("
bare fragment
\n")) + def test_leaves_a_fragment_that_merely_quotes_a_doctype(self): + # Only a doctype at the very start makes the file a document. An + # unanchored match treated this snippet as one and rewrote it. + self.assertIsNone(mark("

Start a page with <!DOCTYPE html>

")) + self.assertIsNone(mark("
example: 
")) + + def test_puts_the_head_inside_html_when_a_doctype_precedes_it(self): + # The head belongs inside html. Matching whichever token came first + # put it between the doctype and the html tag. + out = mark("\nx") + assert out is not None + self.assertIn(f'{TAG}', out) + self.assertTrue(out.startswith("")) + def test_is_idempotent(self): once = mark("") assert once is not None diff --git a/.gitignore b/.gitignore index 56d938b2..a22767f2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # in the package's own .gitignore (see pkg-r/.gitignore). .Rproj.user .Rprofile +__pycache__/ /docs/r/ /docs/py/ plans/ From 29fc64d9d4843ddfcb0412f79f2edde459d4fcda Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 11:20:37 -0600 Subject: [PATCH 08/14] docs: script to build and serve both sites locally 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. --- AGENTS.md | 2 + scripts/preview-docs.sh | 101 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100755 scripts/preview-docs.sh diff --git a/AGENTS.md b/AGENTS.md index 11eaf78a..d66f5601 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ This repository is a monorepo holding two implementations of commons: the R pack Work from the relevant package's directory, not the repository root: `pkg-r/` for R (`devtools::load_all()`, `R CMD check`) and `pkg-py/` for Python (`uv run ruff check`, `uv run pyrefly check src tests`, `uv run pytest`). CI is scoped the same way. Run all of a package's checks before pushing; the pyrefly invocation needs its explicit `src tests` paths, because with none it consults the repo's git ignore files and a worktree checked out under an ignored directory silently type-checks nothing. +The documentation sites are the exception, because they are published as one tree and their links to each other are relative. `scripts/preview-docs.sh` builds them into that tree and serves it, which is the layout a pull request preview publishes. It skips a site whose toolchain is missing rather than failing. + Neither package has been widely adopted or publicly released; changes can be made without a deprecation cycle (or even reference to the way that it used to work). Use soft wrapping for prose in Markdown files, including skills and vignettes. diff --git a/scripts/preview-docs.sh b/scripts/preview-docs.sh new file mode 100755 index 00000000..2dc01c49 --- /dev/null +++ b/scripts/preview-docs.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Build the documentation sites and serve them the way they are deployed. +# +# The sites are published as one tree: the landing page at the root, the R +# site under r/, and the Python site under py/. Their links to each other are +# relative, so opening a built file directly resolves none of them. This +# script assembles that same tree in docs/ and serves it, which is also the +# layout a pull request preview publishes, so what you see here is what a +# reviewer sees there. +# +# Usage: +# scripts/preview-docs.sh build both sites and serve +# scripts/preview-docs.sh r build only the R site and serve +# scripts/preview-docs.sh py build only the Python site and serve +# scripts/preview-docs.sh --no-serve build, then stop +# scripts/preview-docs.sh --port 8000 +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +sites="" +serve=true +port=4444 + +while [ $# -gt 0 ]; do + case "$1" in + r|py) sites="$sites $1" ;; + --no-serve) serve=false ;; + --port) shift; port="${1:?--port needs a number}" ;; + # Prints the header block, stopping at the first line that is not a + # comment, so the two cannot drift apart. + -h|--help) sed -n '2,${/^#/!q; s/^# \{0,1\}//p;}' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac + shift +done +[ -n "$sites" ] || sites="r py" + +# Collected rather than printed as they happen, so the reason a site is +# missing is still on screen once a build has scrolled past. +skipped=() + +build_r() { + if ! command -v Rscript >/dev/null 2>&1; then + skipped+=("r: Rscript is not on PATH") + return + fi + if ! Rscript -e 'quit(status = !requireNamespace("pkgdown", quietly = TRUE))' 2>/dev/null; then + skipped+=("r: the pkgdown package is not installed (install.packages(\"pkgdown\"))") + return + fi + # pkgdown runs the examples, so it needs commons itself installed, exactly + # as the workflow does through setup-r-dependencies. Checking here turns + # what is otherwise a long build ending in an R stack trace into one line. + if ! Rscript -e 'quit(status = !requireNamespace("commons", quietly = TRUE))' 2>/dev/null; then + skipped+=("r: commons is not installed (pak::local_install_deps(\"pkg-r\"), then pak::local_install(\"pkg-r\"))") + return + fi + echo "==> Building the R site into docs/r" + # Matches .github/workflows/pkgdown.yaml. The destination comes from + # pkg-r/_pkgdown.yml, so it lands in docs/r without being named here. + (cd "$root/pkg-r" && Rscript -e 'pkgdown::build_site(new_process = FALSE, install = FALSE)') +} + +build_py() { + if ! command -v uv >/dev/null 2>&1; then + skipped+=("py: uv is not on PATH (https://docs.astral.sh/uv/)") + return + fi + if ! command -v quarto >/dev/null 2>&1; then + skipped+=("py: quarto is not on PATH (https://quarto.org/docs/get-started/)") + return + fi + echo "==> Building the Python site into docs/py" + # Matches .github/workflows/quartodoc.yaml, including the copy: quarto + # warns and refuses to clean an output directory outside its project, so + # the site renders in place and is copied into position. + (cd "$root/pkg-py" && uv sync --extra shiny --group docs --quiet) + (cd "$root/pkg-py/docs" && uv run quartodoc build && uv run quarto render) + rm -rf "$root/docs/py" + cp -r "$root/pkg-py/docs/_site" "$root/docs/py" +} + +for site in $sites; do + "build_$site" +done + +for note in "${skipped[@]+"${skipped[@]}"}"; do + echo "Skipped $note" >&2 +done + +# Naming what is absent beats letting someone click a dead link and wonder +# whether they broke the build. +for site in r py; do + [ -d "$root/docs/$site" ] || echo "Note: docs/$site is not built, so links to it will 404." >&2 +done + +if [ "$serve" = true ]; then + echo "==> Serving $root/docs at http://localhost:$port/ (Ctrl-C to stop)" + cd "$root/docs" && python3 -m http.server "$port" +fi From bef16a4f9e9f7345a5efb74ec2d0edafad5ec653 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 11:32:12 -0600 Subject: [PATCH 09/14] docs: make the preview script's skip messages copyable 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. --- scripts/preview-docs.sh | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/scripts/preview-docs.sh b/scripts/preview-docs.sh index 2dc01c49..641693d4 100755 --- a/scripts/preview-docs.sh +++ b/scripts/preview-docs.sh @@ -2,11 +2,10 @@ # Build the documentation sites and serve them the way they are deployed. # # The sites are published as one tree: the landing page at the root, the R -# site under r/, and the Python site under py/. Their links to each other are -# relative, so opening a built file directly resolves none of them. This -# script assembles that same tree in docs/ and serves it, which is also the -# layout a pull request preview publishes, so what you see here is what a -# reviewer sees there. +# site under r/, and the Python site under py/, which mirrors the deployment +# arrangement. This script assembles the documentation page in docs/ and +# serves it in the same layout a pull request preview publishes, so it +# should match what a PR reviewer will see. # # Usage: # scripts/preview-docs.sh build both sites and serve @@ -37,23 +36,37 @@ done [ -n "$sites" ] || sites="r py" # Collected rather than printed as they happen, so the reason a site is -# missing is still on screen once a build has scrolled past. +# missing is still on screen once a build has scrolled past. Each entry is a +# reason followed by a command to paste: naming a package leaves the reader to +# work out where to type it, and the answer differs between the shell and the +# R console. skipped=() +skip() { + skipped+=("$1 + $2") +} + build_r() { if ! command -v Rscript >/dev/null 2>&1; then - skipped+=("r: Rscript is not on PATH") + skip "the R site: Rscript is not on PATH." \ + "See https://cloud.r-project.org/ to install R." return fi if ! Rscript -e 'quit(status = !requireNamespace("pkgdown", quietly = TRUE))' 2>/dev/null; then - skipped+=("r: the pkgdown package is not installed (install.packages(\"pkgdown\"))") + skip "the R site: pkgdown is not installed." \ + "Rscript -e 'install.packages(\"pkgdown\", repos = \"https://cloud.r-project.org\")'" return fi # pkgdown runs the examples, so it needs commons itself installed, exactly # as the workflow does through setup-r-dependencies. Checking here turns # what is otherwise a long build ending in an R stack trace into one line. + # + # The snippet installs pak first if it has to, and runs from $root, so it + # works whatever directory the reader pasted it into. if ! Rscript -e 'quit(status = !requireNamespace("commons", quietly = TRUE))' 2>/dev/null; then - skipped+=("r: commons is not installed (pak::local_install_deps(\"pkg-r\"), then pak::local_install(\"pkg-r\"))") + skip "the R site: commons is not installed, and pkgdown runs its examples." \ + "(cd $root && Rscript -e 'if (!requireNamespace(\"pak\", quietly = TRUE)) install.packages(\"pak\", repos = \"https://cloud.r-project.org\"); pak::local_install_deps(\"pkg-r\"); pak::local_install(\"pkg-r\")')" return fi echo "==> Building the R site into docs/r" @@ -64,11 +77,13 @@ build_r() { build_py() { if ! command -v uv >/dev/null 2>&1; then - skipped+=("py: uv is not on PATH (https://docs.astral.sh/uv/)") + skip "the Python site: uv is not on PATH." \ + "curl -LsSf https://astral.sh/uv/install.sh | sh" return fi if ! command -v quarto >/dev/null 2>&1; then - skipped+=("py: quarto is not on PATH (https://quarto.org/docs/get-started/)") + skip "the Python site: quarto is not on PATH." \ + "See https://quarto.org/docs/get-started/ for your platform." return fi echo "==> Building the Python site into docs/py" @@ -86,7 +101,7 @@ for site in $sites; do done for note in "${skipped[@]+"${skipped[@]}"}"; do - echo "Skipped $note" >&2 + printf '\nSkipped %s\n' "$note" >&2 done # Naming what is absent beats letting someone click a dead link and wonder From f580f2148672bcd6d4954ad4ef3443bebd32634f Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 11:34:01 -0600 Subject: [PATCH 10/14] docs: set the preview script's fix commands off as a block 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. --- scripts/preview-docs.sh | 48 +++++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/scripts/preview-docs.sh b/scripts/preview-docs.sh index 641693d4..e3eabae8 100755 --- a/scripts/preview-docs.sh +++ b/scripts/preview-docs.sh @@ -42,19 +42,36 @@ done # R console. skipped=() +# skip [command...] +# +# Commands are printed one per line, indented and with nothing in front of +# them, so a block can be selected and pasted as-is. No box drawing, because +# the border characters come along with the copy. skip() { - skipped+=("$1 - $2") + local what="$1" reason="$2" + shift 2 + local block="Skipped $what: $reason" + if [ $# -gt 0 ]; then + block="$block + + To fix it, run: +" + local command + for command in "$@"; do + block="$block + $command" + done + fi + skipped+=("$block") } build_r() { if ! command -v Rscript >/dev/null 2>&1; then - skip "the R site: Rscript is not on PATH." \ - "See https://cloud.r-project.org/ to install R." + skip "the R site" "Rscript is not on PATH. Install R from https://cloud.r-project.org/." return fi if ! Rscript -e 'quit(status = !requireNamespace("pkgdown", quietly = TRUE))' 2>/dev/null; then - skip "the R site: pkgdown is not installed." \ + skip "the R site" "pkgdown is not installed." \ "Rscript -e 'install.packages(\"pkgdown\", repos = \"https://cloud.r-project.org\")'" return fi @@ -65,8 +82,17 @@ build_r() { # The snippet installs pak first if it has to, and runs from $root, so it # works whatever directory the reader pasted it into. if ! Rscript -e 'quit(status = !requireNamespace("commons", quietly = TRUE))' 2>/dev/null; then - skip "the R site: commons is not installed, and pkgdown runs its examples." \ - "(cd $root && Rscript -e 'if (!requireNamespace(\"pak\", quietly = TRUE)) install.packages(\"pak\", repos = \"https://cloud.r-project.org\"); pak::local_install_deps(\"pkg-r\"); pak::local_install(\"pkg-r\")')" + # Absolute paths, so no cd is needed and pasting these does not move the + # reader's shell. The pak line appears only when pak is actually absent, + # rather than as a conditional the reader has to evaluate. + local install=() + if ! Rscript -e 'quit(status = !requireNamespace("pak", quietly = TRUE))' 2>/dev/null; then + install+=("Rscript -e 'install.packages(\"pak\", repos = \"https://cloud.r-project.org\")'") + fi + install+=("Rscript -e 'pak::local_install_deps(\"$root/pkg-r\")'") + install+=("Rscript -e 'pak::local_install(\"$root/pkg-r\")'") + skip "the R site" "commons is not installed, and pkgdown runs its examples." \ + "${install[@]}" return fi echo "==> Building the R site into docs/r" @@ -77,13 +103,12 @@ build_r() { build_py() { if ! command -v uv >/dev/null 2>&1; then - skip "the Python site: uv is not on PATH." \ + skip "the Python site" "uv is not on PATH." \ "curl -LsSf https://astral.sh/uv/install.sh | sh" return fi if ! command -v quarto >/dev/null 2>&1; then - skip "the Python site: quarto is not on PATH." \ - "See https://quarto.org/docs/get-started/ for your platform." + skip "the Python site" "quarto is not on PATH. Install it from https://quarto.org/docs/get-started/." return fi echo "==> Building the Python site into docs/py" @@ -101,8 +126,9 @@ for site in $sites; do done for note in "${skipped[@]+"${skipped[@]}"}"; do - printf '\nSkipped %s\n' "$note" >&2 + printf '\n%s\n' "$note" >&2 done +[ ${#skipped[@]} -eq 0 ] || echo >&2 # Naming what is absent beats letting someone click a dead link and wonder # whether they broke the build. From fad8e655538936dbd3717b60166f6e33e9c0fa4e Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 11:38:43 -0600 Subject: [PATCH 11/14] docs: link the R site where pkgdown actually put it 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. --- .github/workflows/pkgdown.yaml | 24 ++++++++++++++++++++++-- scripts/preview-docs.sh | 34 ++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 6bde569a..99e4f011 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -67,6 +67,26 @@ jobs: # fail there. Fork previews are not worth the alternative: publishing # fork-authored HTML to posit-dev.github.io means serving unreviewed # content from our own origin. + # pkgdown's automatic development mode puts a released version's site at + # the root of destination and a development version's in dev/ below it, + # so the entry point moves with the version in DESCRIPTION. Linking to + # r/ unconditionally hands the reviewer a directory listing. + - name: Find the built site's entry point + id: entry + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: | + if [ -f docs/r/index.html ]; then + echo "path=r/" >> "$GITHUB_OUTPUT" + elif [ -f docs/r/dev/index.html ]; then + echo "path=r/dev/" >> "$GITHUB_OUTPUT" + else + echo "The R site has no index.html to link to." >&2 + exit 1 + fi + - name: Mark the preview noindex if: > github.event_name == 'pull_request' && @@ -95,6 +115,6 @@ jobs: with: header: preview-r message: | - **R site preview:** [https://posit-dev.github.io/commons/pr-${{ github.event.number }}/r/](https://posit-dev.github.io/commons/pr-${{ github.event.number }}/r/) + **R site preview:** [https://posit-dev.github.io/commons/pr-${{ github.event.number }}/${{ steps.entry.outputs.path }}](https://posit-dev.github.io/commons/pr-${{ github.event.number }}/${{ steps.entry.outputs.path }}) - Built from the latest commit on this branch. The landing page's Python link resolves inside the preview only if this pull request also rebuilt the Python site. + 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. diff --git a/scripts/preview-docs.sh b/scripts/preview-docs.sh index e3eabae8..0ec70566 100755 --- a/scripts/preview-docs.sh +++ b/scripts/preview-docs.sh @@ -130,13 +130,39 @@ for note in "${skipped[@]+"${skipped[@]}"}"; do done [ ${#skipped[@]} -eq 0 ] || echo >&2 -# Naming what is absent beats letting someone click a dead link and wonder -# whether they broke the build. +# pkgdown's automatic development mode decides where a site lands from the +# version in DESCRIPTION: a released version goes to the root of destination, +# a development version to dev/ below it. So the R entry point moves, and +# pointing at r/ on a development version serves a directory listing. The +# landing page links to r/, which is right for the deployed site and wrong +# here, so the addresses below are worth reading rather than guessing. +entry_point() { + if [ -f "$root/docs/$1/index.html" ]; then + echo "$1/" + elif [ -f "$root/docs/$1/dev/index.html" ]; then + echo "$1/dev/" + fi +} + +echo +echo "Pages:" +printf ' %-14s http://localhost:%s/\n' "landing page" "$port" for site in r py; do - [ -d "$root/docs/$site" ] || echo "Note: docs/$site is not built, so links to it will 404." >&2 + case "$site" in + r) label="R site" ;; + py) label="Python site" ;; + esac + path="$(entry_point "$site")" + if [ -n "$path" ]; then + printf ' %-14s http://localhost:%s/%s\n' "$label" "$port" "$path" + else + # Named rather than omitted: a missing row reads as a broken build. + printf ' %-14s not built, so links to it will 404\n' "$label" + fi done +echo if [ "$serve" = true ]; then - echo "==> Serving $root/docs at http://localhost:$port/ (Ctrl-C to stop)" + echo "==> Serving $root/docs (Ctrl-C to stop)" cd "$root/docs" && python3 -m http.server "$port" fi From 931f14a7736f9fb783127213a5db0c8285d06675 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sat, 12 Sep 2026 11:43:11 -0600 Subject: [PATCH 12/14] docs: make the landing page's r/ link work in a preview 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. --- .github/scripts/link-r-dev.sh | 45 ++++++++++++++++++++++++++++++++++ .github/workflows/pkgdown.yaml | 9 +++++++ scripts/preview-docs.sh | 3 +++ 3 files changed, 57 insertions(+) create mode 100755 .github/scripts/link-r-dev.sh diff --git a/.github/scripts/link-r-dev.sh b/.github/scripts/link-r-dev.sh new file mode 100755 index 00000000..32ec240e --- /dev/null +++ b/.github/scripts/link-r-dev.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Point /r/ at the development site when that is the only site built. +# +# pkgdown's automatic development mode sends a released version's site to the +# root of its destination and a development version's to dev/ below it. Every +# build from a branch is a development version, so r/ is empty and the landing +# page's link to it lands on nothing. +# +# Moving dev/ up is the obvious fix and the wrong one: the build writes its own +# absolute address into search.json, sitemap.xml, and the pages themselves, so +# a moved tree keeps telling the browser to go to r/dev/, and site search +# breaks. A redirect leaves the build untouched. +# +# Only for local previews and pull request previews. On the published site r/ +# is the released documentation, and overwriting its index with a redirect to +# the development docs would be a regression for every reader. +set -euo pipefail + +docs="${1:?usage: link-r-dev.sh }" + +if [ -f "$docs/r/index.html" ]; then + echo "r/ already has an index, leaving it alone." + exit 0 +fi +if [ ! -f "$docs/r/dev/index.html" ]; then + echo "Neither r/ nor r/dev/ has an index, nothing to point at." >&2 + exit 0 +fi + +cat > "$docs/r/index.html" <<'HTML' + + + + + + +Redirecting to the development documentation + + +

This preview contains the development documentation. Redirecting to dev/.

+ + +HTML + +echo "Wrote $docs/r/index.html, redirecting to dev/." diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 99e4f011..bfd916f9 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -67,6 +67,15 @@ jobs: # fail there. Fork previews are not worth the alternative: publishing # fork-authored HTML to posit-dev.github.io means serving unreviewed # content from our own origin. + # Preview only. The production deploy above publishes r/ as the released + # documentation, and this would replace its index with a redirect. + - name: Point r/ at the development site + if: > + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.fork != true + working-directory: . + run: .github/scripts/link-r-dev.sh docs + # pkgdown's automatic development mode puts a released version's site at # the root of destination and a development version's in dev/ below it, # so the entry point moves with the version in DESCRIPTION. Linking to diff --git a/scripts/preview-docs.sh b/scripts/preview-docs.sh index 0ec70566..3517bdc3 100755 --- a/scripts/preview-docs.sh +++ b/scripts/preview-docs.sh @@ -99,6 +99,9 @@ build_r() { # Matches .github/workflows/pkgdown.yaml. The destination comes from # pkg-r/_pkgdown.yml, so it lands in docs/r without being named here. (cd "$root/pkg-r" && Rscript -e 'pkgdown::build_site(new_process = FALSE, install = FALSE)') + # So the landing page's link to r/ works here. The published site keeps the + # released documentation there, which is why this is not part of the build. + "$root/.github/scripts/link-r-dev.sh" "$root/docs" } build_py() { From 998df03e09b67792691ccf209918876ce7ebd3d8 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sun, 13 Sep 2026 11:54:29 -0600 Subject: [PATCH 13/14] docs: drop pandoc's math deprecation lines from the preview build 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. --- scripts/preview-docs.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/preview-docs.sh b/scripts/preview-docs.sh index 3517bdc3..3e2749a4 100755 --- a/scripts/preview-docs.sh +++ b/scripts/preview-docs.sh @@ -98,7 +98,15 @@ build_r() { echo "==> Building the R site into docs/r" # Matches .github/workflows/pkgdown.yaml. The destination comes from # pkg-r/_pkgdown.yml, so it lands in docs/r without being named here. - (cd "$root/pkg-r" && Rscript -e 'pkgdown::build_site(new_process = FALSE, install = FALSE)') + # + # pkgdown and rmarkdown ask pandoc for math with the --mathml and --mathjax + # spellings pandoc 3.11 deprecated, once per topic and per vignette, and + # neither flag is ours to set. Drop those lines from stderr and leave + # everything else, including the exit status, alone. + { + (cd "$root/pkg-r" && Rscript -e 'pkgdown::build_site(new_process = FALSE, install = FALSE)') \ + 2>&1 1>&3 | sed '/^\[WARNING\] Deprecated: --math/d' >&2 + } 3>&1 # So the landing page's link to r/ works here. The published site keeps the # released documentation there, which is why this is not part of the build. "$root/.github/scripts/link-r-dev.sh" "$root/docs" @@ -149,7 +157,7 @@ entry_point() { echo echo "Pages:" -printf ' %-14s http://localhost:%s/\n' "landing page" "$port" +printf ' %-14s http://localhost:%s/ \n' "landing page" "$port" for site in r py; do case "$site" in r) label="R site" ;; @@ -157,7 +165,7 @@ for site in r py; do esac path="$(entry_point "$site")" if [ -n "$path" ]; then - printf ' %-14s http://localhost:%s/%s\n' "$label" "$port" "$path" + printf ' %-14s http://localhost:%s/%s \n' "$label" "$port" "$path" else # Named rather than omitted: a missing row reads as a broken build. printf ' %-14s not built, so links to it will 404\n' "$label" From 1ed83a4d316a50b846d7bbcae2b41c593c22199d Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Sun, 13 Sep 2026 11:54:36 -0600 Subject: [PATCH 14/14] docs: serve the same icons on all three sites 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. --- .github/workflows/verify-shared-synced.yaml | 8 +++++++ docs/favicon/apple-touch-icon.png | Bin 0 -> 46988 bytes docs/favicon/favicon-96x96.png | Bin 0 -> 15830 bytes docs/favicon/favicon.ico | Bin 0 -> 15086 bytes docs/favicon/favicon.svg | 1 + docs/favicon/site.webmanifest | 21 ++++++++++++++++++ docs/favicon/web-app-manifest-192x192.png | Bin 0 -> 52759 bytes docs/favicon/web-app-manifest-512x512.png | Bin 0 -> 274071 bytes docs/index.html | 6 ++++- favicon/README.md | 15 +++++++++++++ favicon/apple-touch-icon.png | Bin 0 -> 46988 bytes favicon/favicon-96x96.png | Bin 0 -> 15830 bytes favicon/favicon.ico | Bin 0 -> 15086 bytes favicon/favicon.svg | 1 + favicon/site.webmanifest | 21 ++++++++++++++++++ favicon/web-app-manifest-192x192.png | Bin 0 -> 52759 bytes favicon/web-app-manifest-512x512.png | Bin 0 -> 274071 bytes pkg-py/docs/_quarto.yml | 5 +++++ pkg-py/docs/favicon.html | 7 ++++++ pkg-py/docs/favicon/apple-touch-icon.png | Bin 0 -> 46988 bytes pkg-py/docs/favicon/favicon-96x96.png | Bin 0 -> 15830 bytes pkg-py/docs/favicon/favicon.ico | Bin 0 -> 15086 bytes pkg-py/docs/favicon/favicon.svg | 1 + pkg-py/docs/favicon/site.webmanifest | 21 ++++++++++++++++++ .../docs/favicon/web-app-manifest-192x192.png | Bin 0 -> 52759 bytes .../docs/favicon/web-app-manifest-512x512.png | Bin 0 -> 274071 bytes pkg-r/pkgdown/favicon/apple-touch-icon.png | Bin 0 -> 46988 bytes pkg-r/pkgdown/favicon/favicon-96x96.png | Bin 0 -> 15830 bytes pkg-r/pkgdown/favicon/favicon.ico | Bin 0 -> 15086 bytes pkg-r/pkgdown/favicon/favicon.svg | 1 + pkg-r/pkgdown/favicon/site.webmanifest | 21 ++++++++++++++++++ .../favicon/web-app-manifest-192x192.png | Bin 0 -> 52759 bytes .../favicon/web-app-manifest-512x512.png | Bin 0 -> 274071 bytes scripts/sync-shared.sh | 8 ++++--- 34 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 docs/favicon/apple-touch-icon.png create mode 100644 docs/favicon/favicon-96x96.png create mode 100644 docs/favicon/favicon.ico create mode 100644 docs/favicon/favicon.svg create mode 100644 docs/favicon/site.webmanifest create mode 100644 docs/favicon/web-app-manifest-192x192.png create mode 100644 docs/favicon/web-app-manifest-512x512.png create mode 100644 favicon/README.md create mode 100644 favicon/apple-touch-icon.png create mode 100644 favicon/favicon-96x96.png create mode 100644 favicon/favicon.ico create mode 100644 favicon/favicon.svg create mode 100644 favicon/site.webmanifest create mode 100644 favicon/web-app-manifest-192x192.png create mode 100644 favicon/web-app-manifest-512x512.png create mode 100644 pkg-py/docs/favicon.html create mode 100644 pkg-py/docs/favicon/apple-touch-icon.png create mode 100644 pkg-py/docs/favicon/favicon-96x96.png create mode 100644 pkg-py/docs/favicon/favicon.ico create mode 100644 pkg-py/docs/favicon/favicon.svg create mode 100644 pkg-py/docs/favicon/site.webmanifest create mode 100644 pkg-py/docs/favicon/web-app-manifest-192x192.png create mode 100644 pkg-py/docs/favicon/web-app-manifest-512x512.png create mode 100644 pkg-r/pkgdown/favicon/apple-touch-icon.png create mode 100644 pkg-r/pkgdown/favicon/favicon-96x96.png create mode 100644 pkg-r/pkgdown/favicon/favicon.ico create mode 100644 pkg-r/pkgdown/favicon/favicon.svg create mode 100644 pkg-r/pkgdown/favicon/site.webmanifest create mode 100644 pkg-r/pkgdown/favicon/web-app-manifest-192x192.png create mode 100644 pkg-r/pkgdown/favicon/web-app-manifest-512x512.png diff --git a/.github/workflows/verify-shared-synced.yaml b/.github/workflows/verify-shared-synced.yaml index c6851da7..195eb4ae 100644 --- a/.github/workflows/verify-shared-synced.yaml +++ b/.github/workflows/verify-shared-synced.yaml @@ -10,12 +10,16 @@ on: - tests/shared/** - prompts/** - www/** + - favicon/** - logos/** - pkg-r/tests/testthat/fixtures/shared/** - pkg-r/inst/prompts/** - pkg-r/inst/www/** - pkg-py/src/commons/prompts/** - pkg-py/src/commons/www/** + - pkg-r/pkgdown/favicon/** + - pkg-py/docs/favicon/** + - docs/favicon/** - pkg-py/docs/assets/logos/** - pkg-py/docs/assets/figs/** - pkg-r/pkgdown/assets/logos/** @@ -26,12 +30,16 @@ on: - tests/shared/** - prompts/** - www/** + - favicon/** - logos/** - pkg-r/tests/testthat/fixtures/shared/** - pkg-r/inst/prompts/** - pkg-r/inst/www/** - pkg-py/src/commons/prompts/** - pkg-py/src/commons/www/** + - pkg-r/pkgdown/favicon/** + - pkg-py/docs/favicon/** + - docs/favicon/** - pkg-py/docs/assets/logos/** - pkg-py/docs/assets/figs/** - pkg-r/pkgdown/assets/logos/** diff --git a/docs/favicon/apple-touch-icon.png b/docs/favicon/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b2736c9f42390d6a96f8041a74d8a02079b42bc9 GIT binary patch literal 46988 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