Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/cleanup-pr-previews.yml
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions .github/workflows/preview-deploy.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +80 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Supply-chain risk: third-party actions on mutable major tags

rossjrw/pr-preview-action@v1 (repeated on line 90) and marocchino/sticky-pull-request-comment@v3 (lines 100, 114) use major-version tags rather than pinned SHAs. The deploy job runs in the base-repo context with contents: write and pull-requests: write, so a tag-overwrite or repo compromise by any of these upstreams would have real impact.

Consider pinning to commit SHAs (with the version in a comment for readability), for example:

uses: rossjrw/pr-preview-action@6e624b14d5d44f41a6d0cb4bc3cc6f5c01a2a76b  # v1.6.2

browser-actions/setup-chrome@v1 in preview/action.yml line 95 has the same issue, though that job is read-only.

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 }}

<sub>Built to branch [`gh-pages`](${{ github.server_url }}/${{ github.repository }}/tree/gh-pages) at ${{ steps.preview.outputs.action-start-time }}.</sub>

- 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.

<sub>${{ steps.preview-remove.outputs.action-start-time }}</sub>
82 changes: 82 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -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 }}
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 47 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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-<n>/` 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
Expand All @@ -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.
Loading
Loading