name: Archive Old Changelog Entries on: schedule: # Run every Sunday at 00:00 UTC - cron: '0 0 * * 0' workflow_dispatch: jobs: archive-changelog: if: github.repository == 'community-scripts/ProxmoxVE' runs-on: ubuntu-latest env: BRANCH_NAME: github-action-archive-changelog AUTOMATED_PR_LABEL: "automated pr" permissions: contents: write pull-requests: write steps: - name: Generate a token id: generate-token uses: actions/create-github-app-token@v1 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Generate a token for PR approval and merge id: generate-token-merge uses: actions/create-github-app-token@v1 with: app-id: ${{ secrets.APP_ID_APPROVE_AND_MERGE }} private-key: ${{ secrets.APP_KEY_APPROVE_AND_MERGE }} - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Archive old changelog entries uses: actions/github-script@v7 with: script: | const fs = require('fs').promises; const path = require('path'); const KEEP_DAYS = 30; const ARCHIVE_PATH = '.github/changelogs'; const CHANGELOG_PATH = 'CHANGELOG.md'; // Calculate cutoff date const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - KEEP_DAYS); cutoffDate.setHours(0, 0, 0, 0); console.log(`Cutoff date: ${cutoffDate.toISOString().split('T')[0]}`); // Read changelog and normalize line endings let content = await fs.readFile(CHANGELOG_PATH, 'utf-8'); content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = content.split('\n'); // Parse entries const datePattern = /^## (\d{4})-(\d{2})-(\d{2})$/; let header = []; let recentEntries = []; let archiveData = {}; let currentDate = null; let currentContent = []; let inHeader = true; let inOldHistory = false; let historyDetailsDepth = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(datePattern); // Detect the start of History section:
followed by line with 📜 History if (inHeader && !inOldHistory && line.trim() === '
') { // Look ahead to see if this is the History section const nextLine = lines[i + 1] || ''; if (nextLine.includes('📜 History')) { inOldHistory = true; historyDetailsDepth = 1; continue; } } // Track nested details tags to find the end of History section if (inOldHistory) { if (line.trim() === '
') { historyDetailsDepth++; } if (line.trim() === '
') { historyDetailsDepth--; if (historyDetailsDepth === 0) { // We've closed the main History details tag inOldHistory = false; } } continue; } if (match) { inHeader = false; // Save previous entry if (currentDate && currentContent.length > 0) { const entryText = currentContent.join('\n').trim(); const dateObj = new Date(`${currentDate}T00:00:00Z`); // Always add to archive (by month) const year = currentDate.substring(0, 4); const month = currentDate.substring(5, 7); if (!archiveData[year]) archiveData[year] = {}; if (!archiveData[year][month]) archiveData[year][month] = []; archiveData[year][month].push(`## ${currentDate}\n\n${entryText}`); // Also add to recent entries if within cutoff if (dateObj >= cutoffDate) { recentEntries.push(`## ${currentDate}\n\n${entryText}`); } } currentDate = `${match[1]}-${match[2]}-${match[3]}`; currentContent = []; } else if (inHeader) { header.push(line); } else if (currentDate) { currentContent.push(line); } } // Don't forget the last entry if (currentDate && currentContent.length > 0) { const entryText = currentContent.join('\n').trim(); const dateObj = new Date(`${currentDate}T00:00:00Z`); // Always add to archive (by month) const year = currentDate.substring(0, 4); const month = currentDate.substring(5, 7); if (!archiveData[year]) archiveData[year] = {}; if (!archiveData[year][month]) archiveData[year][month] = []; archiveData[year][month].push(`## ${currentDate}\n\n${entryText}`); // Also add to recent entries if within cutoff if (dateObj >= cutoffDate) { recentEntries.push(`## ${currentDate}\n\n${entryText}`); } } console.log(`Recent entries: ${recentEntries.length}`); console.log(`Years to archive: ${Object.keys(archiveData).length}`); // Month names in English const monthNames = { '01': 'January', '02': 'February', '03': 'March', '04': 'April', '05': 'May', '06': 'June', '07': 'July', '08': 'August', '09': 'September', '10': 'October', '11': 'November', '12': 'December' }; // Create/update archive files for (const year of Object.keys(archiveData).sort().reverse()) { const yearPath = path.join(ARCHIVE_PATH, year); try { await fs.mkdir(yearPath, { recursive: true }); } catch (e) { // Directory exists } for (const month of Object.keys(archiveData[year]).sort().reverse()) { const monthPath = path.join(yearPath, `${month}.md`); // Read existing content if exists let existingContent = ''; try { existingContent = await fs.readFile(monthPath, 'utf-8'); // Normalize line endings to prevent regex issues existingContent = existingContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } catch (e) { // File doesn't exist } // Parse existing entries into a Map (date -> content) for deduplication const allEntries = new Map(); // Helper function to parse entries from content const parseEntries = (content) => { const entries = new Map(); const parts = content.split(/(?=^## \d{4}-\d{2}-\d{2}$)/m); for (const part of parts) { const trimmed = part.trim(); if (!trimmed) continue; const dateMatch = trimmed.match(/^## (\d{4}-\d{2}-\d{2})/); if (dateMatch) { entries.set(dateMatch[1], trimmed); } } return entries; }; // Parse existing content if (existingContent) { const existingEntries = parseEntries(existingContent); for (const [date, content] of existingEntries) { allEntries.set(date, content); } } // Add new entries (existing entries take precedence to avoid overwriting) let addedCount = 0; for (const entry of archiveData[year][month]) { const dateMatch = entry.match(/^## (\d{4}-\d{2}-\d{2})/); if (dateMatch && !allEntries.has(dateMatch[1])) { allEntries.set(dateMatch[1], entry.trim()); addedCount++; } } // Sort entries by date (newest first) and write const sortedDates = [...allEntries.keys()].sort().reverse(); const sortedContent = sortedDates.map(date => allEntries.get(date)).join('\n\n'); if (addedCount > 0 || !existingContent) { await fs.writeFile(monthPath, sortedContent + '\n', 'utf-8'); console.log(`Updated: ${monthPath} (${allEntries.size} total entries, +${addedCount} new)`); } } } // Build history section let historySection = []; historySection.push(''); historySection.push('
'); historySection.push('

📜 History

'); historySection.push(''); // Get all years from archive directory let allYears = []; try { const archiveDir = await fs.readdir(ARCHIVE_PATH); allYears = archiveDir.filter(f => /^\d{4}$/.test(f)).sort().reverse(); } catch (e) { allYears = Object.keys(archiveData).sort().reverse(); } for (const year of allYears) { historySection.push(''); historySection.push('
'); historySection.push(`

${year}

`); historySection.push(''); // Get months for this year let months = []; try { const yearDir = await fs.readdir(path.join(ARCHIVE_PATH, year)); months = yearDir .filter(f => f.endsWith('.md')) .map(f => f.replace('.md', '')) .sort() .reverse(); } catch (e) { months = Object.keys(archiveData[year] || {}).sort().reverse(); } for (const month of months) { const monthName = monthNames[month] || month; const monthPath = path.join(ARCHIVE_PATH, year, `${month}.md`); // Count entries in month file let entryCount = 0; try { let monthContent = await fs.readFile(monthPath, 'utf-8'); monthContent = monthContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); entryCount = (monthContent.match(/^## \d{4}-\d{2}-\d{2}$/gm) || []).length; } catch (e) { entryCount = (archiveData[year]?.[month] || []).length; } const relativePath = `.github/changelogs/${year}/${month}.md`; historySection.push(''); historySection.push('
'); historySection.push(`

${monthName} (${entryCount} entries)

`); historySection.push(''); historySection.push(`[View ${monthName} ${year} Changelog](${relativePath})`); historySection.push(''); historySection.push('
'); } historySection.push(''); historySection.push('
'); } historySection.push(''); historySection.push('
'); // Build new CHANGELOG.md (History first, then recent entries) const newChangelog = [ ...header, '', historySection.join('\n'), '', recentEntries.join('\n\n') ].join('\n'); await fs.writeFile(CHANGELOG_PATH, newChangelog, 'utf-8'); console.log('CHANGELOG.md updated successfully'); - name: Check for changes id: verify-diff run: | git diff --quiet . || echo "changed=true" >> $GITHUB_ENV - name: Commit and push changes if: env.changed == 'true' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" git add CHANGELOG.md .github/changelogs/ git commit -m "Archive old changelog entries" git checkout -b $BRANCH_NAME || git checkout $BRANCH_NAME git push origin $BRANCH_NAME --force - name: Create pull request if not exists if: env.changed == 'true' env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | PR_EXISTS=$(gh pr list --head "${BRANCH_NAME}" --json number --jq '.[].number') if [ -z "$PR_EXISTS" ]; then gh pr create --title "[Github Action] Archive old changelog entries" \ --body "This PR is auto-generated by a Github Action to archive old changelog entries (older than 14 days) to .github/changelogs/YEAR/MONTH.md" \ --head $BRANCH_NAME \ --base main \ --label "$AUTOMATED_PR_LABEL" fi - name: Approve and merge pull request if: env.changed == 'true' env: GH_TOKEN: ${{ steps.generate-token-merge.outputs.token }} run: | git config --global user.name "github-actions-automege[bot]" git config --global user.email "github-actions-automege[bot]@users.noreply.github.com" PR_NUMBER=$(gh pr list --head "${BRANCH_NAME}" --json number --jq '.[].number') if [ -n "$PR_NUMBER" ]; then gh pr review $PR_NUMBER --approve gh pr merge $PR_NUMBER --squash --admin fi