334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
#!/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 非 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<number>} 回應的 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);
|
||
});
|