/** * Clean Old Release 主程式: * 依建立時間將 release 分為正式版與 beta 版(tag 名稱含 beta), * 各自保留最新指定筆數,其餘舊 release 連同對應 git tag 一併刪除。 */ const logger = require('./logger'); // 未捕捉例外一律以 ERR 格式輸出後非零結束 process.on('uncaughtException', (error) => { logger.err(`未捕捉例外:${error.stack || error.message}`); process.exit(1); }); process.on('unhandledRejection', (reason) => { logger.err(`未處理的 Promise 拒絕:${reason instanceof Error ? reason.stack : reason}`); process.exit(1); }); /** * 讀取並驗證保留筆數輸入(須為 0 以上整數);未提供時採用預設值, * 驗證失敗會輸出 ERR 訊息並以 exit code 1 終止程序(不拋出例外)。 * @param {string} name 輸入參數名稱(log 顯示用) * @param {string|undefined} raw 環境變數原始值(INPUT_*);undefined 或空字串視為未提供 * @param {number} fallback 未提供時的預設值(本身不做驗證,呼叫端須傳入合法值) * @returns {number} 驗證後的保留筆數(0 為合法值,代表該群全部刪除) */ function parseKeepCount(name, raw, fallback) { if (raw === undefined || raw === '') { logger.inf(`輸入 ${name} 未提供,採用預設值 ${fallback}`, '讀取輸入'); return fallback; } const value = Number(raw); if (!Number.isInteger(value) || value < 0) { logger.err(`輸入 ${name} 的值「${raw}」不是 0 以上的整數`, '讀取輸入'); process.exit(1); } logger.inf(`輸入 ${name} = ${value}`, '讀取輸入'); return value; } /** * 讀取執行所需的環境設定(repo、API URL、token),任一缺漏即輸出 ERR 並以 exit code 1 結束。 * repo 取自 GITHUB_REPOSITORY;API URL 優先取 GITHUB_API_URL,否則由 GITHUB_SERVER_URL 推得 /api/v1; * token 依序取 GITHUB_TOKEN、GITEA_TOKEN,內容不輸出至 log。 * @returns {{repository: string, apiUrl: string, token: string}} 環境設定;apiUrl 已去除尾端斜線 */ function readEnvironment() { const repository = process.env.GITHUB_REPOSITORY; if (!repository) { logger.err('找不到環境變數 GITHUB_REPOSITORY,無法判斷目標 repo', '讀取輸入'); process.exit(1); } let apiUrl = process.env.GITHUB_API_URL; if (!apiUrl && process.env.GITHUB_SERVER_URL) { apiUrl = `${process.env.GITHUB_SERVER_URL.replace(/\/+$/, '')}/api/v1`; logger.wrn(`環境變數 GITHUB_API_URL 未提供,改由 GITHUB_SERVER_URL 推得:${apiUrl}`, '讀取輸入'); } if (!apiUrl) { logger.err('找不到環境變數 GITHUB_API_URL 或 GITHUB_SERVER_URL,無法組出 Gitea API 位址', '讀取輸入'); process.exit(1); } apiUrl = apiUrl.replace(/\/+$/, ''); const token = process.env.GITHUB_TOKEN || process.env.GITEA_TOKEN; if (!token) { logger.err('找不到環境變數 GITHUB_TOKEN/GITEA_TOKEN,請於 workflow 以 env 傳入(例如 GITHUB_TOKEN: ${{ gitea.token }})', '讀取輸入'); process.exit(1); } logger.inf(`目標 repo:${repository}`, '讀取輸入'); logger.inf(`Gitea API 位址:${apiUrl}`, '讀取輸入'); logger.inf('已取得 API token(內容不輸出)', '讀取輸入'); return { repository, apiUrl, token }; } /** * 呼叫 Gitea API:組合 URL、附加 token 認證標頭後發出請求。 * 不檢查 HTTP 狀態碼,一律回傳整包 Response 由呼叫端判斷; * 網路層例外(連線失敗等)不在此攔截,會沿 Promise 鏈向外拋。 * @param {{apiUrl: string, token: string}} env 環境設定(readEnvironment() 的回傳值) * @param {string} method HTTP 方法(如 GET、DELETE) * @param {string} path API 路徑(/repos/... 開頭,須自行完成 URL 編碼) * @returns {Promise} fetch 回應 */ async function callApi(env, method, path) { const url = `${env.apiUrl}${path}`; logger.trc(`${method} ${url}`, '呼叫 API'); return fetch(url, { method, headers: { Authorization: `token ${env.token}`, 'Content-Type': 'application/json', }, }); } /** * 逐頁(每頁 50 筆)取得 repo 全部 release,直到單頁回傳不足 50 筆為止; * 任一頁查詢失敗即輸出 ERR 並以 exit code 1 終止(fail-fast,不做部分清理)。 * @param {{repository: string, apiUrl: string, token: string}} env 環境設定(readEnvironment() 的回傳值) * @returns {Promise>} release 清單(僅列出實際使用的欄位,API 回傳物件另有其他欄位) */ async function fetchAllReleases(env) { const releases = []; const pageSize = 50; for (let page = 1; ; page += 1) { const response = await callApi(env, 'GET', `/repos/${env.repository}/releases?page=${page}&limit=${pageSize}`); if (!response.ok) { logger.err(`查詢 release 失敗(第 ${page} 頁):HTTP ${response.status} ${await response.text()}`, '查詢 release'); process.exit(1); } const batch = await response.json(); logger.trc(`第 ${page} 頁取得 ${batch.length} 筆 release`, '查詢 release'); releases.push(...batch); if (batch.length < pageSize) { break; } } logger.inf(`共取得 ${releases.length} 筆 release`, '查詢 release'); return releases; } /** * 依保留筆數挑出某群組要刪除的舊 release(依 created_at 新→舊排序後保留前 N 筆)。 * 不修改傳入的陣列(排序作用於複本),僅輸出 log,無其他副作用。 * @param {string} groupName 群組名稱(log 顯示用,如「正式版」「beta 版」) * @param {Array<{id: number, tag_name: string, created_at: string}>} releases 該群組的 release 清單 * @param {number} keepCount 要保留的最新筆數;大於清單長度時不刪除任何項目 * @returns {Array<{id: number, tag_name: string, created_at: string}>} 要刪除的 release 清單(新→舊排序) */ function pickDeletions(groupName, releases, keepCount) { const sorted = [...releases].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); const kept = sorted.slice(0, keepCount); const toDelete = sorted.slice(keepCount); logger.inf(`${groupName}共 ${sorted.length} 筆,保留最新 ${kept.length} 筆,待刪除 ${toDelete.length} 筆`, '分群'); for (const release of kept) { logger.dbg(`${groupName}保留:${release.tag_name}(建立於 ${release.created_at})`, '分群'); } return toDelete; } /** * 透過 Gitea API 刪除一筆 release,成功後再刪除其對應的 git tag; * tag 已不存在(404)視為成功並輸出警告,其餘任一步失敗即記錄 ERR 並回報失敗。 * 注意:release 刪除成功但 tag 刪除失敗時屬部分完成,殘留 tag 需人工清理。 * @param {{repository: string, apiUrl: string, token: string}} env 環境設定(readEnvironment() 的回傳值,token 須具刪除 release/tag 權限) * @param {{id: number, tag_name: string, created_at: string}} release 要刪除的 release;tag_name 由本函式做 URL 編碼,呼叫端不需預先編碼 * @returns {Promise} release 與 tag 皆刪除成功(或 tag 已不存在)為 true;任一 DELETE 非 2xx(tag 404 除外)為 false。HTTP 錯誤不拋例外,網路層例外會向外拋 */ async function deleteRelease(env, release) { logger.inf(`刪除 release:${release.tag_name}(id=${release.id},建立於 ${release.created_at})`, '刪除'); const releaseResponse = await callApi(env, 'DELETE', `/repos/${env.repository}/releases/${release.id}`); if (!releaseResponse.ok) { logger.err(`刪除 release ${release.tag_name} 失敗:HTTP ${releaseResponse.status} ${await releaseResponse.text()}`, '刪除'); return false; } logger.inf(`release ${release.tag_name} 已刪除`, '刪除'); const tagResponse = await callApi(env, 'DELETE', `/repos/${env.repository}/tags/${encodeURIComponent(release.tag_name)}`); if (tagResponse.status === 404) { logger.wrn(`tag ${release.tag_name} 不存在(可能已被刪除),略過`, '刪除'); return true; } if (!tagResponse.ok) { logger.err(`刪除 tag ${release.tag_name} 失敗:HTTP ${tagResponse.status} ${await tagResponse.text()}`, '刪除'); return false; } logger.inf(`tag ${release.tag_name} 已刪除`, '刪除'); return true; } /** * 主流程:讀取輸入 → 逐頁查詢 release → 以 tag 名稱含 beta 分群 → 依保留筆數挑選待刪 → 逐筆循序刪除 → 總結。 * 無 release 或兩群皆在保留筆數內時提前結束;任一筆刪除失敗仍繼續刪其餘,最後以 exit code 1 結束。 * 前置條件:runner 須注入 GITHUB_REPOSITORY 與 GITHUB_API_URL(或 GITHUB_SERVER_URL),並提供 GITHUB_TOKEN/GITEA_TOKEN。 */ async function main() { logger.inf('開始執行 Clean Old Release'); const keepCount = parseKeepCount('keep_count', process.env.INPUT_KEEP_COUNT, 5); const keepCountBeta = parseKeepCount('keep_count_beta', process.env.INPUT_KEEP_COUNT_BETA, 10); const env = readEnvironment(); const releases = await fetchAllReleases(env); if (releases.length === 0) { logger.inf('repo 沒有任何 release,無需清理', '完成'); return; } const betaReleases = []; const stableReleases = []; for (const release of releases) { (release.tag_name.toLowerCase().includes('beta') ? betaReleases : stableReleases).push(release); } logger.inf(`分群結果:正式版 ${stableReleases.length} 筆、beta 版 ${betaReleases.length} 筆`, '分群'); const toDelete = [ ...pickDeletions('正式版', stableReleases, keepCount), ...pickDeletions('beta 版', betaReleases, keepCountBeta), ]; if (toDelete.length === 0) { logger.inf('兩群皆在保留筆數內,無需刪除任何 release', '完成'); return; } let failureCount = 0; for (const release of toDelete) { const success = await deleteRelease(env, release); if (!success) { failureCount += 1; } } if (failureCount > 0) { logger.err(`清理完成,但有 ${failureCount}/${toDelete.length} 筆刪除失敗`, '完成'); process.exit(1); } logger.inf(`清理完成,共刪除 ${toDelete.length} 筆舊 release(含對應 tag)`, '完成'); } main();