#!/usr/bin/env node 'use strict'; /** * 在 stdout 輸出一條視覺分隔線(前後各一個換行 + 50 個等號), * 用於在 log 中切割不同區段。 * * @returns {void} */ function separator() { process.stdout.write('\n==================================================\n'); } /** * 輸出一個區段標題區塊:上緣等號分隔線、標題文字、下緣虛線, * 用於在 log 中標示流程進入新階段。 * * @param {string} title 區段標題文字。 * @returns {void} */ function section(title) { separator(); process.stdout.write(`${title}\n`); process.stdout.write('--------------------------------------------------\n'); } /** * 取得目前 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, '/'); } /** * 以 `[INF][時間]:` 前綴將一般資訊訊息輸出到 stdout(時間為 Asia/Taipei)。 * * @param {string} msg 要輸出的訊息內容。 * @returns {void} */ function info(msg) { console.log(`[INF][${timestamp()}]: ${msg}`); } /** * 以 `[INF][時間]:` 前綴將成功訊息輸出到 stdout(時間為 Asia/Taipei)。 * 規範等級碼無「成功」,故等級採 INF,成功語意保留於訊息文字。 * * @param {string} msg 要輸出的訊息內容。 * @returns {void} */ function success(msg) { console.log(`[INF][${timestamp()}]: ${msg}`); } /** * 以 `[WRN][時間]:` 前綴將警告訊息輸出到 stdout(時間為 Asia/Taipei)。 * * @param {string} msg 要輸出的訊息內容。 * @returns {void} */ function warn(msg) { console.log(`[WRN][${timestamp()}]: ${msg}`); } /** * 以 `[ERR][時間]:` 前綴將錯誤訊息輸出到 stderr(時間為 Asia/Taipei)。 * 僅負責輸出,不會結束程序(是否離開由呼叫端決定)。 * * @param {string} msg 要輸出的錯誤訊息內容。 * @returns {void} */ function fail(msg) { console.error(`[ERR][${timestamp()}]: ${msg}`); } /** * 判斷值是否視為「空 / 未設定」。 * 涵蓋 undefined、null、空字串,以及字面字串 'null' *(後者用於處理 CI/環境變數把未設定值帶成字串 "null" 的情況)。 * * @param {*} value 要檢查的值。 * @returns {boolean} 視為空值時為 true,否則為 false。 */ function isEmptyOrNull(value) { return value === undefined || value === null || value === '' || value === 'null'; } /** * 檢查必填值。先以 [INFO] 印出 name=value, * 若值被判定為空(見 isEmptyOrNull),印出 [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; section('參數檢查'); 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 失敗時直接呼叫 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 狀態碼。 */ 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`; section('取得成品資訊'); 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 { section('刪除舊版本成品'); 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}`); } } } section('刪除未指定 release 的 tag'); // 重新取得 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}`); } } separator(); } main().catch((err) => { fail(err && err.stack ? err.stack : String(err)); process.exit(1); });