diff --git a/.github/workflows/preview-link.yml b/.github/workflows/preview-link.yml new file mode 100644 index 0000000..13e9256 --- /dev/null +++ b/.github/workflows/preview-link.yml @@ -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 + # "/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 diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index ecf3f0e..43af409 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -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: @@ -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 diff --git a/tests/test_migration.py b/tests/test_migration.py index 354de78..7e40f1b 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -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("#")) @@ -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 '/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)