Skip to content

Preview by @carolinedivittorio #13898

Preview by @carolinedivittorio

Preview by @carolinedivittorio #13898

---
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,
});