Preview by @carolinedivittorio #13898
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 | |
| 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 | |
| SOURCE_BRANCH="${{ github.event.pull_request.head.ref }}" | |
| echo "[INFO] Source branch: $SOURCE_BRANCH" | |
| # Generate the safe prefix that would have been used for this branch | |
| # This transforms the branch name to match the preview branch naming convention: | |
| # 1. Remove all non-alphanumeric characters (tr -cd '[:alnum:]') | |
| # 2. Take only the first 6 characters (cut -c1-6) | |
| # Example: "feature/my-branch-123" -> "featur" | |
| safe_prefix=$(echo "$SOURCE_BRANCH" | tr -cd '[:alnum:]' | cut -c1-6) | |
| echo "[INFO] Looking for preview branches matching pattern: preview-${safe_prefix}-*" | |
| # Find and delete all preview branches with this prefix | |
| git fetch origin | |
| BRANCHES=$(git branch -r | grep "origin/preview-${safe_prefix}-" | sed 's|origin/||' || true) | |
| 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 secure preview branch name | |
| id: branch-name | |
| run: | | |
| set -euo pipefail | |
| echo "[INFO] Generating collision-resistant preview branch name" | |
| safe_prefix=$(echo "$SOURCE_BRANCH" | tr -cd '[:alnum:]' | cut -c1-6) | |
| timestamp=$(date +%s) | |
| short_sha="${GITHUB_SHA:0:7}" | |
| PREVIEW_BRANCH="preview-${safe_prefix}-${timestamp}-${short_sha}" | |
| 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" | |
| # Check if the branch exists remotely | |
| if git ls-remote --exit-code --heads origin "$PREVIEW_BRANCH" >/dev/null 2>&1; then | |
| echo "[ERROR] Preview branch $PREVIEW_BRANCH already exists. This should be extremely rare." | |
| echo "[INFO] Please retry the workflow to generate a new branch name." | |
| exit 1 | |
| fi | |
| echo "[SUCCESS] Preview branch name is unique" | |
| - 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 | |
| echo "[INFO] Pushing preview branch to origin" | |
| git push origin "$PREVIEW_BRANCH" | |
| 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, | |
| }); |