Mintlify Deployment Preview Links #1656
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: Mintlify Deployment Preview Links | |
| on: | |
| deployment_status: | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| deployments: read | |
| jobs: | |
| update-preview-links: | |
| # Only run when Mintlify deployment succeeds | |
| if: | | |
| github.event.deployment_status.state == 'success' && | |
| github.event.deployment.environment == 'staging' && | |
| contains(github.event.deployment_status.creator.login, 'mintlify') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.event.deployment.ref }} | |
| - name: Get PR number from deployment ref | |
| id: get-pr | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| // The deployment ref is the branch name | |
| const branch = context.payload.deployment.ref; | |
| const deploymentSha = context.payload.deployment.sha; | |
| // Find PRs for this branch (check all states to detect recently merged) | |
| const prs = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| head: `${context.repo.owner}:${branch}`, | |
| state: 'all', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 1 | |
| }); | |
| if (prs.data.length > 0) { | |
| const pr = prs.data[0]; | |
| // Check if PR is already closed - preview likely torn down | |
| if (pr.state === 'closed') { | |
| core.warning(`PR #${pr.number} is already ${pr.merged_at ? 'merged' : 'closed'}. Preview likely torn down. Skipping comment update.`); | |
| core.setOutput('skip_update', 'true'); | |
| return null; | |
| } | |
| // Check if this deployment is for the latest commit in the PR | |
| const prHeadSha = pr.head.sha; | |
| if (deploymentSha !== prHeadSha) { | |
| core.warning(`Deployment SHA (${deploymentSha}) doesn't match PR head SHA (${prHeadSha})`); | |
| core.warning(`This appears to be a deployment for an older commit. Skipping update.`); | |
| core.setOutput('skip_update', 'true'); | |
| return null; | |
| } | |
| core.setOutput('pr_number', pr.number); | |
| core.setOutput('base_ref', pr.base.ref); | |
| core.setOutput('base_sha', pr.base.sha); | |
| core.setOutput('skip_update', 'false'); | |
| core.info(`Found open PR #${pr.number} for branch ${branch}`); | |
| core.info(`Base branch: ${pr.base.ref} (${pr.base.sha})`); | |
| core.info(`Deployment SHA matches PR head: ${deploymentSha}`); | |
| return pr.number; | |
| } else { | |
| core.warning(`No PR found for branch ${branch}`); | |
| core.setOutput('skip_update', 'true'); | |
| return null; | |
| } | |
| - name: Get all PR changed files | |
| if: steps.get-pr.outputs.pr_number && steps.get-pr.outputs.skip_update != 'true' | |
| id: changed | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const prNumber = parseInt('${{ steps.get-pr.outputs.pr_number }}'); | |
| // Get all files changed in the PR | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| per_page: 100 | |
| }); | |
| // Filter for relevant file types | |
| const relevantFiles = files.filter(file => { | |
| const filename = file.filename; | |
| return filename.endsWith('.mdx') || | |
| filename.endsWith('.md') || | |
| filename === 'docs.json' || | |
| filename === 'mint.json' || | |
| filename.startsWith('snippets/') || | |
| filename.startsWith('images/') || | |
| filename.startsWith('icons/') || | |
| filename.startsWith('logo/') || | |
| filename.startsWith('static/') || | |
| filename === 'style.css'; | |
| }); | |
| // Group files by status - include previous_filename for renamed files | |
| const added = relevantFiles.filter(f => f.status === 'added').map(f => f.filename); | |
| const modified = relevantFiles.filter(f => f.status === 'modified').map(f => f.filename); | |
| const removed = relevantFiles.filter(f => f.status === 'removed').map(f => f.filename); | |
| const renamed = relevantFiles.filter(f => f.status === 'renamed').map(f => ({ | |
| old: f.previous_filename, | |
| new: f.filename | |
| })); | |
| // Store all categorized files for the comment builder | |
| core.setOutput('added_files', JSON.stringify(added)); | |
| core.setOutput('modified_files', JSON.stringify(modified)); | |
| core.setOutput('removed_files', JSON.stringify(removed)); | |
| core.setOutput('renamed_files', JSON.stringify(renamed)); | |
| core.setOutput('any_changed', relevantFiles.length > 0 ? 'true' : 'false'); | |
| core.info(`Found ${relevantFiles.length} changed files in PR #${prNumber}`); | |
| core.info(`Added: ${added.length}, Modified: ${modified.length}, Renamed: ${renamed.length}, Removed: ${removed.length}`); | |
| - name: Update PR comment with preview links | |
| if: steps.get-pr.outputs.pr_number && steps.get-pr.outputs.skip_update != 'true' && steps.changed.outputs.any_changed == 'true' | |
| uses: actions/github-script@v8 | |
| env: | |
| PR_NUMBER: ${{ steps.get-pr.outputs.pr_number }} | |
| PREVIEW_URL: ${{ github.event.deployment_status.environment_url }} | |
| ADDED_FILES: ${{ steps.changed.outputs.added_files }} | |
| MODIFIED_FILES: ${{ steps.changed.outputs.modified_files }} | |
| REMOVED_FILES: ${{ steps.changed.outputs.removed_files }} | |
| RENAMED_FILES: ${{ steps.changed.outputs.renamed_files }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const prNumber = parseInt(process.env.PR_NUMBER); | |
| const previewUrl = process.env.PREVIEW_URL; | |
| // Parse the categorized files | |
| const added = JSON.parse(process.env.ADDED_FILES || '[]'); | |
| const modified = JSON.parse(process.env.MODIFIED_FILES || '[]'); | |
| const removed = JSON.parse(process.env.REMOVED_FILES || '[]'); | |
| const renamed = JSON.parse(process.env.RENAMED_FILES || '[]'); | |
| if (!previewUrl) { | |
| core.warning('No preview URL found in deployment status'); | |
| return; | |
| } | |
| const totalFiles = added.length + modified.length + removed.length + renamed.length; | |
| core.info(`Mintlify Preview URL: ${previewUrl}`); | |
| core.info(`Total changed files: ${totalFiles}`); | |
| core.info(`Added: ${added.length}, Modified: ${modified.length}, Renamed: ${renamed.length}, Removed: ${removed.length}`); | |
| // Helper function to detect if a file is an image | |
| function isImageFile(filepath) { | |
| const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.bmp']; | |
| const ext = path.extname(filepath).toLowerCase(); | |
| if (imageExtensions.includes(ext)) return true; | |
| // Also check if it's in an image-related directory | |
| return filepath.startsWith('images/') || | |
| filepath.startsWith('icons/') || | |
| filepath.startsWith('logo/') || | |
| filepath.startsWith('static/'); | |
| } | |
| // Function to get title from file path | |
| function titleFromPath(p) { | |
| let base = path.basename(p, path.extname(p)); | |
| // Handle special icon naming pattern | |
| if (base.includes('Name=') || base.includes('Mode=')) { | |
| const nameMatch = base.match(/Name=([^,\s]+)/); | |
| const modeMatch = base.match(/Mode=([^,\s]+)/); | |
| if (nameMatch || modeMatch) { | |
| const parts = []; | |
| if (nameMatch) parts.push(nameMatch[1]); | |
| if (modeMatch) parts.push(modeMatch[1]); | |
| base = parts.join(' '); | |
| } | |
| } | |
| if (base === 'index') { | |
| const dir = path.dirname(p); | |
| const parent = path.basename(dir); | |
| return parent.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); | |
| } | |
| return base.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); | |
| } | |
| // Function to get URL from file path | |
| function urlFromPath(p) { | |
| // Remove file extension | |
| let urlPath = p.replace(/\.(mdx?|md)$/, ''); | |
| // Remove 'index' from the end if present | |
| if (urlPath.endsWith('/index')) { | |
| urlPath = urlPath.slice(0, -6); | |
| } | |
| return `${previewUrl}/${urlPath}`; | |
| } | |
| // Helper function to create file table rows | |
| function createFileTable(files, maxShow = 10, showPreview = true, isImage = false) { | |
| let table = showPreview ? | |
| `| File | Preview |\n|------|----------|\n` : | |
| `| File |\n|------|\n`; | |
| const toShow = files.slice(0, maxShow); | |
| toShow.forEach(file => { | |
| if (showPreview) { | |
| if (file.endsWith('.mdx') || file.endsWith('.md')) { | |
| const title = titleFromPath(file); | |
| const url = urlFromPath(file); | |
| table += `| \`${file}\` | [${title}](${url}) |\n`; | |
| } else if (isImage) { | |
| // Images can also be previewed directly | |
| const title = titleFromPath(file); | |
| const url = `${previewUrl}/${file}`; | |
| table += `| \`${file}\` | [View ${title}](${url}) |\n`; | |
| } else { | |
| table += `| \`${file}\` | - |\n`; | |
| } | |
| } else { | |
| table += `| \`${file}\` |\n`; | |
| } | |
| }); | |
| if (files.length > maxShow) { | |
| table += showPreview ? | |
| `| *... and ${files.length - maxShow} more files* | |\n` : | |
| `| *... and ${files.length - maxShow} more files* |\n`; | |
| } | |
| return table; | |
| } | |
| // Build comment body | |
| let body = `<!-- mintlify-preview-links -->\n`; | |
| body += `## 📚 Mintlify Preview Links\n\n`; | |
| body += `🔗 **[View Full Preview](${previewUrl})**\n\n`; | |
| const maxPerSubcategory = 10; | |
| let hasChanges = false; | |
| // Renamed/Moved files | |
| if (renamed.length > 0) { | |
| hasChanges = true; | |
| body += `### 🔄 Renamed/Moved (${renamed.length} total)\n\n`; | |
| const pagesRenamed = renamed.filter(r => r.new.endsWith('.mdx') || r.new.endsWith('.md')); | |
| const imagesRenamed = renamed.filter(r => !pagesRenamed.some(p => p.new === r.new) && isImageFile(r.new)); | |
| const otherRenamed = renamed.filter(r => !pagesRenamed.some(p => p.new === r.new) && !imagesRenamed.some(i => i.new === r.new)); | |
| // Pages (with preview links) | |
| if (pagesRenamed.length > 0) { | |
| body += `#### 📄 Pages (${pagesRenamed.length})\n\n`; | |
| body += `| Old Path → New Path | Preview |\n|---------------------|----------|\n`; | |
| const toShow = pagesRenamed.slice(0, maxPerSubcategory); | |
| toShow.forEach(({old: oldPath, new: newPath}) => { | |
| const title = titleFromPath(newPath); | |
| const url = urlFromPath(newPath); | |
| body += `| \`${oldPath}\`<br>→ \`${newPath}\` | [${title}](${url}) |\n`; | |
| }); | |
| if (pagesRenamed.length > maxPerSubcategory) { | |
| body += `| *... and ${pagesRenamed.length - maxPerSubcategory} more pages* | |\n`; | |
| } | |
| body += `\n`; | |
| } | |
| // Images (with preview links) | |
| if (imagesRenamed.length > 0) { | |
| if (imagesRenamed.length > 5) { | |
| body += `<details>\n<summary>🖼️ Images (${imagesRenamed.length})</summary>\n\n`; | |
| } else { | |
| body += `#### 🖼️ Images (${imagesRenamed.length})\n\n`; | |
| } | |
| body += `| Old Path → New Path | Preview |\n|---------------------|----------|\n`; | |
| const toShow = imagesRenamed.slice(0, maxPerSubcategory); | |
| toShow.forEach(({old: oldPath, new: newPath}) => { | |
| const title = titleFromPath(newPath); | |
| const url = `${previewUrl}/${newPath}`; | |
| body += `| \`${oldPath}\`<br>→ \`${newPath}\` | [View ${title}](${url}) |\n`; | |
| }); | |
| if (imagesRenamed.length > maxPerSubcategory) { | |
| body += `| *... and ${imagesRenamed.length - maxPerSubcategory} more images* | |\n`; | |
| } | |
| if (imagesRenamed.length > 5) { | |
| body += `\n</details>\n`; | |
| } | |
| body += `\n`; | |
| } | |
| // Other files (no preview) | |
| if (otherRenamed.length > 0) { | |
| body += `<details>\n<summary>⚙️ Other (${otherRenamed.length})</summary>\n\n`; | |
| body += `| Old Path → New Path |\n|---------------------|\n`; | |
| const toShow = otherRenamed.slice(0, maxPerSubcategory); | |
| toShow.forEach(({old: oldPath, new: newPath}) => { | |
| body += `| \`${oldPath}\`<br>→ \`${newPath}\` |\n`; | |
| }); | |
| if (otherRenamed.length > maxPerSubcategory) { | |
| body += `| *... and ${otherRenamed.length - maxPerSubcategory} more files* |\n`; | |
| } | |
| body += `\n</details>\n\n`; | |
| } | |
| } | |
| // Added files | |
| if (added.length > 0) { | |
| hasChanges = true; | |
| body += `### ✨ Added (${added.length} total)\n\n`; | |
| const pagesAdded = added.filter(f => f.endsWith('.mdx') || f.endsWith('.md')); | |
| const imagesAdded = added.filter(f => !pagesAdded.includes(f) && isImageFile(f)); | |
| const otherAdded = added.filter(f => !pagesAdded.includes(f) && !imagesAdded.includes(f)); | |
| // Pages (with preview links) | |
| if (pagesAdded.length > 0) { | |
| body += `#### 📄 Pages (${pagesAdded.length})\n\n`; | |
| body += createFileTable(pagesAdded, maxPerSubcategory, true, false); | |
| body += `\n`; | |
| } | |
| // Images (with preview links) | |
| if (imagesAdded.length > 0) { | |
| if (imagesAdded.length > 5) { | |
| body += `<details>\n<summary>🖼️ Images (${imagesAdded.length})</summary>\n\n`; | |
| } else { | |
| body += `#### 🖼️ Images (${imagesAdded.length})\n\n`; | |
| } | |
| body += createFileTable(imagesAdded, maxPerSubcategory, true, true); | |
| if (imagesAdded.length > 5) { | |
| body += `</details>\n`; | |
| } | |
| body += `\n`; | |
| } | |
| // Other files (no preview) | |
| if (otherAdded.length > 0) { | |
| body += `<details>\n<summary>⚙️ Other (${otherAdded.length})</summary>\n\n`; | |
| body += createFileTable(otherAdded, maxPerSubcategory, false); | |
| body += `\n</details>\n\n`; | |
| } | |
| } | |
| // Changed files (Modified) | |
| if (modified.length > 0) { | |
| hasChanges = true; | |
| body += `### 📝 Changed (${modified.length} total)\n\n`; | |
| const pagesModified = modified.filter(f => f.endsWith('.mdx') || f.endsWith('.md')); | |
| const imagesModified = modified.filter(f => !pagesModified.includes(f) && isImageFile(f)); | |
| const otherModified = modified.filter(f => !pagesModified.includes(f) && !imagesModified.includes(f)); | |
| // Pages (with preview links) | |
| if (pagesModified.length > 0) { | |
| body += `#### 📄 Pages (${pagesModified.length})\n\n`; | |
| body += createFileTable(pagesModified, maxPerSubcategory, true, false); | |
| body += `\n`; | |
| } | |
| // Images (with preview links) | |
| if (imagesModified.length > 0) { | |
| if (imagesModified.length > 5) { | |
| body += `<details>\n<summary>🖼️ Images (${imagesModified.length})</summary>\n\n`; | |
| } else { | |
| body += `#### 🖼️ Images (${imagesModified.length})\n\n`; | |
| } | |
| body += createFileTable(imagesModified, maxPerSubcategory, true, true); | |
| if (imagesModified.length > 5) { | |
| body += `</details>\n`; | |
| } | |
| body += `\n`; | |
| } | |
| // Other files (no preview) | |
| if (otherModified.length > 0) { | |
| body += `<details>\n<summary>⚙️ Other (${otherModified.length})</summary>\n\n`; | |
| body += createFileTable(otherModified, maxPerSubcategory, false); | |
| body += `\n</details>\n\n`; | |
| } | |
| } | |
| // Deleted files (no preview links) | |
| if (removed.length > 0) { | |
| hasChanges = true; | |
| body += `### 🗑️ Deleted (${removed.length} total)\n\n`; | |
| const pagesRemoved = removed.filter(f => f.endsWith('.mdx') || f.endsWith('.md')); | |
| const imagesRemoved = removed.filter(f => !pagesRemoved.includes(f) && isImageFile(f)); | |
| const otherRemoved = removed.filter(f => !pagesRemoved.includes(f) && !imagesRemoved.includes(f)); | |
| body += `<details>\n<summary>View deleted files</summary>\n\n`; | |
| // Pages (no preview) | |
| if (pagesRemoved.length > 0) { | |
| body += `**📄 Pages (${pagesRemoved.length})**\n\n`; | |
| body += `| File |\n|------|\n`; | |
| const toShow = pagesRemoved.slice(0, maxPerSubcategory); | |
| toShow.forEach(file => { | |
| body += `| \`${file}\` |\n`; | |
| }); | |
| if (pagesRemoved.length > maxPerSubcategory) { | |
| body += `| *... and ${pagesRemoved.length - maxPerSubcategory} more pages* |\n`; | |
| } | |
| body += `\n`; | |
| } | |
| // Images (no preview) | |
| if (imagesRemoved.length > 0) { | |
| body += `**🖼️ Images (${imagesRemoved.length})**\n\n`; | |
| body += `| File |\n|------|\n`; | |
| const toShow = imagesRemoved.slice(0, maxPerSubcategory); | |
| toShow.forEach(file => { | |
| body += `| \`${file}\` |\n`; | |
| }); | |
| if (imagesRemoved.length > maxPerSubcategory) { | |
| body += `| *... and ${imagesRemoved.length - maxPerSubcategory} more images* |\n`; | |
| } | |
| body += `\n`; | |
| } | |
| // Other files (no preview) | |
| if (otherRemoved.length > 0) { | |
| body += `**⚙️ Other (${otherRemoved.length})**\n\n`; | |
| body += `| File |\n|------|\n`; | |
| const toShow = otherRemoved.slice(0, maxPerSubcategory); | |
| toShow.forEach(file => { | |
| body += `| \`${file}\` |\n`; | |
| }); | |
| if (otherRemoved.length > maxPerSubcategory) { | |
| body += `| *... and ${otherRemoved.length - maxPerSubcategory} more files* |\n`; | |
| } | |
| body += `\n`; | |
| } | |
| body += `</details>\n\n`; | |
| } | |
| if (!hasChanges) { | |
| body += `*No documentation changes detected in this PR.*\n\n`; | |
| } | |
| // Add deployment info and timestamp | |
| const deploymentSha = context.payload.deployment.sha.substring(0, 7); | |
| const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0] + ' UTC'; | |
| body += `\n---\n`; | |
| body += `🤖 *Generated automatically when Mintlify deployment succeeds*\n`; | |
| body += `📍 *Deployment: \`${deploymentSha}\` at ${timestamp}*`; | |
| // Find existing comment | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }); | |
| let existingComment = comments.find(c => | |
| c.body && c.body.includes('<!-- mintlify-preview-links -->') | |
| ); | |
| // Create or update comment | |
| if (existingComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existingComment.id, | |
| body | |
| }); | |
| core.info(`Updated existing comment #${existingComment.id}`); | |
| } else { | |
| const newComment = await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body | |
| }); | |
| core.info(`Created new comment #${newComment.data.id}`); | |
| } |