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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/preview-link.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: preview-link

# Put the preview link on a pull request the moment it is opened.
#
# preview.yml builds on PUSH and comments only if a pull request already exists
# for that branch. Push first and open the pull request afterwards -- which is
# the natural order, and the one the worktree flow encourages -- and the comment
# step correctly finds nothing and exits. The preview is built and serving; only
# the notification is missing, and there is no other way to find the URL: the
# directory is a hash of the branch name and nothing links to it.
#
# So this comments when the pull request is opened, WITHOUT rebuilding. It reads
# the same URL preview.yml would have published to and checks that it is really
# serving before saying anything, which is the same discipline preview.yml uses.
# If the preview is not up -- push and open in quick succession, and the build
# takes minutes -- it says nothing, and the push-triggered run will comment when
# it finishes.

on:
pull_request:
types: [opened, reopened]

permissions:
contents: read
pull-requests: write

jobs:
link:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Comment the preview link, if there is a preview
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# head.ref, NOT GITHUB_REF_NAME: on a pull_request event that is
# "<number>/merge", and the preview directory is keyed on the BRANCH.
BRANCH: ${{ github.event.pull_request.head.ref }}
PR: ${{ github.event.pull_request.number }}
run: |
# One definition of the path, imported rather than reimplemented, so
# it cannot drift from what preview_mark.py actually publishes to.
HASH=$(python3 -c "import sys; sys.path.insert(0, 'scripts'); \
import preview_mark; print(preview_mark.preview_path('$BRANCH'))")
URL="https://underworld-technical-notes.github.io/underworldcode.org-preview/${HASH}/"

CODE=$(curl -s -o /dev/null -w '%{http_code}' "$URL" || echo 000)
if [ "$CODE" != "200" ]; then
echo "no preview serving at $URL (HTTP $CODE) -- the push build will comment"
exit 0
fi

# Link the notes this pull request touches, the same as preview.yml.
LINKS=""
for SLUG in $(gh pr diff "$PR" --name-only \
| sed -n 's|^articles/\([^/]*\)/.*|\1|p' | sort -u); do
[ -f "articles/${SLUG}/metadata.yml" ] || continue
TITLE=$(sed -n 's/^title: *//p' "articles/${SLUG}/metadata.yml" | head -1)
LINKS="${LINKS}- [${TITLE:-$SLUG}](${URL}${SLUG}/)"$'\n'
done

if [ -n "$LINKS" ]; then
BODY=$(printf '**Preview**\n\n%s\nOr the [whole site](%s).\n\nShows notes at draft and review, which the published site withholds. Not indexed, no comments, and not the citable version.' "$LINKS" "$URL")
else
BODY=$(printf '**Preview:** %s\n\nNo article changed on this branch. Shows notes at draft and review, which the published site withholds. Not indexed, no comments, and not the citable version.' "$URL")
fi

# Update the existing comment rather than adding another. Matching on
# "**Preview" and not "**Preview:**": the linked variant has no colon,
# which is why one pull request collected thirteen of these.
EXISTING=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | startswith("**Preview")) | .id' | head -1)
if [ -n "$EXISTING" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING}" -f body="$BODY"
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -f body="$BODY"
fi
29 changes: 23 additions & 6 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,28 @@ name: preview
on:
push:
branches-ignore: [main]
# A deposit-queue branch changes this one file and nothing a reader would
# look at. Publishing a whole preview site for it costs minutes and tells
# nobody anything; deposit-pdf.yml builds the PDF instead, which is the
# artefact that is actually under review at that point.
paths-ignore: ['deposit-queue.txt']
# Only build when something a reader would actually SEE has changed. A
# branch that touches workflows or tests has nothing to preview, and a
# six-minute build to publish an unchanged site helps nobody -- including
# a deposit-queue branch, whose one-line change is answered by the PDF that
# deposit-pdf.yml builds instead.
#
# Safe as a filter because `preview` is NOT a required status check -- only
# `test` is. A paths filter on a required check would leave the run
#remaining "expected" and block the merge forever rather than skipping it.
# If that ever changes, this has to change with it.
#
# scripts/ is included deliberately: those build the site, so a change
# there can alter every page without touching an article.
paths:
- 'articles/**'
- 'pages-src/**'
- 'static/**'
- 'templates/**'
- 'scripts/**'
- 'myst.yml'
- 'authors.yml'
- 'classification.yml'
workflow_dispatch:

