-
Notifications
You must be signed in to change notification settings - Fork 0
Add reusable PR-preview/publish workflow family #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
931689d
Add reusable PR-preview/publish workflow family
claude 670826b
Address PR #34 review: harden inputs and de-duplicate setup
claude a0e3310
Merge remote-tracking branch 'origin/main' into claude/quirky-hawking…
d-morrison 0e86de3
preview: track PR base branch in freeze restore key
d-morrison File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| 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> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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) andmarocchino/sticky-pull-request-comment@v3(lines 100, 114) use major-version tags rather than pinned SHAs. Thedeployjob runs in the base-repo context withcontents: writeandpull-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:
browser-actions/setup-chrome@v1inpreview/action.ymlline 95 has the same issue, though that job is read-only.