Skip to content
Open
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
26 changes: 0 additions & 26 deletions .github/workflows/build.yml

This file was deleted.

93 changes: 93 additions & 0 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: deploy-prod

# Builds the docs on every push to the default branch and publishes them to the
# ROOT of the gh-pages branch — the production site — while preserving any
# pr-preview/ directories so PR previews are never clobbered.
#
# Runs in the trusted base-repo context (push to the default branch), so it has
# write access and needs no fork-safety split.
#
# Environment is driven by repo Actions variables, so the SAME workflow serves the
# github.io sub-path, a staging custom domain, and production with no code edits:
# vars.DOCS_URL default: https://<owner>.github.io
# vars.DOCS_BASE_URL default: /<repo>/ (set to "/" for a root custom domain)
# vars.PAGES_CNAME optional: when set, written as the gh-pages CNAME each deploy

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: write

# Shared with preview-deploy so prod and preview writes to gh-pages never race.
concurrency:
group: gh-pages-deploy
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: yarn

- run: yarn install

- name: Build production site
env:
DOCS_URL: ${{ vars.DOCS_URL || format('https://{0}.github.io', github.repository_owner) }}
DOCS_BASE_URL: ${{ vars.DOCS_BASE_URL || format('/{0}/', github.event.repository.name) }}
run: yarn build

- name: Publish to gh-pages root (preserve pr-preview/)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PAGES_CNAME: ${{ vars.PAGES_CNAME }}
run: |
set -euo pipefail
REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"

if git clone --quiet --depth 1 --branch gh-pages --single-branch "$REPO_URL" gh-pages 2>/dev/null; then
cd gh-pages
else
mkdir gh-pages && cd gh-pages
git init --quiet
git remote add origin "$REPO_URL"
git checkout --quiet --orphan gh-pages
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# Replace the production site at the root while keeping pr-preview/ (and .git).
# Defined as a function so it can be replayed verbatim on a push retry.
publish() {
find . -mindepth 1 -maxdepth 1 ! -name '.git' ! -name 'pr-preview' -exec rm -rf {} +
cp -R "${GITHUB_WORKSPACE}/build/." .
touch .nojekyll # serve Docusaurus output verbatim (skip GitHub Pages' Jekyll)
if [ -n "${PAGES_CNAME:-}" ]; then echo "$PAGES_CNAME" > CNAME; fi
git add -A
}

publish
if git diff --quiet --cached; then
echo "No changes to publish."
exit 0
fi
git commit --quiet -m "Deploy production ($GITHUB_SHA)"

# Serialized by the concurrency group; if the branch moved anyway, adopt the
# latest tree and replay (never touching pr-preview/) before retrying.
if ! git push --quiet origin gh-pages; then
echo "Push rejected; re-syncing gh-pages and retrying."
git fetch --quiet --depth 1 origin gh-pages
git reset --hard origin/gh-pages
publish
git diff --quiet --cached || git commit --quiet -m "Deploy production ($GITHUB_SHA)"
git push --quiet origin gh-pages
fi
85 changes: 85 additions & 0 deletions .github/workflows/preview-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: preview-build

# Builds the docs for every PR and uploads the result as an artifact.
#
# SECURITY: this is triggered by `pull_request` (NOT `pull_request_target`), so code
# from forked PRs runs with a read-only token and NO access to repository secrets. This
# job only ever produces a build artifact. All privileged work (publishing to GitHub
# Pages, commenting on the PR) happens in the separate `preview-deploy` workflow, which
# runs in the trusted base-repo context and never executes PR code.

on:
pull_request:
branches: [main, "release/*"]
types: [opened, synchronize, reopened, closed]

permissions:
contents: read

