Preview by @fjmorris #19796
Workflow file for this run
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: Create Preview with Mintlify | |
| run-name: Preview by @${{ github.actor }} | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, closed] | |
| workflow_dispatch: | |
| concurrency: | |
| group: create-preview-${{ github.ref_name }} | |
| cancel-in-progress: true | |
| # Least-privileged defaults. We'll elevate at the job level only where needed. | |
| permissions: | |
| contents: read | |
| actions: read | |
| jobs: | |
| cleanup-preview-branches: | |
| # Run only when PR is closed or merged | |
| if: github.event.action == 'closed' | |
| permissions: | |
| contents: write | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Delete preview branches for this PR | |
| env: | |
| SOURCE_BRANCH: ${{ github.event.pull_request.head.ref }} | |
| run: | | |
| set -euo pipefail | |
| PR_STATE="${{ github.event.pull_request.merged && 'merged' || 'closed' }}" | |
| echo "[INFO] Cleaning up preview branches for $PR_STATE PR #${{ github.event.pull_request.number }}" | |
| # Get the source branch name | |
| echo "[INFO] Source branch: $SOURCE_BRANCH" | |
| # Recompute the deterministic preview branch name for this source | |
| # branch. This must stay identical to the derivation in the | |
| # create-preview job, or preview branches are orphaned on the remote. | |
| slug=$(printf '%s' "$SOURCE_BRANCH" | tr '[:upper:]' '[:lower:]' \ | |
| | tr -cs 'a-z0-9' '-' | sed 's/^-*//; s/-*$//' | cut -c1-24 | sed 's/-*$//') | |
| if [ -z "$slug" ]; then | |
| slug="pr" | |
| fi | |
| hash=$(printf '%s' "$SOURCE_BRANCH" | sha256sum | cut -c1-8) | |
| PREVIEW_BRANCH="preview-${slug}-${hash}" | |
| echo "[INFO] Deterministic preview branch for this PR: $PREVIEW_BRANCH" | |
| # Also sweep any legacy branches named | |
| # preview-<first 6 alphanumerics>-<timestamp>-<short sha> that were | |
| # created for this source branch before the naming became deterministic. | |
| legacy_prefix=$(echo "$SOURCE_BRANCH" | tr -cd '[:alnum:]' | cut -c1-6) | |
| echo "[INFO] Also looking for legacy preview branches matching: preview-${legacy_prefix}-*" | |
| # Find and delete the deterministic branch plus any legacy ones | |
| git fetch origin | |
| BRANCHES=$( | |
| { | |
| git branch -r --list "origin/$PREVIEW_BRANCH" || true | |
| git branch -r | grep "origin/preview-${legacy_prefix}-" || true | |
| } | sed 's|^[[:space:]]*origin/||' | sed '/^$/d' | sort -u | |
| ) | |
| if [ -z "$BRANCHES" ]; then | |
| echo "[INFO] No preview branches found for this PR" | |
| else | |
| echo "[INFO] Found preview branches to delete:" | |
| echo "$BRANCHES" | |
| for branch in $BRANCHES; do | |
| # Safety check: ensure branch starts with 'preview-' | |
| if [[ ! "$branch" =~ ^preview- ]]; then | |
| echo "[ERROR] Branch '$branch' does not start with 'preview-'" | |
| echo "[ERROR] Refusing to delete non-preview branch for safety" | |
| exit 1 | |
| fi | |
| echo "[INFO] Deleting branch: $branch" | |
| git push origin --delete "$branch" || echo "[WARN] Failed to delete $branch (may already be deleted)" | |
| done | |
| echo "[SUCCESS] Cleanup complete" | |
| fi | |
| create-preview: | |
| # This job needs to push a branch, so contents: write here only. | |
| permissions: | |
| contents: write | |
| runs-on: ubuntu-latest | |
| # Skip for PRs from forks - they don't have write access | |
| # Skip when PR is closed | |
| if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch') && github.event.action != 'closed' | |
| # Expose the generated preview branch name for the next job | |
| outputs: | |
| preview_branch: ${{ steps.branch-name.outputs.branch_name }} | |
| env: | |
| SOURCE_BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} | |
| GITHUB_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| steps: | |
| - name: Validate and sanitize branch context | |
| id: validate | |
| run: | | |
| set -euo pipefail | |
| echo "[INFO] Validating current branch context" | |
| echo "[INFO] Current ref: ${{ github.ref }}" | |
| echo "[INFO] Branch name: $SOURCE_BRANCH" | |
| # Validate source branch contains only safe characters and isn't too long | |
| if [[ ! "$SOURCE_BRANCH" =~ ^[\.a-zA-Z0-9/_-]+$ ]]; then | |
| echo "[ERROR] Branch name contains invalid characters. Only alphanumeric, slash, underscore, and hyphen are allowed." | |
| exit 1 | |
| fi | |
| if [[ ${#SOURCE_BRANCH} -gt 100 ]]; then | |
| echo "[ERROR] Branch name too long (max 100)." | |
| exit 1 | |
| fi | |
| # Sanitize for logging (first 20 chars max) | |
| SAFE_LOG_BRANCH=$(echo "$SOURCE_BRANCH" | cut -c1-20) | |
| echo "safe_branch_log=$SAFE_LOG_BRANCH" >> $GITHUB_OUTPUT | |
| echo "[SUCCESS] Branch validation passed for: $SAFE_LOG_BRANCH" | |
| - name: Checkout current branch | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} | |
| fetch-depth: 0 | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.12' | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@v7 | |
| - name: Install dependencies | |
| run: make install | |
| - name: Build documentation | |
| run: make build | |
| - name: Generate deterministic preview branch name | |
| id: branch-name | |
| run: | | |
| set -euo pipefail | |
| echo "[INFO] Generating deterministic preview branch name" | |
| # Derive the preview branch name from the source branch alone so the | |
| # Mintlify preview URL (<subdomain>-<branch-name>.mintlify.site) stays | |
| # the same across every push to this pull request. | |
| # Length budget: a DNS label caps at 63 octets and the site subdomain | |
| # plus its separator consume 19, leaving 44 for the branch name. This | |
| # scheme is "preview-" (8) + slug (max 24) + "-" (1) + hash (8) = 41, | |
| # keeping a few octets of headroom. | |
| # The hash is a SHA-256 prefix of the full source branch name, so two | |
| # branches that truncate to the same slug still get distinct previews. | |
| slug=$(printf '%s' "$SOURCE_BRANCH" | tr '[:upper:]' '[:lower:]' \ | |
| | tr -cs 'a-z0-9' '-' | sed 's/^-*//; s/-*$//' | cut -c1-24 | sed 's/-*$//') | |
| if [ -z "$slug" ]; then | |
| slug="pr" | |
| fi | |
| hash=$(printf '%s' "$SOURCE_BRANCH" | sha256sum | cut -c1-8) | |
| PREVIEW_BRANCH="preview-${slug}-${hash}" | |
| echo "branch_name=$PREVIEW_BRANCH" >> $GITHUB_OUTPUT | |
| echo "[INFO] Preview branch will be: $PREVIEW_BRANCH" | |
| - name: Configure Git | |
| run: | | |
| git config --global user.name "github-actions[bot]" | |
| git config --global user.email "github-actions[bot]@users.noreply.github.com" | |
| - name: Check if preview branch already exists | |
| run: | | |
| set -euo pipefail | |
| PREVIEW_BRANCH="${{ steps.branch-name.outputs.branch_name }}" | |
| echo "[INFO] Checking if preview branch already exists: $PREVIEW_BRANCH" | |
| # The branch name is deterministic, so after the first push to a pull | |
| # request this branch is expected to exist. It is updated in place, | |
| # which is what keeps the preview URL stable. | |
| if git ls-remote --exit-code --heads origin "$PREVIEW_BRANCH" >/dev/null 2>&1; then | |
| echo "[INFO] Preview branch already exists and will be updated in place" | |
| else | |
| echo "[INFO] Preview branch does not exist yet and will be created" | |
| fi | |
| - name: Create and push preview branch | |
| run: | | |
| set -euo pipefail | |
| PREVIEW_BRANCH="${{ steps.branch-name.outputs.branch_name }}" | |
| SAFE_BRANCH_LOG="${{ steps.validate.outputs.safe_branch_log }}" | |
| echo "[INFO] Creating preview branch: $PREVIEW_BRANCH" | |
| # Create new branch from current state | |
| git checkout -b "$PREVIEW_BRANCH" | |
| # Add build artifacts (force add since build/ is likely in .gitignore) | |
| echo "[INFO] Adding build artifacts to preview branch (forced)" | |
| git add -f build/ | |
| # Check if there are changes to commit | |
| if git diff --cached --quiet; then | |
| echo "[WARN] No build artifacts to commit" | |
| else | |
| echo "[INFO] Committing build artifacts" | |
| git commit -m "Add build artifacts for preview deployment | |
| Source branch: $SAFE_BRANCH_LOG | |
| Generated from commit: $GITHUB_SHA | |
| Timestamp: $(date -u +"%Y-%m-%d %H:%M:%S UTC") | |
| This branch contains the built documentation for preview deployment. | |
| Do not merge this branch to main." | |
| fi | |
| # Push the preview branch. | |
| # Each run rebuilds from the source branch tip and commits build | |
| # artifacts on top, so the new history is not a descendant of the | |
| # previous preview commit and a plain push is refused as a | |
| # non-fast-forward. Fetch the remote ref first so the lease is | |
| # meaningful, then push under that lease: a concurrent run that moved | |
| # the ref causes a rejection rather than a silent overwrite. | |
| echo "[INFO] Pushing preview branch to origin" | |
| git fetch origin "+refs/heads/$PREVIEW_BRANCH:refs/remotes/origin/$PREVIEW_BRANCH" 2>/dev/null || true | |
| if git rev-parse --verify --quiet "refs/remotes/origin/$PREVIEW_BRANCH" >/dev/null; then | |
| remote_sha=$(git rev-parse "refs/remotes/origin/$PREVIEW_BRANCH") | |
| echo "[INFO] Updating existing preview branch (remote at ${remote_sha:0:7})" | |
| git push --force-with-lease="$PREVIEW_BRANCH:$remote_sha" origin "$PREVIEW_BRANCH" | |
| else | |
| echo "[INFO] Creating preview branch on origin" | |
| git push origin "$PREVIEW_BRANCH" | |
| fi | |
| echo "[SUCCESS] Successfully pushed preview branch" | |
| - name: Save preview branch info (log) | |
| run: | | |
| PREVIEW_BRANCH="${{ steps.branch-name.outputs.branch_name }}" | |
| SAFE_BRANCH_LOG="${{ steps.validate.outputs.safe_branch_log }}" | |
| echo "[SUCCESS] Preview branch created: $PREVIEW_BRANCH" | |
| echo "[INFO] Branch name: $PREVIEW_BRANCH" | |
| echo "[INFO] Source branch: $SAFE_BRANCH_LOG" | |
| echo "[INFO] Source commit: $GITHUB_SHA" | |
| echo "[INFO] Created at: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" | |
| echo "" | |
| echo "[INFO] The preview branch is now ready for Mintlify deployment." | |
| echo "" | |
| echo "[INFO] 🔗 Branch URL: https://github.com/${{ github.repository }}/tree/$PREVIEW_BRANCH" | |
| - name: Write preview branch metadata for downstream workflows | |
| run: | | |
| set -euo pipefail | |
| echo "${{ steps.branch-name.outputs.branch_name }}" > preview_branch.txt | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| echo "${{ github.event.pull_request.number }}" > pr_number.txt | |
| else | |
| : > pr_number.txt | |
| fi | |
| - name: Upload preview branch metadata artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: preview-branch-metadata | |
| path: | | |
| preview_branch.txt | |
| pr_number.txt | |
| comment-on-pr: | |
| # Only this job gets permission to write PR comments. | |
| permissions: | |
| pull-requests: write | |
| runs-on: ubuntu-latest | |
| needs: create-preview | |
| if: github.event_name == 'pull_request' && needs.create-preview.result == 'success' | |
| steps: | |
| - name: Trigger Mintlify preview deployment | |
| id: mintlify | |
| env: | |
| MINTLIFY_API_KEY: ${{ secrets.MINTLIFY_API_KEY }} | |
| MINTLIFY_PROJECT_ID: ${{ secrets.MINTLIFY_PROJECT_ID }} | |
| BRANCH: ${{ needs.create-preview.outputs.preview_branch }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${MINTLIFY_API_KEY:-}" ] || [ -z "${MINTLIFY_PROJECT_ID:-}" ]; then | |
| echo "Missing required secrets." | |
| echo "Set secrets.MINTLIFY_API_KEY (admin key starting with mint_) and secrets.MINTLIFY_PROJECT_ID." | |
| exit 1 | |
| fi | |
| if [ -z "${BRANCH:-}" ]; then | |
| echo "Missing preview branch name from create-preview job." | |
| exit 1 | |
| fi | |
| echo "Triggering Mintlify preview for branch: ${BRANCH}" | |
| response="$(curl -sS -X POST \ | |
| "https://api.mintlify.com/v1/project/preview/${MINTLIFY_PROJECT_ID}" \ | |
| -H "Authorization: Bearer ${MINTLIFY_API_KEY}" \ | |
| -H "Content-Type: application/json" \ | |
| --data "{\"branch\":\"${BRANCH}\"}")" | |
| RESPONSE="$response" python -c 'import json,os,sys; resp=os.environ.get("RESPONSE",""); | |
| try: data=json.loads(resp) if resp else {} | |
| except Exception: print("Failed to parse Mintlify API response as JSON.", file=sys.stderr); print(resp, file=sys.stderr); sys.exit(1) | |
| status_id=(data.get("statusId","") or ""); preview_url=(data.get("previewUrl","") or ""); error=(data.get("error","") or ""); | |
| if error and not (status_id or preview_url): print(f"Mintlify API error: {error}", file=sys.stderr); sys.exit(1) | |
| out=os.environ["GITHUB_OUTPUT"]; f=open(out,"a",encoding="utf-8"); f.write(f"status_id={status_id}\n"); f.write(f"preview_url={preview_url}\n"); f.close()' | |
| - name: Comment on PR | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const preview = `${{ needs.create-preview.outputs.preview_branch }}`; | |
| const previewUrlRaw = `${{ steps.mintlify.outputs.preview_url }}`; | |
| const COMMENT_MARKER = '<!-- mintlify-docs-preview -->'; | |
| /** Map src/... .mdx path to site path (no leading slash), or null */ | |
| function fileToSitePath(filename) { | |
| if (!filename || (!filename.endsWith('.mdx') && !filename.endsWith('.md'))) return null; | |
| if (!filename.startsWith('src/')) return null; | |
| let p = filename.slice('src/'.length); | |
| if (p.endsWith('.mdx')) p = p.slice(0, -'.mdx'.length); | |
| else if (p.endsWith('.md')) p = p.slice(0, -'.md'.length); | |
| const base = p.split('/').pop(); | |
| if (base === 'index') { | |
| const dir = p.slice(0, Math.max(0, p.length - '/index'.length)); | |
| p = dir; | |
| } | |
| // OSS dual-language URLs are /oss/python/... or /oss/javascript/... | |
| // Files live under src/oss/<section>/ without the language segment. | |
| if ( | |
| p.startsWith('oss/') && | |
| !p.startsWith('oss/javascript/') && | |
| !p.startsWith('oss/python/') | |
| ) { | |
| p = `oss/python/${p.slice('oss/'.length)}`; | |
| } | |
| return p; | |
| } | |
| function pageUrl(previewBase, filename) { | |
| const sitePath = fileToSitePath(filename); | |
| if (!sitePath || !previewBase) return null; | |
| const base = previewBase.replace(/\/$/, ''); | |
| return `${base}/${sitePath}`; | |
| } | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.issue.number, | |
| per_page: 100, | |
| }); | |
| const mdFiles = files.filter(f => { | |
| const name = f.filename; | |
| if (!(name.endsWith('.mdx') || name.endsWith('.md'))) return false; | |
| const segments = name.split('/'); | |
| if (segments.includes('code-samples') || segments.includes('snippets')) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| const ranked = [...mdFiles].sort((a, b) => { | |
| const score = (x) => | |
| (x.changes ?? 0) || | |
| ((x.additions ?? 0) + (x.deletions ?? 0)); | |
| return score(b) - score(a); | |
| }); | |
| const MAX_PAGES = 5; | |
| const rankedRoutable = ranked.filter(f => fileToSitePath(f.filename)); | |
| const top = rankedRoutable.slice(0, MAX_PAGES); | |
| const truncated = rankedRoutable.length > MAX_PAGES; | |
| const lines = []; | |
| lines.push(COMMENT_MARKER); | |
| lines.push(`Mintlify preview branch generated: \`${preview}\``); | |
| if (previewUrlRaw) { | |
| lines.push(''); | |
| lines.push(`Site preview: ${previewUrlRaw}`); | |
| lines.push(''); | |
| lines.push('> [!IMPORTANT]'); | |
| lines.push( | |
| '> Preview links may take a few minutes to start working while the deployment finishes.' | |
| ); | |
| } | |
| if (top.length && previewUrlRaw) { | |
| lines.push(''); | |
| lines.push('Changed documentation pages (preview deep links):'); | |
| for (const f of top) { | |
| const u = pageUrl(previewUrlRaw, f.filename); | |
| if (u) lines.push(`- [\`${f.filename}\`](${u})`); | |
| else lines.push(`- \`${f.filename}\` _(could not build preview URL)_`); | |
| } | |
| if (truncated) { | |
| lines.push(''); | |
| lines.push( | |
| `_Only the top ${MAX_PAGES} changed markdown files by diff size are listed._` | |
| ); | |
| } | |
| } | |
| const body = lines.join('\n'); | |
| // Remove prior preview comments created by this workflow | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| per_page: 100, | |
| }); | |
| const shouldDelete = (commentBody) => { | |
| if (!commentBody) return false; | |
| return ( | |
| commentBody.includes(COMMENT_MARKER) || | |
| commentBody.includes('Mintlify preview branch generated:') || | |
| commentBody.includes('Mintlify preview ID generated:') || | |
| commentBody.includes('## Mintlify preview') | |
| ); | |
| }; | |
| const toDelete = comments.filter(c => c.user?.type === 'Bot' && shouldDelete(c.body)); | |
| for (const c of toDelete) { | |
| await github.rest.issues.deleteComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: c.id, | |
| }); | |
| } | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body, | |
| }); |