concurrency:
Expand Down Expand Up @@ -190,7 +207,7 @@ jobs:
BODY=$(printf '**Preview:** %s\n\nNo article changed on this branch. Built from `%s`. Shows notes at draft and review, which the published site withholds. Not indexed, no comments, and not the citable version.' "$URL" "${GITHUB_SHA::7}")
fi
EXISTING=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | startswith("**Preview:**")) | .id' | head -1)
--jq '.[] | select(.body | startswith("**Preview")) | .id' | head -1)
if [ -n "$EXISTING" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING}" -f body="$BODY"
else
Expand Down
56 changes: 55 additions & 1 deletion tests/test_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1901,8 +1901,17 @@ def test_a_deposit_pull_request_gets_a_pdf_not_a_preview():
builds the archival PDF and attaches it to the run instead.
"""
preview = (ROOT / ".github" / "workflows" / "preview.yml").read_text(encoding="utf-8")
assert "paths-ignore" in preview and "deposit-queue.txt" in preview, \
# Assert the OUTCOME, not the mechanism: this began as a paths-ignore on
# deposit-queue.txt and became a positive paths list, which excludes it by
# not naming it. Either satisfies the point; pinning the mechanism made
# this fail on a change that strengthened it.
prev_cfg = "\n".join(l for l in preview.splitlines()
if not l.lstrip().startswith("#"))
assert "paths:" in prev_cfg or "paths-ignore:" in prev_cfg, \
"a deposit-queue branch must not trigger a full preview build"
assert "deposit-queue.txt" not in prev_cfg.split("jobs:")[0] \
or "paths-ignore:" in prev_cfg, \
"deposit-queue.txt must not be a path that triggers the preview"

pdf = (ROOT / ".github" / "workflows" / "deposit-pdf.yml").read_text(encoding="utf-8")
config = "\n".join(l for l in pdf.splitlines() if not l.lstrip().startswith("#"))
Expand Down Expand Up @@ -1937,3 +1946,48 @@ def test_the_deposit_stops_while_identifiers_are_unrecorded():
first_live = config.index("--live")
assert guard < first_live, \
"the guard must come before the first step that can deposit"


def test_a_pull_request_gets_its_preview_link_even_if_opened_later():
"""preview.yml comments on PUSH, and only if a PR already exists.

Push the branch, open the pull request afterwards -- the natural order --
and the comment step finds no PR and exits. The preview is built and
serving, but nothing links to it and the directory is a hash of the branch
name, so it cannot be found. That happened to UWTN 2026-012.

Also guards the dedupe prefix. The linked variant of the body starts
"**Preview" with no colon, so a rule matching "**Preview:**" never finds
it: PR #7 collected thirteen preview comments before this was noticed.
"""
link = (ROOT / ".github" / "workflows" / "preview-link.yml").read_text(encoding="utf-8")
config = "\n".join(l for l in link.splitlines() if not l.lstrip().startswith("#"))
assert "types: [opened, reopened]" in config, "it has to fire when the PR appears"
assert "pull_request.head.ref" in config, \
"GITHUB_REF_NAME is '<n>/merge' here; the preview path keys on the branch"
assert "preview_mark" in config, "import the path, do not reimplement it"
assert "myst build" not in config and "preview_build" not in config, \
"this comments on an existing preview; it must not rebuild one"

for name in ("preview.yml", "preview-link.yml"):
text = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8")
assert 'startswith("**Preview")' in text, \
"%s dedupe must match the linked variant, which has no colon" % name


def test_the_preview_only_runs_when_there_is_something_to_preview():
"""Six minutes to republish an unchanged site helps nobody.

A branch that touches only workflows or tests has nothing to render, and
the preview used to build for it anyway. This is safe as a paths filter
ONLY because `preview` is not a required status check -- a required check
skipped by a paths filter stays "expected" and blocks the merge forever
instead of passing. If preview is ever made required, the filter has to go.
"""
text = (ROOT / ".github" / "workflows" / "preview.yml").read_text(encoding="utf-8")
config = "\n".join(l for l in text.splitlines() if not l.lstrip().startswith("#"))
assert "paths:" in config, "the preview should not build for unrenderable changes"
for needed in ("'articles/**'", "'scripts/**'", "'myst.yml'"):
assert needed in config, (
"%s changes what the site looks like; leaving it out means no "
"preview when one is wanted" % needed)
Loading