2 Commits
Author SHA1 Message Date
JefferyandClaude Fable 5 e10f9c95c4 chore(ai-review 狀態): 清空已全數解決的 findings.json
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 7m28s
CI / 3. RESULT (pull_request) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:37:13 +08:00
JefferyandClaude Fable 5 45588ee96e fix(cleanup-release): 修正刪除失敗未回報、輸入驗證與 URL 遮罩並重用連線
- tag 刪除失敗後補上 hadFailure 檢查,確保以非 0 結束
- GITEA_SERVER_URL 的 log 改為遮罩後 origin,API URL 清除 userinfo/query
- release id 驗證為正整數才組 DELETE URL
- isEmptyOrNull 不再把字串 'null' 當空值,改在環境變數來源正規化
- 共用 keep-alive https.Agent 減少重複建連線
- 分頁抓取加入 MAX_PAGES 上限避免無限迴圈
- 批次大小抽成 DELETE_CONCURRENCY 常數
- 錯誤處理保留 currentStage 階段上下文

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 09:37:13 +08:00
2 changed files with 72 additions and 86 deletions
+1 -66
View File
@@ -1,66 +1 @@
[
{
"level": "critical",
"role": "Mage",
"location": "src/index.js:400",
"problem": "這裡在刪除 tag 的流程中只把失敗記進 `hadFailure`,但流程結束後沒有再檢查或拋錯。最小重現:只要任一個 tag 刪除回傳 404/500,程式仍會以 0 結束,外層 CI 會誤判為清理成功,但實際上遺留的 tag 還在。",
"suggestion": "在 `processInBatches(tagJson, ...)` 結束後補上 `if (hadFailure) throw new Error(...)`,讓任何 tag 刪除失敗都會正確回傳非 0 狀態。",
"is_new": true
},
{
"level": "warning",
"role": "Assassin",
"location": "src/index.js:155",
"problem": "這裡把 `GITEA_SERVER_URL` 原樣寫進 log,若 URL 內含 userinfo、查詢字串或被惡意塞入敏感資訊,這些內容會直接落到 action log,形成可被讀取的資料外洩點。",
"suggestion": "不要記錄完整 URL;只輸出必要的非敏感資訊,例如遮罩後的主機名,或改成只記錄是否存在。",
"is_new": true
},
{
"level": "warning",
"role": "Assassin",
"location": "src/index.js:345",
"problem": "`releaseItem.id` 直接進入 DELETE URL,完全信任 API 回來的值;如果回應被污染或伺服器回傳惡意資料,攻擊者就能把刪除請求導向非預期路徑。",
"suggestion": "在組 URL 前先確認 `id` 一定是正整數,拒絕任何非數字或異常範圍的值,再送出刪除請求。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/index.js:144",
"problem": "這個空值判斷把字串 `'null'` 也當成空值。最小重現:若某個 release 的 `tag_name` 真的就是 `null`,它會在 `releaseTags` 建立時被排除,後續 tag 清理會把這個原本應保留的 tag 誤刪。",
"suggestion": "不要在通用空值判斷裡把字串 `'null'` 視為空值;只保留 `undefined`、`null` 與空字串。如果某些輸入來源真的會傳出字面值 `'null'`,請在那個來源各自做正規化。",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/index.js:183",
"problem": "這裡每次 request 都重新走一次預設 HTTPS 連線,沒有重用 keep-alive 連線。後面又會連續打多次頁面查詢與刪除 API,TLS 握手和 socket 建立會被重複支付,release/tag 數量一多就很浪費延遲與 CPU。",
"suggestion": "改成共用 `https.Agent({ keepAlive: true })`,並把同一個 agent 傳給所有 GET/DELETE request,減少重複建連線的成本。",
"is_new": true
},
{
"level": "info",
"role": "Assassin",
"location": "src/index.js:228",
"problem": "分頁迴圈沒有上限,只要對方持續回傳非空頁面,這個 action 就會無限抓取;惡意或故障中的 API 可以把 runner 卡死,消耗時間與配額。",
"suggestion": "加入最大頁數、重複頁檢測或總筆數上限,超過就中止並回報異常,避免被外部回應拖成無限迴圈。",
"is_new": true
},
{
"level": "info",
"role": "Bard",
"location": "src/index.js:337",
"problem": "批次大小直接裸寫 `4`,而且在後面同樣又出現一次;這種數字沒有名字,像臨時即興的節拍,之後要調整時很難一眼找到所有節點。",
"suggestion": "把批次大小抽成具名常數,例如 `const DELETE_CONCURRENCY = 4;`,兩個呼叫點共用,畫面會更整齊,也更好維護。",
"is_new": true
},
{
"level": "info",
"role": "Leo",
"location": "src/index.js:409",
"problem": "在 `catch` 裡先把 `currentStage` 清空再記錄錯誤,會讓最後那筆失敗 log 失去「到底是在哪個階段炸掉」的上下文。等到未來有人要追問題時,只能回頭翻前面的輸出,比對成本會很高。",
"suggestion": "保留最後的 `currentStage`,或在進入 `catch` 時把階段一起寫進錯誤訊息;如果擔心汙染後續輸出,可以在輸出完成後再重設,而不是先清空。",
"is_new": true
}
]
[]
+71 -20
View File
@@ -1,5 +1,10 @@
const https = require('https');
const DELETE_CONCURRENCY = 4;
const MAX_PAGES = 1000;
const keepAliveAgent = new https.Agent({ keepAlive: true });
const taipeiFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Taipei',
year: 'numeric',
@@ -142,7 +147,31 @@ function fail(message) {
* @returns {boolean} 如果是空值則回傳 `true`。
*/
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 {*} value 參數值。
* @param {*} [displayValue=value] 寫進 log 的顯示值,敏感內容可先遮罩。
*/
function requireValue(name, value) {
info(`${name}=${value}`);
function requireValue(name, value, displayValue = value) {
info(`${name}=${displayValue}`);
if (isEmptyOrNull(value)) {
fail(`${name} is required`);
@@ -193,6 +223,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
{
method,
headers,
agent: keepAliveAgent,
},
(res) => {
const chunks = [];
@@ -216,7 +247,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
}
/**
* 逐頁抓取 JSON 陣列資料,直到回傳空頁為止。
* 逐頁抓取 JSON 陣列資料,直到回傳空頁為止;超過 `MAX_PAGES` 即中止並回報異常
*
* @param {string} baseUrl 不含 page 參數的 API URL。
* @param {Record<string, string>} headers request 標頭。
@@ -226,6 +257,10 @@ async function fetchAllPages(baseUrl, headers) {
const all = [];
for (let page = 1; ; page += 1) {
if (page > MAX_PAGES) {
throw new Error(`GET ${baseUrl} 分頁超過 ${MAX_PAGES} 頁上限,中止抓取以避免無限迴圈`);
}
const pageUrl = `${baseUrl}?page=${page}`;
const { statusCode, body } = await request(pageUrl, { headers });
@@ -286,11 +321,13 @@ async function processInBatches(items, batchSize, handler) {
* 執行 release 與 tag 清理流程。
*/
async function main() {
const { GITEA_SERVER_URL, GITEA_REPOSITORY, RUNNER_TOKEN = '', KEEP_COUNT = '' } =
process.env;
const GITEA_SERVER_URL = normalizeEnvValue(process.env.GITEA_SERVER_URL);
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('參數檢查');
requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL);
requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL, maskUrlForLog(GITEA_SERVER_URL));
requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY);
requireValue('KEEP_COUNT', KEEP_COUNT);
requireInteger('KEEP_COUNT', KEEP_COUNT);
@@ -304,7 +341,14 @@ async function main() {
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('取得成品資訊');
info(`GET ${releaseApiUrl}`);
@@ -334,17 +378,18 @@ async function main() {
section('刪除舊版本成品');
const releaseToDelete = releaseJson.slice(keepCount);
await processInBatches(releaseToDelete, 4, async (releaseItem) => {
if (!releaseItem || isEmptyOrNull(releaseItem.id)) {
await processInBatches(releaseToDelete, DELETE_CONCURRENCY, async (releaseItem) => {
const releaseId = releaseItem?.id;
if (!Number.isSafeInteger(releaseId) || releaseId <= 0) {
warn(
`略過沒有 id 的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`,
`略過 id 不是正整數的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`,
);
return;
}
const releaseTag = sanitizeLogText(releaseItem.tag_name || '');
const releaseName = sanitizeLogText(releaseItem.name || '');
const deleteUrl = `${releaseApiUrl}/${releaseItem.id}`;
const deleteUrl = `${releaseApiUrl}/${releaseId}`;
info(`DELETE ${releaseTag} (${releaseName})`);
const { statusCode } = await deleteResource(deleteUrl, authHeaders);
@@ -358,7 +403,7 @@ async function main() {
}
if (hadFailure) {
throw new Error('至少有一筆 release 或 tag 刪除失敗');
throw new Error('至少有一筆 release 刪除失敗');
}
section('刪除未指定 release 的 tag');
@@ -370,13 +415,13 @@ async function main() {
.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}`);
const tagJson = await fetchAllPages(tagApiUrl, authHeaders);
info(`TAG_COUNT=${tagJson.length}`);
await processInBatches(tagJson, 4, async (tagItem) => {
await processInBatches(tagJson, DELETE_CONCURRENCY, async (tagItem) => {
const tagName = tagItem?.name;
if (isEmptyOrNull(tagName)) {
warn('略過沒有名稱的 tag');
@@ -401,10 +446,16 @@ async function main() {
}
});
if (hadFailure) {
throw new Error('至少有一筆 tag 刪除失敗');
}
}
main().catch((error) => {
currentStage = '';
fail(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
main()
.catch((error) => {
fail(error instanceof Error ? error.stack || error.message : String(error));
process.exitCode = 1;
})
.finally(() => {
keepAliveAgent.destroy();
});