altdoc-multiversion-docs.yml
Render an altdoc-based R package’s Quarto docs site and deploy multiple versions side by side on gh-pages: PRs to /pr-preview/pr-<number>/, pushes to the default branch to /dev/, published releases to /latest-tag/ and /vX.Y.Z/, and a root / redirect landing page into the default docs version. Ported from Morrison-Lab/rpt’s bespoke docs.yaml into a reusable workflow (see UCD-SERG/serocalculator#504).
One-time setup: set Settings -> Pages -> Source to “Deploy from a branch”, branch gh-pages / (root). Requires an altdoc/quarto_website.yml navbar with a “Versions” menu entry for the version-dropdown step to rewrite (see the Example section).
Unlike the preview / preview-deploy family, this keeps a single job for build, deploy, and PR-preview cleanup, rather than splitting into a fork-safe read-only build half and a base-repo-context deploy half. Hardening that trust boundary is a possible follow-up, not part of this port.
Inputs
| Input | Type | Default | Description |
|---|---|---|---|
r-packages |
string | (see workflow file) |
r-lib/actions package specs to install. Must include local::. and any::altdoc (or a fork). |
needs |
string | "connect\nwebsite" |
r-lib/actions/setup-r-dependencies needs: list. |
apt-packages |
string | '' |
Extra apt packages to install before rendering (space-separated). |
setup-julia |
boolean | false |
Install Julia before rendering (for packages with a JuliaCall dependency). |
checkout-submodules |
string | 'recursive' |
Passed to actions/checkout’s submodules ('recursive', 'true', or 'false'). |
default-branch |
string | 'main' |
The repo’s default branch, used for dev-docs deploys and to read the dev version from DESCRIPTION even on release builds. |
quarto-config-path |
string | 'altdoc/quarto_website.yml' |
Path to the altdoc Quarto config file, relative to the repo root. |
docs-base-url |
string | '' |
Override the docs base URL. Leave empty to derive it from the calling repository. |
preview-branch |
string | 'gh-pages' |
Branch PR previews and version subdirectories are deployed to. |
timeout-minutes |
number | 30 |
Timeout for the docs job as a whole, and for the render step within it. Both are set from this one value. |
rewrite-pr-preview-links |
boolean | true |
On PR builds, repoint Quarto’s repo-action links and the navbar GitHub icon at the PR’s head branch and conversation page. |
rewrite-issue-links |
boolean | true |
Repoint rendered “Report an issue” links at the base repository’s issues page. |
dispatch-version |
string | '' |
Forward the caller’s workflow_dispatch version input ('dev' or 'stable'): a reusable workflow only sees with:-passed values. |
dispatch-release-tag |
string | '' |
Forward the caller’s workflow_dispatch release_tag input, required when dispatch-version is 'stable'. |
legacy-paths |
string | '' |
Retired docs subdirectories to keep working, as old=new pairs (e.g. main=dev). See Retired version directories. |
root-landing-target |
string | '' |
Pin the docs subdirectory the site root redirects to (e.g. latest-tag). See Where the site root points. |
version-dropdown-title-template |
string | '{version}' |
Label for the navbar “Versions” menu, where {version} is the version being rendered. See Showing which version a reader is on. |
version-in-navbar-title |
boolean | true |
Show that version next to the navbar title, the way pkgdown does. |
Where the site root points
The site root (/) is a redirect page rather than a copy of any one version, and by default each deploy points it at whatever that deploy just built: a default-branch push points it at /dev/, a release or a stable dispatch points it at /latest-tag/.
That default suits a site whose readers are mostly contributors. It suits a published package less well, because the readers arriving from CRAN, from a citation, or from a search result outnumber them, and a default-branch push is the more frequent event – so the root spends most of its time pointing at documentation for behavior those readers do not have installed.
Set root-landing-target to pin it:
root-landing-target: latest-tagTwo things it does not do. It does not change where anything deploys: each version still lands in its own subdirectory, and only the root’s redirect target moves. And it is not validated against the subdirectories that exist, so a value naming a version that was never deployed leaves the root pointing at a 404.
Showing which version a reader is on
A reader who arrives at /v1.1.0/reference/index.html from a search result should be able to tell they are on an archived release without reading the URL. Two places say so, each switchable:
- The navbar “Versions” menu is labeled with the version this build renders –
v1.2.3 (stable),0.1.0.9000 (dev), or an archivedv1.1.0– matching the entry it corresponds to, rather than a static “Versions”. Setversion-dropdown-title-templateto change the format, e.g.'Version: {version}'. - The navbar title carries that version beside the package name, after pkgdown, muted for a release and red for a development build. Set
version-in-navbar-title: falseto leave the title alone.
The badge is written to Quarto’s website.navbar.title, not website.title, so the page <title>, the feed, and social-card metadata keep the plain site title.
Which version a build renders comes from the event: a release build renders its own tag, a workflow_dispatch with version: stable renders the tag it was given, and everything else – default-branch pushes and PR previews – renders the branch, so it is labeled as the development version.
Retired version directories
A site that previously published the default branch’s docs to /<branch>/ – the insightsengineering/r-pkgdown-multiversion layout – has every /main/... link broken by the move to /dev/, this workflow’s layout and pkgdown’s own development-mode convention.
Set legacy-paths to a comma- or newline-separated list of old=new pairs to keep the old links alive:
legacy-paths: |
main=dev
reference/index.html=latest-tag/reference.html
reference/*=latest-tag/man/*
articles/*=latest-tag/vignettes/articles/*Three forms are supported:
Single-segment directory rename (
main=dev): rewrites the prefix/main/to/dev/, preserving any tail.Exact whole-path key (
reference/index.html=latest-tag/reference.html): redirects that exact path. Exact keys take precedence over wildcard patterns.Prefix glob and template (
reference/*=latest-tag/man/*): matches requests starting withreference/and substitutes the matched tail into the target’s*. The longest prefix wins among multiple prefix patterns.
That generates a site-root 404.html rewriting any request under /main/ to the same path under /dev/, so /main/reference/index.html lands on /dev/reference/index.html. GitHub Pages serves no server-side redirects but does serve a site-root 404.html for unresolved paths, which is what makes deep links work – a per-directory index.html meta-refresh only catches the directory root.
Two consequences worth knowing:
- Redirection needs JavaScript. Without it the page renders as a plain not-found notice linking to the docs root.
- The redirect replaces the site’s 404 page. Any path the site cannot resolve renders this page, not GitHub’s default one.
The pairs are explicit rather than derived from whichever version this build deployed, so /main/ resolves to the same place after a release build as after a default-branch build.
Canonical URLs
This workflow deploys the same rendered site to several paths — /dev/, /latest-tag/, /vX.Y.Z/, and /pr-preview/pr-<N>/. Without a signal saying which copy is authoritative, a search engine sees the same page many times over, and an archive or /dev/ can outrank /latest-tag/ — landing a reader on documentation for a version they are not running.
Every indexable page therefore gets a <link rel="canonical"> pointing at its /latest-tag/ equivalent, injected after the render and before any deploy. Nothing needs configuring; the URL is derived from docs-base-url (or from the repository, when that input is empty).
Two behaviours are worth knowing about.
A canonical is only emitted when the target exists. A page that is new on the default branch has no /latest-tag/ counterpart yet, so it names its own deployed URL instead. Pointing a canonical at a URL that 404s is worse than emitting none: it asks the indexer to credit a page that is not there. The set of pages currently under /latest-tag/ is read from the deploy branch at build time rather than assumed.
PR previews are marked noindex rather than canonicalized. A preview is ephemeral and is not the authoritative copy of anything, so the correct request is “do not index this” rather than “index that other page instead”.
404.html is left untagged, since canonicalizing the page served for missing URLs would point every miss at a real page. A page that already carries a canonical — one written by hand, or emitted by a future Quarto release — is left alone rather than double-tagged.
Permissions
Grant contents: write (deploys push to gh-pages), pull-requests: write, and issues: write (the PR-preview sticky comment and link rewrites).
Example
Do not name a concurrency: group gh-pages anywhere in your caller workflow – neither a top-level block nor one on the calling job, which deadlock identically. Any other group name is fine, and is how you serialize the caller’s own runs – with one exception for this capability. Its build job declares a second group, whose ${{ }} part is the pull request number on a pull_request event and github.ref otherwise. The whole group is therefore either altdoc-multiversion-docs-<pr-number> or altdoc-multiversion-docs-<ref>, and a caller naming either spelling requests the identical group and collides, even though the two strings differ. Both halves matter here, since this stub triggers on pull_request as well as on push, so do not prefix your own group with altdoc-multiversion-docs- at all. altdoc-multiversion-docs.yml serializes gh-pages deploys internally on its deploy job (group: gh-pages). A caller-level block with the same group name deadlocks GitHub Actions against the nested job: deploy fails with no runner, no steps, and no log, and the site silently stops publishing (gha#809).
# Copy to .github/workflows/docs.yaml in your repo.
name: Docs
on:
push:
branches: [main]
release:
types: [published]
pull_request:
types: [opened, reopened, synchronize, closed]
workflow_dispatch:
inputs:
version:
description: Documentation version to deploy
required: true
default: dev
type: choice
options: [dev, stable]
release_tag:
description: Stable release tag to deploy when version=stable (e.g. v1.2.3)
required: false
type: string
jobs:
docs:
permissions:
contents: write
pull-requests: write
issues: write
uses: Morrison-Lab/gha/.github/workflows/altdoc-multiversion-docs.yml@v2
with:
dispatch-version: ${{ github.event.inputs.version }}
dispatch-release-tag: ${{ github.event.inputs.release_tag }}
# legacy-paths: main=dev # keep pre-migration /main/ links working
# root-landing-target: latest-tag # root redirects to released docs
# version-dropdown-title-template: 'Version: {version}'
# version-in-navbar-title: falseYour altdoc/quarto_website.yml navbar needs a placeholder “Versions” entry for the version-dropdown step to find and rewrite:
website:
navbar:
right:
- text: Versions
menu:
- text: "$ALTDOC_PACKAGE_VERSION (dev)"
href: https://<owner>.github.io/<repo>/dev/See the examples/ directory for the full caller stub (altdoc-multiversion-docs.yml), with every optional input commented out.