feat(release-cleanup): 改為 Node 主程式並容器化
將 action 由 bash(curl/jq) 改寫為 Node 主程式 app/index.js(沿用原清理邏輯: 正式版與 beta 版各自保留 KEEP_COUNT 筆、刪除多餘 release 與未綁定 release 的 tag), entrypoint.sh 改為輸出橫幅後 exec node 主程式,Dockerfile 改用 node alpine 六步結構, 並補上 app/package.json 與 action.yaml 輸入說明。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cb943e49a7
commit
7ac64709b9
+307
@@ -0,0 +1,307 @@
|
||||
#!/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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 [INFO] 前綴將一般資訊訊息輸出到 stdout。
|
||||
*
|
||||
* @param {string} msg 要輸出的訊息內容。
|
||||
* @returns {void}
|
||||
*/
|
||||
function info(msg) {
|
||||
console.log(`[INFO] ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 [OK] 前綴將成功訊息輸出到 stdout。
|
||||
*
|
||||
* @param {string} msg 要輸出的訊息內容。
|
||||
* @returns {void}
|
||||
*/
|
||||
function success(msg) {
|
||||
console.log(`[OK] ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 [WARN] 前綴將警告訊息輸出到 stdout。
|
||||
*
|
||||
* @param {string} msg 要輸出的訊息內容。
|
||||
* @returns {void}
|
||||
*/
|
||||
function warn(msg) {
|
||||
console.log(`[WARN] ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 [ERR] 前綴將錯誤訊息輸出到 stderr。
|
||||
* 僅負責輸出,不會結束程序(是否離開由呼叫端決定)。
|
||||
*
|
||||
* @param {string} msg 要輸出的錯誤訊息內容。
|
||||
* @returns {void}
|
||||
*/
|
||||
function fail(msg) {
|
||||
console.error(`[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] 印出 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<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;
|
||||
|
||||
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<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 要刪除之資源的完整 URL(release 或 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`;
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "release-cleanup",
|
||||
"version": "1.0.0",
|
||||
"description": "清理舊成品(Gitea release 與未綁定 release 的 tag)",
|
||||
"author": "Jeffery",
|
||||
"license": "MIT",
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user