From 931689dc38f0f0fd1e35844ebc70307de3fb87a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 01:26:27 +0000 Subject: [PATCH 1/3] Add reusable PR-preview/publish workflow family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralizes the three-workflow Quarto PR-preview pipeline (build, deploy, cleanup) that d-morrison/rme carried as fully-inlined local workflows, so consumer repos call thin stubs instead of maintaining their own copy. Fixes to this pipeline (e.g. the rme#913 git-clean-ordering regression) now land once, centrally. Mirrors the repo's two-layer pattern: - preview/action.yml — composite action for the build half: checkout, write PR metadata *after* checkout (so `git clean -ffdx` can't wipe it from the artifact, per rme#913), Quarto/R/renv setup, apt deps, label-gated pdf/docx/revealjs renders, freeze cache, and artifact upload. Parameterized for non-rme consumers (R version, apt packages, renv on/off, local-package install, Chrome, submodules, render profile). - .github/workflows/preview.yml — reusable build workflow (read-only, fork context) wrapping the composite, with the concurrency cancel-in-progress and recognized-label gates preserved. - .github/workflows/preview-deploy.yml — reusable deploy workflow: on workflow_run completion, publishes the artifact to gh-pages and comments the preview link in the base-repo context. Kept split from the build half so untrusted fork code never holds write permissions (the trust boundary). - .github/workflows/cleanup-pr-previews.yml — reusable scheduled housekeeping that removes preview directories for closed PRs. Adds caller stubs under examples/, documents required caller permissions and the build-name/workflow_run wiring in the README, records the family in the table and CHANGELOG, and drops publish/preview from the "later" scope list. Refs d-morrison/gha#33. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BXDXuaTiRaLUud6ET7EJxZ --- .github/workflows/cleanup-pr-previews.yml | 83 ++++++++++ .github/workflows/preview-deploy.yml | 126 +++++++++++++++ .github/workflows/preview.yml | 79 +++++++++ CHANGELOG.md | 16 ++ README.md | 50 +++++- examples/cleanup-pr-previews.yml | 19 +++ examples/preview-deploy.yml | 23 +++ examples/preview.yml | 37 +++++ preview/action.yml | 188 ++++++++++++++++++++++ 9 files changed, 618 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/cleanup-pr-previews.yml create mode 100644 .github/workflows/preview-deploy.yml create mode 100644 .github/workflows/preview.yml create mode 100644 examples/cleanup-pr-previews.yml create mode 100644 examples/preview-deploy.yml create mode 100644 examples/preview.yml create mode 100644 preview/action.yml diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml new file mode 100644 index 0000000..1af52e7 --- /dev/null +++ b/.github/workflows/cleanup-pr-previews.yml @@ -0,0 +1,83 @@ +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}..." + + gh_output=$(gh pr view "$pr_number" \ + --repo "$GITHUB_REPOSITORY" \ + --json state \ + --jq '.state' 2>&1) && gh_exit=0 || gh_exit=$? + + if [ "$gh_exit" -ne 0 ]; then + # Distinguish "PR does not exist" from other failures + if echo "$gh_output" | grep -qi "could not resolve\|no pull requests found\|404\|not found"; then + pr_state="NOT_FOUND" + else + echo "Warning: could not query PR #${pr_number} (exit ${gh_exit}): ${gh_output}" >&2 + echo "Skipping PR #${pr_number} to avoid accidental deletion." + continue + fi + else + pr_state="$gh_output" + 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..60c6a92 --- /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..c9cfdbd --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,79 @@ +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 + 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 6850f05..ca1c1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ below with migration steps. ### Added +- 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 a55734c..5b27809 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ Pin to `@v1` (a moving major tag updated as fixes land). Do not reference | `check-news.yml` | Enforce a `NEWS.md` changelog entry on PRs (wraps `UCD-SERG/changelog-check-action`) | `changelog` | | `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` | +| `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 @@ -81,6 +84,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. @@ -113,6 +121,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 @@ -133,6 +177,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..9260cef --- /dev/null +++ b/preview/action.yml @@ -0,0 +1,188 @@ +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: + tinytex: false + + - 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: Set up TinyTeX + if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:pdf') + uses: quarto-dev/quarto-actions/setup@v2 + env: + GH_TOKEN: ${{ github.token }} + with: + tinytex: true + + - 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') }}-main- + 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 + run: quarto render --profile ${{ inputs.render-profile }} --to pdf + + - name: Render docx + if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:docx') + shell: bash + run: quarto render --profile ${{ inputs.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 + run: quarto render --profile ${{ inputs.render-profile }} --to revealjs --no-clean + + - name: Render html + if: github.event.action != 'closed' + shell: bash + run: quarto render --profile ${{ inputs.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 From 670826bd43c1903b9ec6807d16f69272f02da9a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 01:35:15 +0000 Subject: [PATCH 2/3] Address PR #34 review: harden inputs and de-duplicate setup - preview/action.yml: route `render-profile` through an `env:` var and quote it in all four render steps, so a value with shell metacharacters can't be injected into the render command (GitHub expands ${{ }} before bash runs). - preview/action.yml: collapse the two quarto-actions/setup calls into one, gating TinyTeX install on the `preview:pdf` label via an expression instead of a second setup step (DRY; avoids a redundant Quarto install on PDF builds). - preview-deploy.yml: use the built-in $GITHUB_REPOSITORY env var in the gate step instead of an inline ${{ github.repository }} expression, matching the repo's env-routing convention. - cleanup-pr-previews.yml: detect a missing PR by the REST API's HTTP 404 status rather than grepping gh's human-readable error text, which is an unstable CLI implementation detail; transient errors still skip deletion. - preview.yml: note that the boolean workflow_call inputs are coerced to 'true'/'false' strings when passed to the composite action. SHA-pinning of third-party actions is deferred to #35 (repo-wide policy; the whole repo uses major tags today, so pinning only this family would be inconsistent). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BXDXuaTiRaLUud6ET7EJxZ --- .github/workflows/cleanup-pr-previews.yml | 23 ++++++++++-------- .github/workflows/preview-deploy.yml | 2 +- .github/workflows/preview.yml | 3 +++ preview/action.yml | 29 +++++++++++++---------- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/.github/workflows/cleanup-pr-previews.yml b/.github/workflows/cleanup-pr-previews.yml index 1af52e7..6a510b5 100644 --- a/.github/workflows/cleanup-pr-previews.yml +++ b/.github/workflows/cleanup-pr-previews.yml @@ -41,22 +41,25 @@ jobs: pr_number=$(basename "$dir" | sed 's/pr-//') echo "Checking PR #${pr_number}..." - gh_output=$(gh pr view "$pr_number" \ + if pr_state=$(gh pr view "$pr_number" \ --repo "$GITHUB_REPOSITORY" \ --json state \ - --jq '.state' 2>&1) && gh_exit=0 || gh_exit=$? - - if [ "$gh_exit" -ne 0 ]; then - # Distinguish "PR does not exist" from other failures - if echo "$gh_output" | grep -qi "could not resolve\|no pull requests found\|404\|not found"; then + --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} (exit ${gh_exit}): ${gh_output}" >&2 - echo "Skipping PR #${pr_number} to avoid accidental deletion." + echo "Warning: could not query PR #${pr_number} (HTTP ${http_status:-unknown}); skipping to avoid accidental deletion." >&2 continue fi - else - pr_state="$gh_output" fi if [ "$pr_state" != "OPEN" ]; then diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 60c6a92..e5d4e3b 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -31,7 +31,7 @@ jobs: run: | set -euo pipefail count=$(gh api \ - "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \ + "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" diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index c9cfdbd..6b26634 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -69,6 +69,9 @@ jobs: 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 }} diff --git a/preview/action.yml b/preview/action.yml index 9260cef..c29bbe6 100644 --- a/preview/action.yml +++ b/preview/action.yml @@ -70,7 +70,10 @@ runs: env: GH_TOKEN: ${{ github.token }} with: - tinytex: false + # 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' @@ -107,14 +110,6 @@ runs: shell: bash run: R CMD INSTALL . - - name: Set up TinyTeX - if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:pdf') - uses: quarto-dev/quarto-actions/setup@v2 - env: - GH_TOKEN: ${{ github.token }} - with: - tinytex: true - - name: Install TinyTeX packages required for PDF rendering if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:pdf') shell: bash @@ -138,22 +133,30 @@ runs: - name: Render pdf if: github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview:pdf') shell: bash - run: quarto render --profile ${{ inputs.render-profile }} --to pdf + 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 - run: quarto render --profile ${{ inputs.render-profile }} --to docx --no-clean + 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 - run: quarto render --profile ${{ inputs.render-profile }} --to revealjs --no-clean + 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 - run: quarto render --profile ${{ inputs.render-profile }} --to html --no-clean + env: + RENDER_PROFILE: ${{ inputs.render-profile }} + run: quarto render --profile "$RENDER_PROFILE" --to html --no-clean - name: List files if: github.event.action != 'closed' From 0e86de3505503fa5f30c075962e64b3811166943 Mon Sep 17 00:00:00 2001 From: d-morrison Date: Sat, 20 Jun 2026 00:09:01 -0700 Subject: [PATCH 3/3] preview: track PR base branch in freeze restore key Replace the hardcoded `-main-` fallback level in the Quarto freeze cache restore-keys with `${{ github.event.pull_request.base.ref }}`, so consumers whose default branch isn't `main` still hit that fallback instead of falling through to the broader renv.lock-only key. No behavior change for rme (base ref is `main`). Addresses the round-2 review finding on PR #34. Co-Authored-By: Claude Opus 4.8 --- preview/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/preview/action.yml b/preview/action.yml index c29bbe6..497baa2 100644 --- a/preview/action.yml +++ b/preview/action.yml @@ -126,7 +126,7 @@ runs: 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') }}-main- + quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-${{ github.event.pull_request.base.ref }}- quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}- quarto-freezer-${{ runner.os }}-