From e7ebd18d2186e30476fb3f22866c765fb1ee638a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:01:05 +0000 Subject: [PATCH 1/4] Add GitHub Pages previews of decks changed in pull requests Following dandi/usage-page's preview.yml/preview-deploy.yml split: a read-only `pull_request` build and a `workflow_run` deploy to the gh-pages branch via rossjrw/pr-preview-action, with a sticky comment linking each deck. Only top-level *.html decks added/changed by the PR are previewed. tools/build_preview.py follows their local references (reveal.js, css/, pics/, ...) and copies just those files, fetching git-annex content from datasets.datalad.org first; content not available there is listed in the build summary rather than failing the build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WaJ8d7w97BG5hbJVRr9B3M --- .github/workflows/preview-deploy.yml | 160 +++++++++++++++++++++++++++ .github/workflows/preview.yml | 110 ++++++++++++++++++ README.md | 6 + tools/build_preview.py | 148 +++++++++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 .github/workflows/preview-deploy.yml create mode 100644 .github/workflows/preview.yml create mode 100755 tools/build_preview.py diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 00000000..0b2887e1 --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,160 @@ +name: Preview deploy + +# Publishes the decks built by `preview.yml` under `pr-preview/pr-/` on the +# `gh-pages` branch. A pull request from a fork only gets a read-only token, +# so the build cannot push to `gh-pages` itself; `workflow_run` always runs +# this file as it is on the default branch, with write access. +# +# Nothing from the pull request is executed here: its build is copied to +# `gh-pages` as files, and the pull request number and deck names it reports +# are validated (the number against the API) before they are used. + +on: + workflow_run: + workflows: + - Preview + types: + - completed + +# Only one run per pull request branch waits here, so a newer build supersedes +# an older one still queued. Pushes from different branches to `gh-pages` are +# rebased onto each other by the deploy step rather than force-pushed. +concurrency: + group: preview-deploy-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + deploy-preview: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + actions: read + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Download pull request record + uses: actions/download-artifact@v8 + with: + name: preview-meta + path: ${{ runner.temp }}/preview-meta + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify pull request + id: pr + env: + GH_TOKEN: ${{ github.token }} + META_DIR: ${{ runner.temp }}/preview-meta + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} + run: | + PR_NUMBER=$(head -c 16 "$META_DIR/pr-number" | tr -d '[:space:]') + PREVIEW_ACTION=$(head -c 16 "$META_DIR/action" | tr -d '[:space:]') + if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid pull request number." + exit 1 + fi + if [[ "$PREVIEW_ACTION" != deploy && "$PREVIEW_ACTION" != remove && "$PREVIEW_ACTION" != skip ]]; then + echo "::error::Invalid preview action." + exit 1 + fi + if [[ "$PREVIEW_ACTION" == skip ]]; then + echo "Pull request #$PR_NUMBER changes no decks; nothing to preview." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # The record comes from the pull request's own run, so make sure it + # names the pull request that run was for. A mismatch is also what + # an outdated run looks like once a newer commit has been pushed; + # that run's own deploy will publish the newer build instead. + PR_HEAD=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.head.sha + " " + .head.repo.full_name') + if [[ "$PR_HEAD" != "$HEAD_SHA $HEAD_REPO" ]]; then + echo "Pull request #$PR_NUMBER is no longer at $HEAD_SHA from $HEAD_REPO; skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Most pull requests change no decks: closing them has no preview + # to remove, and should not create commits on `gh-pages`. + if [[ "$PREVIEW_ACTION" == remove ]] \ + && ! gh api --silent "repos/$GITHUB_REPOSITORY/contents/pr-preview/pr-$PR_NUMBER?ref=gh-pages" 2>/dev/null; then + echo "Pull request #$PR_NUMBER has no preview to remove." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "$PREVIEW_ACTION" == deploy ]]; then + # Deck names end up in a comment: accept plain file names only. + head -n 100 "$META_DIR/decks" | grep -E '^[A-Za-z0-9][A-Za-z0-9._+-]*\.html$' > "$RUNNER_TEMP/decks" || true + if ! [ -s "$RUNNER_TEMP/decks" ]; then + echo "::error::No valid deck names." + exit 1 + fi + fi + + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "action=$PREVIEW_ACTION" >> "$GITHUB_OUTPUT" + + - name: Download site + if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'deploy' + uses: actions/download-artifact@v8 + with: + name: preview-site + path: preview-site + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Deploy preview + id: preview + if: steps.pr.outputs.skip != 'true' + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: preview-site + preview-branch: gh-pages + action: ${{ steps.pr.outputs.action }} + pr-number: ${{ steps.pr.outputs.number }} + deploy-commit-message: Deploy preview for PR ${{ steps.pr.outputs.number }} + remove-commit-message: Remove preview for PR ${{ steps.pr.outputs.number }} + # The action's own comment looks for the pull request in the + # triggering event, which `workflow_run` does not carry. + comment: false + + - name: Compose preview comment + if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'deploy' + env: + PREVIEW_URL: ${{ steps.preview.outputs.preview-url }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + DEPLOYMENTS_URL: ${{ github.server_url }}/${{ github.repository }}/deployments + RUN_URL: ${{ github.event.workflow_run.html_url }} + run: | + { + echo ":rocket: Preview of $HEAD_SHA:" + echo + while read -r deck; do + echo "- [$deck](${PREVIEW_URL%/}/$deck)" + done < "$RUNNER_TEMP/decks" + echo + echo "It will be live once the [GitHub Pages deployment]($DEPLOYMENTS_URL) finishes." + echo "Images whose git-annex content is not on datasets.datalad.org yet are listed in the [build summary]($RUN_URL)." + } > "$RUNNER_TEMP/comment.md" + + - name: Comment preview link + if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'deploy' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: pr-preview + number: ${{ steps.pr.outputs.number }} + path: ${{ runner.temp }}/comment.md + + - name: Comment preview removal + if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'remove' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: pr-preview + number: ${{ steps.pr.outputs.number }} + message: | + Preview removed because the pull request was closed. diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..e9fde3f0 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,110 @@ +name: Preview + +# Builds a preview of only the decks (top-level *.html) a pull request adds or +# changes, together with just the files they use (reveal.js, css/, pics/...), +# with git-annex'ed content fetched from datasets.datalad.org. +# +# Runs with a read-only token, so it is safe for pull requests from forks. +# The build is handed to `preview-deploy.yml` as an artifact, and that +# workflow — which runs from the default branch with write access — publishes +# it to `gh-pages`. + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + +concurrency: preview-${{ github.ref }} + +env: + ANNEX_REMOTE_URL: https://datasets.datalad.org/centerforopenneuroscience/talks/.git + +jobs: + build-preview: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + if: github.event.action != 'closed' + uses: actions/checkout@v7 + with: + # full history for git-annex; HEAD is the merge of the pull request + # into its base, so HEAD^1 is the base + fetch-depth: 0 + + - name: Find changed decks + id: decks + if: github.event.action != 'closed' + run: | + # top-level *.html only: decks live at the repository root + git diff --name-only --diff-filter=AMR HEAD^1 HEAD -- '*.html' \ + | grep -E '^[^/]+\.html$' > "$RUNNER_TEMP/decks" || true + echo "Decks to preview:" + cat "$RUNNER_TEMP/decks" + if [ -s "$RUNNER_TEMP/decks" ]; then + echo "found=true" >> "$GITHUB_OUTPUT" + fi + + - name: Get reveal.js + if: steps.decks.outputs.found == 'true' + # not `submodules: true`: 2022-nih-compcore has a relative URL that + # only resolves on datasets.datalad.org + run: git submodule update --init --depth 1 reveal.js reveal.js-mermaid-plugin + + - name: Set up git-annex + if: steps.decks.outputs.found == 'true' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq git-annex + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch --no-tags origin '+refs/heads/git-annex:refs/remotes/origin/git-annex' + git remote add --fetch datasets.datalad.org "$ANNEX_REMOTE_URL" + git annex init --quiet + + - name: Build site + if: steps.decks.outputs.found == 'true' + run: | + mapfile -t DECKS < "$RUNNER_TEMP/decks" + python3 tools/build_preview.py preview-site "${DECKS[@]}" | tee "$RUNNER_TEMP/build.log" + if grep -q '^MISSING: ' "$RUNNER_TEMP/build.log"; then + { + echo "### Files not available for the preview" + echo + echo "Not on any reachable annex remote yet (e.g. not pushed to datasets.datalad.org), or referenced but nonexistent:" + echo + sed -n 's/^MISSING: \(.*\)/- `\1`/p' "$RUNNER_TEMP/build.log" + } >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload site + if: steps.decks.outputs.found == 'true' + uses: actions/upload-artifact@v7 + with: + name: preview-site + path: preview-site/ + include-hidden-files: true + retention-days: 1 + + - name: Record pull request + env: + PR_NUMBER: ${{ github.event.number }} + PREVIEW_ACTION: ${{ github.event.action == 'closed' && 'remove' || (steps.decks.outputs.found == 'true' && 'deploy' || 'skip') }} + run: | + mkdir -p preview-meta + echo "$PR_NUMBER" > preview-meta/pr-number + echo "$PREVIEW_ACTION" > preview-meta/action + if [ "$PREVIEW_ACTION" = deploy ]; then + cp "$RUNNER_TEMP/decks" preview-meta/decks + fi + + - name: Upload pull request record + uses: actions/upload-artifact@v7 + with: + name: preview-meta + path: preview-meta/ + retention-days: 1 diff --git a/README.md b/README.md index d2097f4b..4316782f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ git remote add --fetch datasets.datalad.org https://datasets.datalad.org/centerf to get access to the files stored on this remote (I didn't bother adding it as auto-enabling git-annex type=git remote yet). +## Pull request previews + +For pull requests that add or change a deck (top-level `*.html`), GitHub Actions publishes a preview of just those decks, with only the files they use, to `pr-preview/pr-/` on the `gh-pages` branch and links them in a PR comment. +git-annex'ed content is fetched from datasets.datalad.org, so `datalad push` new images there to see them in the preview. +To build the same locally: `tools/build_preview.py .html ...`. + ## Advice for creating presentations - ``clone`` the repository to your local computer and ``datalad get`` all subdatasets (``datalad get -n -r .``). diff --git a/tools/build_preview.py b/tools/build_preview.py new file mode 100755 index 00000000..c79fb212 --- /dev/null +++ b/tools/build_preview.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Assemble a minimal static site with only the given decks and what they use. + +Usage: tools/build_preview.py OUTDIR DECK.html [DECK.html ...] + +Starting from the decks, follows local references (src/href/data-src/ +data-background*/data-markdown attributes, markdown links and CSS url()) +through HTML, CSS and Markdown files, and copies every referenced file into +OUTDIR under its repository path. Files whose git-annex content is not +present are fetched with `git annex get` first (when git-annex is +available). Files that could not be obtained are reported, not fatal, so a +deck still previews with whatever content is available. + +Used by .github/workflows/preview.yml, but also works locally. +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from urllib.parse import unquote, urlsplit + +REPO = Path(__file__).resolve().parent.parent + +# Files that are scanned for further references +SCANNED_SUFFIXES = {".html", ".htm", ".css", ".md"} + +# Each pattern captures (kind, quote, reference) +REF_PATTERNS = [ + # HTML attributes, e.g. src="..", data-src='..', data-background-image=".." + re.compile( + r"""\b(src|href|data-src|poster|data-markdown|data-background(?:-image|-video|-iframe)?)""" + r"""\s*=\s*(["'])(.+?)\2""", + re.I, + ), + # markdown images: ![alt](target "title") + re.compile(r"""!\[[^\]]*\]\(\s*]+)"""), + # CSS url(...) + re.compile(r"""url\(\s*()(["']?)([^"')]+)\2\s*\)""", re.I), +] + +# href is mostly used for hyperlinks (to other decks, PDFs, ...), which are +# not needed to render the deck; follow it only for stylesheets. +HREF_FOLLOWED_SUFFIXES = {".css"} + + +def local_target(ref, referrer): + """Return the repository-relative path `ref` points to, or None.""" + ref = ref.strip() + if not ref or ref.startswith(("#", "/", "data:", "{", "$")): + return None + parts = urlsplit(ref) + if parts.scheme or parts.netloc or not parts.path: + return None + target = (REPO / referrer).parent / unquote(parts.path) + target = Path(os.path.normpath(target)) + try: + rel = target.relative_to(REPO) + except ValueError: + return None # points outside of the repository + if rel.parts[0] == ".git": + return None + return rel + + +def refs_in(path): + try: + text = (REPO / path).read_text(encoding="utf-8", errors="replace") + except OSError: + return + for pattern in REF_PATTERNS: + for m in pattern.finditer(text): + kind, _, ref = m.groups() + target = local_target(ref, path) + if target is None: + continue + if kind.lower() == "href" and target.suffix.lower() not in HREF_FOLLOWED_SUFFIXES: + continue + yield target + + +def collect(decks): + """Walk references starting from the decks; return the set of files.""" + files, seen = set(), set() + queue = list(decks) + while queue: + rel = queue.pop() + if rel in seen: + continue + seen.add(rel) + full = REPO / rel + # an annexed file without content is a broken symlink: keep it so its + # content can be fetched, but there is nothing to scan in it yet + if full.is_dir() and not full.is_symlink(): + continue + if not os.path.lexists(full): + files.add(rel) # reported as missing later + continue + files.add(rel) + if rel.suffix.lower() in SCANNED_SUFFIXES: + queue.extend(refs_in(rel)) + return files + + +def annex_get(paths): + if not paths or not shutil.which("git-annex"): + return + # one call for all; failures for individual files are reported afterwards + subprocess.run( + ["git", "annex", "get", "--"] + [str(p) for p in sorted(paths)], + cwd=REPO, + check=False, + ) + + +def main(outdir, decks): + outdir = Path(outdir).resolve() + decks = [Path(d) for d in decks] + files = collect(decks) + + annex_get([f for f in files if (REPO / f).is_symlink() and not (REPO / f).exists()]) + + copied, missing = [], [] + for rel in sorted(files): + src = REPO / rel + if not src.exists(): # nonexistent, or annexed without content + missing.append(rel) + continue + dst = outdir / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) # dereferences annex symlinks + copied.append(rel) + + print(f"Copied {len(copied)} file(s) for {len(decks)} deck(s) into {outdir}") + for rel in missing: + print(f"MISSING: {rel}") + return missing + + +if __name__ == "__main__": + if len(sys.argv) < 3: + sys.exit(__doc__) + missing = main(sys.argv[1], sys.argv[2:]) + if os.environ.get("GITHUB_ACTIONS"): + for rel in missing: + print(f"::warning file={rel}::Not available for the preview: {rel}") From 76a5cf2480011ec8c2fe23610416991947064fd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:09:44 +0000 Subject: [PATCH 2/4] Make PR previews manual: workflow_dispatch with a PR number Previews are now built only on request (Actions -> Preview -> Run workflow), with `deploy` or `remove`. Since nothing runs automatically on pull_request anymore, the workflow_run split is replaced by two jobs in one workflow: a read-only build of the PR's merge ref and a deploy job with write access that only receives the built files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WaJ8d7w97BG5hbJVRr9B3M --- .github/workflows/preview-deploy.yml | 160 -------------------------- .github/workflows/preview.yml | 161 ++++++++++++++++++++------- README.md | 3 +- 3 files changed, 122 insertions(+), 202 deletions(-) delete mode 100644 .github/workflows/preview-deploy.yml diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml deleted file mode 100644 index 0b2887e1..00000000 --- a/.github/workflows/preview-deploy.yml +++ /dev/null @@ -1,160 +0,0 @@ -name: Preview deploy - -# Publishes the decks built by `preview.yml` under `pr-preview/pr-/` on the -# `gh-pages` branch. A pull request from a fork only gets a read-only token, -# so the build cannot push to `gh-pages` itself; `workflow_run` always runs -# this file as it is on the default branch, with write access. -# -# Nothing from the pull request is executed here: its build is copied to -# `gh-pages` as files, and the pull request number and deck names it reports -# are validated (the number against the API) before they are used. - -on: - workflow_run: - workflows: - - Preview - types: - - completed - -# Only one run per pull request branch waits here, so a newer build supersedes -# an older one still queued. Pushes from different branches to `gh-pages` are -# rebased onto each other by the deploy step rather than force-pushed. -concurrency: - group: preview-deploy-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: false - -jobs: - deploy-preview: - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - permissions: - actions: read - contents: write - pull-requests: write - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Download pull request record - uses: actions/download-artifact@v8 - with: - name: preview-meta - path: ${{ runner.temp }}/preview-meta - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Verify pull request - id: pr - env: - GH_TOKEN: ${{ github.token }} - META_DIR: ${{ runner.temp }}/preview-meta - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} - run: | - PR_NUMBER=$(head -c 16 "$META_DIR/pr-number" | tr -d '[:space:]') - PREVIEW_ACTION=$(head -c 16 "$META_DIR/action" | tr -d '[:space:]') - if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then - echo "::error::Invalid pull request number." - exit 1 - fi - if [[ "$PREVIEW_ACTION" != deploy && "$PREVIEW_ACTION" != remove && "$PREVIEW_ACTION" != skip ]]; then - echo "::error::Invalid preview action." - exit 1 - fi - if [[ "$PREVIEW_ACTION" == skip ]]; then - echo "Pull request #$PR_NUMBER changes no decks; nothing to preview." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # The record comes from the pull request's own run, so make sure it - # names the pull request that run was for. A mismatch is also what - # an outdated run looks like once a newer commit has been pushed; - # that run's own deploy will publish the newer build instead. - PR_HEAD=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.head.sha + " " + .head.repo.full_name') - if [[ "$PR_HEAD" != "$HEAD_SHA $HEAD_REPO" ]]; then - echo "Pull request #$PR_NUMBER is no longer at $HEAD_SHA from $HEAD_REPO; skipping." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Most pull requests change no decks: closing them has no preview - # to remove, and should not create commits on `gh-pages`. - if [[ "$PREVIEW_ACTION" == remove ]] \ - && ! gh api --silent "repos/$GITHUB_REPOSITORY/contents/pr-preview/pr-$PR_NUMBER?ref=gh-pages" 2>/dev/null; then - echo "Pull request #$PR_NUMBER has no preview to remove." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [[ "$PREVIEW_ACTION" == deploy ]]; then - # Deck names end up in a comment: accept plain file names only. - head -n 100 "$META_DIR/decks" | grep -E '^[A-Za-z0-9][A-Za-z0-9._+-]*\.html$' > "$RUNNER_TEMP/decks" || true - if ! [ -s "$RUNNER_TEMP/decks" ]; then - echo "::error::No valid deck names." - exit 1 - fi - fi - - echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - echo "action=$PREVIEW_ACTION" >> "$GITHUB_OUTPUT" - - - name: Download site - if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'deploy' - uses: actions/download-artifact@v8 - with: - name: preview-site - path: preview-site - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Deploy preview - id: preview - if: steps.pr.outputs.skip != 'true' - uses: rossjrw/pr-preview-action@v1 - with: - source-dir: preview-site - preview-branch: gh-pages - action: ${{ steps.pr.outputs.action }} - pr-number: ${{ steps.pr.outputs.number }} - deploy-commit-message: Deploy preview for PR ${{ steps.pr.outputs.number }} - remove-commit-message: Remove preview for PR ${{ steps.pr.outputs.number }} - # The action's own comment looks for the pull request in the - # triggering event, which `workflow_run` does not carry. - comment: false - - - name: Compose preview comment - if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'deploy' - env: - PREVIEW_URL: ${{ steps.preview.outputs.preview-url }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - DEPLOYMENTS_URL: ${{ github.server_url }}/${{ github.repository }}/deployments - RUN_URL: ${{ github.event.workflow_run.html_url }} - run: | - { - echo ":rocket: Preview of $HEAD_SHA:" - echo - while read -r deck; do - echo "- [$deck](${PREVIEW_URL%/}/$deck)" - done < "$RUNNER_TEMP/decks" - echo - echo "It will be live once the [GitHub Pages deployment]($DEPLOYMENTS_URL) finishes." - echo "Images whose git-annex content is not on datasets.datalad.org yet are listed in the [build summary]($RUN_URL)." - } > "$RUNNER_TEMP/comment.md" - - - name: Comment preview link - if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'deploy' - uses: marocchino/sticky-pull-request-comment@v3 - with: - header: pr-preview - number: ${{ steps.pr.outputs.number }} - path: ${{ runner.temp }}/comment.md - - - name: Comment preview removal - if: steps.pr.outputs.skip != 'true' && steps.pr.outputs.action == 'remove' - uses: marocchino/sticky-pull-request-comment@v3 - with: - header: pr-preview - number: ${{ steps.pr.outputs.number }} - message: | - Preview removed because the pull request was closed. diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index e9fde3f0..f3b0f227 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -1,62 +1,89 @@ name: Preview -# Builds a preview of only the decks (top-level *.html) a pull request adds or +# Run manually (Actions -> Preview -> Run workflow) with a pull request +# number to publish, under `pr-preview/pr-/` on the `gh-pages` branch, a +# preview of only the decks (top-level *.html) that pull request adds or # changes, together with just the files they use (reveal.js, css/, pics/...), # with git-annex'ed content fetched from datasets.datalad.org. +# Run it with `remove` to delete that preview again. # -# Runs with a read-only token, so it is safe for pull requests from forks. -# The build is handed to `preview-deploy.yml` as an artifact, and that -# workflow — which runs from the default branch with write access — publishes -# it to `gh-pages`. +# The pull request's content is built with a read-only token; only the +# resulting files are handed to the deploy job, which has write access. on: - pull_request: - types: - - opened - - reopened - - synchronize - - closed + workflow_dispatch: + inputs: + pr: + description: Pull request number + required: true + type: number + action: + description: Deploy or remove the preview + required: true + type: choice + options: + - deploy + - remove + default: deploy -concurrency: preview-${{ github.ref }} +concurrency: + group: preview-${{ inputs.pr }} + cancel-in-progress: false env: ANNEX_REMOTE_URL: https://datasets.datalad.org/centerforopenneuroscience/talks/.git jobs: build-preview: + if: inputs.action == 'deploy' runs-on: ubuntu-latest permissions: contents: read + pull-requests: read + outputs: + decks: ${{ steps.decks.outputs.decks }} + head-sha: ${{ steps.pr.outputs.head-sha }} steps: + - name: Look up pull request + id: pr + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr }} + run: | + read -r STATE HEAD_SHA < <(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.state + " " + .head.sha') + if [[ "$STATE" != open ]]; then + echo "::error::Pull request #$PR_NUMBER is $STATE." + exit 1 + fi + echo "head-sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" + - name: Checkout - if: github.event.action != 'closed' uses: actions/checkout@v7 with: - # full history for git-annex; HEAD is the merge of the pull request - # into its base, so HEAD^1 is the base + # the merge of the pull request into its base, so HEAD^1 is the + # base; full history for git-annex + ref: refs/pull/${{ inputs.pr }}/merge fetch-depth: 0 - name: Find changed decks id: decks - if: github.event.action != 'closed' run: | # top-level *.html only: decks live at the repository root - git diff --name-only --diff-filter=AMR HEAD^1 HEAD -- '*.html' \ - | grep -E '^[^/]+\.html$' > "$RUNNER_TEMP/decks" || true - echo "Decks to preview:" - cat "$RUNNER_TEMP/decks" - if [ -s "$RUNNER_TEMP/decks" ]; then - echo "found=true" >> "$GITHUB_OUTPUT" + DECKS=$(git diff --name-only --diff-filter=AMR HEAD^1 HEAD -- '*.html' \ + | grep -E '^[A-Za-z0-9][A-Za-z0-9._+-]*\.html$' | tr '\n' ' ' || true) + if [ -z "$DECKS" ]; then + echo "::error::Pull request #${{ inputs.pr }} changes no decks; nothing to preview." + exit 1 fi + echo "Decks to preview: $DECKS" + echo "decks=$DECKS" >> "$GITHUB_OUTPUT" - name: Get reveal.js - if: steps.decks.outputs.found == 'true' # not `submodules: true`: 2022-nih-compcore has a relative URL that # only resolves on datasets.datalad.org run: git submodule update --init --depth 1 reveal.js reveal.js-mermaid-plugin - name: Set up git-annex - if: steps.decks.outputs.found == 'true' run: | sudo apt-get update -qq sudo apt-get install -y -qq git-annex @@ -67,9 +94,10 @@ jobs: git annex init --quiet - name: Build site - if: steps.decks.outputs.found == 'true' + env: + DECKS: ${{ steps.decks.outputs.decks }} run: | - mapfile -t DECKS < "$RUNNER_TEMP/decks" + read -ra DECKS <<< "$DECKS" python3 tools/build_preview.py preview-site "${DECKS[@]}" | tee "$RUNNER_TEMP/build.log" if grep -q '^MISSING: ' "$RUNNER_TEMP/build.log"; then { @@ -82,7 +110,6 @@ jobs: fi - name: Upload site - if: steps.decks.outputs.found == 'true' uses: actions/upload-artifact@v7 with: name: preview-site @@ -90,21 +117,73 @@ jobs: include-hidden-files: true retention-days: 1 - - name: Record pull request + deploy-preview: + needs: build-preview + # also run for `remove`, where the build is skipped + if: ${{ !cancelled() && (inputs.action == 'remove' || needs.build-preview.result == 'success') }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Download site + if: inputs.action == 'deploy' + uses: actions/download-artifact@v8 + with: + name: preview-site + path: preview-site + + - name: Deploy preview + id: preview + uses: rossjrw/pr-preview-action@v1 + with: + source-dir: preview-site + preview-branch: gh-pages + action: ${{ inputs.action }} + pr-number: ${{ inputs.pr }} + deploy-commit-message: Deploy preview for PR ${{ inputs.pr }} + remove-commit-message: Remove preview for PR ${{ inputs.pr }} + # The action's own comment looks for the pull request in the + # triggering event, which `workflow_dispatch` does not carry. + comment: false + + - name: Compose preview comment + if: inputs.action == 'deploy' env: - PR_NUMBER: ${{ github.event.number }} - PREVIEW_ACTION: ${{ github.event.action == 'closed' && 'remove' || (steps.decks.outputs.found == 'true' && 'deploy' || 'skip') }} + PREVIEW_URL: ${{ steps.preview.outputs.preview-url }} + HEAD_SHA: ${{ needs.build-preview.outputs.head-sha }} + DECKS: ${{ needs.build-preview.outputs.decks }} + DEPLOYMENTS_URL: ${{ github.server_url }}/${{ github.repository }}/deployments + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - mkdir -p preview-meta - echo "$PR_NUMBER" > preview-meta/pr-number - echo "$PREVIEW_ACTION" > preview-meta/action - if [ "$PREVIEW_ACTION" = deploy ]; then - cp "$RUNNER_TEMP/decks" preview-meta/decks - fi + { + echo ":rocket: Preview of $HEAD_SHA:" + echo + for deck in $DECKS; do + echo "- [$deck](${PREVIEW_URL%/}/$deck)" + done + echo + echo "It will be live once the [GitHub Pages deployment]($DEPLOYMENTS_URL) finishes." + echo "Images whose git-annex content is not on datasets.datalad.org yet are listed in the [build summary]($RUN_URL)." + echo "Re-run the [Preview workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/preview.yml) to update it." + } > "$RUNNER_TEMP/comment.md" - - name: Upload pull request record - uses: actions/upload-artifact@v7 + - name: Comment preview link + if: inputs.action == 'deploy' + uses: marocchino/sticky-pull-request-comment@v3 with: - name: preview-meta - path: preview-meta/ - retention-days: 1 + header: pr-preview + number: ${{ inputs.pr }} + path: ${{ runner.temp }}/comment.md + + - name: Comment preview removal + if: inputs.action == 'remove' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: pr-preview + number: ${{ inputs.pr }} + message: | + Preview removed. diff --git a/README.md b/README.md index 4316782f..2cbcb168 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ to get access to the files stored on this remote (I didn't bother adding it as a ## Pull request previews -For pull requests that add or change a deck (top-level `*.html`), GitHub Actions publishes a preview of just those decks, with only the files they use, to `pr-preview/pr-/` on the `gh-pages` branch and links them in a PR comment. +To preview the decks (top-level `*.html`) a pull request adds or changes, run the *Preview* workflow (Actions → Preview → Run workflow) with the PR number. +It publishes just those decks, with only the files they use, to `pr-preview/pr-/` on the `gh-pages` branch and links them in a PR comment; run it with `remove` to delete the preview. git-annex'ed content is fetched from datasets.datalad.org, so `datalad push` new images there to see them in the preview. To build the same locally: `tools/build_preview.py .html ...`. From 2b418e5b1dd729007282884167c489f393c9209c Mon Sep 17 00:00:00 2001 From: Cody Baker <51133164+CodyCBakerPhD@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:12:15 -0400 Subject: [PATCH 3/4] Apply suggestion from @CodyCBakerPhD --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index 2cbcb168..d2097f4b 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,6 @@ git remote add --fetch datasets.datalad.org https://datasets.datalad.org/centerf to get access to the files stored on this remote (I didn't bother adding it as auto-enabling git-annex type=git remote yet). -## Pull request previews - -To preview the decks (top-level `*.html`) a pull request adds or changes, run the *Preview* workflow (Actions → Preview → Run workflow) with the PR number. -It publishes just those decks, with only the files they use, to `pr-preview/pr-/` on the `gh-pages` branch and links them in a PR comment; run it with `remove` to delete the preview. -git-annex'ed content is fetched from datasets.datalad.org, so `datalad push` new images there to see them in the preview. -To build the same locally: `tools/build_preview.py .html ...`. - ## Advice for creating presentations - ``clone`` the repository to your local computer and ``datalad get`` all subdatasets (``datalad get -n -r .``). From af392169ce9cd5099043cf51f205273b3b02fc7e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 20:47:35 +0000 Subject: [PATCH 4/4] Preview: run the builder from the workflow's commit, fail loudly The first run built PR #26, whose checkout has no tools/build_preview.py (it only exists on this branch), so the build step had nothing to run. That went unnoticed because `python | tee` without pipefail succeeds, and upload-artifact only warned about the empty directory; the deploy job then failed to find the artifact. - Take tools/build_preview.py from $GITHUB_SHA (the workflow's own ref), so any PR can be previewed, and run it against the PR checkout (the script now builds from the current directory). - set -o pipefail in the build step; if-no-files-found: error on upload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WaJ8d7w97BG5hbJVRr9B3M --- .github/workflows/preview.yml | 8 +++++++- tools/build_preview.py | 6 ++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index f3b0f227..85e02cd2 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -97,8 +97,13 @@ jobs: env: DECKS: ${{ steps.decks.outputs.decks }} run: | + set -o pipefail + # the builder comes from this workflow's own commit: the pull + # request being previewed need not (and usually does not) have it + git fetch --no-tags --quiet origin "$GITHUB_SHA" + git show "$GITHUB_SHA:tools/build_preview.py" > "$RUNNER_TEMP/build_preview.py" read -ra DECKS <<< "$DECKS" - python3 tools/build_preview.py preview-site "${DECKS[@]}" | tee "$RUNNER_TEMP/build.log" + python3 "$RUNNER_TEMP/build_preview.py" preview-site "${DECKS[@]}" | tee "$RUNNER_TEMP/build.log" if grep -q '^MISSING: ' "$RUNNER_TEMP/build.log"; then { echo "### Files not available for the preview" @@ -114,6 +119,7 @@ jobs: with: name: preview-site path: preview-site/ + if-no-files-found: error include-hidden-files: true retention-days: 1 diff --git a/tools/build_preview.py b/tools/build_preview.py index c79fb212..cd07fb54 100755 --- a/tools/build_preview.py +++ b/tools/build_preview.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Assemble a minimal static site with only the given decks and what they use. -Usage: tools/build_preview.py OUTDIR DECK.html [DECK.html ...] +Usage (from the repository root): tools/build_preview.py OUTDIR DECK.html [DECK.html ...] Starting from the decks, follows local references (src/href/data-src/ data-background*/data-markdown attributes, markdown links and CSS url()) @@ -22,7 +22,9 @@ from pathlib import Path from urllib.parse import unquote, urlsplit -REPO = Path(__file__).resolve().parent.parent +# The repository to build from is the current directory, not the one this +# script lives in: the Preview workflow runs it on another checkout. +REPO = Path.cwd() # Files that are scanned for further references SCANNED_SUFFIXES = {".html", ".htm", ".css", ".md"}