From f379d6a3daaaa4a44159ae8649840ab4a88c1edb Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 27 Sep 2026 21:05:38 +0200 Subject: [PATCH 01/14] pr-preview: add per-pull-request website preview actions Three composite actions that publish an armed pull request's built site to -pr.staged.apache.org, with a review overlay that turns a marked region into a screenshot and a link to its diff line. preview-annotate stamps source lines for Jekyll and Astro builds; preview-artifact packages any static site; preview-publish publishes, retires and cleans up. Extracted from apache/magpie-site, with generic names. Generated-by: Claude Opus 5 --- .github/workflows/pr-preview-test.yml | 144 +++++ .gitignore | 2 + .pre-commit-config.yaml | 3 +- .rat-excludes | 8 + LICENSE | 9 + README.md | 4 + RELEASING.md | 7 +- pr-preview/README.md | 376 +++++++++++++ pr-preview/adapters/astro/README.md | 34 ++ .../astro/babel-plugin-preview-src.mjs | 62 +++ .../astro/babel-plugin-preview-src.test.mjs | 54 ++ pr-preview/adapters/jekyll/README.md | 72 +++ pr-preview/adapters/jekyll/prepare.sh | 50 ++ pr-preview/adapters/jekyll/preview_src.rb | 235 ++++++++ pr-preview/adapters/jekyll/test/Gemfile | 23 + pr-preview/adapters/jekyll/test/Gemfile.lock | 133 +++++ .../adapters/jekyll/test/fixture/_config.yml | 21 + .../jekyll/test/fixture/_includes/nav.html | 3 + .../jekyll/test/fixture/_layouts/default.html | 14 + .../adapters/jekyll/test/fixture/index.md | 12 + .../adapters/jekyll/test/preview_src_test.rb | 132 +++++ pr-preview/lib/anchors.mjs | 72 +++ pr-preview/lib/anchors.test.mjs | 78 +++ pr-preview/lib/armed.mjs | 40 ++ pr-preview/lib/armed.test.mjs | 73 +++ pr-preview/lib/artifact.mjs | 144 +++++ pr-preview/lib/artifact.test.mjs | 95 ++++ pr-preview/lib/comments.mjs | 27 + pr-preview/lib/comments.test.mjs | 48 ++ pr-preview/lib/files.mjs | 91 +++ pr-preview/lib/files.test.mjs | 88 +++ pr-preview/lib/git.mjs | 143 +++++ pr-preview/lib/git.test.mjs | 134 +++++ pr-preview/lib/github.mjs | 156 ++++++ pr-preview/lib/github.test.mjs | 182 ++++++ pr-preview/lib/plan.mjs | 52 ++ pr-preview/lib/plan.test.mjs | 99 ++++ pr-preview/lib/publish.mjs | 481 ++++++++++++++++ pr-preview/lib/publish.test.mjs | 478 ++++++++++++++++ pr-preview/lib/stale.mjs | 63 +++ pr-preview/lib/stale.test.mjs | 69 +++ pr-preview/lib/validate.mjs | 79 +++ pr-preview/lib/validate.test.mjs | 92 ++++ pr-preview/overlay/globals.d.ts | 40 ++ pr-preview/overlay/logic.mjs | 68 +++ pr-preview/overlay/logic.test.mjs | 102 ++++ pr-preview/overlay/review.js | 350 ++++++++++++ pr-preview/package-lock.json | 517 ++++++++++++++++++ pr-preview/package.json | 12 + pr-preview/preview-annotate/action.yml | 84 +++ pr-preview/preview-artifact/action.yml | 66 +++ pr-preview/preview-publish/action.yml | 80 +++ pr-preview/vendor/html2canvas-pro/LICENSE | 21 + .../html2canvas-pro/html2canvas-pro.min.js | 11 + scripts/release_actions.py | 25 +- scripts/test_release_actions.py | 19 + 56 files changed, 5574 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/pr-preview-test.yml create mode 100644 pr-preview/README.md create mode 100644 pr-preview/adapters/astro/README.md create mode 100644 pr-preview/adapters/astro/babel-plugin-preview-src.mjs create mode 100644 pr-preview/adapters/astro/babel-plugin-preview-src.test.mjs create mode 100644 pr-preview/adapters/jekyll/README.md create mode 100755 pr-preview/adapters/jekyll/prepare.sh create mode 100644 pr-preview/adapters/jekyll/preview_src.rb create mode 100644 pr-preview/adapters/jekyll/test/Gemfile create mode 100644 pr-preview/adapters/jekyll/test/Gemfile.lock create mode 100644 pr-preview/adapters/jekyll/test/fixture/_config.yml create mode 100644 pr-preview/adapters/jekyll/test/fixture/_includes/nav.html create mode 100644 pr-preview/adapters/jekyll/test/fixture/_layouts/default.html create mode 100644 pr-preview/adapters/jekyll/test/fixture/index.md create mode 100644 pr-preview/adapters/jekyll/test/preview_src_test.rb create mode 100644 pr-preview/lib/anchors.mjs create mode 100644 pr-preview/lib/anchors.test.mjs create mode 100644 pr-preview/lib/armed.mjs create mode 100644 pr-preview/lib/armed.test.mjs create mode 100644 pr-preview/lib/artifact.mjs create mode 100644 pr-preview/lib/artifact.test.mjs create mode 100644 pr-preview/lib/comments.mjs create mode 100644 pr-preview/lib/comments.test.mjs create mode 100644 pr-preview/lib/files.mjs create mode 100644 pr-preview/lib/files.test.mjs create mode 100644 pr-preview/lib/git.mjs create mode 100644 pr-preview/lib/git.test.mjs create mode 100644 pr-preview/lib/github.mjs create mode 100644 pr-preview/lib/github.test.mjs create mode 100644 pr-preview/lib/plan.mjs create mode 100644 pr-preview/lib/plan.test.mjs create mode 100644 pr-preview/lib/publish.mjs create mode 100644 pr-preview/lib/publish.test.mjs create mode 100644 pr-preview/lib/stale.mjs create mode 100644 pr-preview/lib/stale.test.mjs create mode 100644 pr-preview/lib/validate.mjs create mode 100644 pr-preview/lib/validate.test.mjs create mode 100644 pr-preview/overlay/globals.d.ts create mode 100644 pr-preview/overlay/logic.mjs create mode 100644 pr-preview/overlay/logic.test.mjs create mode 100644 pr-preview/overlay/review.js create mode 100644 pr-preview/package-lock.json create mode 100644 pr-preview/package.json create mode 100644 pr-preview/preview-annotate/action.yml create mode 100644 pr-preview/preview-artifact/action.yml create mode 100644 pr-preview/preview-publish/action.yml create mode 100644 pr-preview/vendor/html2canvas-pro/LICENSE create mode 100644 pr-preview/vendor/html2canvas-pro/html2canvas-pro.min.js diff --git a/.github/workflows/pr-preview-test.yml b/.github/workflows/pr-preview-test.yml new file mode 100644 index 000000000..4ef0e4ef4 --- /dev/null +++ b/.github/workflows/pr-preview-test.yml @@ -0,0 +1,144 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +name: Test the PR preview actions +on: + pull_request: + paths: + - "pr-preview/**" + - ".github/workflows/pr-preview-test.yml" + push: + # Branches only, so release tag pushes do not re-run it. + branches: + - main + paths: + - "pr-preview/**" + - ".github/workflows/pr-preview-test.yml" + +permissions: + contents: read + +jobs: + node: + name: "publisher, overlay and Astro adapter" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + + - name: Install the locked test dependencies + working-directory: pr-preview + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Unit tests + working-directory: pr-preview + run: npm test + + - name: preview-annotate outputs the Astro plugin + id: astro + uses: ./pr-preview/preview-annotate + with: + generator: astro + + - name: The plugin path resolves + env: + PLUGIN: ${{ steps.astro.outputs.babel-plugin }} + run: test -f "$PLUGIN" + + - name: preview-annotate rejects an unknown generator + id: unknown + continue-on-error: true + uses: ./pr-preview/preview-annotate + with: + generator: gatsby + + - name: It did not fail + if: steps.unknown.outcome != 'failure' + run: | + echo "::error::preview-annotate accepted an unknown generator" + exit 1 + + jekyll: + name: "Jekyll adapter, end to end" + runs-on: ubuntu-latest + timeout-minutes: 10 + container: + image: ruby:3.3@sha256:21e6a5125535e13999143c49b6471f68c92a74d8faad0a3541c51b11de0266ad + env: + # Install exactly what the lockfile names, checksums included. + BUNDLE_GEMFILE: pr-preview/adapters/jekyll/test/Gemfile + BUNDLE_FROZEN: "true" + FIXTURE: pr-preview/adapters/jekyll/test/fixture + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the locked test dependencies + run: bundle install + + - name: Plugin tests + run: bundle exec ruby pr-preview/adapters/jekyll/test/preview_src_test.rb + + - name: Production build + run: bundle exec jekyll build -s "$FIXTURE" -d "$RUNNER_TEMP/production" + + - name: preview-annotate installs the plugin + uses: ./pr-preview/preview-annotate + with: + generator: jekyll + source: pr-preview/adapters/jekyll/test/fixture + production-output: ${{ runner.temp }}/production + + - name: Preview build is annotated with repository paths + run: | + bundle exec jekyll build -s "$FIXTURE" -d "$RUNNER_TEMP/preview" + grep -q 'data-preview-src="pr-preview/adapters/jekyll/test/fixture/index.md:7"' "$RUNNER_TEMP/preview/index.html" + + - name: preview-annotate rejects an annotated production build + id: dirty + continue-on-error: true + uses: ./pr-preview/preview-annotate + with: + generator: none + production-output: ${{ runner.temp }}/preview + + - name: It did not fail + if: steps.dirty.outcome != 'failure' + run: | + echo "::error::preview-annotate accepted an annotated production build" + exit 1 + + - name: preview-artifact packages the preview + if: github.event_name == 'pull_request' + uses: ./pr-preview/preview-artifact + with: + path: ${{ runner.temp }}/preview + retention-days: 1 + + - name: The metadata names this pull request + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: grep -q "\"pr\":$PR_NUMBER," "$RUNNER_TEMP/preview/preview-meta.json" diff --git a/.gitignore b/.gitignore index d4a37e763..5b33358d6 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ approved_patterns.yml # RAT work dir utils/.rat/** + +node_modules/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca72b1343..026bf2170 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -113,7 +113,8 @@ repos: (?x) ^(?:.*/)?AGENTS\.md$| ^(?:.*/)?CLAUDE\.md$| - ^stash/.*\.md$ + ^stash/.*\.md$| + ^pr-preview/adapters/jekyll/test/fixture/ args: - --comment-style - "" diff --git a/.rat-excludes b/.rat-excludes index a08089e32..e9e0f86d0 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -14,3 +14,11 @@ # limitations under the License. **/uv.lock +# pr-preview: vendored MIT library (credited in LICENSE), JSON and lock files +# that cannot carry a header, and a Jekyll fixture site whose line numbers the +# adapter's tests assert. +pr-preview/vendor/** +pr-preview/package.json +pr-preview/package-lock.json +pr-preview/adapters/jekyll/test/Gemfile.lock +pr-preview/adapters/jekyll/test/fixture/** diff --git a/LICENSE b/LICENSE index e3fe95dc3..ca491520a 100644 --- a/LICENSE +++ b/LICENSE @@ -207,3 +207,12 @@ This product includes code from cache GitHub action in the `stash` directory. Copyright (c) 2018 GitHub, Inc. and contributors Homepage: https://github.com/actions/cache License: The MIT License (MIT): https://raw.githubusercontent.com/actions/cache/refs/heads/main/LICENSE + +-------------------------------------------------------------------------------- + +This product bundles html2canvas-pro 2.4.3 in the `pr-preview/vendor/html2canvas-pro` +directory. + +Copyright (c) 2024-present yorickshan and html2canvas-pro contributors +Homepage: https://github.com/yorickshan/html2canvas-pro +License: The MIT License (MIT): see pr-preview/vendor/html2canvas-pro/LICENSE diff --git a/README.md b/README.md index b8b76e568..580d8e2da 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ verification from `verify-action-build` directly. - [ASF Infrastructure Pelican Action](/pelican/README.md): Generate and publish project websites with GitHub Actions - [Stash Action](/stash/README.md): Manage large build caches - [ASF Allowlist Check](/allowlist-check/README.md): Verify workflow action refs are on the ASF allowlist + - [PR Previews](/pr-preview/README.md): Publish per-pull-request website previews on ASF staging, with a review overlay that links a marked region to its diff line ## Versioning and Pinning Actions @@ -135,6 +136,9 @@ which you repeat after the `@`: | `pelican` | `apache/infrastructure-actions/pelican@ # pelican/v1.2.3` | | `stash/save` | `apache/infrastructure-actions/stash/save@ # save/v1.2.3` | | `stash/restore` | `apache/infrastructure-actions/stash/restore@ # restore/v1.2.3` | +| `pr-preview/preview-annotate` | `apache/infrastructure-actions/pr-preview/preview-annotate@ # preview-annotate/v1.0.0` | +| `pr-preview/preview-artifact` | `apache/infrastructure-actions/pr-preview/preview-artifact@ # preview-artifact/v1.0.0` | +| `pr-preview/preview-publish` | `apache/infrastructure-actions/pr-preview/preview-publish@ # preview-publish/v1.0.0` | Pinning to a commit SHA with the version in a trailing comment is the recommended, [Zizmor](https://zizmor.sh/)-friendly form: the SHA is immutable, diff --git a/RELEASING.md b/RELEASING.md index 1ecc46c56..c4425af80 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -35,6 +35,9 @@ prefix a consumer repeats in the `@ref`: | `pelican` | `pelican` | `pelican/v1.2.3` | | `stash/save` | `save` | `save/v1.2.3` | | `stash/restore` | `restore` | `restore/v1.2.3` | +| `pr-preview/preview-annotate` | `preview-annotate` | `preview-annotate/v1.2.3` | +| `pr-preview/preview-artifact` | `preview-artifact` | `preview-artifact/v1.2.3` | +| `pr-preview/preview-publish` | `preview-publish` | `preview-publish/v1.2.3` | For every `X.Y.Z` release we also move a **major** tag (`/vN`) to the same commit, so consumers can track a major line if they prefer. @@ -62,7 +65,9 @@ on every push to `main` that touches an action's files. The workflow runs 1. Diffs the pushed range and maps changed files to the affected action(s). A change under `stash/shared/` releases **both** stash actions, since they - import that shared code at runtime. + import that shared code at runtime. Likewise `pr-preview/adapters/` + releases `preview-annotate`, and `pr-preview/lib/`, `overlay/` and + `vendor/` release `preview-publish`. 2. Picks the bump type (see below). 3. For each affected action, computes the next version from the newest existing `/vX.Y.Z` tag (the first release seeds `v1.0.0`), creates diff --git a/pr-preview/README.md b/pr-preview/README.md new file mode 100644 index 000000000..63db5dd0c --- /dev/null +++ b/pr-preview/README.md @@ -0,0 +1,376 @@ + +# PR previews on ASF staging + +Publish a live preview of a pull request's website at +`https://-pr.staged.apache.org/`, and let reviewers comment on it from +the page itself: drag a box over what you mean, and the preview copies a +captioned screenshot and opens the pull request — on the exact diff line that +produced that part of the page, when the site generator can tell us. + +- [How it works](#how-it-works) +- [Quick start](#quick-start) +- [Choose your path](#choose-your-path) — Jekyll, Astro, Pelican, anything else +- [Using a preview](#using-a-preview) +- [Lifecycle and retention](#lifecycle-and-retention) +- [Security model](#security-model) +- [Reference](#reference) +- [Development](#development) + +## How it works + +Three actions, split along the one boundary that matters: **the job that +builds pull-request code never holds a write token, and the job that holds a +write token never runs pull-request code.** + +```mermaid +graph LR + pr["Pull request"] --> build + subgraph build["Your build workflow (pull_request, read-only)"] + a["build the site"] --> b["preview-annotate
(optional)"] --> c["build again"] --> d["preview-artifact"] + end + d -- "preview-site artifact" --> publish + subgraph publish["Your publish workflow (schedule, write token)"] + e["preview-publish"] + end + e -- "push preview/pr<N>-staging" --> staging["<site>-pr<N>.staged.apache.org"] + e -- "status comment" --> pr +``` + +| Action | Runs in | Does | +|---|---|---| +| [`preview-annotate`](preview-annotate/action.yml) | your PR build | Switches on source annotation for your generator, so elements carry `data-preview-src="path:line"`. Optional. | +| [`preview-artifact`](preview-artifact/action.yml) | your PR build | Writes `preview-meta.json` and uploads the built site as the `preview-site` artifact. | +| [`preview-publish`](preview-publish/action.yml) | a scheduled workflow | Publishes armed pull requests, retires the rest, keeps one status comment per PR up to date. | + +Nothing is published until a committer asks for it: previews are **opt-in per +pull request**, with a `/show-preview` comment. + +## Quick start + +A Jekyll site in `apache/foo-site`, built with `bundle exec jekyll build` into +`_site`. Replace each `` with the commit of the tag in the trailing +comment (see [Versioning](../README.md#versioning-and-pinning-actions)). + +**1. Add the preview steps to your pull-request build** +(`.github/workflows/build.yml`): + +```yaml +name: Build +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # ... set up Ruby the way you already do ... + - name: Production build + run: bundle exec jekyll build + + - name: Switch on source annotation + if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/pr-preview/preview-annotate@ # preview-annotate/v1.0.0 + with: + generator: jekyll + production-output: _site # fails the build if the production output is annotated + + - name: Preview build + if: github.event_name == 'pull_request' + run: bundle exec jekyll build + + - name: Upload the preview + if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/pr-preview/preview-artifact@ # preview-artifact/v1.0.0 + with: + path: _site +``` + +**2. Add the publisher** (`.github/workflows/preview-publish.yml`): + +```yaml +name: Publish PR previews +on: + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + inputs: + pr: + description: "Publish only this PR now (skips the /show-preview check)" + required: false + type: string +permissions: + contents: write # push and delete preview/* branches + pull-requests: write # comment on pull requests + issues: write + actions: read # find and download the preview-site artifact +concurrency: + group: preview-publish + cancel-in-progress: false +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: apache/infrastructure-actions/pr-preview/preview-publish@ # preview-publish/v1.0.0 + with: + site-name: foo # apache/foo-site stages at foo-pr.staged.apache.org + build-workflow: build.yml + pr: ${{ inputs.pr }} +``` + +No checkout is needed: the publisher never looks at your repository's files. + +**3. Try it.** Open a pull request, let the build go green, and comment +`/show-preview` on it (you need write access). Within one cron interval the +bot replies with the preview URL; staging takes a few more minutes to serve it. + +## Choose your path + +The build side is the only part that depends on your site generator. Pick the +row that matches your site; the publisher is identical for all of them. + +| Your site | `preview-annotate` | What reviewers get | Notes | +|---|---|---|---| +| **Jekyll** | `generator: jekyll` | Diff line for Markdown blocks, layout and include tags | [Jekyll path](#jekyll) | +| **Astro** (React/JSX components) | `generator: astro` | Diff line for JSX elements | [Astro path](#astro) | +| **Pelican** (incl. the [ASF Pelican action](../pelican/README.md)) | `generator: none` or skip it | Screenshot + Conversation tab | [Pelican path](#pelican) | +| **Hugo, Sphinx, Docusaurus, MkDocs, plain HTML, anything static** | `generator: none` or skip it | Screenshot + Conversation tab | [Any other generator](#any-other-generator) | + +Without annotation everything else still works — the preview, the overlay, +the captioned screenshot. Only the jump to the exact diff line is missing, and +the screenshot caption still records the page URL and commit. + +### Jekyll + +The [Quick start](#quick-start) is the Jekyll path. `preview-annotate` +installs a plugin into your site's `_plugins/` for the preview build only; no +Gemfile change. It stamps: + +- **Markdown pages and collection documents** — headings, paragraphs, list + items, tables, code blocks, blockquotes — from kramdown's source lines. +- **Layouts and includes** — every opening HTML tag, before Liquid runs. + +Details, limits and the plugin's tests: [`adapters/jekyll/`](adapters/jekyll/README.md). + +- Set `source:` if your site is not at the repository root. +- **Safe mode is not supported.** The `github-pages` gem forces + `safe: true`, which ignores `_plugins`; the action refuses rather than + producing a preview with nothing stamped. Use `generator: none` there. + +### Astro + +Astro builds through Vite, so annotation is a Babel plugin wired into the +React integration. Make `astro.config.mjs` pick it up when the action says so: + +```js +integrations: [ + react( + process.env.PREVIEW_ANNOTATE === "1" + ? { babel: { plugins: [[process.env.PREVIEW_BABEL_PLUGIN, { root: process.cwd() }]] } } + : {}, + ), +], +``` + +Then in the build workflow, pass the plugin to the preview build step: + +```yaml + - run: npm run build + - if: github.event_name == 'pull_request' + id: annotate + uses: apache/infrastructure-actions/pr-preview/preview-annotate@ # preview-annotate/v1.0.0 + with: + generator: astro + production-output: dist + - if: github.event_name == 'pull_request' + env: + PREVIEW_ANNOTATE: "1" + PREVIEW_BABEL_PLUGIN: ${{ steps.annotate.outputs.babel-plugin }} + run: npm run build + - if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/pr-preview/preview-artifact@ # preview-artifact/v1.0.0 + with: + path: dist +``` + +Only `.tsx`/`.jsx` is stamped; `.astro` templates and Markdown content fall +back to the Conversation tab. See [`adapters/astro/`](adapters/astro/README.md). + +### Pelican + +There is no Pelican adapter yet, so skip `preview-annotate`. With the +[ASF Pelican action](../pelican/README.md), build without publishing on pull +requests and upload its output: + +```yaml + - uses: apache/infrastructure-actions/pelican@ # pelican/vX.Y.Z + with: + publish: ${{ github.event_name != 'pull_request' }} + output: output + - if: github.event_name == 'pull_request' + uses: apache/infrastructure-actions/pr-preview/preview-artifact@ # preview-artifact/v1.0.0 + with: + path: output +``` + +### Any other generator + +Build as you normally do on `pull_request`, then call `preview-artifact` with +your output directory. That is the whole integration. + +If your generator can emit attributes itself, you can add annotation without +an adapter: stamp any element with `data-preview-src=":"` in the **preview build only**. The overlay uses the nearest +stamped ancestor of the marked region, so partial coverage is fine. + +## Using a preview + +- **Arm it.** Comment `/show-preview` — the whole comment, on its own line — on + the pull request. Only commenters with write access count. A maintainer can + also run the publish workflow by hand with `pr: `, which publishes at + once and records the arming in a bot comment. +- **Follow it.** From then on every new head commit is republished once its + build goes green. The bot keeps **one** status comment per pull request up to + date (published / waiting on a build / refused / retired). +- **Review on it.** The page shows a banner naming the pull request, so a + forwarded link is never mistaken for the live site. Press `c` or the + *Comment on this preview* button, drag a box, and the preview copies a + captioned screenshot and offers an **Open PR** button — to the Files tab on + the source line when it was resolved, otherwise the Conversation tab. Paste + into the comment box. + +## Lifecycle and retention + +Everything this system creates is either replaced in place or removed on a +schedule; nothing accumulates per commit. + +| Thing | Kept for | Removed when | +|---|---|---| +| `preview-site` artifact | `retention-days` (default **30**) | GitHub expires it. The publisher only needs the artifact for the PR's **current** head commit; an older one is never used again. If it expires before a preview is armed, the status comment says *waiting on a build* — re-run the build. | +| `preview/pr-staging` branch | While the PR is open **and** armed | The PR closes or merges, or every `/show-preview` comment is deleted (and it was not armed by dispatch). Each publish is a single force-pushed orphan commit, so the branch never grows history. | +| The staged site `-pr.staged.apache.org` | While the branch is live | **Never unstaged.** ASF staging keeps serving the last content after its branch is deleted. So retirement is two steps: first a **tombstone** — a one-page "this preview has been retired" notice with `robots.txt: Disallow: /` — is force-pushed over the preview, and only on the **next** run is the branch deleted. The hostname keeps serving the tombstone, never the pull request's code. | +| Status / how-to / arming comments | The life of the pull request | Never deleted; edited in place, so each PR has at most one of each. | +| Your repository's head branches of closed PRs | Until the PR closes | Only with `reap-head-branches: "true"`: deleted once every PR from the branch is closed, and only if nobody pushed to it after. Protected, `main`, `publish`, `preview/*` and `asf-*` branches are never touched. | + +Cost and noise: + +- The publisher runs every 15 minutes and exits quickly when nothing changed. + An unchanged head is not republished. +- Every preview push is a branch update, which ASF mirrors to your + `commits@` list. Previews are opt-in per PR for exactly this reason. +- Staging content is `noindex`; `robots.txt` disallows everything. + +## Security model + +- **`preview-artifact` refuses anything but `pull_request`.** Under + `pull_request_target` the build would run pull-request code while holding a + privileged token. +- **`preview-publish` refuses anything but `schedule` and + `workflow_dispatch`.** It never checks out or executes repository code; it + only downloads the artifact. +- **The artifact is untrusted input.** Before publishing, the publisher checks + `preview-meta.json` against the pull request's real number and head commit, + refuses archive entries with absolute or `..` paths and symlinks, and strips + `.git`, `.gitignore` and `.gitmodules` from the tree. +- **The publisher writes `.asf.yaml` itself**, after copying the artifact, so + a pull request can never ship a `publish:` block or another staging profile. +- **Arming is checked per commenter** against actual repository permission, + and only a comment that is exactly the command counts. +- **Bot comments are edited only when authored by a bot and ending in the + exact marker**, so nobody can plant a marker in a human comment to have it + overwritten. +- **Tokens are redacted** from every git error before it can reach a log. + +## Reference + +### `preview-annotate` + +| Input | Required | Default | Description | +|---|---|---|---| +| `generator` | yes | | `jekyll`, `astro` or `none` | +| `source` | no | `.` | Jekyll: the site source directory, holding `_config.yml` | +| `production-output` | no | | Fail if this directory already carries `data-preview-src` | + +| Output | Description | +|---|---| +| `babel-plugin` | `astro` only: the Babel plugin's path, to pass to the preview build as `PREVIEW_BABEL_PLUGIN` | + +### `preview-artifact` + +| Input | Required | Default | Description | +|---|---|---|---| +| `path` | yes | | Directory holding the built preview site | +| `retention-days` | no | `30` | Days to keep the `preview-site` artifact | + +### `preview-publish` + +| Input | Required | Default | Description | +|---|---|---|---| +| `site-name` | yes | | First label of the staging hostname: `foo` for `foo-pr.staged.apache.org` | +| `build-workflow` | no | `build.yml` | File name of the workflow that runs `preview-artifact` | +| `pr` | no | | Publish just this PR now, skipping `/show-preview` | +| `reap-head-branches` | no | `false` | Also delete head branches of closed PRs | +| `token` | no | `github.token` | Needs `contents`, `pull-requests`, `issues` write and `actions` read | + +### The build contract + +If you build the artifact without `preview-artifact`, this is what the +publisher expects: an artifact named `preview-site`, from a successful run of +`build-workflow` for the PR's head commit, holding the static site with +`preview-meta.json` at its root: + +```json +{"pr": 123, "headSha": "<40-hex head commit>"} +``` + +## Development + +```sh +cd pr-preview +npm ci +npm test # publisher, overlay, Astro adapter + +export BUNDLE_GEMFILE=adapters/jekyll/test/Gemfile +bundle install +bundle exec ruby adapters/jekyll/test/preview_src_test.rb # Jekyll adapter +``` + +CI runs both in [`pr-preview-test.yml`](../.github/workflows/pr-preview-test.yml), +plus an end-to-end run of `preview-annotate` and `preview-artifact` against +the Jekyll fixture site. + +| Path | What it is | +|---|---| +| `lib/` | The publisher: arming, planning, publishing, retiring, GitHub and git clients | +| `overlay/` | The review overlay injected into every published page | +| `adapters/` | Generator-specific source annotation | +| `vendor/html2canvas-pro/` | Screenshot library served with the overlay (MIT) | + +Originally developed for the [Apache Magpie website](https://github.com/apache/magpie-site), +whose design notes cover the reasoning in more depth: +[preview deployments](https://github.com/apache/magpie-site/blob/main/docs/designs/2026-09-16-pr-preview-deployments-design.md), +[review overlay](https://github.com/apache/magpie-site/blob/main/docs/designs/2026-09-17-preview-review-overlay-design.md), +[Jekyll adapter](https://github.com/apache/magpie-site/blob/main/docs/designs/2026-09-27-preview-jekyll-adapter-design.md). diff --git a/pr-preview/adapters/astro/README.md b/pr-preview/adapters/astro/README.md new file mode 100644 index 000000000..f8cc4b0ab --- /dev/null +++ b/pr-preview/adapters/astro/README.md @@ -0,0 +1,34 @@ + +# Astro adapter + +A Babel plugin, wired through `@astrojs/react`, that stamps every JSX host +element (lowercase tag) with `data-preview-src=":"` +in a preview build. + +Enable it with [`preview-annotate`](../../preview-annotate/action.yml) and +`generator: astro`, whose `babel-plugin` output is this plugin's path. Pass +it to the preview build as `PREVIEW_BABEL_PLUGIN`, with `PREVIEW_ANNOTATE=1`, +for `astro.config.mjs` to read — see the [Astro path](../../README.md#astro). + +Coverage: `.tsx` and `.jsx` only. `.astro` templates and Markdown content are +compiled by Astro, not Babel, so a region there falls back to the pull +request's Conversation tab. + +Tests: `npm test` in `pr-preview/`. diff --git a/pr-preview/adapters/astro/babel-plugin-preview-src.mjs b/pr-preview/adapters/astro/babel-plugin-preview-src.mjs new file mode 100644 index 000000000..91633389d --- /dev/null +++ b/pr-preview/adapters/astro/babel-plugin-preview-src.mjs @@ -0,0 +1,62 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { relative } from "node:path"; + +/** + * Stamps every JSX host element with the source file and line it came from, so + * a preview's overlay can resolve a marked region back to a diff line. + * + * Only host elements (lowercase names) are stamped: a component element renders + * host elements of its own, and those carry the location the reviewer can act + * on. Active only when the build sets PREVIEW_ANNOTATE=1 — the + * production build must emit none of this. + * + * This is a Babel plugin wired through @astrojs/react, so it only sees JSX: + * `.tsx` and `.jsx` files. `.astro` templates and Markdown content are + * never annotated — that would need the Astro compiler, not Babel. + */ +export default function previewSrc({ types: t }) { + return { + name: "preview-src", + visitor: { + JSXOpeningElement(path, state) { + const name = path.node.name; + if (name.type !== "JSXIdentifier") return; + if (!/^[a-z]/.test(name.name)) return; + + const already = path.node.attributes.some( + (a) => a.type === "JSXAttribute" && a.name?.name === "data-preview-src", + ); + if (already) return; + + const line = path.node.loc?.start?.line; + if (!line) return; + + const root = state.opts?.root ?? state.file.opts.root ?? process.cwd(); + const file = relative(root, state.filename ?? state.file.opts.filename ?? ""); + + path.node.attributes.push( + t.jsxAttribute( + t.jsxIdentifier("data-preview-src"), + t.stringLiteral(`${file}:${line}`), + ), + ); + }, + }, + }; +} diff --git a/pr-preview/adapters/astro/babel-plugin-preview-src.test.mjs b/pr-preview/adapters/astro/babel-plugin-preview-src.test.mjs new file mode 100644 index 000000000..1c181378c --- /dev/null +++ b/pr-preview/adapters/astro/babel-plugin-preview-src.test.mjs @@ -0,0 +1,54 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { transformSync } from "@babel/core"; +import plugin from "./babel-plugin-preview-src.mjs"; + +const run = (code, filename = "/repo/src/components/Thing.tsx") => + transformSync(code, { + filename, + root: "/repo", + plugins: [[plugin, { root: "/repo" }]], + parserOpts: { plugins: ["jsx", "typescript"] }, + configFile: false, + babelrc: false, + }).code; + +test("stamps a host element with its repo-relative path and line", () => { + const out = run("const a =
hi
;"); + assert.match(out, /data-preview-src="src\/components\/Thing\.tsx:1"/); +}); + +test("leaves component elements alone", () => { + // A component renders host elements of its own, which get stamped there. + const out = run("const a = ;"); + assert.doesNotMatch(out, /data-preview-src/); +}); + +test("does not overwrite an existing attribute", () => { + const out = run('const a =
;'); + assert.match(out, /data-preview-src="kept"/); + assert.equal(out.match(/data-preview-src/g).length, 1); +}); + +test("records the line each element starts on", () => { + const out = run("const a = (\n
\n x\n
\n);"); + assert.match(out, /data-preview-src="src\/components\/Thing\.tsx:2"/); + assert.match(out, /data-preview-src="src\/components\/Thing\.tsx:3"/); +}); diff --git a/pr-preview/adapters/jekyll/README.md b/pr-preview/adapters/jekyll/README.md new file mode 100644 index 000000000..11c5b2e0c --- /dev/null +++ b/pr-preview/adapters/jekyll/README.md @@ -0,0 +1,72 @@ + + +# Jekyll adapter + +Makes a Jekyll site's preview build stamp `data-preview-src=":"` on +rendered elements, so the review overlay can take a reviewer from a marked +region to the source line in the pull request's diff. See the +[overview](../../README.md) for how this fits the rest of the preview +system. + +## What gets stamped + +| Source | How | Granularity | +|---|---|---| +| Markdown pages and collection documents | From the source line kramdown records for each block, shifted past the front matter | Headings, paragraphs, list items, blockquotes, tables and rows, code blocks, definition lists, embedded HTML blocks | +| Layouts (`_layouts/`) | Each opening HTML tag of the raw template, before Liquid runs | Every tag outside `html`, `head`, `body`, `meta`, `link`, `script`, `style`, `title`, `base`, `noscript`, `template` | +| Includes (`include`, `include_relative`) | Same as layouts | Same as layouts | + +Left alone: Liquid output (`{{ "" }}`), `{% raw %}`, `{% comment %}` and +`{% highlight %}` blocks, ` +{% raw %}

literal

{% endraw %} + + + diff --git a/pr-preview/adapters/jekyll/test/fixture/index.md b/pr-preview/adapters/jekyll/test/fixture/index.md new file mode 100644 index 000000000..86058319c --- /dev/null +++ b/pr-preview/adapters/jekyll/test/fixture/index.md @@ -0,0 +1,12 @@ +--- +layout: default +title: Home +--- +# Hello + +A paragraph. + +- one +- two + +> quoted diff --git a/pr-preview/adapters/jekyll/test/preview_src_test.rb b/pr-preview/adapters/jekyll/test/preview_src_test.rb new file mode 100644 index 000000000..27dbbfad8 --- /dev/null +++ b/pr-preview/adapters/jekyll/test/preview_src_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Run: BUNDLE_GEMFILE=pr-preview/adapters/jekyll/test/Gemfile bundle exec ruby pr-preview/adapters/jekyll/test/preview_src_test.rb +# Needs the jekyll gem (4.x) and minitest. + +require "minitest/autorun" +require "tmpdir" +require "fileutils" +require_relative "../preview_src" + +FIXTURE = File.expand_path("fixture", __dir__) +PREPARE = File.expand_path("../prepare.sh", __dir__) + +class PreviewSrcBuildTest < Minitest::Test + def self.built + @built ||= begin + dest = Dir.mktmpdir("preview-src-site") + ENV["PREVIEW_SRC_ROOT"] = FIXTURE + config = Jekyll.configuration( + "source" => FIXTURE, "destination" => dest, "quiet" => true, + "disable_disk_cache" => true, + ) + Jekyll::Site.new(config).process + File.read(File.join(dest, "index.html")) + ensure + ENV.delete("PREVIEW_SRC_ROOT") + end + end + + def html = self.class.built + + def test_markdown_blocks_carry_their_file_line + assert_match(/]*data-preview-src="index\.md:5"/, html) + assert_match(/

A paragraph\.<\/p>/, html) + assert_match(/

  • one/, html) + assert_match(/
  • two/, html) + assert_match(/
    /, html) + end + + def test_layout_tags_carry_their_file_line + assert_includes html, %(
    ) + assert_includes html, %(
    ) + end + + def test_include_tags_carry_their_file_line + assert_includes html, %(