diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml
new file mode 100644
index 0000000..6a510b5
--- /dev/null
+++ b/.github/workflows/cleanup-pr-previews.yml
@@ -0,0 +1,86 @@
+name: Clean up PR Previews (reusable)
+
+# Housekeeping half of the PR-preview family: walk the gh-pages `pr-preview/`
+# tree and delete subdirectories for PRs that are no longer open. The caller
+# stub (see examples/cleanup-pr-previews.yml) owns the `schedule` /
+# `workflow_dispatch` triggers.
+
+on:
+ workflow_call:
+ inputs:
+ preview-dir:
+ description: Directory on gh-pages that holds the per-PR preview subdirectories.
+ type: string
+ default: 'pr-preview'
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: read
+ steps:
+ - name: Check out gh-pages branch
+ uses: actions/checkout@v5
+ with:
+ ref: gh-pages
+
+ - name: Find and remove stale PR previews
+ id: cleanup
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PREVIEW_DIR: ${{ inputs.preview-dir }}
+ run: |
+ set -euo pipefail
+ removed=0
+ if [ ! -d "$PREVIEW_DIR" ]; then
+ echo "No $PREVIEW_DIR directory found, nothing to clean up."
+ else
+ for dir in "$PREVIEW_DIR"/pr-*/; do
+ [ -d "$dir" ] || continue
+ pr_number=$(basename "$dir" | sed 's/pr-//')
+ echo "Checking PR #${pr_number}..."
+
+ if pr_state=$(gh pr view "$pr_number" \
+ --repo "$GITHUB_REPOSITORY" \
+ --json state \
+ --jq '.state' 2>/dev/null); then
+ : # got the PR state
+ else
+ # The query failed. Distinguish "PR does not exist" (safe to
+ # delete the preview) from a transient error (skip, to avoid
+ # deleting a live preview). Key on the REST API's HTTP status
+ # rather than gh's human-readable error text, which can change
+ # across CLI versions.
+ http_status=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number" -i 2>/dev/null \
+ | head -n1 | awk '{print $2}') || true
+ if [ "$http_status" = "404" ]; then
+ pr_state="NOT_FOUND"
+ else
+ echo "Warning: could not query PR #${pr_number} (HTTP ${http_status:-unknown}); skipping to avoid accidental deletion." >&2
+ continue
+ fi
+ fi
+
+ if [ "$pr_state" != "OPEN" ]; then
+ echo "PR #${pr_number} is ${pr_state} - removing preview at ${dir}"
+ rm -rf "$dir"
+ removed=$((removed + 1))
+ else
+ echo "PR #${pr_number} is still open - keeping preview"
+ fi
+ done
+ fi
+
+ echo "Removed ${removed} stale PR preview(s)."
+ echo "removed=${removed}" >> "$GITHUB_OUTPUT"
+
+ - name: Commit and push changes
+ if: steps.cleanup.outputs.removed != '0'
+ run: |
+ set -euo pipefail
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add -A
+ git commit -m "chore: remove stale PR previews [skip ci]"
+ git push
diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml
new file mode 100644
index 0000000..e5d4e3b
--- /dev/null
+++ b/.github/workflows/preview-deploy.yml
@@ -0,0 +1,126 @@
+name: Quarto Preview Deploy (reusable)
+
+# Deploy half of the PR-preview family. The caller stub (see
+# examples/preview-deploy.yml) triggers this on `workflow_run` completion of
+# the build workflow; this reusable workflow inherits the caller's
+# `github.event.workflow_run.*` context. It runs in the BASE-repo context (not
+# the fork), so its GITHUB_TOKEN can push to gh-pages and comment on the PR.
+# Never merge this into the build half — that split is the trust boundary.
+
+on:
+ workflow_call:
+
+jobs:
+ # Cheap gate: verify the build job actually ran (vs. being skipped due to an
+ # unrecognized label). When all jobs in a workflow are skipped GitHub still
+ # reports conclusion == 'success', which would otherwise cause the deploy job
+ # to fire and fail on artifact download.
+ gate:
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ if: github.event.workflow_run.conclusion == 'success'
+ outputs:
+ has-artifact: ${{ steps.check.outputs.found }}
+ steps:
+ - name: Check artifact exists
+ id: check
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ run: |
+ set -euo pipefail
+ count=$(gh api \
+ "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts" \
+ --jq '[.artifacts[] | select(.name == "pr-preview-site")] | length')
+ echo "found=$([ "$count" -gt 0 ] && echo 'true' || echo 'false')" >> "$GITHUB_OUTPUT"
+
+ deploy:
+ needs: gate
+ if: needs.gate.outputs.has-artifact == 'true'
+ runs-on: ubuntu-latest
+ # Serialize deploys for the same PR branch so two back-to-back build
+ # completions don't race on gh-pages. cancel-in-progress: false queues
+ # rather than kills — a mid-push cancel could corrupt gh-pages.
+ concurrency:
+ group: preview-deploy-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }}
+ cancel-in-progress: false
+ # This job runs in the base-repo context (not the fork), so the
+ # GITHUB_TOKEN has write access to push to gh-pages and comment on PRs.
+ permissions:
+ contents: write
+ pull-requests: write
+ actions: read # needed to download artifacts from the triggering run
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v5
+
+ - name: Download preview artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: pr-preview-site
+ path: _preview_download/
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ github.token }}
+
+ - name: Read PR metadata
+ id: meta
+ # Fail loudly if the metadata is missing. A bare `$(cat missing)` inside
+ # `echo` yields empty output and exit 0, so the step would "pass" with
+ # empty pr-number/action and the deploy would silently skip (the
+ # regression d-morrison/rme#913 recovered from).
+ run: |
+ set -euo pipefail
+ pr_number=$(cat _preview_download/meta/pr-number.txt) || { echo "::error::Missing pr-number.txt in preview artifact"; exit 1; }
+ action=$(cat _preview_download/meta/action.txt) || { echo "::error::Missing action.txt in preview artifact"; exit 1; }
+ echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
+ echo "action=$action" >> "$GITHUB_OUTPUT"
+
+ - name: Deploy PR Preview
+ if: steps.meta.outputs.action == 'deploy'
+ uses: rossjrw/pr-preview-action@v1
+ id: preview
+ with:
+ source-dir: _preview_download/site/
+ pr-number: ${{ steps.meta.outputs.pr-number }}
+ comment: false
+ action: deploy
+
+ - name: Remove PR Preview
+ if: steps.meta.outputs.action == 'remove'
+ uses: rossjrw/pr-preview-action@v1
+ id: preview-remove
+ with:
+ pr-number: ${{ steps.meta.outputs.pr-number }}
+ comment: false
+ action: remove
+
+ - name: Comment with preview link
+ uses: marocchino/sticky-pull-request-comment@v3
+ if: steps.meta.outputs.action == 'deploy' && steps.preview.outputs.deployment-action != 'none'
+ continue-on-error: true
+ with:
+ header: pr-preview
+ number: ${{ steps.meta.outputs.pr-number }}
+ recreate: true
+ message: |
+ [PR Preview Action](https://github.com/rossjrw/pr-preview-action) ${{ steps.preview.outputs.action-version }}
+ :---:
+ :rocket: **Preview available at:** ${{ steps.preview.outputs.preview-url }}
+
+ Built to branch [`gh-pages`](${{ github.server_url }}/${{ github.repository }}/tree/gh-pages) at ${{ steps.preview.outputs.action-start-time }}.
+
+ - name: Remove preview comment
+ uses: marocchino/sticky-pull-request-comment@v3
+ if: steps.meta.outputs.action == 'remove' && steps.preview-remove.outputs.deployment-action == 'remove'
+ continue-on-error: true
+ with:
+ header: pr-preview
+ number: ${{ steps.meta.outputs.pr-number }}
+ message: |
+ [PR Preview Action](https://github.com/rossjrw/pr-preview-action) ${{ steps.preview-remove.outputs.action-version }}
+ :---:
+ Preview removed because the pull request was closed.
+
+ ${{ steps.preview-remove.outputs.action-start-time }}
diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 0000000..6b26634
--- /dev/null
+++ b/.github/workflows/preview.yml
@@ -0,0 +1,82 @@
+name: Quarto Preview Build (reusable)
+
+# Build half of the PR-preview family. Runs read-only in the (possibly fork)
+# PR context and uploads the rendered site + PR metadata as an artifact; the
+# deploy half (preview-deploy.yml) picks that up in the base-repo context via
+# workflow_run and publishes to gh-pages. Keeping the two halves split is the
+# trust boundary — never collapse them into one privileged job.
+#
+# The caller stub (see examples/preview.yml) owns the `on: pull_request:`
+# triggers (types + paths); this reusable workflow inherits the caller's event
+# context.
+
+on:
+ workflow_call:
+ inputs:
+ r-version:
+ description: R version to install with r-lib/actions/setup-r.
+ type: string
+ default: '4.6.0'
+ apt-packages:
+ description: >-
+ System (apt) packages to install before R/Quarto setup, space-separated.
+ Defaults to the shared renv stack plus the preview build helpers; set
+ to an empty string to skip the apt step.
+ type: string
+ default: >-
+ jags libcurl4-openssl-dev libpng-dev libfontconfig1-dev libjpeg-dev
+ libglpk-dev libharfbuzz-dev libfribidi-dev libfreetype6-dev libtiff5-dev
+ libwebp-dev libnode109 cmake libgit2-dev libnode-dev libx11-dev pandoc
+ use-renv:
+ description: Restore R dependencies with renv. Set false for non-renv repos.
+ type: boolean
+ default: true
+ install-package:
+ description: Run `R CMD INSTALL .` to install the caller repo as a local package before rendering.
+ type: boolean
+ default: true
+ setup-chrome:
+ description: Install Chrome (needed by Quarto for revealjs / screenshot rendering).
+ type: boolean
+ default: true
+ submodules:
+ description: Passed to actions/checkout `submodules` ('recursive', 'true', or 'false').
+ type: string
+ default: 'recursive'
+ render-profile:
+ description: Quarto `--profile` to render with.
+ type: string
+ default: 'website'
+
+jobs:
+ build:
+ # Run for all events except labels other than the recognized preview labels,
+ # so an unrelated label add doesn't spin up a (useless) build.
+ if: github.event.action != 'labeled' || contains(fromJSON('["clear freezer", "preview:pdf", "preview:docx", "preview:revealjs"]'), github.event.label.name)
+ runs-on: ubuntu-latest
+ # Cancel an in-progress preview build when a newer commit on the same ref
+ # supersedes it. Without cancel-in-progress the default is false, so a fresh
+ # push QUEUES behind the stale run and the up-to-date preview is delayed
+ # until the obsolete one finishes (see d-morrison/rme#812).
+ concurrency:
+ group: preview-${{ github.ref }}
+ cancel-in-progress: true
+ # Read-only: this job runs in the fork's restricted context and must never
+ # write to the base repo. All writes are handled by preview-deploy.yml,
+ # which runs in the base-repo context via workflow_run.
+ permissions:
+ contents: read
+ steps:
+ - name: Build preview
+ uses: d-morrison/gha/preview@v1
+ # The `type: boolean` inputs above are coerced to the strings
+ # 'true'/'false' when passed to the composite action, which compares
+ # them as strings (e.g. `inputs.use-renv == 'true'`).
+ with:
+ r-version: ${{ inputs.r-version }}
+ apt-packages: ${{ inputs.apt-packages }}
+ use-renv: ${{ inputs.use-renv }}
+ install-package: ${{ inputs.install-package }}
+ setup-chrome: ${{ inputs.setup-chrome }}
+ submodules: ${{ inputs.submodules }}
+ render-profile: ${{ inputs.render-profile }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 946ad8a..c2eebed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,22 @@ below with migration steps.
workflow (`quarto-publish.yml`) adds the deploy, optional submodule init, and
a `pre-render-artifact` input so a caller can inject build-time assets (e.g.
recorded media) before render. First consumer: `Lacaedemon/sparta` (#37).
+- PR-preview / publish family (#33) — centralizes the three-workflow preview
+ pipeline rme carried inline:
+ - `preview` composite action + `preview.yml` reusable workflow — build half;
+ renders the Quarto site read-only in the (possibly fork) PR context and
+ uploads it + PR metadata as an artifact. Parameterized for non-rme
+ consumers (R version, apt packages, renv on/off, local-package install,
+ Chrome, submodules, render profile). Writes PR metadata **after** checkout
+ so `git clean -ffdx` can't wipe it from the artifact (d-morrison/rme#913),
+ and keeps the `preview:pdf`/`preview:docx`/`preview:revealjs` and
+ `clear freezer` label gates.
+ - `preview-deploy.yml` reusable workflow — deploy half; on `workflow_run`
+ completion publishes the artifact to `gh-pages` in the base-repo context
+ and comments the preview link. Kept split from the build half so untrusted
+ fork code never holds write permissions (the trust boundary).
+ - `cleanup-pr-previews.yml` reusable workflow — scheduled housekeeping that
+ deletes preview directories for closed PRs.
- `check-phi` — scans pull requests (added lines only; whole tree on `push`)
for content that looks like PHI: US Social Security numbers, medical record
numbers, dates of birth, and PHI-suggestive column headers in delimited data
diff --git a/README.md b/README.md
index 3b5d886..b730900 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,9 @@ Pin to `@v1` (a moving major tag updated as fixes land). Do not reference
| `claude.yml` | Agent-mode Claude Code bot: responds to `@claude` mentions, edits files, opens/updates PRs | `setup-r`, `install-quarto`, `use-renv`, `apt-packages`, `pip-packages`, `checkout-submodules`, `link-skills`, `eager-pr`, `prompt-addendum`, `webfetch-allowlist-url`, `reviewer` |
| `claude-code-review.yml` | Read-only Claude PR review (runs the `code-review` plugin; inline findings on `pull_request` runs, consolidated summary on dispatched runs) | `pr-number`, `prompt-addendum`, `checkout-submodules` |
| `quarto-publish.yml` | Render a Quarto site and deploy it to GitHub Pages | `path`, `setup-r`, `r-packages`, `use-renv`, `tinytex`, `apt-packages`, `output-dir`, `checkout-submodules`, `pre-render-artifact`, `pre-render-artifact-path`, `deploy` |
+| `preview.yml` | Build half of the PR-preview family: render a Quarto site in the (possibly fork) PR context and upload it + PR metadata as an artifact (read-only) | `r-version`, `apt-packages`, `use-renv`, `install-package`, `setup-chrome`, `submodules`, `render-profile` |
+| `preview-deploy.yml` | Deploy half: on `workflow_run` completion of the build, publish the artifact to `gh-pages` and comment the preview link (base-repo context) | — |
+| `cleanup-pr-previews.yml` | Housekeeping: delete `gh-pages` preview directories for PRs that are no longer open | `preview-dir` |
## Permissions
@@ -86,6 +89,11 @@ that need to write must have the **caller** grant it on the calling job:
submodule contents instead of reporting them as uninitialized. Public
submodules clone anonymously; private ones additionally need a
`SUBMODULES_TOKEN` secret.
+- `preview` (build half, read-only) → only `contents: read` (the default).
+- `preview-deploy` (deploy half, pushes `gh-pages` + comments) → grant
+ `contents: write`, `pull-requests: write`, `actions: read`.
+- `cleanup-pr-previews` (commits deletions to `gh-pages`) → grant
+ `contents: write`, `pull-requests: read`.
The stubs in [`examples/`](examples) already include the right `permissions:`
blocks — copy them as-is.
@@ -118,6 +126,42 @@ exist but are **off by default** (too noisy in source); enable them via the
(defaults to `.github/phi-allowlist.txt` when present; override with the
`allowlist-file` input). Use `fail: false` to downgrade to warnings.
+## PR previews (`preview` family)
+
+The PR-preview family publishes a rendered Quarto site for each open PR to a
+`pr-preview/pr-/` directory on `gh-pages`. It is **three** cooperating
+workflows — install all three stubs from [`examples/`](examples):
+
+1. **`preview.yml`** (build) — triggered on `pull_request`. Renders the site and
+ uploads it plus the PR metadata as a `pr-preview-site` artifact. Runs
+ **read-only** in the (possibly fork) PR context, so it can't write to the
+ base repo.
+2. **`preview-deploy.yml`** (deploy) — triggered on `workflow_run` completion of
+ the build. Downloads the artifact and publishes it to `gh-pages` in the
+ **base-repo** context (where the token can write), then comments the preview
+ link on the PR.
+3. **`cleanup-pr-previews.yml`** (housekeeping) — scheduled. Removes preview
+ directories for PRs that have closed.
+
+The build/deploy split is a **trust boundary**: untrusted fork code only ever
+runs in the read-only build half, while the privileged `gh-pages` push happens
+in the deploy half against base-repo code. Don't collapse them into one job.
+
+Two wiring requirements:
+
+- The deploy stub's `on: workflow_run: workflows:` value **must match the
+ build stub's `name:`** (both default to `Quarto Preview Build` in the
+ examples). That string is how `workflow_run` finds the build.
+- `workflow_run` and `schedule` triggers only fire for the copy of the file on
+ the **default branch**, so previews and cleanup don't take effect until the
+ stubs are merged to `main`.
+
+The build half is parameterized for non-rme consumers (R version, the apt
+package list, renv on/off, `R CMD INSTALL .` on/off, Chrome, submodules, render
+profile). Label-gated extras are preserved: add `preview:pdf`, `preview:docx`,
+or `preview:revealjs` to a PR to render those formats too, and `clear freezer`
+to bypass the Quarto freeze cache.
+
## Versioning
Releases are tagged `vX.Y.Z`; the `vX` major tag moves to the latest compatible
@@ -138,6 +182,6 @@ automatically. A **private** consumer must allow access to this repo under
## Scope
-This is the pilot set (the byte-identical / near-identical workflow families).
-Additional families (spell check, lint-changed-files, pr-commands, R-CMD-check,
-publish/preview) may be added later.
+This started as the pilot set (the byte-identical / near-identical workflow
+families) plus the PR-preview/publish family. Additional families (spell check,
+lint-changed-files, pr-commands, R-CMD-check) may be added later.
diff --git a/examples/cleanup-pr-previews.yml b/examples/cleanup-pr-previews.yml
new file mode 100644
index 0000000..15fed03
--- /dev/null
+++ b/examples/cleanup-pr-previews.yml
@@ -0,0 +1,19 @@
+# Copy to .github/workflows/cleanup-pr-previews.yml in your repo.
+# Housekeeping half of the PR-preview family: periodically deletes gh-pages
+# preview directories for PRs that are no longer open. The calling job grants
+# contents:write so it can commit the deletions back to gh-pages.
+name: Clean up PR Previews
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '0 0 * * 0' # Weekly on Sunday at midnight UTC
+
+jobs:
+ cleanup:
+ permissions:
+ contents: write
+ pull-requests: read
+ uses: d-morrison/gha/.github/workflows/cleanup-pr-previews.yml@v1
+ # with:
+ # preview-dir: pr-preview # gh-pages dir holding the per-PR previews
diff --git a/examples/preview-deploy.yml b/examples/preview-deploy.yml
new file mode 100644
index 0000000..6236ee6
--- /dev/null
+++ b/examples/preview-deploy.yml
@@ -0,0 +1,23 @@
+# Copy to .github/workflows/preview-deploy.yml in your repo.
+# Deploy half of the PR-preview family: triggered when the build workflow
+# finishes, downloads its artifact, and publishes the preview to gh-pages. This
+# runs in the BASE-repo context, so its token can write — keeping it separate
+# from the read-only build half is the trust boundary.
+#
+# NOTE: the `workflows:` value below MUST match the `name:` of your build
+# workflow (examples/preview.yml). `workflow_run` triggers only fire when this
+# file lives on the default branch.
+name: Quarto Preview Deploy
+
+on:
+ workflow_run:
+ workflows: ["Quarto Preview Build"]
+ types: [completed]
+
+jobs:
+ deploy:
+ permissions:
+ contents: write # push to gh-pages
+ pull-requests: write # post the preview-link comment
+ actions: read # download the build artifact
+ uses: d-morrison/gha/.github/workflows/preview-deploy.yml@v1
diff --git a/examples/preview.yml b/examples/preview.yml
new file mode 100644
index 0000000..12fe6f3
--- /dev/null
+++ b/examples/preview.yml
@@ -0,0 +1,37 @@
+# Copy to .github/workflows/preview.yml in your repo.
+# Build half of the PR-preview family: renders the Quarto site in the (possibly
+# fork) PR context and uploads it + PR metadata as an artifact. The deploy half
+# (preview-deploy.yml) publishes it to gh-pages. This job is read-only
+# (contents: read) — it must never write to the base repo.
+#
+# IMPORTANT: keep this workflow's `name:` in sync with the `workflows:` list in
+# preview-deploy.yml — that's how the deploy half finds this run.
+name: Quarto Preview Build
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize, labeled, closed]
+ # Trim/extend these globs to the source paths that affect your rendered site.
+ paths:
+ - 'man/**'
+ - 'vignettes/**'
+ - '_extensions/**'
+ - '_quarto*.yml'
+ - '*.qmd'
+ - 'chapters/**/*.qmd'
+ - '_subfiles/**'
+ - '*.scss'
+
+jobs:
+ build:
+ permissions:
+ contents: read
+ uses: d-morrison/gha/.github/workflows/preview.yml@v1
+ # with:
+ # r-version: '4.6.0'
+ # apt-packages: 'jags libglpk-dev' # override the default system-deps list
+ # use-renv: true # set false for non-renv repos
+ # install-package: true # `R CMD INSTALL .` before rendering
+ # setup-chrome: true # needed for revealjs / screenshots
+ # submodules: recursive
+ # render-profile: website
diff --git a/preview/action.yml b/preview/action.yml
new file mode 100644
index 0000000..497baa2
--- /dev/null
+++ b/preview/action.yml
@@ -0,0 +1,191 @@
+name: Build Quarto PR preview
+description: >-
+ Build a Quarto site for a pull-request preview and stage it (plus the PR
+ metadata the deploy half needs) into a single artifact. Runs read-only in the
+ (possibly fork) PR context — all writes to the base repo happen later in the
+ deploy half (see the preview-deploy reusable workflow), which runs in the
+ base-repo context via workflow_run. The R-package / Quarto specifics are
+ parameterized so non-rme consumers can reuse this.
+inputs:
+ r-version:
+ description: R version to install with r-lib/actions/setup-r.
+ default: '4.6.0'
+ apt-packages:
+ description: >-
+ System (apt) packages to install before R/Quarto setup, space-separated.
+ Defaults to the shared renv stack plus the preview build helpers. Set to
+ an empty string to skip the apt step entirely.
+ default: >-
+ jags libcurl4-openssl-dev libpng-dev libfontconfig1-dev libjpeg-dev
+ libglpk-dev libharfbuzz-dev libfribidi-dev libfreetype6-dev libtiff5-dev
+ libwebp-dev libnode109 cmake libgit2-dev libnode-dev libx11-dev pandoc
+ use-renv:
+ description: Restore R dependencies with renv (r-lib/actions/setup-renv). Set 'false' for non-renv repos.
+ default: 'true'
+ install-package:
+ description: Run `R CMD INSTALL .` to install the caller repo as a local package before rendering.
+ default: 'true'
+ setup-chrome:
+ description: Install Chrome (needed by Quarto for revealjs / screenshot rendering).
+ default: 'true'
+ submodules:
+ description: Passed to actions/checkout `submodules` (e.g. 'recursive', 'true', or 'false').
+ default: 'recursive'
+ render-profile:
+ description: Quarto `--profile` to render with.
+ default: 'website'
+runs:
+ using: composite
+ steps:
+ - name: Check out repository
+ if: github.event.action != 'closed'
+ uses: actions/checkout@v5
+ with:
+ submodules: ${{ inputs.submodules }}
+
+ # IMPORTANT: write the metadata AFTER checkout. actions/checkout runs
+ # `git clean -ffdx` (clean: true is the default), which deletes any
+ # untracked files already in the workspace — including a
+ # `_preview_upload/` directory created beforehand. With the metadata
+ # written before checkout, `_preview_upload/meta/` was wiped and never
+ # reached the artifact, so the deploy half read an empty pr-number, the
+ # deploy step's `action == 'deploy'` guard was false, and every deploy-path
+ # preview was silently skipped (the job still passed). See d-morrison/rme#913.
+ # For the 'closed'/remove event the checkout step is skipped, but this step
+ # is unguarded and still runs, so the remove path is unaffected.
+ - name: Save PR metadata
+ shell: bash
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ PREVIEW_ACTION: ${{ github.event.action == 'closed' && 'remove' || 'deploy' }}
+ run: |
+ set -euo pipefail
+ mkdir -p _preview_upload/meta
+ echo "$PR_NUMBER" > _preview_upload/meta/pr-number.txt
+ echo "$PREVIEW_ACTION" > _preview_upload/meta/action.txt
+
+ - name: Set up Quarto
+ if: github.event.action != 'closed'
+ uses: quarto-dev/quarto-actions/setup@v2
+ env:
+ GH_TOKEN: ${{ github.token }}
+ with:
+ # Install TinyTeX in the same setup only when a PDF render is requested;
+ # composite actions can't vary `with:` conditionally, so gate it on the
+ # label expression rather than running a second setup step.
+ tinytex: ${{ contains(github.event.pull_request.labels.*.name, 'preview:pdf') }}
+
+ - name: Set up R
+ if: github.event.action != 'closed'
+ uses: r-lib/actions/setup-r@v2
+ with:
+ r-version: ${{ inputs.r-version }}
+ use-public-rspm: true
+
+ - name: Install system dependencies
+ if: github.event.action != 'closed' && inputs.apt-packages != ''
+ shell: bash
+ env:
+ APT_PACKAGES: ${{ inputs.apt-packages }}
+ run: |
+ set -euo pipefail
+ sudo apt-get update
+ # Intentional word-splitting: APT_PACKAGES is a space-separated list.
+ sudo apt-get install -y $APT_PACKAGES
+
+ - name: Setup Chrome
+ if: github.event.action != 'closed' && inputs.setup-chrome == 'true'
+ uses: browser-actions/setup-chrome@v1
+
+ - name: Restore R dependencies (renv)
+ if: github.event.action != 'closed' && inputs.use-renv == 'true'
+ uses: r-lib/actions/setup-renv@v2
+ env:
+ GITHUB_PAT: ${{ github.token }}
+ with:
+ bypass-cache: never
+
+ - name: Install local package
+ if: github.event.action != 'closed' && inputs.install-package == 'true'
+ shell: bash
+ run: R CMD INSTALL .
+
+ - name: Install TinyTeX packages required for PDF rendering
+ if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:pdf')
+ shell: bash
+ run: |
+ set -euo pipefail
+ Rscript -e "tinytex::tlmgr_repo('https://mirror.ctan.org/systems/texlive/tlnet')"
+ Rscript -e "tinytex::tlmgr_install(c('luacolor', 'lua-ul'))"
+
+ - name: Restore Quarto freeze
+ if: github.event.action != 'closed' && !contains(github.event.pull_request.labels.*.name, 'clear freezer')
+ uses: actions/cache/restore@v4
+ with:
+ path: _freeze
+ key: quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-${{ github.event.pull_request.head.sha }}-${{ github.run_attempt }}
+ restore-keys: |
+ quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-${{ github.event.pull_request.head.sha }}-
+ quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-${{ github.event.pull_request.base.ref }}-
+ quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-
+ quarto-freezer-${{ runner.os }}-
+
+ - name: Render pdf
+ if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:pdf')
+ shell: bash
+ env:
+ RENDER_PROFILE: ${{ inputs.render-profile }}
+ run: quarto render --profile "$RENDER_PROFILE" --to pdf
+
+ - name: Render docx
+ if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:docx')
+ shell: bash
+ env:
+ RENDER_PROFILE: ${{ inputs.render-profile }}
+ run: quarto render --profile "$RENDER_PROFILE" --to docx --no-clean
+
+ - name: Render revealjs
+ if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:revealjs')
+ shell: bash
+ env:
+ RENDER_PROFILE: ${{ inputs.render-profile }}
+ run: quarto render --profile "$RENDER_PROFILE" --to revealjs --no-clean
+
+ - name: Render html
+ if: github.event.action != 'closed'
+ shell: bash
+ env:
+ RENDER_PROFILE: ${{ inputs.render-profile }}
+ run: quarto render --profile "$RENDER_PROFILE" --to html --no-clean
+
+ - name: List files
+ if: github.event.action != 'closed'
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "contents of _site:"
+ ls _site/
+ echo "contents of .:"
+ ls .
+
+ - name: Save Quarto freeze
+ if: github.event.action != 'closed'
+ uses: actions/cache/save@v4
+ with:
+ path: _freeze
+ key: quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-${{ github.event.pull_request.head.sha }}-${{ github.run_attempt }}
+
+ - name: Stage site for upload
+ if: github.event.action != 'closed'
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p _preview_upload/site/
+ cp -r _site/. _preview_upload/site/
+
+ - name: Upload preview artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: pr-preview-site
+ path: _preview_upload/
+ retention-days: 1