#!/usr/bin/env node 'use strict'; /** * 目前流程階段名稱,作為每行 log 的 `[階段]` 前綴使用; * 為空字串時省略整個 `[階段]` 區塊。 * @type {string} */ let currentStage = ''; /** * 設定目前流程階段名稱,之後輸出的 log 會以 `[階段]` 前綴標示所屬階段。 * 傳入空字串可清除階段前綴。 * * @param {string} name 階段名稱(顯示於每行 log 開頭的 `[階段]` 區塊)。 * @returns {void} */ function stage(name) { currentStage = name; } /** * 取得目前 Asia/Taipei 時區的時間字串,格式為 `yyyy/MM/dd HH:mm:ss`。 * 使用 Node 內建 Intl(`toLocaleString('sv-SE')` 天生輸出 24 小時制 * `YYYY-MM-DD HH:mm:ss`,再把 `-` 換成 `/`),不需額外相依。 * * @returns {string} 例如 `2026/06/30 16:53:06`。 */ function timestamp() { return new Date() .toLocaleString('sv-SE', { timeZone: 'Asia/Taipei' }) .replace(/-/g, '/'); } /** * 依統一格式組出一行 log:`[階段?][等級][時間]: 訊息`。 * 階段取自目前 currentStage(為空字串時省略整個 `[階段]` 區塊); * 時間為 Asia/Taipei 的 `yyyy/MM/dd HH:mm:ss`。 * * @param {string} level 等級碼(INF/WRN/ERR)。 * @param {string} msg 訊息內容。 * @returns {string} 組裝後的 log 字串。 */ function formatLog(level, msg) { const stagePart = currentStage ? `[${currentStage}]` : ''; return `${stagePart}[${level}][${timestamp()}]: ${msg}`; } /** * 以 `[階段?][INF][時間]:` 前綴將一般資訊訊息輸出到 stdout(時間為 Asia/Taipei)。 * * @param {string} msg 要輸出的訊息內容。 * @returns {void} */ function info(msg) { console.log(formatLog('INF', msg)); } /** * 以 `[階段?][INF][時間]:` 前綴將成功訊息輸出到 stdout(時間為 Asia/Taipei)。 * 規範等級碼無「成功」,故等級採 INF,成功語意保留於訊息文字。 * * @param {string} msg 要輸出的訊息內容。 * @returns {void} */ function success(msg) { console.log(formatLog('INF', msg)); } /** * 以 `[階段?][WRN][時間]:` 前綴將警告訊息輸出到 stdout(時間為 Asia/Taipei)。 * * @param {string} msg 要輸出的訊息內容。 * @returns {void} */ function warn(msg) { console.log(formatLog('WRN', msg)); } /** * 以 `[階段?][ERR][時間]:` 前綴將錯誤訊息輸出到 stderr(時間為 Asia/Taipei)。 * 僅負責輸出,不會結束程序(是否離開由呼叫端決定)。 * * @param {string} msg 要輸出的錯誤訊息內容。 * @returns {void} */ function fail(msg) { console.error(formatLog('ERR', msg)); } /** * 判斷值是否視為「空 / 未設定」。 * 涵蓋 undefined、null、空字串,以及字面字串 'null' *(後者用於處理 CI/環境變數把未設定值帶成字串 "null" 的情況)。 * * @param {*} value 要檢查的值。 * @returns {boolean} 視為空值時為 true,否則為 false。 */ function isEmptyOrNull(value) { return value === undefined || value === null || value === '' || value === 'null'; } /** * 檢查必填值。先以 info([INF])印出 name=value, * 若值被判定為空(見 isEmptyOrNull),以 fail([ERR])輸出並以 exit code 1 結束程序。 * * @param {string} name 參數名稱(用於 log 與錯誤訊息)。 * @param {*} value 要驗證的參數值。 * @returns {void} * @throws 不擲出例外;驗證失敗時直接呼叫 process.exit(1) 結束程序。 */ function requireValue(name, value) { info(`${name}=${value}`); if (isEmptyOrNull(value)) { fail(`${name} is required`); process.exit(1); } } /** * 驗證值是否為非負整數字串(正則 /^[0-9]+$/,允許前導零、不允許負號或小數)。 * 不符合時印出 [ERR] 並以 exit code 1 結束程序。 * * @param {string} name 參數名稱(用於錯誤訊息)。 * @param {*} value 要驗證的值;會先轉為字串再比對。 * @returns {void} * @throws 不擲出例外;驗證失敗時直接呼叫 process.exit(1) 結束程序。 */ function requireInteger(name, value) { if (!/^[0-9]+$/.test(String(value))) { fail(`${name} must be a non-negative integer`); process.exit(1); } } /** * 判斷 release/tag 名稱是否為 beta 版本, * 規則為名稱中含有子字串 "-beta."(例如 v1.2.3-beta.1)。 * * @param {string} [tagName] tag 或 release 的名稱;falsy 時視為非 beta。 * @returns {boolean} 為 beta 版本時 true,否則 false。 */ function isBeta(tagName) { return /-beta\./.test(tagName || ''); } /** * Action 主流程:依保留數量清理 Gitea repo 的舊 release,並刪除沒有對應 release 的 tag。 * * 讀取環境變數 GITEA_SERVER_URL、GITEA_REPOSITORY、GITEA_TOKEN、KEEP_COUNT, * 流程為: * 1. 參數檢查(缺值或 KEEP_COUNT 非整數時以 exit code 1 結束)。 * 2. 取得所有 release,依建立時間由新到舊排序,並分為正式版與 beta 版。 * 3. 正式版與 beta 版各自保留最新 KEEP_COUNT 筆,刪除其餘 release。 * 4. 重新取得 release 後,刪除所有未被任何 release 指定的 tag。 * * Token 為空時以匿名身分呼叫 API。release/tag 的單筆刪除失敗只記錄錯誤不中斷; * 取得分頁失敗或參數不合法則直接以 exit code 1 結束程序。 * * @async * @returns {Promise} */ async function main() { const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL; const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY; const GITEA_TOKEN = process.env.GITEA_TOKEN; const KEEP_COUNT_RAW = process.env.KEEP_COUNT; // 於容器啟動橫幅與後續 log 之間留一空行,方便在 CI log 中閱讀 process.stdout.write('\n'); stage('參數檢查'); requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL); requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY); requireValue('KEEP_COUNT', KEEP_COUNT_RAW); requireInteger('KEEP_COUNT', KEEP_COUNT_RAW); const KEEP_COUNT = parseInt(KEEP_COUNT_RAW, 10); if (isEmptyOrNull(GITEA_TOKEN)) { warn('GITEA_TOKEN is empty; release API calls will be anonymous'); } else { info('GITEA_TOKEN=[redacted]'); } const authHeaders = {}; if (!isEmptyOrNull(GITEA_TOKEN)) { authHeaders['Authorization'] = `token ${GITEA_TOKEN}`; } /** * 對指定 API base URL 逐頁(?page=N,從 1 起)抓取資料, * 直到某頁回傳非陣列或空陣列為止,回傳所有頁面合併後的陣列。 * 任一頁 HTTP 非 2xx 時印出 [ERR] 並以 exit code 1 結束程序。 * 沿用外層 main 的 authHeaders(有 token 時帶 Authorization)。 * * @async * @param {string} baseUrl 不含分頁參數的 API 端點(例如 .../releases 或 .../tags)。 * @returns {Promise>} 所有頁面項目合併後的陣列。 * @throws HTTP 非 2xx 時不擲例外,直接呼叫 process.exit(1) 結束程序; * 但底層 fetch 網路錯誤或回應非 JSON 造成的 res.json() 解析失敗仍會上拋, * 最終由檔尾 main().catch 統一捕捉後 process.exit(1)。 */ async function fetchAllPages(baseUrl) { const all = []; let page = 1; for (;;) { const pageUrl = `${baseUrl}?page=${page}`; const res = await fetch(pageUrl, { headers: authHeaders }); if (!res.ok) { fail(`GET ${pageUrl} 失敗, HTTP ${res.status}`); process.exit(1); } const pageJson = await res.json(); if (!Array.isArray(pageJson) || pageJson.length === 0) { break; } all.push(...pageJson); page += 1; } return all; } /** * 對指定 URL 發送 DELETE 請求(沿用外層 main 的 authHeaders), * 回傳 HTTP 狀態碼供呼叫端判斷成敗(成功慣例為 204)。 * 不輸出訊息、不判斷成敗、不結束程序。 * * @async * @param {string} url 要刪除之資源的完整 URL(release 或 tag 端點)。 * @returns {Promise} 回應的 HTTP 狀態碼。 * @throws 不主動擲例外;但底層 fetch 網路錯誤會上拋,由 main().catch 統一捕捉後 process.exit(1)。 */ async function deleteUrl(url) { const res = await fetch(url, { method: 'DELETE', headers: authHeaders }); return res.status; } const releaseApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases`; stage('取得舊版本'); info(`GET ${releaseApiUrl}`); let releases = await fetchAllPages(releaseApiUrl); releases = releases.sort( (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() ).reverse(); const normalReleases = releases.filter((r) => !isBeta(r.tag_name)); const betaReleases = releases.filter((r) => isBeta(r.tag_name)); info(`RELEASE_COUNT=${releases.length}`); info(`NORMAL_RELEASE_COUNT=${normalReleases.length}`); info(`BETA_RELEASE_COUNT=${betaReleases.length}`); info(`KEEP_COUNT=${KEEP_COUNT}`); const normalToDelete = normalReleases.length > KEEP_COUNT ? normalReleases.slice(KEEP_COUNT) : []; const betaToDelete = betaReleases.length > KEEP_COUNT ? betaReleases.slice(KEEP_COUNT) : []; const releasesToDelete = [...normalToDelete, ...betaToDelete]; if (releasesToDelete.length === 0) { success('沒有需要清理的舊版本成品'); } else { stage('刪除舊版本'); info(`DELETE_RELEASE_COUNT=${releasesToDelete.length}`); for (const release of releasesToDelete) { const releaseId = release.id; const releaseTag = release.tag_name; const releaseName = release.name; if (isEmptyOrNull(releaseId)) { warn(`略過沒有 id 的成品: ${releaseTag} (${releaseName})`); continue; } const url = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${releaseId}`; info(`DELETE ${releaseTag} (${releaseName})`); const code = await deleteUrl(url); if (code === 204) { success(`成功刪除: ${releaseTag} (${releaseName})`); } else { fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${code}`); } } } stage('刪除舊版本'); // 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品 const currentReleases = await fetchAllPages(releaseApiUrl); const releaseTags = new Set(currentReleases.map((r) => r.tag_name)); const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`; info(`GET ${tagApiUrl}`); const tags = await fetchAllPages(tagApiUrl); info(`TAG_COUNT=${tags.length}`); for (const tag of tags) { const tagName = tag.name; if (isEmptyOrNull(tagName)) { warn('略過沒有名稱的 tag'); continue; } if (releaseTags.has(tagName)) { info(`保留指定 release 的 tag: ${tagName}`); continue; } const url = `${tagApiUrl}/${tagName}`; info(`DELETE tag ${tagName}`); const code = await deleteUrl(url); if (code === 204) { success(`成功刪除未指定 release 的 tag: ${tagName}`); } else { fail(`刪除 tag 失敗: ${tagName}, HTTP ${code}`); } } } main().catch((err) => { fail(err && err.stack ? err.stack : String(err)); process.exit(1); });