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,