preview-publish #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: preview-publish | |
| on: | |
| workflow_run: | |
| workflows: ["preview-build"] | |
| types: | |
| - completed | |
| # Runs in trusted context (base repo) and consumes the artifact produced by | |
| # preview-build.yml. Holds the write permissions needed to push the preview | |
| # branch and edit the PR comment. | |
| permissions: | |
| contents: write | |
| # `pull-requests: write` is required (despite PR comments using the issues | |
| # API endpoint) because GitHub Actions enforces PR-targeted writes against | |
| # the `pull-requests` scope. Without this, peter-evans/create-or-update-comment | |
| # silently no-ops on PRs. | |
| pull-requests: write | |
| issues: write | |
| actions: read | |
| jobs: | |
| publish: | |
| name: Publish preview & comment | |
| if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| # Resolve PR number and head SHA from the trusted workflow_run event payload | |
| # rather than the artifact, which is produced by untrusted PR code. | |
| # workflow_run.pull_requests is empty for fork PRs, so fall back to the | |
| # commits-to-PRs API on head_sha. | |
| - name: Resolve trusted PR metadata | |
| id: pr | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const headSha = context.payload.workflow_run.head_sha; | |
| let prNumber = context.payload.workflow_run.pull_requests?.[0]?.number; | |
| if (!prNumber) { | |
| const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| commit_sha: headSha, | |
| }); | |
| const match = prs.find(p => p.head.sha === headSha && p.state === 'open'); | |
| if (!match) { | |
| core.setFailed(`No open PR found for head SHA ${headSha}`); | |
| return; | |
| } | |
| prNumber = match.number; | |
| } | |
| if (!Number.isInteger(prNumber) || prNumber <= 0) { | |
| core.setFailed(`Invalid PR number resolved: ${prNumber}`); | |
| return; | |
| } | |
| if (!/^[0-9a-f]{40}$/.test(headSha)) { | |
| core.setFailed(`Invalid head SHA from event payload: ${headSha}`); | |
| return; | |
| } | |
| // Guard against the close/build race: if the PR was closed while | |
| // preview-build was running, preview-cleanup may have already deleted | |
| // the preview branch. Re-creating it here would orphan a branch with | |
| // no further cleanup trigger. | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| if (pr.state !== 'open') { | |
| core.notice(`PR #${prNumber} is ${pr.state}; skipping preview publish.`); | |
| core.setOutput('skip', 'true'); | |
| return; | |
| } | |
| core.setOutput('skip', 'false'); | |
| core.setOutput('number', String(prNumber)); | |
| core.setOutput('head_sha', headSha); | |
| - name: Download preview artifact | |
| if: steps.pr.outputs.skip != 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: preview-dist | |
| path: preview-dist | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| run-id: ${{ github.event.workflow_run.id }} | |
| # The artifact comes from untrusted PR code. `cp -r` follows symlinks by | |
| # default, so a symlink in the artifact tree could exfiltrate runner-local | |
| # files into the preview branch (which jsDelivr will publish). Refuse any | |
| # symlink rather than trying to handle it safely. | |
| - name: Reject symlinks in artifact | |
| if: steps.pr.outputs.skip != 'true' | |
| run: | | |
| set -euo pipefail | |
| if find preview-dist -type l -print -quit | grep -q .; then | |
| echo "Refusing artifact: contains symlinks" >&2 | |
| find preview-dist -type l >&2 | |
| exit 1 | |
| fi | |
| # Validate the artifact's self-reported metadata against the trusted values | |
| # before using anything from it. The `changed` list is sanitized to | |
| # `[A-Za-z0-9_-]+` entries so it can't be used for path traversal or shell | |
| # injection downstream. | |
| - name: Validate artifact metadata | |
| if: steps.pr.outputs.skip != 'true' | |
| id: meta | |
| env: | |
| TRUSTED_PR_NUMBER: ${{ steps.pr.outputs.number }} | |
| TRUSTED_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| artifact_pr_number=$(jq -r '.pr_number' preview-dist/metadata.json) | |
| artifact_head_sha=$(jq -r '.head_sha' preview-dist/metadata.json) | |
| if [ "$artifact_pr_number" != "$TRUSTED_PR_NUMBER" ]; then | |
| echo "Artifact PR number ($artifact_pr_number) does not match trusted ($TRUSTED_PR_NUMBER)" >&2 | |
| exit 1 | |
| fi | |
| if [ "$artifact_head_sha" != "$TRUSTED_HEAD_SHA" ]; then | |
| echo "Artifact head SHA ($artifact_head_sha) does not match trusted ($TRUSTED_HEAD_SHA)" >&2 | |
| exit 1 | |
| fi | |
| changed=$(jq -c '[.changed_packages[]? | select(type == "string" and test("^[A-Za-z0-9_-]+$"))]' preview-dist/metadata.json) | |
| echo "pr_number=$TRUSTED_PR_NUMBER" >> "$GITHUB_OUTPUT" | |
| echo "head_sha=$TRUSTED_HEAD_SHA" >> "$GITHUB_OUTPUT" | |
| echo "changed=$changed" >> "$GITHUB_OUTPUT" | |
| - name: Push preview branch | |
| if: steps.pr.outputs.skip != 'true' | |
| id: push | |
| env: | |
| PR_NUMBER: ${{ steps.meta.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.meta.outputs.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| branch="preview/pr-${PR_NUMBER}" | |
| # Move artifact contents outside the worktree before we wipe it. | |
| mv preview-dist /tmp/preview-dist | |
| git config user.name "jspsych-preview-bot" | |
| git config user.email "[email protected]" | |
| # Force-push a fresh orphan branch each run so history stays shallow. | |
| git checkout --orphan "$branch" | |
| git rm -rf . > /dev/null 2>&1 || true | |
| # Defensive cleanup of any untracked leftovers. | |
| find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + | |
| cp -r /tmp/preview-dist/packages . | |
| cp /tmp/preview-dist/metadata.json . | |
| git add packages metadata.json | |
| git commit -m "Preview build for PR #${PR_NUMBER} @ ${HEAD_SHA}" | |
| git push --force origin "$branch" | |
| preview_sha=$(git rev-parse HEAD) | |
| echo "preview_sha=$preview_sha" >> "$GITHUB_OUTPUT" | |
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | |
| - name: Build comment body | |
| if: steps.pr.outputs.skip != 'true' | |
| env: | |
| PREVIEW_SHA: ${{ steps.push.outputs.preview_sha }} | |
| PREVIEW_BRANCH: ${{ steps.push.outputs.branch }} | |
| PR_HEAD_SHA: ${{ steps.meta.outputs.head_sha }} | |
| PR_NUMBER: ${{ steps.meta.outputs.pr_number }} | |
| CHANGED: ${{ steps.meta.outputs.changed }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| short="${PREVIEW_SHA:0:7}" | |
| head_short="${PR_HEAD_SHA:0:7}" | |
| base="https://cdn.jsdelivr.net/gh/${REPO}@${PREVIEW_SHA}" | |
| src_dir="/tmp/preview-dist" | |
| # Per-package URLs sourced from what the build actually produced. | |
| # Skip any directory whose name doesn't match a known-safe pattern, since | |
| # the artifact ultimately comes from untrusted PR code. | |
| all_links="" | |
| for pkg_dir in "$src_dir"/packages/*/; do | |
| name=$(basename "$pkg_dir") | |
| if ! [[ "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then | |
| continue | |
| fi | |
| pkg_name=$(jq -r '.name // empty' "${pkg_dir}package.json" 2>/dev/null || echo "") | |
| if ! [[ "$pkg_name" =~ ^@?[A-Za-z0-9_/-]+$ ]]; then | |
| pkg_name="$name" | |
| fi | |
| if [ -f "${pkg_dir}dist/index.browser.min.js" ]; then | |
| all_links+="- \`${pkg_name}\` → \`${base}/packages/${name}/dist/index.browser.min.js\`"$'\n' | |
| fi | |
| done | |
| # Quick-start: jspsych core + a sensible plugin (changed plugin if any, else html-keyboard-response). | |
| quickstart_plugin="" | |
| for pkg in $(echo "$CHANGED" | jq -r '.[]?'); do | |
| case "$pkg" in | |
| plugin-*) | |
| if [ -f "${src_dir}/packages/${pkg}/dist/index.browser.min.js" ]; then | |
| quickstart_plugin="$pkg" | |
| break | |
| fi | |
| ;; | |
| esac | |
| done | |
| if [ -z "$quickstart_plugin" ] && [ -f "${src_dir}/packages/plugin-html-keyboard-response/dist/index.browser.min.js" ]; then | |
| quickstart_plugin="plugin-html-keyboard-response" | |
| fi | |
| { | |
| echo '<script src="'"${base}"'/packages/jspsych/dist/index.browser.min.js"></script>' | |
| echo '<link rel="stylesheet" href="'"${base}"'/packages/jspsych/css/jspsych.css">' | |
| if [ -n "$quickstart_plugin" ]; then | |
| echo '<script src="'"${base}"'/packages/'"${quickstart_plugin}"'/dist/index.browser.min.js"></script>' | |
| fi | |
| } > /tmp/quickstart.html | |
| changed_md=$(echo "$CHANGED" | jq -r 'if (. // [] | length) == 0 then "_none detected_" else map("`" + . + "`") | join(", ") end') | |
| timestamp=$(date -u +'%Y-%m-%d %H:%M UTC') | |
| { | |
| echo '<!-- jspsych-preview-bot -->' | |
| echo '### 📦 Preview build ready' | |
| echo '' | |
| echo "Built from PR head \`${head_short}\` and published at \`${short}\` on branch [\`${PREVIEW_BRANCH}\`](https://github.com/${REPO}/tree/${PREVIEW_BRANCH})." | |
| echo 'URLs below are pinned to an immutable commit SHA, so they are safe to share and are cached permanently by jsDelivr.' | |
| echo '' | |
| echo "**Changed packages:** ${changed_md}" | |
| echo '' | |
| echo '**Quick-start HTML:**' | |
| echo '' | |
| echo '```html' | |
| cat /tmp/quickstart.html | |
| echo '```' | |
| echo '' | |
| echo '<details><summary>All package URLs</summary>' | |
| echo '' | |
| echo "$all_links" | |
| echo '</details>' | |
| echo '' | |
| echo "_Last updated ${timestamp} for PR head \`${head_short}\`._" | |
| } > comment.md | |
| - name: Find existing preview comment | |
| if: steps.pr.outputs.skip != 'true' | |
| id: find | |
| uses: peter-evans/find-comment@v3 | |
| with: | |
| issue-number: ${{ steps.meta.outputs.pr_number }} | |
| comment-author: 'github-actions[bot]' | |
| body-includes: '<!-- jspsych-preview-bot -->' | |
| - name: Create or update preview comment | |
| if: steps.pr.outputs.skip != 'true' | |
| uses: peter-evans/create-or-update-comment@v4 | |
| with: | |
| comment-id: ${{ steps.find.outputs.comment-id }} | |
| issue-number: ${{ steps.meta.outputs.pr_number }} | |
| body-path: comment.md | |
| edit-mode: replace |