Skip to content

Add reusable PR-preview/publish workflow family#34

Merged
d-morrison merged 4 commits into
mainfrom
claude/quirky-hawking-t1tdzt
Jun 20, 2026
Merged

Add reusable PR-preview/publish workflow family#34
d-morrison merged 4 commits into
mainfrom
claude/quirky-hawking-t1tdzt

Conversation

@d-morrison

Copy link
Copy Markdown
Owner

Closes #33.

What

Centralizes the three-workflow Quarto PR-preview / publish pipeline that d-morrison/rme currently carries as fully-inlined local workflows (preview.yml, preview-deploy.yml, cleanup-pr-previews.yml) into reusable workflow_call workflows + a composite action here, so consumer repos call thin stubs instead of maintaining their own copy. Fixes to the pipeline (e.g. the rme#913 git-clean-ordering regression) now land once, centrally.

How it's structured

Mirrors this repo's existing two-layer pattern (composite action + reusable workflow):

New file Role
preview/action.yml Composite action — the build half: checkout → write PR metadata → Quarto/R/renv setup → apt deps → label-gated renders → freeze cache → artifact upload.
.github/workflows/preview.yml Reusable build workflow (read-only, fork context) wrapping the composite.
.github/workflows/preview-deploy.yml Reusable deploy workflowworkflow_run-triggered; publishes the artifact to gh-pages and comments the preview link in the base-repo context.
.github/workflows/cleanup-pr-previews.yml Reusable cleanup workflow — scheduled housekeeping for closed PRs.
examples/preview.yml, examples/preview-deploy.yml, examples/cleanup-pr-previews.yml Caller stubs with the correct permissions: blocks.

Load-bearing pieces preserved (per the issue's design notes)

  • Split build/deploy trust boundary — build runs read-only in the (possibly fork) PR context; the privileged gh-pages push happens in the separate workflow_run deploy half against base-repo code. Not collapsed into one job.
  • git clean ordering — PR metadata is written after actions/checkout so git clean -ffdx can't wipe it from the artifact (rme#913).
  • Concurrency — build job keeps cancel-in-progress: true (rme#812); deploy keeps the per-branch serialized group with cancel-in-progress: false.
  • Label-gated render variantspreview:pdf / preview:docx / preview:revealjs, plus clear freezer freeze-bypass.
  • Quarto freeze cache keyed on renv.lock + head SHA.
  • Loud-fail metadata read + the skipped-build artifact gate in the deploy half.

Parameterized for non-rme consumers

The build half takes inputs: r-version, apt-packages (defaults to rme's exact list; set empty to skip), use-renv, install-package (R CMD INSTALL .), setup-chrome, submodules, render-profile. Cleanup takes preview-dir.

Docs

  • README: added the three workflows to the "Available reusable workflows" table, added a dedicated PR previews section (including the build-name:workflow_run wiring requirement and the default-branch caveat), extended the Permissions list, and dropped publish/preview from the "later" scope note.
  • CHANGELOG: recorded the family under Unreleased → Added.

Notes / out of scope

  • Not added to _selftest.yml: the build half needs a real Quarto/R project (this repo isn't one) and deploy/cleanup need gh-pages + workflow_run, so a meaningful self-test isn't feasible here. The family will be exercised by the rme migration. Same caveat the check-phi selftest comment already documents for brand-new capabilities.
  • REVDEPS.md not updated: the issue scopes that to "once rme migrates" — no consumer is registered yet.
  • Follow-up: once this is tagged into @v1, open the companion rme PR replacing its three local preview workflows with these stubs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BXDXuaTiRaLUud6ET7EJxZ


Generated by Claude Code

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXDXuaTiRaLUud6ET7EJxZ
Copilot AI review requested due to automatic review settings June 19, 2026 01:27
@github-actions github-actions Bot removed the request for review from Copilot June 19, 2026 01:27
@claude

This comment has been minimized.

Comment thread preview/action.yml Outdated
Comment thread preview/action.yml Outdated
Comment thread .github/workflows/preview-deploy.yml Outdated
Comment on lines +80 to +82
- name: Deploy PR Preview
if: steps.meta.outputs.action == 'deploy'
uses: rossjrw/pr-preview-action@v1

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.

Comment on lines +49 to +51
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

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.

Fragile: error-message grep is a GH CLI implementation detail

The pattern "could not resolve\|no pull requests found\|404\|not found" relies on the exact wording of GH CLI error messages, which can change across CLI versions without notice. If a message changes, a non-existent PR would fall into the generic "skip" branch rather than NOT_FOUND, potentially leaving stale previews indefinitely.

A more robust approach is to use gh api and distinguish on the HTTP status code:

http_code=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number" \
  --jq '.state' \
  -i 2>/dev/null | head -1 | awk '{print $2}') || true
if [ "$http_code" = "404" ]; then
  pr_state="NOT_FOUND"
fi

Alternatively, just treat any non-zero exit from gh pr view as "not found" for simplicity (accepting the risk of deleting previews on transient API errors), making the intent explicit with a comment.

Comment thread preview/action.yml Outdated
Comment on lines +73 to +75
tinytex: false

- name: Set up R

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.

Input type asymmetry (informational)

The reusable workflow (preview.yml) declares use-renv, install-package, and setup-chrome as type: boolean. GitHub coerces them to the strings 'true'/'false' when passing to this composite action, so the == 'true' comparisons work correctly.

Worth a one-line comment here for future maintainers:

          # Boolean inputs from workflow_call are coerced to strings 'true'/'false'
          use-renv: ${{ inputs.use-renv }}

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BXDXuaTiRaLUud6ET7EJxZ
Copilot AI review requested due to automatic review settings June 19, 2026 01:35
@github-actions github-actions Bot removed the request for review from Copilot June 19, 2026 01:35
@claude

This comment has been minimized.

Copy link
Copy Markdown
Owner Author

Thanks for the review — addressed all six findings in 670826b. Dispositions:

# Finding Disposition
1 Shell injection via render-profile (security) Fixed — all four render steps now route the value through env: RENDER_PROFILE and use "$RENDER_PROFILE", so ${{ }} is no longer expanded into the command text.
2 Duplicate Quarto setup Fixed — collapsed to a single quarto-actions/setup with tinytex: ${{ contains(labels, 'preview:pdf') }}; dropped the second setup step (TinyTeX package install step kept).
3 Third-party actions on mutable tags (supply chain) Deferred → #35. Every third-party action in the repo (incl. sticky-pull-request-comment@v3 and pr-preview-action@v1 already in use) is on a major tag; pinning only this family to SHAs would be inconsistent. Filed #35 to do it uniformly repo-wide (ideally via pin-github-action/Dependabot).
4 GITHUB_REPOSITORY vs inline expression Fixed — gate step now uses the built-in $GITHUB_REPOSITORY env var.
5 Fragile error-message grep in cleanup Fixed — "PR not found" is now detected via the REST API's HTTP 404 status (gh api … -i) instead of grepping gh's error wording; transient errors still skip deletion to avoid removing a live preview.
6 Boolean/string type note Fixed — added a comment in preview.yml explaining the workflow_call boolean → string coercion the composite relies on.

@claude review


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

👀 Picked up by workflow run #27800017401. Setup runs first; Claude itself responds after that.

@d-morrison

Copy link
Copy Markdown
Owner Author

@claude review

@github-actions

Copy link
Copy Markdown
Contributor

👀 Picked up by workflow run #27812339890. Setup runs first; Claude itself responds after that.

@d-morrison

Copy link
Copy Markdown
Owner Author

Claude Code CLI (local session) is driving this PR to clean — paws off until I'm done.

…-t1tdzt

# Conflicts:
#	CHANGELOG.md
#	README.md
Copilot AI review requested due to automatic review settings June 20, 2026 07:01
@github-actions github-actions Bot removed the request for review from Copilot June 20, 2026 07:01
@claude

This comment has been minimized.

@d-morrison

Copy link
Copy Markdown
Owner Author

ARD summary — review of the completed review

Synced the branch with main (resolved CHANGELOG/README conflicts from the parallel quarto-publish merge, #38) and dispositioned all six findings. Five were already addressed by commit 670826b; the re-review that would have confirmed them was canceled by cancel-in-progress before posting, leaving the stale verdict.

# Finding Disposition Notes
1 Shell injection via render-profile (blocking) Addressed Already fixed in 670826b — all four render steps route the value through env: RENDER_PROFILE and reference "$RENDER_PROFILE" (preview/action.yml:133–159).
2 Duplicate Quarto setup Addressed Collapsed to a single setup@v2 with tinytex: ${{ contains(…, 'preview:pdf') }} (preview/action.yml:67–76).
3 GITHUB_REPOSITORY vs. inline ${{ github.repository }} Addressed Now uses the built-in ${GITHUB_REPOSITORY} env var (preview-deploy.yml:34).
4 Third-party actions on mutable major tags 🟡 Rebut + Defer → #48 See below.
5 Fragile error-message grep in cleanup Addressed Reworked to key on the REST API's HTTP 404 status instead of CLI error text (cleanup-pr-previews.yml:44–63).
6 Boolean/string type asymmetry (informational) Addressed One-line comment added explaining the workflow_call boolean→string coercion (preview.yml:72–74).

On #4 (SHA pinning) — rebut + defer

The concern is legitimate (the deploy job has contents: write + pull-requests: write), but pinning only these three actions here would be wrong for this repo:

  • Repo-wide convention is major-version tags — there are currently zero SHA pins anywhere in d-morrison/gha (quarto-dev/quarto-actions@v2, lycheeverse/lychee-action@v2, UCD-SERG/changelog-check-action@v2, anthropics/claude-code-action@v1, actions/*@vN, …). Singling out 3 of ~10 would make the preview files read differently from every other workflow.
  • No dependabot — without .github/dependabot.yml, SHA-pinned actions would silently miss upstream security/bug fixes, which in an unmonitored repo can be worse than a maintained major tag.

The right fix is a repo-wide policy decision (pin everything + add dependabot, or keep major tags everywhere), which is out of scope for "add the preview family." Tracked in #48.

The merge push (a0e3310) re-triggers the review on the current commit.

Comment thread preview/action.yml Outdated
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-

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.

Minor: hardcoded main in freeze restore key

This fallback level will only warm the freeze from a base-branch build when the caller's default branch is literally main. Consumers with master or another default branch name skip straight to the broader renv.lock-only key.

github.event.pull_request.base.ref is available in the pull_request event context and gives the actual base branch name:

Suggested change
quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-main-
quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-${{ github.event.pull_request.base.ref }}-

Non-blocking — the outer fallback keys still provide a reasonable warm start — but worth fixing before rolling out to master-based repos.

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 20, 2026 07:09
@d-morrison

Copy link
Copy Markdown
Owner Author

ARD summary — round 2 (re: follow-up review)

The follow-up review confirmed all 6 original findings resolved and raised one new minor item.

Finding Disposition Notes
Hardcoded main in freeze-cache restore key (preview/action.yml:129) Addressed (0e86de3) Replaced the -main- fallback level with -${{ github.event.pull_request.base.ref }}-, so non-main-default consumers still hit that fallback instead of dropping to the broader renv.lock-only key. No behavior change for rme (base ref is main). Swept the rest of the family — no other hardcoded branch literals.

The deferred SHA-pinning item is tracked in #48 (the pre-existing #35 was closed as a duplicate of it, so there's a single open canonical issue).

Push 0e86de3 re-triggers the review on the current commit.

@github-actions github-actions Bot removed the request for review from Copilot June 20, 2026 07:09
@claude

claude Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @d-morrison's task in 2m 47s —— View job


Round 3 Review: Add reusable PR-preview/publish workflow family

Tasks:

  • Load tools
  • Read current state of changed files
  • Verify the main → base-ref fix (commit 0e86de3)
  • Check for any new issues
  • Post findings

Verification of prior finding

preview/action.yml:129 — hardcoded main in freeze restore key: ✅ Correctly fixed.

The restore-key now reads:

quarto-freezer-${{ runner.os }}-${{ hashFiles('renv.lock') }}-${{ github.event.pull_request.base.ref }}-

github.event.pull_request.base.ref is available in the pull_request event context (including labeled and closed types), and the freeze-restore step is already guarded with if: github.event.action != 'closed', so the expression is only evaluated when the context is present. No issues.


Full sweep — no new findings

Reviewed the complete current state of all 9 changed files against the prior findings and for anything new:

Area Status
Shell injection via render-profile (action.yml:133–159) ✅ All four render steps use env: RENDER_PROFILE + "$RENDER_PROFILE"
Single Quarto setup (action.yml:67–76) ✅ One setup@v2 call; tinytex: expression gates on label
GITHUB_REPOSITORY in deploy gate (preview-deploy.yml:34) ✅ Uses ${GITHUB_REPOSITORY} built-in env var
API-based PR state detection in cleanup (cleanup-pr-previews.yml:55–62) ✅ Keys on HTTP 404 status from gh api -i
Boolean→string coercion comment (preview.yml:72–74) ✅ Present
Freeze restore-key base-branch fallback (action.yml:129) ✅ Now uses github.event.pull_request.base.ref
Trust boundary (split build/deploy) ✅ Preserved; read-only build in pull_request context, writes in workflow_run context
Concurrency semantics cancel-in-progress: true on build, false on deploy
git clean ordering (metadata written post-checkout) ✅ Comment at action.yml:46–55 explains the invariant
Artifact gate logic (preview-deploy.yml:18–36) ✅ String comparison on output is correct; actions: read permission present
pr-number newline stripping $(cat ...) command substitution strips trailing newlines
Example caller permissions (examples/preview-deploy.yml:19–22) ✅ Grants the full set the reusable workflow's jobs need
SHA pinning on third-party actions 📋 Deferred → #48 (repo-wide policy; consistent with current convention)

Verdict

All six findings from round 1 and the one finding from round 2 are resolved. No new issues found. This PR is ready to merge.

@d-morrison

Copy link
Copy Markdown
Owner Author

Clean — done. PR is free (paws off lifted).

Round-3 review verdict: "All six findings from round 1 and the one finding from round 2 are resolved. No new issues found. This PR is ready to merge." Zero flagged items under any heading; all required checks green (review, selftest → bib/chars/links/phi/publish). Branch is synced with main (no longer conflicting).

The one deferred item — SHA-pinning third-party actions — is tracked repo-wide in #48.

Not merging unless you ask.

@d-morrison d-morrison merged commit 3de2c97 into main Jun 20, 2026
7 checks passed
@d-morrison d-morrison deleted the claude/quirky-hawking-t1tdzt branch June 20, 2026 07:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add reusable preview/publish workflow family

2 participants