# One build per PR; newer pushes cancel in-flight builds for the same PR.
concurrency:
group: preview-build-${{ github.event.number }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout PR code
uses: actions/checkout@v4
with:
# Untrusted PR code runs in this job; don't leave the auth token behind in
# .git/config where a build script could read it.
persist-credentials: false

# Carry the PR number + event action across to the trusted deploy workflow.
# The `workflow_run` event does not expose the PR number for forked PRs, so we
# pass it explicitly via this artifact.
- name: Save PR metadata
run: |
mkdir -p pr-meta
echo "${{ github.event.number }}" > pr-meta/number
echo "${{ github.event.action }}" > pr-meta/action
- name: Upload PR metadata
uses: actions/upload-artifact@v4
with:
name: pr-meta
path: pr-meta/
retention-days: 1
if-no-files-found: error

# On `closed` the deploy workflow only needs the metadata above to tear the
# preview down, so skip the (slow) build entirely.
- name: Set up Node.js
if: github.event.action != 'closed'
uses: actions/setup-node@v4
with:
node-version: "22"
cache: yarn

# Plain `yarn install` to mirror the upstream build (build.yml / amplify.yml).
- name: Install dependencies
if: github.event.action != 'closed'
run: yarn install

- name: Build Docusaurus site
if: github.event.action != 'closed'
env:
# Point this build at the path the PR will be published to. When a root custom
# domain is configured (the PAGES_CNAME repo variable), previews live at
# <domain>/pr-preview/pr-<N>/; otherwise at the github.io project sub-path
# <owner>.github.io/<repo>/pr-preview/pr-<N>/. Defaults in docusaurus.config.js
# keep production builds unchanged.
DOCS_URL: ${{ vars.PAGES_CNAME && format('https://{0}', vars.PAGES_CNAME) || format('https://{0}.github.io', github.repository_owner) }}
DOCS_BASE_URL: ${{ vars.PAGES_CNAME && format('/pr-preview/pr-{0}/', github.event.number) || format('/{0}/pr-preview/pr-{1}/', github.event.repository.name, github.event.number) }}
run: yarn build

- name: Upload site artifact
if: github.event.action != 'closed'
uses: actions/upload-artifact@v4
with:
name: pr-preview-site
path: build/
retention-days: 1
if-no-files-found: error
186 changes: 186 additions & 0 deletions .github/workflows/preview-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
name: preview-deploy

# Publishes (and tears down) the per-PR preview produced by `preview-build`.
#
# This workflow runs from the DEFAULT branch in the trusted base-repo context, so it has
# write access to the repository. It NEVER executes PR code: it only downloads the
# already-built static artifact and publishes it to the `gh-pages` branch under
# pr-preview/pr-<N>/. This is the GitHub-recommended pattern for safely handling
# artifacts that were built from forked pull requests.

on:
workflow_run:
workflows: ["preview-build"]
types: [completed]

permissions:
contents: write # push to gh-pages
pull-requests: write # post/update the preview comment
issues: write # the PR comment is posted via the issues endpoint
actions: read # download artifacts from the triggering run

# Serialize all writes to the shared gh-pages branch across every PR.
concurrency:
group: gh-pages-deploy
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
# Only act on successful builds that originated from a pull_request.
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Download PR metadata
uses: actions/download-artifact@v4
with:
name: pr-meta
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: pr-meta

- name: Read and validate PR metadata
id: meta
run: |
set -euo pipefail
number="$(cat pr-meta/number)"
action="$(cat pr-meta/action)"
# pr-meta is produced in the untrusted build context, so validate before
# using it in filesystem paths / API calls (a forked PR could tamper with it).
if ! [[ "$number" =~ ^[0-9]+$ ]]; then
echo "::error::Refusing to deploy: PR number '$number' is not a positive integer."
exit 1
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
echo "action=$action" >> "$GITHUB_OUTPUT"

- name: Download site artifact
if: steps.meta.outputs.action != 'closed'
uses: actions/download-artifact@v4
with:
name: pr-preview-site
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: site

- name: Publish or remove preview on gh-pages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.meta.outputs.number }}
PR_ACTION: ${{ steps.meta.outputs.action }}
SRC_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
REPO_URL="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
TARGET="pr-preview/pr-${PR_NUMBER}"

