diff --git a/.github/workflows/cleanup-previews.yml b/.github/workflows/cleanup-previews.yml
new file mode 100644
index 0000000000..9f3f05976a
--- /dev/null
+++ b/.github/workflows/cleanup-previews.yml
@@ -0,0 +1,161 @@
+name: Cleanup Stale PR Previews
+
+on:
+ schedule:
+ # Run every day at 2:00 AM UTC
+ - cron: '0 2 * * *'
+ workflow_dispatch: # Allow manual trigger
+
+permissions:
+ contents: write
+ pull-requests: read
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout gh-pages branch
+ uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+ fetch-depth: 0
+
+ - name: Clean up stale preview directories
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const path = require('path');
+ const { execSync } = require('child_process');
+
+ // Configuration
+ const RETENTION_DAYS = 7; // Keep previews for PRs closed within this many days
+
+ // Helper function to safely extract error information
+ function extractErrorInfo(error) {
+ const status = (typeof error === 'object' && error !== null && 'status' in error)
+ ? error.status
+ : undefined;
+ const message = (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string')
+ ? error.message
+ : String(error);
+ return { status, message };
+ }
+
+ // Get all open and recently closed PRs (with pagination)
+ const openPRs = await github.paginate(github.rest.pulls.list, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'open',
+ per_page: 100
+ });
+
+ const closedPRs = await github.paginate(github.rest.pulls.list, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ state: 'closed',
+ per_page: 100,
+ sort: 'updated',
+ direction: 'desc'
+ });
+
+ // Keep previews for open PRs and PRs closed in the last N days
+ const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
+ const cutoffDate = new Date(Date.now() - retentionMs);
+ const activePRNumbers = new Set([
+ ...openPRs.map(pr => pr.number),
+ ...closedPRs
+ .filter(pr => pr.closed_at && new Date(pr.closed_at) > cutoffDate)
+ .map(pr => pr.number)
+ ]);
+
+ console.log(`Retention period: ${RETENTION_DAYS} days`);
+ console.log(`Active PR numbers to keep: ${Array.from(activePRNumbers).join(', ')}`);
+
+ // Check if pr-preview directory exists
+ const previewDir = 'pr-preview';
+ if (!fs.existsSync(previewDir)) {
+ console.log('No pr-preview directory found, nothing to clean up');
+ return;
+ }
+
+ // List all preview directories
+ const previewDirs = fs.readdirSync(previewDir)
+ .filter(dir => dir.startsWith('pr-'))
+ .map(dir => {
+ const match = dir.match(/^pr-(\d+)$/);
+ return match ? { dir, prNumber: parseInt(match[1]) } : null;
+ })
+ .filter(x => x !== null);
+
+ console.log(`Found ${previewDirs.length} preview directories`);
+
+ // Identify stale directories
+ const staleDirectories = previewDirs.filter(
+ ({ prNumber }) => !activePRNumbers.has(prNumber)
+ );
+
+ if (staleDirectories.length === 0) {
+ console.log('No stale preview directories to remove');
+ return;
+ }
+
+ console.log(`Found ${staleDirectories.length} stale preview directories to remove:`);
+ staleDirectories.forEach(({ dir, prNumber }) => {
+ console.log(` - ${dir} (PR #${prNumber})`);
+ });
+
+ // Remove stale directories
+ staleDirectories.forEach(({ dir }) => {
+ const dirPath = path.join(previewDir, dir);
+ try {
+ execSync(`rm -rf "${dirPath}"`, { stdio: 'inherit' });
+ console.log(`Removed: ${dirPath}`);
+ } catch (error) {
+ const { message } = extractErrorInfo(error);
+ console.error(`Failed to remove preview directory: ${dirPath}`, message);
+ throw error;
+ }
+ });
+
+ // Commit and push changes
+ try {
+ execSync('git config --local user.name "github-actions[bot]"', { stdio: 'inherit' });
+ execSync('git config --local user.email "github-actions[bot]@users.noreply.github.com"', { stdio: 'inherit' });
+ console.log('Git config set successfully');
+ } catch (error) {
+ const { message } = extractErrorInfo(error);
+ console.error('Failed to configure git:', message);
+ throw error;
+ }
+
+ try {
+ execSync('git add pr-preview', { stdio: 'inherit' });
+ console.log('Changes staged successfully');
+ } catch (error) {
+ const { message } = extractErrorInfo(error);
+ console.error('Failed to stage changes:', message);
+ throw error;
+ }
+
+ try {
+ execSync(`git commit -m "chore: cleanup ${staleDirectories.length} stale PR preview(s)"`, { stdio: 'inherit' });
+ console.log('Changes committed successfully');
+ } catch (error) {
+ const { message } = extractErrorInfo(error);
+ if (message && message.includes('nothing to commit')) {
+ console.log('No changes to commit');
+ return;
+ }
+ console.error('Failed to commit changes:', message);
+ throw error;
+ }
+
+ try {
+ execSync('git push', { stdio: 'inherit' });
+ console.log('Changes pushed successfully');
+ } catch (error) {
+ const { message } = extractErrorInfo(error);
+ console.error('Failed to push changes:', message);
+ throw error;
+ }
diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml
index 2fe14c9e26..196563df01 100644
--- a/.github/workflows/jekyll.yml
+++ b/.github/workflows/jekyll.yml
@@ -21,11 +21,11 @@ permissions:
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
- group: "pages"
+ group: "production-deploy"
cancel-in-progress: false
jobs:
- build-and-deploy:
+ build_and_deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -48,5 +48,8 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./_site
+ # keep_files is intentionally set to true so that existing content (including PR preview directories)
+ # is not deleted on each deploy. Old preview directories are periodically cleaned up by
+ # .github/workflows/cleanup-previews.yml to prevent unbounded growth of the gh-pages branch.
keep_files: true
publish_branch: gh-pages
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
index 63b06aaa2a..d54e557ee8 100644
--- a/.github/workflows/pr-preview.yml
+++ b/.github/workflows/pr-preview.yml
@@ -14,7 +14,7 @@ permissions:
pull-requests: write
concurrency:
- group: preview-${{ github.ref }}
+ group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
@@ -142,8 +142,30 @@ jobs:
currentBody = currentBody.substring(0, markerIndex).trim();
}
- const previewSection = `## Alternate language PR\n${alternateLangLink}\n\n---\n\n## 🚀 PR Preview\n\n| Name | Link(s) |\n|------|------|\n| **Latest commit** | [${context.payload.pull_request.head.sha.substring(0, 7)}](${commitUrl}) |\n| **Page preview** | ${previewLinks} |${otherFilesContent ? `\n| **Other changed files** | ${otherFilesContent} |` : ''}\n\n---\n*Preview updates automatically with each commit*`;
-
+ const otherFilesRow = otherFilesContent
+ ? `| **Other changed files** | ${otherFilesContent} |`
+ : null;
+
+ const previewLines = [
+ '',
+ '## Alternate language PR',
+ alternateLangLink,
+ '',
+ '---',
+ '',
+ '## 🚀 PR Preview',
+ '',
+ '| Name | Link(s) |',
+ '|------|----------|',
+ `| **Latest commit** | [${context.payload.pull_request.head.sha.substring(0, 7)}](${commitUrl}) |`,
+ `| **Page preview** | ${previewLinks} |`,
+ ...(otherFilesRow ? [otherFilesRow] : []),
+ '',
+ '---',
+ '*Preview updates automatically with each commit*',
+ ];
+
+ const previewSection = previewLines.join('\n');
// Update PR description
await github.rest.pulls.update({
owner: context.repo.owner,