重新整理 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
+71 -20
View File
@@ -1,5 +1,10 @@
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', {
timeZone: 'Asia/Taipei',
year: 'numeric',
11
@@ -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 === '';
}
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Maya
問題fetchAllPages 新增了分頁、HTTP 狀態碼檢查、JSON 陣列驗證與空頁終止,但沒有看到對這些分支的測試。這是核心資料取得邏輯,若分頁終止條件或錯誤處理出問題,後面的刪除流程就會建立在錯誤資料上。
建議:補測 fetchAllPages:成功串接多頁資料、遇到空頁停止、非 2xx 回應拋錯、回傳非陣列 JSON 拋錯。建議用 stub/mock HTTP server 驗證回傳資料與例外訊息。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:`fetchAllPages` 新增了分頁、HTTP 狀態碼檢查、JSON 陣列驗證與空頁終止,但沒有看到對這些分支的測試。這是核心資料取得邏輯,若分頁終止條件或錯誤處理出問題,後面的刪除流程就會建立在錯誤資料上。 **建議**:補測 `fetchAllPages`:成功串接多頁資料、遇到空頁停止、非 2xx 回應拋錯、回傳非陣列 JSON 拋錯。建議用 stub/mock HTTP server 驗證回傳資料與例外訊息。
/**
* 正規化環境變數值;workflow 模板缺值時可能代入字面值 `'null'`,一律視為未提供。
*
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Assassin
問題:這裡把 GITEA_SERVER_URL 原樣寫進 log,若 URL 內含 userinfo、查詢字串或被惡意塞入敏感資訊,這些內容會直接落到 action log,形成可被讀取的資料外洩點。
建議:不要記錄完整 URL;只輸出必要的非敏感資訊,例如遮罩後的主機名,或改成只記錄是否存在。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:這裡把 `GITEA_SERVER_URL` 原樣寫進 log,若 URL 內含 userinfo、查詢字串或被惡意塞入敏感資訊,這些內容會直接落到 action log,形成可被讀取的資料外洩點。 **建議**:不要記錄完整 URL;只輸出必要的非敏感資訊,例如遮罩後的主機名,或改成只記錄是否存在。
* @param {string | undefined} value 環境變數原始值。
* @returns {string | undefined} 正規化後的值。
*/
function normalizeEnvValue(value) {
return value === 'null' ? undefined : value;
}
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🔵 建議
審查員:Bard
問題requestJson 這個名字太窄,因為它不只用來拿 JSON,也拿一般 HTTP 回應與 DELETE 結果;名稱比實作更嚴格,讀者會先被騙一次。
建議:改名成 requestrequestUrl 之類較中性的名稱,JSON 解析再交給上層 helper。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:`requestJson` 這個名字太窄,因為它不只用來拿 JSON,也拿一般 HTTP 回應與 DELETE 結果;名稱比實作更嚴格,讀者會先被騙一次。 **建議**:改名成 `request`、`requestUrl` 之類較中性的名稱,JSON 解析再交給上層 helper。
/**
* 將 URL 遮罩成只含 origin 的文字,供 log 使用。
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Assassin
問題tagName 同樣來自遠端 API,直接輸出到 log 會讓惡意 tag 名稱注入假訊息或控制終端畫面。攻擊者只要能建立特製 tag,就能污染審計紀錄。
建議:對 tag 名稱做輸出編碼或控制字元過濾,並優先使用結構化日誌,避免未信任字串直接影響 log 內容。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:`tagName` 同樣來自遠端 API,直接輸出到 log 會讓惡意 tag 名稱注入假訊息或控制終端畫面。攻擊者只要能建立特製 tag,就能污染審計紀錄。 **建議**:對 tag 名稱做輸出編碼或控制字元過濾,並優先使用結構化日誌,避免未信任字串直接影響 log 內容。
*
* @param {*} value 原始 URL 值。
* @returns {string} 遮罩後的 origin,無法解析時回傳提示文字。
*/
function maskUrlForLog(value) {
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Assassin
問題:這個驗證只擋掉非數字,0 仍然會通過;若攻擊者能控制 KEEP_COUNT,就能把保留數設成 0,後續流程會把所有 release 刪光,還會把所有未被保留的 tag 一併清掉。
建議:把下限改成至少 1,並在進入刪除流程前再做一次保護檢查;如果真的需要全清,應該改成獨立的高風險開關,而不是混在一般輸入參數裡。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:這個驗證只擋掉非數字,`0` 仍然會通過;若攻擊者能控制 `KEEP_COUNT`,就能把保留數設成 0,後續流程會把所有 release 刪光,還會把所有未被保留的 tag 一併清掉。 **建議**:把下限改成至少 `1`,並在進入刪除流程前再做一次保護檢查;如果真的需要全清,應該改成獨立的高風險開關,而不是混在一般輸入參數裡。
try {
return new URL(String(value)).origin;
} catch (error) {
return '[invalid URL]';
}
}
/**
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Mage
問題:這裡直接拒絕 http: 連線,導致任何使用 http:// 的 Gitea 部署都會在第一個 API 請求就失敗。最小重現情境是把 GITEA_SERVER_URL 設成內網常見的 http://gitea.local,整個清理流程會完全無法執行。
建議:若這個 action 需要支援常見的自架環境,應移除固定只允許 HTTPS 的限制,或把協定限制做成可配置;若確實只支援 HTTPS,也要在 action 說明中明確標示,避免使用者在 http 環境下直接踩雷。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:這裡直接拒絕 `http:` 連線,導致任何使用 `http://` 的 Gitea 部署都會在第一個 API 請求就失敗。最小重現情境是把 `GITEA_SERVER_URL` 設成內網常見的 `http://gitea.local`,整個清理流程會完全無法執行。 **建議**:若這個 action 需要支援常見的自架環境,應移除固定只允許 HTTPS 的限制,或把協定限制做成可配置;若確實只支援 HTTPS,也要在 action 說明中明確標示,避免使用者在 `http` 環境下直接踩雷。
@@ -150,9 +179,10 @@ function isEmptyOrNull(value) {
*
* @param {string} name 參數名稱。
* @param {*} value 參數值。
* @param {*} [displayValue=value] 寫進 log 的顯示值,敏感內容可先遮罩。
*/
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Rogue
問題:這裡每次 request 都重新走一次預設 HTTPS 連線,沒有重用 keep-alive 連線。後面又會連續打多次頁面查詢與刪除 API,TLS 握手和 socket 建立會被重複支付,release/tag 數量一多就很浪費延遲與 CPU。
建議:改成共用 https.Agent({ keepAlive: true }),並把同一個 agent 傳給所有 GET/DELETE request,減少重複建連線的成本。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:這裡每次 request 都重新走一次預設 HTTPS 連線,沒有重用 keep-alive 連線。後面又會連續打多次頁面查詢與刪除 API,TLS 握手和 socket 建立會被重複支付,release/tag 數量一多就很浪費延遲與 CPU。 **建議**:改成共用 `https.Agent({ keepAlive: true })`,並把同一個 agent 傳給所有 GET/DELETE request,減少重複建連線的成本。
function requireValue(name, value) {
info(`${name}=${value}`);
function requireValue(name, value, displayValue = value) {
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Rogue
問題:這裡先把每一頁資料全部塞進 all,等於把整個 API 結果完整具現化;release/tag 數量一大時,記憶體會吃到 O(n),而且陣列反覆擴容與拷貝也會多耗 CPU。
建議:改成邊抓邊處理,不要先合併成單一大陣列;如果 API 支援,順便加大每頁筆數,減少往返次數。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:這裡先把每一頁資料全部塞進 `all`,等於把整個 API 結果完整具現化;release/tag 數量一大時,記憶體會吃到 O(n),而且陣列反覆擴容與拷貝也會多耗 CPU。 **建議**:改成邊抓邊處理,不要先合併成單一大陣列;如果 API 支援,順便加大每頁筆數,減少往返次數。
info(`${name}=${displayValue}`);
if (isEmptyOrNull(value)) {
fail(`${name} is required`);
2
@@ -193,6 +223,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
{
method,
headers,
agent: keepAliveAgent,
},
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Leo
問題processInBatches()Promise.all 搭配外層共享的 hadFailure,失敗語意會變得很難推理:一筆例外會直接中斷整批,但其他並行工作仍可能繼續跑,最後到底刪了哪些、漏了哪些,不看執行細節很難判斷。這種控制流對日後補測試或改錯誤處理都不友善。
建議:把每筆處理的結果收斂成明確的成功/失敗回傳值,或在批次內逐筆捕捉錯誤後再彙總;若要保留並行,至少讓批次函式回傳可測試的結果集合,而不是依賴外部可變狀態。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:`processInBatches()` 用 `Promise.all` 搭配外層共享的 `hadFailure`,失敗語意會變得很難推理:一筆例外會直接中斷整批,但其他並行工作仍可能繼續跑,最後到底刪了哪些、漏了哪些,不看執行細節很難判斷。這種控制流對日後補測試或改錯誤處理都不友善。 **建議**:把每筆處理的結果收斂成明確的成功/失敗回傳值,或在批次內逐筆捕捉錯誤後再彙總;若要保留並行,至少讓批次函式回傳可測試的結果集合,而不是依賴外部可變狀態。
(res) => {
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🔵 建議
審查員:Assassin
問題:分頁迴圈沒有上限,只要對方持續回傳非空頁面,這個 action 就會無限抓取;惡意或故障中的 API 可以把 runner 卡死,消耗時間與配額。
建議:加入最大頁數、重複頁檢測或總筆數上限,超過就中止並回報異常,避免被外部回應拖成無限迴圈。

**嚴重等級**:🔵 建議 **審查員**:Assassin **問題**:分頁迴圈沒有上限,只要對方持續回傳非空頁面,這個 action 就會無限抓取;惡意或故障中的 API 可以把 runner 卡死,消耗時間與配額。 **建議**:加入最大頁數、重複頁檢測或總筆數上限,超過就中止並回報異常,避免被外部回應拖成無限迴圈。
const chunks = [];
2
@@ -216,7 +247,7 @@ function request(url, { method = 'GET', headers = {} } = {}) {
}
/**
* 逐頁抓取 JSON 陣列資料,直到回傳空頁為止。
* 逐頁抓取 JSON 陣列資料,直到回傳空頁為止;超過 `MAX_PAGES` 即中止並回報異常
*
* @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 標頭。
@@ -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 });
10
@@ -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) ?? '';
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Rogue
問題:tag 刪除同樣是逐筆 await,當 tag 數量多時會把每次 API 往返都串成排隊,刪除時間幾乎全卡在網路延遲上。
建議:用受限並行處理 tag 刪除,或先收集待刪清單再批次送出,減少總等待時間。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:tag 刪除同樣是逐筆 `await`,當 tag 數量多時會把每次 API 往返都串成排隊,刪除時間幾乎全卡在網路延遲上。 **建議**:用受限並行處理 tag 刪除,或先收集待刪清單再批次送出,減少總等待時間。
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);
2
@@ -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 = '';
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Assassin
問題releaseItem.id 直接進入 DELETE URL,完全信任 API 回來的值;如果回應被污染或伺服器回傳惡意資料,攻擊者就能把刪除請求導向非預期路徑。
建議:在組 URL 前先確認 id 一定是正整數,拒絕任何非數字或異常範圍的值,再送出刪除請求。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:`releaseItem.id` 直接進入 DELETE URL,完全信任 API 回來的值;如果回應被污染或伺服器回傳惡意資料,攻擊者就能把刪除請求導向非預期路徑。 **建議**:在組 URL 前先確認 `id` 一定是正整數,拒絕任何非數字或異常範圍的值,再送出刪除請求。
Outdated
Review

嚴重等級🟡 警告
審查員:Mage
問題GITEA_SERVER_URL 只做非空檢查,沒有先確認它是合法的絕對 URL。只要傳入像 gitea.localhttps:// 這類看起來有值但格式不合法的字串,new URL() 就會直接丟出未處理例外,錯誤也不會明確指出是參數格式問題。
建議:在進入主流程前先對 GITEA_SERVER_URLtry/catch 驗證,失敗時回傳明確的參數錯誤並結束;不要把 URL 解析失敗留到中途才爆。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:`GITEA_SERVER_URL` 只做非空檢查,沒有先確認它是合法的絕對 URL。只要傳入像 `gitea.local`、`https://` 這類看起來有值但格式不合法的字串,`new URL()` 就會直接丟出未處理例外,錯誤也不會明確指出是參數格式問題。 **建議**:在進入主流程前先對 `GITEA_SERVER_URL` 做 `try/catch` 驗證,失敗時回傳明確的參數錯誤並結束;不要把 URL 解析失敗留到中途才爆。
serverBase.password = '';
serverBase.search = '';
serverBase.hash = '';
const serverBaseUrl = serverBase.toString().replace(/\/+$/, '');
const releaseApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/releases`;
section('取得成品資訊');
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🟡 警告
審查員:Mage
問題:前面只要有任何 release 刪除失敗,這裡仍然會繼續做 tag 清理,而且 releaseTags 是依照刪除前的清單算出來的。最小重現情境是某個待刪 release 因權限不足或暫時性網路錯誤沒刪掉,接著它對應的 tag 仍可能被刪除,最後變成 release 還在、tag 卻被移除的半套狀態。
建議:在進入 tag 清理前先檢查 release 刪除是否有失敗;只要有失敗就應中止後續 tag 刪除,或改成只把實際成功刪除的 release 對應 tag 納入待刪集合,避免留下不一致狀態。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:前面只要有任何 release 刪除失敗,這裡仍然會繼續做 tag 清理,而且 `releaseTags` 是依照刪除前的清單算出來的。最小重現情境是某個待刪 release 因權限不足或暫時性網路錯誤沒刪掉,接著它對應的 tag 仍可能被刪除,最後變成 release 還在、tag 卻被移除的半套狀態。 **建議**:在進入 tag 清理前先檢查 release 刪除是否有失敗;只要有失敗就應中止後續 tag 刪除,或改成只把實際成功刪除的 release 對應 tag 納入待刪集合,避免留下不一致狀態。
info(`GET ${releaseApiUrl}`);
1
@@ -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);
1
@@ -358,7 +403,7 @@ async function main() {
}
if (hadFailure) {
throw new Error('至少有一筆 release 或 tag 刪除失敗');
throw new Error('至少有一筆 release 刪除失敗');
}
section('刪除未指定 release 的 tag');
admin marked this conversation as resolved Outdated
Outdated
Review

嚴重等級🔵 建議
審查員:Leo
問題:在 catch 裡先把 currentStage 清空再記錄錯誤,會讓最後那筆失敗 log 失去「到底是在哪個階段炸掉」的上下文。等到未來有人要追問題時,只能回頭翻前面的輸出,比對成本會很高。
建議:保留最後的 currentStage,或在進入 catch 時把階段一起寫進錯誤訊息;如果擔心汙染後續輸出,可以在輸出完成後再重設,而不是先清空。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:在 `catch` 裡先把 `currentStage` 清空再記錄錯誤,會讓最後那筆失敗 log 失去「到底是在哪個階段炸掉」的上下文。等到未來有人要追問題時,只能回頭翻前面的輸出,比對成本會很高。 **建議**:保留最後的 `currentStage`,或在進入 `catch` 時把階段一起寫進錯誤訊息;如果擔心汙染後續輸出,可以在輸出完成後再重設,而不是先清空。
@@ -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();
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 清理。
});