Skip to content

preview-publish

preview-publish #5

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