# A successful (non-close) build must contain a homepage; bail rather than
# publish a broken/empty preview.
if [ "$PR_ACTION" != "closed" ] && [ ! -f "${GITHUB_WORKSPACE}/site/index.html" ]; then
echo "::error::Built site artifact is missing index.html; aborting."
exit 1
fi

# Shallow clone of just gh-pages (fast even as its history grows), or start an
# orphan branch on the very first deploy.
if git clone --quiet --depth 1 --branch gh-pages --single-branch "$REPO_URL" gh-pages 2>/dev/null; then
cd gh-pages
else
mkdir gh-pages && cd gh-pages
git init --quiet
git remote add origin "$REPO_URL"
git checkout --quiet --orphan gh-pages
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# Re-apply only this PR's directory onto the current tree, leaving every other
# PR's preview intact. Idempotent, so it is safe to replay on a push retry.
apply_change() {
rm -rf "$TARGET"
if [ "$PR_ACTION" != "closed" ]; then
mkdir -p "$TARGET"
cp -R "${GITHUB_WORKSPACE}/site/." "$TARGET/"
fi
touch .nojekyll # serve Docusaurus output verbatim (skip GitHub Pages' Jekyll)
git add -A
}

if [ "$PR_ACTION" != "closed" ]; then
MSG="Deploy preview for PR #${PR_NUMBER} (${SRC_SHA})"
else
MSG="Remove preview for PR #${PR_NUMBER}"
fi

apply_change
if git diff --quiet --cached; then
echo "Nothing to publish."
exit 0
fi
git commit --quiet -m "$MSG"

# Deploys are serialized by the workflow concurrency group, so a plain push
# should succeed. If the branch moved anyway, adopt the latest tree and
# re-apply our isolated change (never clobbering other PRs) before retrying.
if ! git push --quiet origin gh-pages; then
echo "Push rejected; re-syncing gh-pages and retrying."
git fetch --quiet --depth 1 origin gh-pages
git reset --hard origin/gh-pages
apply_change
git diff --quiet --cached || git commit --quiet -m "$MSG"
git push --quiet origin gh-pages
fi

- name: Comment the preview URL on the PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.meta.outputs.number }}
PR_ACTION: ${{ steps.meta.outputs.action }}
SRC_SHA: ${{ github.event.workflow_run.head_sha }}
PAGES_CNAME: ${{ vars.PAGES_CNAME }}
run: |
set -euo pipefail
OWNER="${GITHUB_REPOSITORY%%/*}"
REPO="${GITHUB_REPOSITORY##*/}"
# When a root custom domain is configured, previews are served at
# <domain>/pr-preview/pr-<N>/; otherwise at the github.io project sub-path.
if [ -n "${PAGES_CNAME:-}" ]; then
URL="https://${PAGES_CNAME}/pr-preview/pr-${PR_NUMBER}/"
else
OWNER_LC="$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')"
URL="https://${OWNER_LC}.github.io/${REPO}/pr-preview/pr-${PR_NUMBER}/"
fi
SHORT_SHA="${SRC_SHA:0:7}"
MARKER="<!-- docs-pr-preview -->"

if [ "$PR_ACTION" != "closed" ]; then
{
echo "$MARKER"
echo "### 📖 Docs preview is ready"
echo ""
echo "**Preview:** $URL"
echo ""
echo "Built from commit \`$SHORT_SHA\` and published to GitHub Pages. Updates on every push; removed automatically when this PR is closed."
} > body.md
else
{
echo "$MARKER"
echo "### 📖 Docs preview removed"
echo ""
echo "The preview for this PR was taken down because the PR is now closed."
} > body.md
fi

# Sticky behaviour: update our existing marked comment if present, else create it.
existing="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--jq "[.[] | select(.body | contains(\"${MARKER}\"))][0].id // empty")"
if [ -n "$existing" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" -f body="$(cat body.md)" >/dev/null
echo "Updated comment $existing"
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$(cat body.md)" >/dev/null
echo "Created preview comment"
fi
echo "Preview URL: $URL"
Loading
Loading