You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/27 18:23:18 UTC
(magpie-site) branch main updated: feat(preview): support Jekyll sites, including file-and-line lookup (#205)
This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/magpie-site.git
The following commit(s) were added to refs/heads/main by this push:
new ce07e883 feat(preview): support Jekyll sites, including file-and-line lookup (#205)
ce07e883 is described below
commit ce07e8831731eb6b53dc35e815dc70ce65919f5d
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sun Sep 27 20:23:13 2026 +0200
feat(preview): support Jekyll sites, including file-and-line lookup (#205)
Add a Jekyll adapter that stamps data-magpie-src on a preview build:
Markdown blocks from kramdown's source lines, and opening tags in
layouts and includes before Liquid runs. prepare.sh installs it into
_plugins/ for the preview build only.
Generator-specific code now lives under scripts/preview/adapters/; the
Astro Babel plugin moves there unchanged. The build contract is written
down in scripts/preview/README.md, and PREVIEW_SITE_NAME and
PREVIEW_BUILD_WORKFLOW replace the hardcoded staging label and build
workflow, defaulting to this site's values.
A jekyll-adapter CI job builds a fixture site with the plugin in a
digest-pinned ruby:3.3 container, from a checksummed Gemfile.lock.
Generated-by: Claude Opus 5
---
.github/workflows/build.yml | 28 +++
astro.config.mjs | 2 +-
.../2026-09-27-preview-jekyll-adapter-design.md | 90 ++++++++
scripts/preview/README.md | 52 +++++
.../astro}/babel-plugin-magpie-src.mjs | 0
.../astro}/babel-plugin-magpie-src.test.mjs | 0
scripts/preview/adapters/jekyll/README.md | 86 ++++++++
scripts/preview/adapters/jekyll/magpie_src.rb | 235 +++++++++++++++++++++
scripts/preview/adapters/jekyll/prepare.sh | 36 ++++
scripts/preview/adapters/jekyll/test/Gemfile | 6 +
scripts/preview/adapters/jekyll/test/Gemfile.lock | 133 ++++++++++++
.../adapters/jekyll/test/fixture/_config.yml | 2 +
.../jekyll/test/fixture/_includes/nav.html | 3 +
.../jekyll/test/fixture/_layouts/default.html | 14 ++
.../preview/adapters/jekyll/test/fixture/index.md | 12 ++
.../adapters/jekyll/test/magpie_src_test.rb | 119 +++++++++++
scripts/preview/files.mjs | 20 +-
scripts/preview/files.test.mjs | 13 ++
scripts/preview/github.mjs | 19 +-
scripts/preview/github.test.mjs | 23 +-
20 files changed, 888 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 55275fd6..e4a58361 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -51,6 +51,34 @@ jobs:
- name: Run unit tests
run: npm test
+ # The Jekyll source-annotation plugin for PR previews. This site is Astro, so
+ # nothing else here builds Jekyll; the test builds a fixture site with the
+ # plugin loaded. It runs in a pinned Ruby image rather than through a
+ # setup-ruby action, so no third-party action is added.
+ jekyll-adapter:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ container:
+ image: ruby:3.3@sha256:21e6a5125535e13999143c49b6471f68c92a74d8faad0a3541c51b11de0266ad
+ env:
+ # Install exactly what the lockfile names, checksums included, and fail
+ # rather than re-resolve if the Gemfile and lockfile disagree.
+ BUNDLE_GEMFILE: scripts/preview/adapters/jekyll/test/Gemfile
+ BUNDLE_FROZEN: "true"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install the locked test dependencies
+ run: bundle install
+
+ - name: Test the Jekyll adapter
+ run: bundle exec ruby scripts/preview/adapters/jekyll/test/magpie_src_test.rb
+
build:
needs: lint
runs-on: ubuntu-slim
diff --git a/astro.config.mjs b/astro.config.mjs
index 1e8bf6b6..105dc7fa 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -45,7 +45,7 @@ export default defineConfig({
plugins: [
// Preview builds only: stamps data-magpie-src so the review
// overlay can map a marked region back to a diff line.
- ["./scripts/preview/babel-plugin-magpie-src.mjs", { root: process.cwd() }],
+ ["./scripts/preview/adapters/astro/babel-plugin-magpie-src.mjs", { root: process.cwd() }],
],
},
}
diff --git a/docs/designs/2026-09-27-preview-jekyll-adapter-design.md b/docs/designs/2026-09-27-preview-jekyll-adapter-design.md
new file mode 100644
index 00000000..541a2104
--- /dev/null
+++ b/docs/designs/2026-09-27-preview-jekyll-adapter-design.md
@@ -0,0 +1,90 @@
+# PR previews for Jekyll sites
+
+**Status:** implemented · **Date:** 2026-09-27
+
+## Goal
+
+The PR-preview system (`scripts/preview/`) was built for this Astro site, but
+most ASF project sites are Jekyll. Make previews — including the overlay's
+file-and-line lookup — work for a Jekyll site, without changing how this site
+behaves.
+
+The preview system is due to move to its own repository, so it stays here for
+now, and the generator-specific parts are kept in separable directories so that
+move is a file move.
+
+## What was already generic
+
+Almost all of it. The publisher consumes a built directory plus
+`preview-meta.json` and never builds anything; validation, reaping, diff
+anchors and the overlay do not care which generator produced the HTML. Two
+things were Astro-specific, and two were hardcoded to this site:
+
+- **Source annotation** — `data-magpie-src` came from a Babel plugin, so only
+ `.tsx`/`.jsx` was covered.
+- **The build commands** in `build.yml`.
+- **The staging hostname label** `magpie`, and **the build workflow name**
+ `build.yml` the publisher looks up artifacts from.
+
+## Design
+
+**Adapters.** `scripts/preview/adapters/<generator>/` holds the one piece
+that differs per generator. The Babel plugin moved to `adapters/astro/`
+unchanged. `adapters/jekyll/` is new.
+
+**The contract** between a site's build and the publisher is now written down
+in `scripts/preview/README.md`: a `preview-site` artifact, `preview-meta.json`,
+and optional `data-magpie-src` attributes. Optional matters — an element
+without one falls back to the Conversation tab, which is already how `.astro`
+templates behave here.
+
+**Settings.** `PREVIEW_SITE_NAME` and `PREVIEW_BUILD_WORKFLOW` replace the two
+hardcoded values, defaulting to them. Both reach a hostname, YAML or an API
+path, so both are validated to a strict character set, for the same reason the
+PR number is.
+
+**The Jekyll plugin** (`magpie_src.rb`) stamps from two sources:
+
+- *Markdown.* kramdown records a source line on every block element. The plugin
+ adds the front-matter length and stamps blocks while Jekyll converts a
+ document's own content — tracked around `Jekyll::Renderer#convert`, so
+ `markdownify` inside a layout is not mis-stamped with the page's path.
+ kramdown calls `convert` only on the root element and dispatches children
+ directly, so the tree is stamped once, from the root.
+- *Layouts and includes.* Opening HTML tags in the raw template are stamped
+ before Liquid parses it: layouts after the site is read, includes as they
+ are loaded (`Jekyll::Inclusion#content` for `include`, which Jekyll 4 renders
+ through `OptimizedIncludeTag`; `IncludeTag#read_file` for
+ `include_relative`). Liquid spans, raw/comment/highlight blocks, scripts,
+ styles and HTML comments pass through untouched.
+
+It is installed by `prepare.sh`, which copies it into `_plugins/` for the
+preview build only — no Gemfile change, and the production build never loads
+it. `prepare.sh` refuses `safe: true` (the `github-pages` gem), where
+`_plugins` is ignored and the build would silently stamp nothing.
+
+## Known limits
+
+- A Liquid tag in a Markdown page that expands to several lines shifts the
+ line numbers of later blocks on that page. The path stays correct and the
+ screenshot caption names it. Fixing it means mapping lines through Liquid
+ rendering, which Jekyll does not expose.
+- Sites that must build in safe mode cannot use the adapter.
+
+## Alternatives considered
+
+- **Extract the preview system into `apache/magpie`** as a reusable workflow
+ plus a packaging action. Rejected for now: the system is moving to a
+ different repository.
+- **Inject kramdown IAL markers into the Markdown source** before rendering.
+ Exact through Liquid, but it changes Markdown semantics around lists, code
+ fences and tables — too easy to break the page being reviewed.
+
+## Testing
+
+`adapters/jekyll/test/` builds a fixture site with the plugin loaded and
+asserts stamped `path:line` values for Markdown, a layout and an include, plus
+what must stay untouched. CI runs it in the `jekyll-adapter` job, in a
+digest-pinned `ruby:3.3` container, so no new third-party action is needed,
+installing from a checksummed `Gemfile.lock` with `BUNDLE_FROZEN`.
+Verified locally against Jekyll 4.4.1 and 4.3.4.
diff --git a/scripts/preview/README.md b/scripts/preview/README.md
new file mode 100644
index 00000000..e784c622
--- /dev/null
+++ b/scripts/preview/README.md
@@ -0,0 +1,52 @@
+# PR previews
+
+Publishes an armed pull request's built site to
+`https://<site>-pr<N>.staged.apache.org/`, with a review overlay that turns a
+marked region into a screenshot and a link to the matching diff line. The
+design is in
+[`docs/designs/2026-09-16-pr-preview-deployments-design.md`](../../docs/designs/2026-09-16-pr-preview-deployments-design.md)
+and
+[`docs/designs/2026-09-17-preview-review-overlay-design.md`](../../docs/designs/2026-09-17-preview-review-overlay-design.md).
+
+Everything in this directory except `adapters/` is independent of the site
+generator. An adapter is the one generator-specific piece: it makes a preview
+build say which source line each element came from.
+
+| Path | What it is |
+|---|---|
+| `publish.mjs` and its modules | The privileged publisher run by `preview-publish.yml` |
+| `overlay/` | The review overlay injected into every published page |
+| `adapters/astro/` | Babel plugin stamping JSX, used by this site |
+| `adapters/jekyll/` | Jekyll plugin stamping Markdown, layouts and includes |
+
+## The build contract
+
+The publisher never builds anything. It consumes an artifact that the
+unprivileged pull-request build produced, and this is all it expects of it:
+
+1. **An artifact named `preview-site`**, uploaded by a successful run of the
+ build workflow for the pull request's head commit, holding the built static
+ site. Upload with `include-hidden-files: true` if the site has dotfiles.
+2. **`preview-meta.json` at its root**:
+ `{"pr": <number>, "headSha": "<40-hex head commit>"}`. The publisher checks
+ both against the pull request and refuses a mismatch.
+3. **Optionally, `data-magpie-src="<repo-relative path>:<line>"`** on rendered
+ elements. The overlay walks up from the marked region to the nearest stamped
+ element and, when that line is in the diff, opens the Files tab on it. An
+ element without one falls back to the Conversation tab, so an adapter that
+ covers only some sources is still useful.
+
+The attribute is for preview builds only. Build production without the
+adapter and fail the build if `data-magpie-src` appears in it — see the
+assertion step in `.github/workflows/build.yml`.
+
+## Settings for another site
+
+Both default to this site's values.
+
+| Variable | Default | Meaning |
+|---|---|---|
+| `PREVIEW_SITE_NAME` | `magpie` | First label of the staging hostname. ASF staging derives it from the repository, so it must match what infra serves. |
+| `PREVIEW_BUILD_WORKFLOW` | `build.yml` | File name of the workflow whose runs carry the `preview-site` artifact |
+
+Set them in the `env:` of the publish step in `preview-publish.yml`.
diff --git a/scripts/preview/babel-plugin-magpie-src.mjs b/scripts/preview/adapters/astro/babel-plugin-magpie-src.mjs
similarity index 100%
rename from scripts/preview/babel-plugin-magpie-src.mjs
rename to scripts/preview/adapters/astro/babel-plugin-magpie-src.mjs
diff --git a/scripts/preview/babel-plugin-magpie-src.test.mjs b/scripts/preview/adapters/astro/babel-plugin-magpie-src.test.mjs
similarity index 100%
rename from scripts/preview/babel-plugin-magpie-src.test.mjs
rename to scripts/preview/adapters/astro/babel-plugin-magpie-src.test.mjs
diff --git a/scripts/preview/adapters/jekyll/README.md b/scripts/preview/adapters/jekyll/README.md
new file mode 100644
index 00000000..adc7f7c1
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/README.md
@@ -0,0 +1,86 @@
+# Jekyll adapter
+
+Makes a Jekyll site's preview build stamp `data-magpie-src="<path>:<line>"` 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
+[build contract](../../README.md#the-build-contract) 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 (`{{ "<b>" }}`), `{% raw %}`, `{% comment %}` and
+`{% highlight %}` blocks, `<script>`, `<style>` and HTML comments, Markdown
+rendered by `markdownify` inside a layout, and files from a theme gem, which a
+pull request cannot change.
+
+Paths are relative to the repository root: the nearest ancestor of the site
+source holding `.git`, or `MAGPIE_SRC_ROOT` when set.
+
+**Known limit.** A Liquid tag inside a Markdown page that expands to several
+lines shifts the line of every block after it on that page. The file is still
+right, and the screenshot's caption names it.
+
+## Wiring it into a Jekyll site's CI
+
+In the pull-request build, after the production build and its check:
+
+```yaml
+ - name: Assert the production build carries no preview annotation
+ run: |
+ if grep -rq "data-magpie-src" _site/; then
+ echo "::error::data-magpie-src found in a production build"
+ exit 1
+ fi
+
+ - name: Build the preview site with source annotation
+ if: github.event_name == 'pull_request'
+ run: |
+ sh scripts/preview/adapters/jekyll/prepare.sh .
+ bundle exec jekyll build
+
+ - name: Write preview metadata
+ if: github.event_name == 'pull_request'
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ printf '{"pr":%s,"headSha":"%s"}\n' "$PR_NUMBER" "$HEAD_SHA" > _site/preview-meta.json
+
+ - name: Upload preview artifact
+ if: github.event_name == 'pull_request'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: preview-site
+ path: _site
+ include-hidden-files: true
+```
+
+`prepare.sh <site source>` copies `magpie_src.rb` into the site's plugins
+directory (`plugins_dir`, default `_plugins`). Run it only in CI, or delete the
+copied file afterwards: anything built while it is there is annotated. It
+refuses a site with `safe: true` — which the `github-pages` gem forces —
+because safe mode ignores `_plugins` and the build would silently stamp
+nothing.
+
+For publishing, carry over `scripts/preview/`, `vendor/html2canvas-pro/` and
+`.github/workflows/preview-publish.yml`, and set `PREVIEW_SITE_NAME` and
+`PREVIEW_BUILD_WORKFLOW` as described in the
+[settings](../../README.md#settings-for-another-site).
+
+## Tests
+
+```sh
+export BUNDLE_GEMFILE=scripts/preview/adapters/jekyll/test/Gemfile
+bundle install
+bundle exec ruby scripts/preview/adapters/jekyll/test/magpie_src_test.rb
+```
+
+The test builds `test/fixture/` with the plugin loaded and checks the stamped
+lines. CI runs it in the `jekyll-adapter` job of `build.yml`. Verified against
+Jekyll 4.4.1 and 4.3.4.
diff --git a/scripts/preview/adapters/jekyll/magpie_src.rb b/scripts/preview/adapters/jekyll/magpie_src.rb
new file mode 100644
index 00000000..8455767a
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/magpie_src.rb
@@ -0,0 +1,235 @@
+# 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.
+
+# Stamps rendered elements with the source file and line they came from, as
+# data-magpie-src="<repo-relative path>:<line>", so a preview's review overlay
+# can resolve a marked region back to a diff line. The Jekyll counterpart of
+# adapters/astro/babel-plugin-magpie-src.mjs.
+#
+# Preview builds only: prepare.sh copies this file into the site's _plugins/
+# for the preview build, and nothing else loads it. A production build must
+# carry none of these attributes.
+#
+# Two sources are stamped:
+# - Markdown pages and collection documents, from the line kramdown records
+# for each block, shifted past the front matter.
+# - Layouts and includes, by stamping each opening HTML tag of the raw
+# template before Liquid runs.
+#
+# Known limit: a Liquid tag inside Markdown that expands to several lines
+# shifts the line of every block after it. The file is still right.
+
+require "jekyll"
+require "kramdown"
+require "pathname"
+
+module MagpieSrc
+ ATTR = "data-magpie-src"
+
+ # Blocks stamped in Markdown. Inline elements are left alone: the overlay
+ # walks up to the nearest stamped ancestor, and a block is the unit a
+ # reviewer comments on.
+ MARKDOWN_BLOCKS = %i[
+ p header ul ol li blockquote table tr codeblock dl dt dd hr html_element math
+ ].freeze
+
+ # Never stamped in templates: they render nothing a reviewer can mark, or
+ # sit above <body>, where the overlay's walk stops.
+ SKIP_TAGS = %w[html head body meta link script style title base noscript template].freeze
+
+ # Template regions whose contents are not markup to stamp.
+ OPAQUE = [
+ [/\{%-?\s*raw\s*-?%\}/, /\{%-?\s*endraw\s*-?%\}/],
+ [/\{%-?\s*comment\s*-?%\}/, /\{%-?\s*endcomment\s*-?%\}/],
+ [/\{%-?\s*highlight\b/, /\{%-?\s*endhighlight\s*-?%\}/],
+ [/<script\b/i, %r{</script\s*>}i],
+ [/<style\b/i, %r{</style\s*>}i],
+ [/<!--/, /-->/],
+ ].freeze
+
+ LIQUID = /\{\{.*?\}\}|\{%.*?%\}/m
+ OPEN_TAG = /<([a-zA-Z][a-zA-Z0-9-]*)(?=[\s>\/])/
+
+ module_function
+
+ # The directory paths are reported relative to: MAGPIE_SRC_ROOT when set,
+ # else the nearest ancestor of the site source holding .git (a directory, or
+ # a file in a worktree), else the site source itself.
+ def repo_root(source)
+ return Pathname.new(File.expand_path(ENV["MAGPIE_SRC_ROOT"])) if ENV["MAGPIE_SRC_ROOT"].to_s != ""
+
+ dir = Pathname.new(File.expand_path(source))
+ dir.ascend { |d| return d if (d + ".git").exist? }
+ Pathname.new(File.expand_path(source))
+ end
+
+ # The repo-relative path, or nil for a file outside the repository — a
+ # theme gem's layout or include, which no pull request can change.
+ def relative(root, path)
+ rel = Pathname.new(File.expand_path(path)).relative_path_from(root).to_s
+ rel.start_with?("..") ? nil : rel
+ rescue ArgumentError
+ nil
+ end
+
+ # The 1-based line of the front matter's closing delimiter, or 0 when the
+ # file has none, so body line N is file line N + this. Mirrors the pattern
+ # Jekyll itself uses to split front matter from content.
+ def front_matter_lines(path)
+ raw = File.read(path, encoding: "UTF-8")
+ match = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.match(raw)
+ match ? match[0].count("\n") : 0
+ rescue SystemCallError
+ 0
+ end
+
+ # Stamp every opening tag of a raw template with its file line. Liquid spans
+ # and opaque regions are copied through untouched.
+ def stamp_template(content, rel, first_line)
+ closer = nil
+ content.each_line.with_index.map do |line, i|
+ where = "#{rel}:#{first_line + i}"
+ out = +""
+ rest = line
+ until rest.empty?
+ if closer
+ at = closer.match(rest)
+ if at.nil?
+ out << rest
+ break
+ end
+ out << rest[0...at.end(0)]
+ rest = rest[at.end(0)..]
+ closer = nil
+ next
+ end
+
+ opener, close = OPAQUE.map { |o, c| [o.match(rest), c] }
+ .reject { |m, _| m.nil? }
+ .min_by { |m, _| m.begin(0) }
+ stop = opener ? opener.begin(0) : rest.length
+ out << stamp_segment(rest[0...stop], where)
+ break unless opener
+
+ out << rest[stop...opener.end(0)]
+ rest = rest[opener.end(0)..]
+ closer = close
+ end
+ out
+ end.join
+ end
+
+ # Stamp the tags in a run of template text that lies outside Liquid spans.
+ def stamp_segment(text, where)
+ pieces = text.split(/(#{LIQUID})/)
+ pieces.each_with_index.map do |piece, i|
+ next piece if i.odd?
+
+ piece.gsub(OPEN_TAG) do |tag|
+ name = Regexp.last_match(1)
+ SKIP_TAGS.include?(name.downcase) ? tag : %(#{tag} #{ATTR}="#{where}")
+ end
+ end.join
+ end
+
+ # Set while Jekyll converts one document's own content, and read by the
+ # kramdown converter below. Unset for markdownify in a layout, which would
+ # otherwise be stamped with the page's path.
+ def current
+ Thread.current[:magpie_src]
+ end
+
+ def with_document(document)
+ site = document.site
+ # Page#path is source-relative and Document#path absolute; relative_path
+ # is source-relative on both.
+ path = site.in_source_dir(document.relative_path.to_s)
+ rel = File.file?(path) ? relative(repo_root(site.source), path) : nil
+ return yield unless rel
+
+ Thread.current[:magpie_src] = { rel: rel, offset: front_matter_lines(path) }
+ yield
+ ensure
+ Thread.current[:magpie_src] = nil
+ end
+
+ module Renderer
+ def convert(content)
+ MagpieSrc.with_document(document) { super }
+ end
+ end
+
+ def stamp_tree(el, ctx)
+ line = el.options[:location]
+ if line && MARKDOWN_BLOCKS.include?(el.type) && !el.attr.key?(ATTR)
+ el.attr[ATTR] = "#{ctx[:rel]}:#{line + ctx[:offset]}"
+ end
+ el.children.each { |child| stamp_tree(child, ctx) }
+ end
+
+ def stamp_include(site, path, content)
+ rel = relative(repo_root(site.source), path)
+ rel ? stamp_template(content, rel, 1) : content
+ end
+
+ # kramdown calls #convert on the root only; every child is dispatched
+ # straight to its convert_<type>. So the whole tree is stamped from the root.
+ module KramdownHtml
+ def convert(el, *args)
+ ctx = MagpieSrc.current
+ MagpieSrc.stamp_tree(el, ctx) if ctx && el.type == :root
+ super
+ end
+ end
+
+ # `include` is OptimizedIncludeTag, which renders a Jekyll::Inclusion and
+ # reads the file through Inclusion#content; `include_relative` still goes
+ # through IncludeTag#read_file. Both are read once per build and cached.
+ module Inclusion
+ def content
+ @content ||= MagpieSrc.stamp_include(site, path, super)
+ end
+ end
+
+ module IncludeTag
+ def read_file(file, context)
+ MagpieSrc.stamp_include(context.registers[:site], file, super)
+ end
+ end
+end
+
+Jekyll::Renderer.prepend(MagpieSrc::Renderer)
+Kramdown::Converter::Html.prepend(MagpieSrc::KramdownHtml)
+Jekyll::Inclusion.prepend(MagpieSrc::Inclusion)
+Jekyll::Tags::IncludeTag.prepend(MagpieSrc::IncludeTag)
+
+# Layouts are read once per build, after which their content is only parsed.
+Jekyll::Hooks.register :site, :post_read do |site|
+ root = MagpieSrc.repo_root(site.source)
+ site.layouts.each_value do |layout|
+ next unless layout.path && File.file?(layout.path)
+
+ rel = MagpieSrc.relative(root, layout.path)
+ next unless rel
+
+ layout.content = MagpieSrc.stamp_template(
+ layout.content, rel, MagpieSrc.front_matter_lines(layout.path) + 1,
+ )
+ end
+end
diff --git a/scripts/preview/adapters/jekyll/prepare.sh b/scripts/preview/adapters/jekyll/prepare.sh
new file mode 100755
index 00000000..11824763
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/prepare.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements; and to You under the Apache License,
+# Version 2.0. See http://www.apache.org/licenses/LICENSE-2.0
+#
+# Install the source-annotation plugin into a Jekyll site for a PREVIEW build.
+# Run it in the preview build only, never before the production build: the
+# plugin stamps every page with data-magpie-src, which must not ship.
+#
+# Usage: prepare.sh <jekyll site source directory>
+set -eu
+
+site="${1:?usage: prepare.sh <jekyll site source directory>}"
+
+config=""
+for c in "$site/_config.yml" "$site/_config.yaml"; do
+ if [ -f "$c" ]; then config="$c"; break; fi
+done
+if [ -z "$config" ]; then
+ echo "prepare.sh: no _config.yml in $site" >&2
+ exit 1
+fi
+
+# Safe mode, which the github-pages gem forces, ignores _plugins entirely. The
+# build would succeed with nothing stamped, so refuse rather than pretend.
+if grep -Eq '^[[:space:]]*safe:[[:space:]]*true' "$config"; then
+ echo "prepare.sh: $config sets safe: true, which ignores _plugins" >&2
+ exit 1
+fi
+
+plugins_dir="$(sed -n 's/^plugins_dir:[[:space:]]*["'\'']*\([^"'\'' ]*\).*/\1/p' "$config" | head -n 1)"
+plugins_dir="${plugins_dir:-_plugins}"
+
+mkdir -p "$site/$plugins_dir"
+cp "$(dirname "$0")/magpie_src.rb" "$site/$plugins_dir/magpie_src.rb"
+echo "prepare.sh: installed magpie_src.rb into $site/$plugins_dir"
diff --git a/scripts/preview/adapters/jekyll/test/Gemfile b/scripts/preview/adapters/jekyll/test/Gemfile
new file mode 100644
index 00000000..90c2d537
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/Gemfile
@@ -0,0 +1,6 @@
+# Test dependencies for the Jekyll adapter; locked so CI installs exactly
+# these versions. The adapter itself needs only a Jekyll 4 site.
+source "https://rubygems.org"
+
+gem "jekyll", "4.4.1"
+gem "minitest", "~> 5.25"
diff --git a/scripts/preview/adapters/jekyll/test/Gemfile.lock b/scripts/preview/adapters/jekyll/test/Gemfile.lock
new file mode 100644
index 00000000..2ffb68a0
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/Gemfile.lock
@@ -0,0 +1,133 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
+ base64 (0.3.0)
+ bigdecimal (4.1.3)
+ colorator (1.1.0)
+ concurrent-ruby (1.3.8)
+ csv (3.3.6)
+ em-websocket (0.5.3)
+ eventmachine (>= 0.12.9)
+ http_parser.rb (~> 0)
+ eventmachine (1.2.7)
+ ffi (1.17.4-aarch64-linux-gnu)
+ ffi (1.17.4-x86_64-linux-gnu)
+ forwardable-extended (2.6.0)
+ google-protobuf (4.36.2-aarch64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ google-protobuf (4.36.2-x86_64-linux-gnu)
+ bigdecimal
+ rake (~> 13.3)
+ http_parser.rb (0.8.1)
+ i18n (1.15.2)
+ concurrent-ruby (~> 1.0)
+ jekyll (4.4.1)
+ addressable (~> 2.4)
+ base64 (~> 0.2)
+ colorator (~> 1.0)
+ csv (~> 3.0)
+ em-websocket (~> 0.5)
+ i18n (~> 1.0)
+ jekyll-sass-converter (>= 2.0, < 4.0)
+ jekyll-watch (~> 2.0)
+ json (~> 2.6)
+ kramdown (~> 2.3, >= 2.3.1)
+ kramdown-parser-gfm (~> 1.0)
+ liquid (~> 4.0)
+ mercenary (~> 0.3, >= 0.3.6)
+ pathutil (~> 0.9)
+ rouge (>= 3.0, < 5.0)
+ safe_yaml (~> 1.0)
+ terminal-table (>= 1.8, < 4.0)
+ webrick (~> 1.7)
+ jekyll-sass-converter (3.1.0)
+ sass-embedded (~> 1.75)
+ jekyll-watch (2.2.1)
+ listen (~> 3.0)
+ json (2.21.2)
+ kramdown (2.5.2)
+ rexml (>= 3.4.4)
+ kramdown-parser-gfm (1.1.0)
+ kramdown (~> 2.0)
+ liquid (4.0.4)
+ listen (3.10.0)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
+ logger (1.7.0)
+ mercenary (0.4.0)
+ minitest (5.27.0)
+ pathutil (0.16.2)
+ forwardable-extended (~> 2.6)
+ public_suffix (7.0.5)
+ rake (13.4.2)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
+ rexml (3.4.4)
+ rouge (4.7.0)
+ safe_yaml (1.0.5)
+ sass-embedded (1.105.0-aarch64-linux-gnu)
+ google-protobuf (~> 4.31)
+ sass-embedded (1.105.0-x86_64-linux-gnu)
+ google-protobuf (~> 4.31)
+ terminal-table (3.0.2)
+ unicode-display_width (>= 1.1.1, < 3)
+ unicode-display_width (2.6.0)
+ webrick (1.9.2)
+
+PLATFORMS
+ aarch64-linux-gnu
+ x86_64-linux-gnu
+
+DEPENDENCIES
+ jekyll (= 4.4.1)
+ minitest (~> 5.25)
+
+CHECKSUMS
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
+ bundler (4.0.21) sha256=7cbf5499b426076aa16c8d28a59e4c2c3aad5a438402abcd961f0119f29f1dd0
+ colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38
+ concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
+ csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
+ em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8
+ eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
+ ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
+ ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
+ forwardable-extended (2.6.0) sha256=1bec948c469bbddfadeb3bd90eb8c85f6e627a412a3e852acfd7eaedbac3ec97
+ google-protobuf (4.36.2-aarch64-linux-gnu) sha256=c3279ec6eaa59f6ed6276eda2a33cffb4306471183ab79a332e698b8564f5169
+ google-protobuf (4.36.2-x86_64-linux-gnu) sha256=d25a820873e423f1d209ae8c672a4a28b019e2429debd0901d26670a0b4e14db
+ http_parser.rb (0.8.1) sha256=9ae8df145b39aa5398b2f90090d651c67bd8e2ebfe4507c966579f641e11097a
+ i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
+ jekyll (4.4.1) sha256=4c1144d857a5b2b80d45b8cf5138289579a9f8136aadfa6dd684b31fe2bc18c1
+ jekyll-sass-converter (3.1.0) sha256=83925d84f1d134410c11d0c6643b0093e82e3a3cf127e90757a85294a3862443
+ jekyll-watch (2.2.1) sha256=bc44ed43f5e0a552836245a54dbff3ea7421ecc2856707e8a1ee203a8387a7e1
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+ kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
+ kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
+ liquid (4.0.4) sha256=4fcfebb1a045e47918388dbb7a0925e7c3893e58d2bd6c3b3c73ec17a2d8fdb3
+ listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ mercenary (0.4.0) sha256=b25a1e4a59adca88665e08e24acf0af30da5b5d859f7d8f38fba52c28f405138
+ minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5
+ pathutil (0.16.2) sha256=e43b74365631cab4f6d5e4228f812927efc9cb2c71e62976edcb252ee948d589
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+ rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
+ rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
+ rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+ rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739
+ safe_yaml (1.0.5) sha256=a6ac2d64b7eb027bdeeca1851fe7e7af0d668e133e8a88066a0c6f7087d9f848
+ sass-embedded (1.105.0-aarch64-linux-gnu) sha256=26dc2b0f8063f99ad4df226ebecac361c52ce06c63d8acc20ebc615d1450ea09
+ sass-embedded (1.105.0-x86_64-linux-gnu) sha256=c7cb4884c6d1ddad41d52f5950167d5cf77e242d7900ca0ec13170ba43878977
+ terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
+ unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
+ webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
+
+BUNDLED WITH
+ 4.0.21
diff --git a/scripts/preview/adapters/jekyll/test/fixture/_config.yml b/scripts/preview/adapters/jekyll/test/fixture/_config.yml
new file mode 100644
index 00000000..227742f3
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/_config.yml
@@ -0,0 +1,2 @@
+title: Fixture
+markdown: kramdown
diff --git a/scripts/preview/adapters/jekyll/test/fixture/_includes/nav.html b/scripts/preview/adapters/jekyll/test/fixture/_includes/nav.html
new file mode 100644
index 00000000..4d762527
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/_includes/nav.html
@@ -0,0 +1,3 @@
+<nav>
+ <a href="/">{{ "<b>" }}Home</a>
+</nav>
diff --git a/scripts/preview/adapters/jekyll/test/fixture/_layouts/default.html b/scripts/preview/adapters/jekyll/test/fixture/_layouts/default.html
new file mode 100644
index 00000000..a73e7a2c
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/_layouts/default.html
@@ -0,0 +1,14 @@
+---
+---
+<!doctype html>
+<html>
+<head><title>{{ page.title }}</title></head>
+<body>
+<header class="site">{% include nav.html %}</header>
+<main>{{ content }}</main>
+<aside>{{ "**from markdownify**" | markdownify }}</aside>
+<script>var x = "<div>";</script>
+{% raw %}<p>literal</p>{% endraw %}
+<!-- <section>commented</section> -->
+</body>
+</html>
diff --git a/scripts/preview/adapters/jekyll/test/fixture/index.md b/scripts/preview/adapters/jekyll/test/fixture/index.md
new file mode 100644
index 00000000..86058319
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/fixture/index.md
@@ -0,0 +1,12 @@
+---
+layout: default
+title: Home
+---
+# Hello
+
+A paragraph.
+
+- one
+- two
+
+> quoted
diff --git a/scripts/preview/adapters/jekyll/test/magpie_src_test.rb b/scripts/preview/adapters/jekyll/test/magpie_src_test.rb
new file mode 100644
index 00000000..8b75d4a7
--- /dev/null
+++ b/scripts/preview/adapters/jekyll/test/magpie_src_test.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements; and to You under the Apache License,
+# Version 2.0. See http://www.apache.org/licenses/LICENSE-2.0
+#
+# Run: ruby scripts/preview/adapters/jekyll/test/magpie_src_test.rb
+# Needs the jekyll gem (4.x) and minitest.
+
+require "minitest/autorun"
+require "tmpdir"
+require "fileutils"
+require_relative "../magpie_src"
+
+FIXTURE = File.expand_path("fixture", __dir__)
+PREPARE = File.expand_path("../prepare.sh", __dir__)
+
+class MagpieSrcBuildTest < Minitest::Test
+ def self.built
+ @built ||= begin
+ dest = Dir.mktmpdir("magpie-src-site")
+ ENV["MAGPIE_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("MAGPIE_SRC_ROOT")
+ end
+ end
+
+ def html = self.class.built
+
+ def test_markdown_blocks_carry_their_file_line
+ assert_match(/<h1[^>]*data-magpie-src="index\.md:5"/, html)
+ assert_match(/<p data-magpie-src="index\.md:7">A paragraph\.<\/p>/, html)
+ assert_match(/<li data-magpie-src="index\.md:9">one/, html)
+ assert_match(/<li data-magpie-src="index\.md:10">two/, html)
+ assert_match(/<blockquote data-magpie-src="index\.md:12">/, html)
+ end
+
+ def test_layout_tags_carry_their_file_line
+ assert_includes html, %(<header data-magpie-src="_layouts/default.html:7" class="site">)
+ assert_includes html, %(<main data-magpie-src="_layouts/default.html:8">)
+ end
+
+ def test_include_tags_carry_their_file_line
+ assert_includes html, %(<nav data-magpie-src="_includes/nav.html:1">)
+ assert_includes html, %(<a data-magpie-src="_includes/nav.html:2" href="/">)
+ end
+
+ def test_liquid_output_is_not_stamped
+ assert_includes html, "<b>Home"
+ end
+
+ def test_markdownify_in_a_layout_is_not_given_the_page_path
+ assert_includes html, "<p><strong>from markdownify</strong></p>"
+ end
+
+ def test_opaque_regions_and_document_chrome_are_untouched
+ assert_includes html, %(var x = "<div>";)
+ assert_includes html, "<p>literal</p>"
+ assert_includes html, "<!-- <section>commented</section> -->"
+ assert_includes html, "<body>\n"
+ assert_includes html, "<html>\n"
+ assert_includes html, "<head><title>"
+ end
+end
+
+class MagpieSrcUnitTest < Minitest::Test
+ def test_stamp_template_tracks_lines_across_an_opaque_region
+ src = "<div>\n{% comment %}\n<p>x</p>\n{% endcomment %}\n<span>y</span>\n"
+ out = MagpieSrc.stamp_template(src, "f.html", 10)
+ assert_includes out, %(<div data-magpie-src="f.html:10">)
+ assert_includes out, "<p>x</p>"
+ assert_includes out, %(<span data-magpie-src="f.html:14">)
+ end
+
+ def test_stamp_template_leaves_liquid_spans_alone
+ out = MagpieSrc.stamp_template(%(<a href="{{ '<i>' }}">{% if x %}<em>{% endif %}), "f.html", 1)
+ assert_includes out, %(<a data-magpie-src="f.html:1" href="{{ '<i>' }}">)
+ assert_includes out, %({% if x %}<em data-magpie-src="f.html:1">{% endif %})
+ end
+
+ def test_front_matter_lines
+ Dir.mktmpdir do |dir|
+ path = File.join(dir, "p.md")
+ File.write(path, "---\na: 1\n---\nbody\n")
+ assert_equal 3, MagpieSrc.front_matter_lines(path)
+ File.write(path, "no front matter\n")
+ assert_equal 0, MagpieSrc.front_matter_lines(path)
+ end
+ end
+
+ def test_repo_root_walks_up_to_git_and_skips_files_outside_it
+ Dir.mktmpdir do |dir|
+ FileUtils.mkdir_p(File.join(dir, ".git"))
+ site = File.join(dir, "site", "src")
+ FileUtils.mkdir_p(site)
+ root = MagpieSrc.repo_root(site)
+ assert_equal File.realpath(dir), File.realpath(root.to_s)
+ assert_equal "site/src/a.md", MagpieSrc.relative(root, File.join(site, "a.md"))
+ assert_nil MagpieSrc.relative(root, "/usr/lib/gems/theme/_layouts/x.html")
+ end
+ end
+
+ def test_prepare_installs_the_plugin_and_refuses_safe_mode
+ Dir.mktmpdir do |dir|
+ File.write(File.join(dir, "_config.yml"), "plugins_dir: my_plugins\n")
+ assert system(PREPARE, dir, out: File::NULL)
+ assert File.file?(File.join(dir, "my_plugins", "magpie_src.rb"))
+
+ File.write(File.join(dir, "_config.yml"), "safe: true\n")
+ refute system(PREPARE, dir, out: File::NULL, err: File::NULL)
+ end
+ end
+end
diff --git a/scripts/preview/files.mjs b/scripts/preview/files.mjs
index cad52ffe..dd7a3633 100644
--- a/scripts/preview/files.mjs
+++ b/scripts/preview/files.mjs
@@ -14,8 +14,26 @@ function assertPr(pr) {
return String(pr);
}
+/**
+ * The first label of the staging hostname, which ASF staging derives from the
+ * repository: apache/magpie-site stages at magpie-pr<N>.staged.apache.org.
+ * Another site running this publisher sets PREVIEW_SITE_NAME. Read on every
+ * call, and held to hostname characters for the same injection reason as the
+ * PR number.
+ */
+export function siteName(env = process.env) {
+ const name = env.PREVIEW_SITE_NAME || "magpie";
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) {
+ throw new TypeError(
+ `PREVIEW_SITE_NAME must be a lowercase hostname label, got ${JSON.stringify(name)}`,
+ );
+ }
+ return name;
+}
+
export const previewBranch = (pr) => `preview/pr${assertPr(pr)}-staging`;
-export const previewUrl = (pr) => `https://magpie-pr${assertPr(pr)}.staged.apache.org/`;
+export const previewUrl = (pr) =>
+ `https://${siteName()}-pr${assertPr(pr)}.staged.apache.org/`;
/**
* Generated by the publisher, never taken from the artifact: an .asf.yaml
diff --git a/scripts/preview/files.test.mjs b/scripts/preview/files.test.mjs
index 2c4be27d..dfb7787e 100644
--- a/scripts/preview/files.test.mjs
+++ b/scripts/preview/files.test.mjs
@@ -6,6 +6,7 @@ import {
renderTombstone,
previewBranch,
previewUrl,
+ siteName,
} from "./files.mjs";
test("branch and url agree on the profile", () => {
@@ -52,3 +53,15 @@ test("refuses a missing or non-numeric pr", () => {
test("still accepts a numeric string", () => {
assert.equal(previewBranch("176"), "preview/pr176-staging");
});
+
+test("siteName defaults to magpie and accepts another site's label", () => {
+ assert.equal(siteName({}), "magpie");
+ assert.equal(siteName({ PREVIEW_SITE_NAME: "" }), "magpie");
+ assert.equal(siteName({ PREVIEW_SITE_NAME: "tooling-site" }), "tooling-site");
+});
+
+test("siteName refuses anything that is not a hostname label", () => {
+ for (const bad of ["Magpie", "a.b", "-x", "x-", "a\nstaging:", "a b"]) {
+ assert.throws(() => siteName({ PREVIEW_SITE_NAME: bad }), /hostname label/);
+ }
+});
diff --git a/scripts/preview/github.mjs b/scripts/preview/github.mjs
index 42a3c2c8..d23fe1ba 100644
--- a/scripts/preview/github.mjs
+++ b/scripts/preview/github.mjs
@@ -1,6 +1,21 @@
const API = "https://api.github.com";
-export function createClient({ repo, token, fetchImpl = fetch }) {
+/**
+ * The workflow whose successful runs carry the preview-site artifact. Another
+ * site sets PREVIEW_BUILD_WORKFLOW; it is interpolated into an API path, so it
+ * is held to a bare workflow file name.
+ */
+export function buildWorkflow(env = process.env) {
+ const name = env.PREVIEW_BUILD_WORKFLOW || "build.yml";
+ if (!/^[A-Za-z0-9_.-]+\.ya?ml$/.test(name) || name.startsWith(".")) {
+ throw new TypeError(
+ `PREVIEW_BUILD_WORKFLOW must be a workflow file name, got ${JSON.stringify(name)}`,
+ );
+ }
+ return name;
+}
+
+export function createClient({ repo, token, fetchImpl = fetch, workflow = buildWorkflow() }) {
async function request(path, { method = "GET", body } = {}) {
const res = await fetchImpl(`${API}${path}`, {
method,
@@ -103,7 +118,7 @@ export function createClient({ repo, token, fetchImpl = fetch }) {
async latestSuccessfulBuild(headSha) {
const runs = await request(
- `/repos/${repo}/actions/workflows/build.yml/runs?head_sha=${headSha}&status=success&per_page=1`,
+ `/repos/${repo}/actions/workflows/${workflow}/runs?head_sha=${headSha}&status=success&per_page=1`,
);
return runs.workflow_runs?.[0] ?? null;
},
diff --git a/scripts/preview/github.test.mjs b/scripts/preview/github.test.mjs
index 2530e857..12ac530e 100644
--- a/scripts/preview/github.test.mjs
+++ b/scripts/preview/github.test.mjs
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { createClient } from "./github.mjs";
+import { createClient, buildWorkflow } from "./github.mjs";
function fakeFetch(routes) {
const calls = [];
@@ -142,3 +142,24 @@ test("listPullsForHead asks for every state, qualified by the repository owner",
assert.deepEqual(await gh.listPullsForHead("fix/x"), []);
assert.equal(calls.length, 1);
});
+
+test("buildWorkflow defaults to build.yml and accepts another file name", () => {
+ assert.equal(buildWorkflow({}), "build.yml");
+ assert.equal(buildWorkflow({ PREVIEW_BUILD_WORKFLOW: "jekyll.yaml" }), "jekyll.yaml");
+});
+
+test("buildWorkflow refuses paths and non-workflow names", () => {
+ for (const bad of ["../x.yml", "a/b.yml", "build", ".yml", "x.yml?y=1"]) {
+ assert.throws(() => buildWorkflow({ PREVIEW_BUILD_WORKFLOW: bad }), /workflow file name/);
+ }
+});
+
+test("latestSuccessfulBuild queries the configured workflow", async () => {
+ const { impl, calls } = fakeFetch({
+ "GET /repos/apache/x-site/actions/workflows/jekyll.yml/runs?head_sha=abc&status=success&per_page=1":
+ { workflow_runs: [{ id: 7 }] },
+ });
+ const gh = createClient({ repo: "apache/x-site", token: "t", fetchImpl: impl, workflow: "jekyll.yml" });
+ assert.deepEqual(await gh.latestSuccessfulBuild("abc"), { id: 7 });
+ assert.equal(calls.length, 1);
+});