|
| 1 | +import { readFile, writeFile, appendFile } from 'node:fs/promises'; |
| 2 | +import { pathToFileURL } from 'node:url'; |
| 3 | + |
| 4 | +const releaseTag = /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/; |
| 5 | +const normalize = (tag) => tag.replace(/^v/, ''); |
| 6 | + |
| 7 | +export function planRetention(policy, inventory, now = Date.now(), protectedVersions = []) { |
| 8 | + if (!Array.isArray(policy.packages) || policy.packages.length === 0 || |
| 9 | + new Set(policy.packages).size !== policy.packages.length) { |
| 10 | + throw new Error('Policy must contain unique package names'); |
| 11 | + } |
| 12 | + for (const tier of ['stable', 'prerelease']) { |
| 13 | + if (!Number.isInteger(policy[tier]?.keep) || policy[tier].keep < 1 || |
| 14 | + !Number.isInteger(policy[tier]?.days) || policy[tier].days < 1) { |
| 15 | + throw new Error(`Invalid ${tier} retention policy`); |
| 16 | + } |
| 17 | + } |
| 18 | + const protectedTags = [...policy.protectedVersions, ...protectedVersions]; |
| 19 | + if (protectedTags.some((tag) => !releaseTag.test(tag))) { |
| 20 | + throw new Error('Protected versions must be release version tags'); |
| 21 | + } |
| 22 | + const keep = new Set(protectedTags.map(normalize)); |
| 23 | + const releases = new Map(); |
| 24 | + const rows = []; |
| 25 | + for (const pkg of policy.packages) { |
| 26 | + if (!Array.isArray(inventory[pkg]) || inventory[pkg].length === 0) { |
| 27 | + throw new Error(`Missing or empty package inventory: ${pkg}`); |
| 28 | + } |
| 29 | + for (const version of inventory[pkg]) { |
| 30 | + const tags = version.metadata?.container?.tags; |
| 31 | + const created = Date.parse(version.created_at); |
| 32 | + const updated = Date.parse(version.updated_at ?? version.created_at); |
| 33 | + if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string') || |
| 34 | + !Number.isSafeInteger(version.id) || !Number.isFinite(created) || !Number.isFinite(updated)) { |
| 35 | + throw new Error(`Invalid version metadata in ${pkg}`); |
| 36 | + } |
| 37 | + const names = tags.filter((tag) => releaseTag.test(tag)).map(normalize); |
| 38 | + const row = { package: pkg, id: version.id, digest: version.name, tags, releases: names }; |
| 39 | + rows.push(row); |
| 40 | + for (const name of names) { |
| 41 | + // A recently republished component protects the entire release. |
| 42 | + releases.set(name, Math.max(releases.get(name) ?? 0, created, updated)); |
| 43 | + if (tags.some((tag) => !releaseTag.test(tag))) keep.add(name); |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | + for (const tier of ['stable', 'prerelease']) { |
| 48 | + const ordered = [...releases].filter(([name]) => name.includes('-') === (tier === 'prerelease')) |
| 49 | + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); |
| 50 | + ordered.forEach(([name, date], index) => { |
| 51 | + if (index < policy[tier].keep || now - date <= policy[tier].days * 86400000) keep.add(name); |
| 52 | + }); |
| 53 | + // Older or less frequently published packages still need rollback coverage. |
| 54 | + for (const pkg of policy.packages) { |
| 55 | + const available = new Set(rows.filter((row) => row.package === pkg).flatMap((row) => row.releases)); |
| 56 | + ordered.filter(([name]) => available.has(name)).slice(0, policy[tier].keep) |
| 57 | + .forEach(([name]) => keep.add(name)); |
| 58 | + } |
| 59 | + } |
| 60 | + // Shared digests cannot be removed without removing every tag on them. |
| 61 | + let changed; |
| 62 | + do { |
| 63 | + changed = false; |
| 64 | + for (const row of rows) { |
| 65 | + if (row.releases.some((name) => keep.has(name))) { |
| 66 | + for (const name of row.releases) { |
| 67 | + if (!keep.has(name)) { keep.add(name); changed = true; } |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | + } while (changed); |
| 72 | + return rows.map((row) => ({ |
| 73 | + ...row, |
| 74 | + action: row.releases.length > 0 && row.tags.every((tag) => releaseTag.test(tag)) && |
| 75 | + row.releases.every((name) => !keep.has(name)) ? 'delete' : 'keep', |
| 76 | + reason: row.releases.length === 0 ? 'untagged or auxiliary artifact: preserved' : |
| 77 | + row.tags.some((tag) => !releaseTag.test(tag)) ? 'alias or auxiliary tag: preserved' : |
| 78 | + row.releases.some((name) => keep.has(name)) ? 'retention window, pin, or shared digest' : |
| 79 | + 'outside both count and age protections', |
| 80 | + })); |
| 81 | +} |
| 82 | + |
| 83 | +export async function inventoryPackages(policy, request, owner) { |
| 84 | + const inventory = {}; |
| 85 | + for (const pkg of policy.packages) { |
| 86 | + const versions = []; |
| 87 | + for (let page = 1; ; page++) { |
| 88 | + const batch = await request(`/orgs/${owner}/packages/container/${encodeURIComponent(pkg)}/versions?per_page=100&page=${page}`); |
| 89 | + if (!Array.isArray(batch)) throw new Error(`Invalid inventory response: ${pkg}`); |
| 90 | + versions.push(...batch); |
| 91 | + if (batch.length < 100) break; |
| 92 | + } |
| 93 | + inventory[pkg] = versions; |
| 94 | + } |
| 95 | + return inventory; |
| 96 | +} |
| 97 | + |
| 98 | +export async function deletePlanned(plan, request, owner, report) { |
| 99 | + // Preflight ALL candidates before the first DELETE. |
| 100 | + for (const row of plan.filter((entry) => entry.action === 'delete')) { |
| 101 | + const path = `/orgs/${owner}/packages/container/${encodeURIComponent(row.package)}/versions/${row.id}`; |
| 102 | + const current = await request(path); |
| 103 | + if (current.name !== row.digest || |
| 104 | + JSON.stringify([...current.metadata.container.tags].sort()) !== JSON.stringify([...row.tags].sort())) { |
| 105 | + throw new Error(`Version changed since inventory: ${row.package}/${row.id}`); |
| 106 | + } |
| 107 | + } |
| 108 | + for (const row of plan.filter((entry) => entry.action === 'delete')) { |
| 109 | + try { |
| 110 | + await request(`/orgs/${owner}/packages/container/${encodeURIComponent(row.package)}/versions/${row.id}`, 'DELETE'); |
| 111 | + await report({ package: row.package, id: row.id, status: 'deleted' }); |
| 112 | + } catch (error) { |
| 113 | + await report({ package: row.package, id: row.id, status: 'failed', error: error.message }); |
| 114 | + throw error; |
| 115 | + } |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +async function main() { |
| 120 | + const policy = JSON.parse(await readFile(new URL('../ops/release/package-retention.json', import.meta.url), 'utf8')); |
| 121 | + const owner = process.env.GITHUB_REPOSITORY_OWNER; |
| 122 | + const token = process.env.GH_TOKEN; |
| 123 | + if (!owner || !token) throw new Error('GITHUB_REPOSITORY_OWNER and GH_TOKEN are required'); |
| 124 | + const request = async (path, method = 'GET') => { |
| 125 | + const response = await fetch(`https://api.github.com${path}`, { |
| 126 | + method, |
| 127 | + headers: { |
| 128 | + Authorization: `Bearer ${token}`, |
| 129 | + Accept: 'application/vnd.github+json', |
| 130 | + 'X-GitHub-Api-Version': '2022-11-28', |
| 131 | + }, |
| 132 | + signal: AbortSignal.timeout(30000), |
| 133 | + }); |
| 134 | + if (!response.ok) throw new Error(`${method} ${path}: HTTP ${response.status}`); |
| 135 | + return response.status === 204 ? null : response.json(); |
| 136 | + }; |
| 137 | + const pins = (process.env.PROTECTED_VERSIONS ?? '').split(/[\s,]+/).filter(Boolean); |
| 138 | + const now = Date.now(); |
| 139 | + const inventory = await inventoryPackages(policy, request, owner); |
| 140 | + const plan = planRetention(policy, inventory, now, pins); |
| 141 | + await writeFile('package-retention-plan.json', JSON.stringify({ generatedAt: new Date(now), plan }, null, 2)); |
| 142 | + const deleting = process.env.DELETE_PACKAGES === 'true'; |
| 143 | + const summary = [ |
| 144 | + `## Package retention (${deleting ? 'DELETE ENABLED' : 'dry-run'})`, |
| 145 | + '', |
| 146 | + '| Package | Keep | Delete candidates |', |
| 147 | + '| --- | ---: | ---: |', |
| 148 | + ...policy.packages.map((pkg) => |
| 149 | + `| ${pkg} | ${plan.filter((r) => r.package === pkg && r.action === 'keep').length} | ${plan.filter((r) => r.package === pkg && r.action === 'delete').length} |`), |
| 150 | + '', |
| 151 | + 'Full version IDs, tags, and reasons are in package-retention-plan.json.', |
| 152 | + 'Untagged manifests and auxiliary artifacts are deliberately preserved.', |
| 153 | + '', |
| 154 | + ].join('\n'); |
| 155 | + console.log(summary); |
| 156 | + if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, summary); |
| 157 | + if (deleting) { |
| 158 | + if (process.env.GITHUB_EVENT_NAME !== 'workflow_dispatch') throw new Error('Deletion requires a manual workflow dispatch'); |
| 159 | + const refreshed = planRetention(policy, await inventoryPackages(policy, request, owner), now, pins); |
| 160 | + if (JSON.stringify(refreshed) !== JSON.stringify(plan)) throw new Error('Inventory changed; rerun dry-run before deleting'); |
| 161 | + await deletePlanned(plan, request, owner, async (entry) => { |
| 162 | + console.log(JSON.stringify(entry)); |
| 163 | + await appendFile('package-retention-results.jsonl', `${JSON.stringify(entry)}\n`); |
| 164 | + }); |
| 165 | + } |
| 166 | +} |
| 167 | + |
| 168 | +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 169 | + main().catch((error) => { console.error(error.message); process.exitCode = 1; }); |
| 170 | +} |
0 commit comments