Files
release-cleanup/app/index.js
T
JefferyandClaude Opus 4.8 c37b33f397 fix(release-cleanup): 日誌改為 [階段][等級][時間] 格式並移除區段分隔線
- 新增 stage() 設定目前階段、formatLog() 共用組裝,log 統一為 [階段][等級][時間]: 訊息
- 階段對應 參數檢查/取得舊版本/刪除舊版本;移除 separator()/section() 的 ==== / ---- 分隔區塊
- 行為、控制流、exit code 不變;fail 仍走 stderr、其餘走 stdout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 09:07:05 +08:00

331 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<void>}
*/
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<Array<object>>} 所有頁面項目合併後的陣列。
* @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 要刪除之資源的完整 URLrelease 或 tag 端點)。
* @returns {Promise<number>} 回應的 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`;
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);
});