重新整理 cleanup-release 動作與文件 #1

Merged
admin merged 18 commits from ai-review-resolve/develop-20260711-131608 into develop 2026-07-15 02:38:32 +00:00
Showing only changes of commit 45588ee96e - Show all commits
+69 -18
View File
@@ -1,5 +1,10 @@
const https = require('https'); const https = require('https');
admin marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:這個檔案同時承擔 log 格式化、輸入驗證、HTTP 呼叫、分頁抓取、刪除流程與錯誤彙總,責任切得太散。半年後只要想改一個 API 規則,維護者就得在同一個大檔裡來回跳,單元測試也很難把純邏輯跟 I/O 分開。
建議:把共用基礎能力拆成獨立模組,例如 loggergitea clientcleanup workflow,並讓主程式只負責組裝依賴與啟動流程。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:這個檔案同時承擔 log 格式化、輸入驗證、HTTP 呼叫、分頁抓取、刪除流程與錯誤彙總,責任切得太散。半年後只要想改一個 API 規則,維護者就得在同一個大檔裡來回跳,單元測試也很難把純邏輯跟 I/O 分開。 **建議**:把共用基礎能力拆成獨立模組,例如 `logger`、`gitea client`、`cleanup workflow`,並讓主程式只負責組裝依賴與啟動流程。
const DELETE_CONCURRENCY = 4;
const MAX_PAGES = 1000;
const keepAliveAgent = new https.Agent({ keepAlive: true });
const taipeiFormatter = new Intl.DateTimeFormat('en-CA', { const taipeiFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Taipei', timeZone: 'Asia/Taipei',
year: 'numeric', year: 'numeric',
6
@@ -142,7 +147,31 @@ function fail(message) {
* @returns {boolean} 如果是空值則回傳 `true`。 * @returns {boolean} 如果是空值則回傳 `true`。
*/ */
function isEmptyOrNull(value) { function isEmptyOrNull(value) {
return value === undefined || value === null || value === '' || value === 'null'; return value === undefined || value === null || value === '';
}
/**
* 正規化環境變數值;workflow 模板缺值時可能代入字面值 `'null'`,一律視為未提供。
*
* @param {string | undefined} value 環境變數原始值。
* @returns {string | undefined} 正規化後的值。
*/
function normalizeEnvValue(value) {
return value === 'null' ? undefined : value;
}
/**
* 將 URL 遮罩成只含 origin 的文字,供 log 使用。
*
* @param {*} value 原始 URL 值。
* @returns {string} 遮罩後的 origin,無法解析時回傳提示文字。
*/
function maskUrlForLog(value) {
try {
return new URL(String(value)).origin;
} catch (error) {
return '[invalid URL]';
}
} }
/** /**
@@ -150,9 +179,10 @@ function isEmptyOrNull(value) {
* *
* @param {string} name 參數名稱。 * @param {string} name 參數名稱。
* @param {*} value 參數值。 * @param {*} value 參數值。
* @param {*} [displayValue=value] 寫進 log 的顯示值,敏感內容可先遮罩。
*/ */
function requireValue(name, value) { function requireValue(name, value, displayValue = value) {
info(`${name}=${value}`); info(`${name}=${displayValue}`);
if (isEmptyOrNull(value)) { if (isEmptyOrNull(value)) {
fail(`${name} is required`); fail(`${name} is required`);
@@ -193,6 +223,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
{ {
method, method,
headers, headers,
agent: keepAliveAgent,
}, },
(res) => { (res) => {
const chunks = []; const chunks = [];
@@ -216,7 +247,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
} }
/** /**
* 逐頁抓取 JSON 陣列資料,直到回傳空頁為止。 * 逐頁抓取 JSON 陣列資料,直到回傳空頁為止;超過 `MAX_PAGES` 即中止並回報異常
* *
* @param {string} baseUrl 不含 page 參數的 API URL。 * @param {string} baseUrl 不含 page 參數的 API URL。
admin marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Assassin
問題GITEA_REPOSITORY 直接字串串進 release API 路徑,沒有做格式驗證或路徑編碼。只要這個值被污染,攻擊者就能把 ../、額外斜線或其他路徑片段塞進去,讓帶著授權 token 的請求打到非預期的 API 路徑,擴大刪除面。
建議:先把 repository 嚴格限制為 owner/repo 這種固定格式,再對 owner 與 repo 各自做 encodeURIComponent 後組 URL,不要直接把原字串拼進路徑。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:`GITEA_REPOSITORY` 直接字串串進 release API 路徑,沒有做格式驗證或路徑編碼。只要這個值被污染,攻擊者就能把 `../`、額外斜線或其他路徑片段塞進去,讓帶著授權 token 的請求打到非預期的 API 路徑,擴大刪除面。 **建議**:先把 repository 嚴格限制為 `owner/repo` 這種固定格式,再對 owner 與 repo 各自做 `encodeURIComponent` 後組 URL,不要直接把原字串拼進路徑。
* @param {Record<string, string>} headers request 標頭。 * @param {Record<string, string>} headers request 標頭。
@@ -226,6 +257,10 @@ async function fetchAllPages(baseUrl, headers) {
const all = []; const all = [];
for (let page = 1; ; page += 1) { for (let page = 1; ; page += 1) {
if (page > MAX_PAGES) {
throw new Error(`GET ${baseUrl} 分頁超過 ${MAX_PAGES} 頁上限,中止抓取以避免無限迴圈`);
}
const pageUrl = `${baseUrl}?page=${page}`; const pageUrl = `${baseUrl}?page=${page}`;
const { statusCode, body } = await request(pageUrl, { headers }); const { statusCode, body } = await request(pageUrl, { headers });
6
@@ -286,11 +321,13 @@ async function processInBatches(items, batchSize, handler) {
* 執行 release 與 tag 清理流程。 * 執行 release 與 tag 清理流程。
*/ */
async function main() { async function main() {
const { GITEA_SERVER_URL, GITEA_REPOSITORY, RUNNER_TOKEN = '', KEEP_COUNT = '' } = const GITEA_SERVER_URL = normalizeEnvValue(process.env.GITEA_SERVER_URL);
process.env; const GITEA_REPOSITORY = normalizeEnvValue(process.env.GITEA_REPOSITORY);
const RUNNER_TOKEN = normalizeEnvValue(process.env.RUNNER_TOKEN) ?? '';
const KEEP_COUNT = normalizeEnvValue(process.env.KEEP_COUNT) ?? '';
section('參數檢查'); section('參數檢查');
requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL); requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL, maskUrlForLog(GITEA_SERVER_URL));
requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY); requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY);
requireValue('KEEP_COUNT', KEEP_COUNT); requireValue('KEEP_COUNT', KEEP_COUNT);
requireInteger('KEEP_COUNT', KEEP_COUNT); requireInteger('KEEP_COUNT', KEEP_COUNT);
1
@@ -304,7 +341,14 @@ async function main() {
authHeaders.Authorization = `token ${RUNNER_TOKEN}`; authHeaders.Authorization = `token ${RUNNER_TOKEN}`;
} }
const releaseApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases`; const serverBase = new URL(GITEA_SERVER_URL);
serverBase.username = '';
serverBase.password = '';
serverBase.search = '';
serverBase.hash = '';
const serverBaseUrl = serverBase.toString().replace(/\/+$/, '');
const releaseApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/releases`;
section('取得成品資訊'); section('取得成品資訊');
info(`GET ${releaseApiUrl}`); info(`GET ${releaseApiUrl}`);
@@ -334,17 +378,18 @@ async function main() {
section('刪除舊版本成品'); section('刪除舊版本成品');
const releaseToDelete = releaseJson.slice(keepCount); const releaseToDelete = releaseJson.slice(keepCount);
await processInBatches(releaseToDelete, 4, async (releaseItem) => { await processInBatches(releaseToDelete, DELETE_CONCURRENCY, async (releaseItem) => {
if (!releaseItem || isEmptyOrNull(releaseItem.id)) { const releaseId = releaseItem?.id;
if (!Number.isSafeInteger(releaseId) || releaseId <= 0) {
warn( warn(
`略過沒有 id 的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`, `略過 id 不是正整數的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`,
); );
return; return;
} }
const releaseTag = sanitizeLogText(releaseItem.tag_name || ''); const releaseTag = sanitizeLogText(releaseItem.tag_name || '');
const releaseName = sanitizeLogText(releaseItem.name || ''); const releaseName = sanitizeLogText(releaseItem.name || '');
const deleteUrl = `${releaseApiUrl}/${releaseItem.id}`; const deleteUrl = `${releaseApiUrl}/${releaseId}`;
info(`DELETE ${releaseTag} (${releaseName})`); info(`DELETE ${releaseTag} (${releaseName})`);
const { statusCode } = await deleteResource(deleteUrl, authHeaders); const { statusCode } = await deleteResource(deleteUrl, authHeaders);
@@ -358,7 +403,7 @@ async function main() {
} }
if (hadFailure) { if (hadFailure) {
throw new Error('至少有一筆 release 或 tag 刪除失敗'); throw new Error('至少有一筆 release 刪除失敗');
} }
section('刪除未指定 release 的 tag'); section('刪除未指定 release 的 tag');
@@ -370,13 +415,13 @@ async function main() {
.filter((tag) => !isEmptyOrNull(tag)), .filter((tag) => !isEmptyOrNull(tag)),
); );
const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`; const tagApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/tags`;
info(`GET ${tagApiUrl}`); info(`GET ${tagApiUrl}`);
const tagJson = await fetchAllPages(tagApiUrl, authHeaders); const tagJson = await fetchAllPages(tagApiUrl, authHeaders);
info(`TAG_COUNT=${tagJson.length}`); info(`TAG_COUNT=${tagJson.length}`);
await processInBatches(tagJson, 4, async (tagItem) => { await processInBatches(tagJson, DELETE_CONCURRENCY, async (tagItem) => {
const tagName = tagItem?.name; const tagName = tagItem?.name;
if (isEmptyOrNull(tagName)) { if (isEmptyOrNull(tagName)) {
warn('略過沒有名稱的 tag'); warn('略過沒有名稱的 tag');
@@ -401,10 +446,16 @@ async function main() {
} }
}); });
if (hadFailure) {
throw new Error('至少有一筆 tag 刪除失敗');
}
} }
main().catch((error) => { main()
currentStage = ''; .catch((error) => {
fail(error instanceof Error ? error.stack || error.message : String(error)); fail(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1); process.exitCode = 1;
})
.finally(() => {
keepAliveAgent.destroy();
Review

嚴重等級🟡 警告
審查員:Mage
問題:當 releaseItem.id 缺失或不是安全整數時,這裡只警告然後回傳 true,等於把資料異常當成處理成功。最壞情況是 API 回傳壞資料或 schema 改版,舊 release 被靜默跳過,最後 job 仍可能顯示成功。
建議:遇到無效 id 時應直接視為失敗,改成 throw 或回傳 false,讓工作非正常結束並停止後續 tag 清理。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:當 `releaseItem.id` 缺失或不是安全整數時,這裡只警告然後回傳 `true`,等於把資料異常當成處理成功。最壞情況是 API 回傳壞資料或 schema 改版,舊 release 被靜默跳過,最後 job 仍可能顯示成功。 **建議**:遇到無效 `id` 時應直接視為失敗,改成 `throw` 或回傳 `false`,讓工作非正常結束並停止後續 tag 清理。
}); });