refactor(release-cleanup): 將清理邏輯由 bash 改寫為 Node.js #5

Closed
jiantw83 wants to merge 43 commits from refactor/nodejs-rewrite-20260626-103443 into develop
10 changed files with 377 additions and 179 deletions
Showing only changes of commit b1aa8730a2 - Show all commits
+16 -4
View File
@@ -1,10 +1,22 @@
FROM alpine:latest
# =============================================================================
# 用途: 建置 release-cleanup Action 的容器映像。
# 以 Node.js 執行環境打包 /app 下的 Node.js 程式 (專案已從 bash 改寫為 Node.js),
# 並透過 entrypoint.sh 啟動。
# 更新日期: 2026/06/26 10:28:36
# =============================================================================
# 安裝必要的工具
RUN apk add --no-cache --no-check-certificate bash curl jq
# 基底映像:Node.js 20 的 Alpine 版本 (體積小)。
FROM node:20-alpine
# 複製 Node.js 應用程式(不含測試)
# 先單獨複製 package.json,再複製所有 *.js;有利於 Docker layer 快取。
COPY app/package.json /app/package.json
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:注释中的冒号后面缺少空格,阅读节奏感稍显拥挤。
建議:请在冒号后面加上一个空格,让注释读起来更舒畅:# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:注释中的冒号后面缺少空格,阅读节奏感稍显拥挤。 **建議**:请在冒号后面加上一个空格,让注释读起来更舒畅:`# 基底映像: Node.js 20 的 Alpine 版本 (體積小)。`
COPY app/*.js /app/
# 複製容器進入點腳本至根目錄。
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:注释中括号内的说明与前文缺少空格区隔,排版不够优雅。
建議:在括号前面增加一个空格:# 複製 Node.js 應用程式 (不含測試)

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:注释中括号内的说明与前文缺少空格区隔,排版不够优雅。 **建議**:在括号前面增加一个空格:`# 複製 Node.js 應用程式 (不含測試)`
COPY entrypoint.sh /entrypoint.sh
# 賦予進入點腳本可執行權限,否則 ENTRYPOINT 無法執行。
RUN chmod +x /entrypoint.sh
# 設定容器啟動時執行的進入點 (內部會 exec node /app/index.js)。
ENTRYPOINT ["/entrypoint.sh"]
+44
View File
@@ -0,0 +1,44 @@
// 讀取並驗證環境變數,組出後續流程所需的設定物件。
// 對應原本 entrypoint.sh 的「參數檢查」區段。
import { isEmptyOrNull, requireValue, requireInteger } from './validate.js'
import { section, info, warn } from './logger.js'
/**
* 從環境變數載入設定並完成驗證。
* @param {NodeJS.ProcessEnv} env 環境變數來源,預設為 process.env
* @returns 包含 API 位址、token、保留數量等資訊的設定物件
*/
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。
建議:補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:loadConfig 函式雖有呼叫驗證邏輯,但缺乏針對環境變數異常情境(如必填欄位缺失、KEEP_COUNT 非整數)的單元測試,無法確保配置載入流程的穩定性。 **建議**:補齊 app/test/config.test.js,測試當 process.env 缺少必要參數或 KEEP_COUNT 為無效數字時,loadConfig 是否會正確拋出錯誤。
export function loadConfig(env = process.env) {
section('參數檢查')
const serverUrl = env.GITEA_SERVER_URL
const repository = env.GITEA_REPOSITORY
const keepCountRaw = env.KEEP_COUNT
const token = env.GITEA_TOKEN
info(`GITEA_SERVER_URL=${serverUrl}`)
requireValue('GITEA_SERVER_URL', serverUrl)
info(`GITEA_REPOSITORY=${repository}`)
requireValue('GITEA_REPOSITORY', repository)
info(`KEEP_COUNT=${keepCountRaw}`)
requireValue('KEEP_COUNT', keepCountRaw)
requireInteger('KEEP_COUNT', keepCountRaw)
if (isEmptyOrNull(token)) {
warn('GITEA_TOKEN is empty; release API calls will be anonymous')
} else {
info('GITEA_TOKEN=[redacted]')
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題:直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用。
建議:建議在複雜系統中,將環境變數讀取抽離為獨立的 Provider 或 ConfigFactory。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:直接將 env 預設為 process.env,全域相依性可能導致難以追蹤的副作用。 **建議**:建議在複雜系統中,將環境變數讀取抽離為獨立的 Provider 或 ConfigFactory。
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:在 loadConfig 函式中,對 GITEA_SERVER_URL 的 replace(//+$/, '') 操作假設了輸入一定是 string。雖然隨後有 requireValue 和 requireUrl 的驗證,但如果 env.GITEA_SERVER_URL 是非字串(例如數字、陣列、物件),這裡的 replace 可能會拋出 TypeError。
建議:應在 replace 之前確保其型別為 string,或使用 optional chaining 以及更嚴格的類型防護。例如: typeof rawServerUrl === 'string' ? rawServerUrl.replace(//+$/, '') : rawServerUrl 已經做了檢查,但後續的 requireValue 檢查順序應確保傳入的是處理後的字串。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:在 loadConfig 函式中,對 GITEA_SERVER_URL 的 replace(//+$/, '') 操作假設了輸入一定是 string。雖然隨後有 requireValue 和 requireUrl 的驗證,但如果 env.GITEA_SERVER_URL 是非字串(例如數字、陣列、物件),這裡的 replace 可能會拋出 TypeError。 **建議**:應在 replace 之前確保其型別為 string,或使用 optional chaining 以及更嚴格的類型防護。例如: `typeof rawServerUrl === 'string' ? rawServerUrl.replace(//+$/, '') : rawServerUrl` 已經做了檢查,但後續的 `requireValue` 檢查順序應確保傳入的是處理後的字串。
}
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Assassin
問題:將環境變數值直接輸出至日誌,若 GITEA_SERVER_URL 等變數內容被注入惡意字元或過長,可能造成 Log Injection 或日誌系統資源耗盡。
建議:在輸出前進行 sanitization,移除控制字元並限制長度,類似於 gitea-client.js 中的 sanitizeBody

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:將環境變數值直接輸出至日誌,若 `GITEA_SERVER_URL` 等變數內容被注入惡意字元或過長,可能造成 Log Injection 或日誌系統資源耗盡。 **建議**:在輸出前進行 sanitization,移除控制字元並限制長度,類似於 `gitea-client.js` 中的 `sanitizeBody`。
Review

嚴重等級🔵 建議
審查員:Maya
問題:雖然有 requireUrl 驗證,但在 loadConfig 中,對於 GITEA_SERVER_URL 的解析假設其為完整路徑。若環境變數提供的網址不含 api/v1 或路徑有變化,整合後的 releaseApiUrl 可能無效。
建議:建議在 loadConfig 中增加對 serverUrl 的處理,確保結尾沒有多餘的 /,避免拼接 API 路徑時產生類似 //api 的錯誤路徑。

**嚴重等級**:🔵 建議 **審查員**:Maya **問題**:雖然有 requireUrl 驗證,但在 loadConfig 中,對於 GITEA_SERVER_URL 的解析假設其為完整路徑。若環境變數提供的網址不含 api/v1 或路徑有變化,整合後的 releaseApiUrl 可能無效。 **建議**:建議在 loadConfig 中增加對 serverUrl 的處理,確保結尾沒有多餘的 /,避免拼接 API 路徑時產生類似 //api 的錯誤路徑。
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Assassin
問題:僅驗證是否為 URL 格式,未檢查傳入的 GITEA_SERVER_URL 是否指向內部網路敏感資源(如 localhost, 169.254.169.254 等),這在容器化環境中可能導致 SSRF(伺服器端請求偽造)。
建議:在 validate.jsrequireUrl 中加入黑名單機制,禁止解析為內部 IP 位址或 loopback 位址。

**嚴重等級**:🔴 嚴重 **審查員**:Assassin **問題**:僅驗證是否為 URL 格式,未檢查傳入的 `GITEA_SERVER_URL` 是否指向內部網路敏感資源(如 localhost, 169.254.169.254 等),這在容器化環境中可能導致 SSRF(伺服器端請求偽造)。 **建議**:在 `validate.js` 的 `requireUrl` 中加入黑名單機制,禁止解析為內部 IP 位址或 loopback 位址。
return {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:缺乏對 GITEA_TOKEN 長度或格式的邊界測試,以及對 KEEP_COUNT 格式異常的檢查。
建議:在測試檔中增加針對 Token 與 KEEP_COUNT 的邊界測試,並在 loadConfig 內加強格式轉換檢查。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:缺乏對 GITEA_TOKEN 長度或格式的邊界測試,以及對 KEEP_COUNT 格式異常的檢查。 **建議**:在測試檔中增加針對 Token 與 KEEP_COUNT 的邊界測試,並在 loadConfig 內加強格式轉換檢查。
serverUrl,
repository,
token: isEmptyOrNull(token) ? null : token,
keepCount: Number(keepCountRaw),
releaseApiUrl: `${serverUrl}/api/v1/repos/${repository}/releases`,
tagApiUrl: `${serverUrl}/api/v1/repos/${repository}/tags`,
}
}
+53
View File
@@ -0,0 +1,53 @@
// 與 Gitea API 溝通的 HTTP 客戶端,封裝認證標頭、分頁讀取與刪除請求。
// 對應原本 entrypoint.sh 的 fetch_all_pages 與 curl DELETE 呼叫。
export class GiteaClient {
/**
* @param {{ token?: string | null }} options 認證設定;有 token 時帶上 Authorization 標頭
*/
constructor({ token = null } = {}) {
this.headers = {}
if (token) {
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題:目前 MAX_PAGES 是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。
建議:建議將 MAX_PAGES 改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:目前 `MAX_PAGES` 是寫死在程式碼中的常數,未來如果 API 規格變更或是特殊儲存庫的 Release 數量激增,維護者需要進程式碼修改,且這在不同的環境下可能需要不同的上限。 **建議**:建議將 `MAX_PAGES` 改為透過環境變數傳入,並設定一個合理的預設值,增加部署時的彈性。
this.headers.Authorization = `token ${token}`
}
}
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。
建議:請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:sanitizeBody 函式負責 API 回應的字串清理與格式化,但目前缺乏直接的單元測試,無法確保正規表示式能正確處理所有控制字元以及長度限制。 **建議**:請在 app/test/gitea-client.test.js 中新增 sanitizeBody 的單元測試,務必包含正常字串、包含控制字元的字串、空字串/null 值、以及超過 200 字元的極端案例。
/**
* 逐頁讀取分頁式清單 API,直到回傳空陣列為止,合併成單一陣列。
* @param {string} baseUrl 不含 query string 的 API 位址
* @returns {Promise<any[]>} 所有頁面合併後的項目
*/
async fetchAllPages(baseUrl) {
const all = []
let page = 1
while (true) {
const url = `${baseUrl}?page=${page}`
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:在 GiteaClient 建構子中,this.headers 初始化時僅簡單檢查 token 是否存在,且假設所有請求都適用這組標頭。雖然目前專案單純,但若未來擴充需針對不同 API 採取不同標頭時,此處結構會稍顯死板。
建議:考慮將產生 header 的邏輯抽離成一個內部 private 函式(如 _getHeaders()),即便現在很簡單,也能增加未來的彈性。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:在 `GiteaClient` 建構子中,`this.headers` 初始化時僅簡單檢查 `token` 是否存在,且假設所有請求都適用這組標頭。雖然目前專案單純,但若未來擴充需針對不同 API 採取不同標頭時,此處結構會稍顯死板。 **建議**:考慮將產生 header 的邏輯抽離成一個內部 private 函式(如 `_getHeaders()`),即便現在很簡單,也能增加未來的彈性。
const res = await fetch(url, { headers: this.headers })
if (!res.ok) {
throw new Error(`GET ${url} failed: HTTP ${res.status}`)
}
const items = await res.json()
if (!Array.isArray(items) || items.length === 0) {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題fetchAllPages 使用了 AbortSignal.timeout(REQUEST_TIMEOUT_MS),但在測試 app/test/gitea-client.js 時,並未測試過「網路逾時」情境。
建議:在 app/test/gitea-client.js 中增加一個測試案例,模擬 fetch 函數直接拋出 AbortError,驗證 fetchAllPages 能否正確處理該錯誤,而不是讓容器無預警崩潰。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:`fetchAllPages` 使用了 `AbortSignal.timeout(REQUEST_TIMEOUT_MS)`,但在測試 `app/test/gitea-client.js` 時,並未測試過「網路逾時」情境。 **建議**:在 `app/test/gitea-client.js` 中增加一個測試案例,模擬 fetch 函數直接拋出 `AbortError`,驗證 `fetchAllPages` 能否正確處理該錯誤,而不是讓容器無預警崩潰。
Review

嚴重等級🔵 建議
審查員:Rogue
問題fetchAllPages 採取線性逐頁請求,資料量龐大時會導致總請求時間過長。
建議:若 API 支援並行讀取,建議先取得總頁數並行發出請求,而非序列式逐頁讀取。

**嚴重等級**:🔵 建議 **審查員**:Rogue **問題**:`fetchAllPages` 採取線性逐頁請求,資料量龐大時會導致總請求時間過長。 **建議**:若 API 支援並行讀取,建議先取得總頁數並行發出請求,而非序列式逐頁讀取。
Review

嚴重等級🟡 警告
審查員:Maya
問題:fetchAllPages 使用了逾時訊號,但測試套件未驗證網路逾時情境,且總耗時未受限制。
建議:在測試中模擬 AbortError,並考慮對整個 fetchAllPages 流程引入總執行時間限制。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:fetchAllPages 使用了逾時訊號,但測試套件未驗證網路逾時情境,且總耗時未受限制。 **建議**:在測試中模擬 AbortError,並考慮對整個 fetchAllPages 流程引入總執行時間限制。
Review

嚴重等級🔵 建議
審查員:Rogue
問題:fetchAllPages 採線性逐頁請求,資料龐大時效能不佳,且回應處理透過 .text() 再轉 JSON 造成重複記憶體開銷。
建議:若 API 支援,先取得總頁數後並行請求,並直接處理 Response 的 ReadableStream 以提升效能。

**嚴重等級**:🔵 建議 **審查員**:Rogue **問題**:fetchAllPages 採線性逐頁請求,資料龐大時效能不佳,且回應處理透過 .text() 再轉 JSON 造成重複記憶體開銷。 **建議**:若 API 支援,先取得總頁數後並行請求,並直接處理 Response 的 ReadableStream 以提升效能。
break
}
all.push(...items)
page += 1
}
return all
}
/**
* 對指定資源發出 DELETE 請求。
* @param {string} url 目標資源位址
* @returns {Promise<number>} HTTP 狀態碼
*/
async deleteResource(url) {
const res = await fetch(url, { method: 'DELETE', headers: this.headers })
return res.status
}
}
+22
View File
@@ -0,0 +1,22 @@
// 進入點:載入設定、建立 Gitea 客戶端,依序清理舊成品與孤立 tag。
import { loadConfig } from './config.js'
import { GiteaClient } from './gitea-client.js'
import { cleanupReleases } from './releases.js'
import { cleanupOrphanTags } from './tags.js'
import { separator, fail } from './logger.js'
async function main() {
const config = loadConfig()
const client = new GiteaClient({ token: config.token })
await cleanupReleases(client, config)
await cleanupOrphanTags(client, config)
separator()
}
main().catch((error) => {
fail(error.message)
process.exit(1)
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Maya
問題:Action 的核心邏輯 cleanupReleases 與 cleanupOrphanTags 被直接呼叫,但缺少針對清理流程的整合測試或端對端測試,僅有單元測試無法保證整個「清理 -> 再清理 tag」的完整路徑是否會因環境設定或 API 回應產生非預期的行為。
建議:建議補上一個整合測試 (app/test/integration.test.js),模擬完整的 API 回應序列(如:先列出舊版本 -> 刪除舊版本 -> 重新列出 release -> 列出 tag -> 刪除孤立 tag),驗證所有 API 呼叫順序與參數皆符合預期。

**嚴重等級**:🔴 嚴重 **審查員**:Maya **問題**:Action 的核心邏輯 cleanupReleases 與 cleanupOrphanTags 被直接呼叫,但缺少針對清理流程的整合測試或端對端測試,僅有單元測試無法保證整個「清理 -> 再清理 tag」的完整路徑是否會因環境設定或 API 回應產生非預期的行為。 **建議**:建議補上一個整合測試 (app/test/integration.test.js),模擬完整的 API 回應序列(如:先列出舊版本 -> 刪除舊版本 -> 重新列出 release -> 列出 tag -> 刪除孤立 tag),驗證所有 API 呼叫順序與參數皆符合預期。
})
+53
View File
@@ -0,0 +1,53 @@
// 統一的主控台輸出格式,對應原本 entrypoint.sh 的 separator/section/info/... 等函式。
const LINE = '=================================================='
const SUBLINE = '--------------------------------------------------'
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Bard
問題:分隔線字串為魔術字串,硬編碼在模組頂層,不易維護與調整。
建議:將分隔線管理集中化,並考慮提供動態產生方法。

**嚴重等級**:🟡 警告 **審查員**:Bard **問題**:分隔線字串為魔術字串,硬編碼在模組頂層,不易維護與調整。 **建議**:將分隔線管理集中化,並考慮提供動態產生方法。
/**
* 在前後換行的情況下輸出一條等號分隔線至 stdout,用於視覺上區隔不同階段的輸出。
*/
export function separator() {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Maya
問題:logger.js 中的輸出函式(如 separator, section, info, success, warn, fail)負責 Action 的核心視覺輸出格式,但目前缺乏測試驗證其實際輸出內容是否正確對齊並符合格式要求。
建議:請在 app/test/logger.test.js 中補齊對這些函式的測試,驗證其是否正確寫入預期的格式內容(含分隔線與正確的前綴)到 stdout。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:logger.js 中的輸出函式(如 separator, section, info, success, warn, fail)負責 Action 的核心視覺輸出格式,但目前缺乏測試驗證其實際輸出內容是否正確對齊並符合格式要求。 **建議**:請在 app/test/logger.test.js 中補齊對這些函式的測試,驗證其是否正確寫入預期的格式內容(含分隔線與正確的前綴)到 stdout。
process.stdout.write(`\n${LINE}\n`)
}
/**
* 輸出一個區段標題:先印分隔線,再印標題文字與一條虛線,用於標示流程進入新階段。
* @param {string} title 區段標題文字
*/
export function section(title) {
separator()
process.stdout.write(`${title}\n`)
process.stdout.write(`${SUBLINE}\n`)
}
/**
* 以 `[INFO]` 前綴輸出一般資訊訊息至 stdout。
* @param {string} message 訊息內容
*/
export function info(message) {
process.stdout.write(`[INFO] ${message}\n`)
}
/**
* 以 `[OK]` 前綴輸出成功訊息至 stdout(前綴補空白以與其他標籤對齊)。
* @param {string} message 訊息內容
*/
export function success(message) {
process.stdout.write(`[OK] ${message}\n`)
}
/**
* 以 `[WARN]` 前綴輸出警告訊息;為與一般輸出同流,仍寫入 stdout。
* @param {string} message 訊息內容
*/
export function warn(message) {
process.stdout.write(`[WARN] ${message}\n`)
}
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題:雖然目前只有 fail 函式會寫入 stderr,但如果有更多的 error level 需要處理,分散的邏輯會增加維護成本。
建議:考慮在 logger.js 中建立一個通用的 log 函式,處理 levelprefixstream 的對應,讓其他方法(如 info, warn, fail)只負責呼叫該通用函式,降低重複程式碼。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:雖然目前只有 `fail` 函式會寫入 `stderr`,但如果有更多的 error level 需要處理,分散的邏輯會增加維護成本。 **建議**:考慮在 `logger.js` 中建立一個通用的 `log` 函式,處理 `level`、`prefix` 與 `stream` 的對應,讓其他方法(如 `info`, `warn`, `fail`)只負責呼叫該通用函式,降低重複程式碼。
/**
* 以 `[ERR]` 前綴輸出錯誤訊息至 stderr(唯一寫入 stderr 的輸出函式)。
* @param {string} message 訊息內容
*/
export function fail(message) {
process.stderr.write(`[ERR] ${message}\n`)
}
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Assassin
問題:儘管使用了 stderr 輸出錯誤,但在 failError 中直接輸出 error.stack 可能會洩漏專案目錄結構、內部函式名稱等敏感路徑資訊。
建議:在生產環境下考慮隱藏堆疊追蹤,或僅在特定 debug 模式下輸出堆疊。

**嚴重等級**:🔵 建議 **審查員**:Assassin **問題**:儘管使用了 `stderr` 輸出錯誤,但在 `failError` 中直接輸出 `error.stack` 可能會洩漏專案目錄結構、內部函式名稱等敏感路徑資訊。 **建議**:在生產環境下考慮隱藏堆疊追蹤,或僅在特定 debug 模式下輸出堆疊。
+15
View File
@@ -0,0 +1,15 @@
{
"name": "release-cleanup",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "清理 Gitea 舊版本成品與未指定 release 的 tag",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test"
},
"engines": {
"node": ">=18"
}
}
+60
View File
@@ -0,0 +1,60 @@
// 清理舊版本成品的功能模組。
// 對應原本 entrypoint.sh 的「取得成品資訊」與「刪除舊版本成品」區段。
import { section, info, success, fail, warn } from './logger.js'
import { isEmptyOrNull } from './validate.js'
/**
* 依建立時間由新到舊排序,保留最新的 keepCount 筆,回傳其餘待刪除的成品。
* 純函式,方便單元測試。
* @param {any[]} releases 成品清單
* @param {number} keepCount 要保留的筆數
* @returns {any[]} 需要刪除的成品(較舊者)
*/
export function selectReleasesToDelete(releases, keepCount) {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:使用 new Date(release.created_at) 進行排序時,若 created_at 格式非預期或為空,會導致 NaN 並造成排序異常,可能無法正確刪除舊版本。
建議:建議在排序邏輯中增加對 created_at 的有效性檢查,若無效則給予預設值(例如:new Date(0)),確保排序結果可預期。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:使用 `new Date(release.created_at)` 進行排序時,若 `created_at` 格式非預期或為空,會導致 `NaN` 並造成排序異常,可能無法正確刪除舊版本。 **建議**:建議在排序邏輯中增加對 `created_at` 的有效性檢查,若無效則給予預設值(例如:`new Date(0)`),確保排序結果可預期。
const sorted = [...releases].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at),
)
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Rogue
問題:selectReleasesToDelete 針對每一筆 release 重複執行 Date.parse,浪費 CPU 週期。
建議:應在排序前先執行一次 map 轉換,預先計算時間戳記。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:selectReleasesToDelete 針對每一筆 release 重複執行 Date.parse,浪費 CPU 週期。 **建議**:應在排序前先執行一次 map 轉換,預先計算時間戳記。
return sorted.slice(keepCount)
}
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:在 selectReleasesToDelete 函式中,對於 Date.parse 無法解析的日期直接視為 0,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。
建議:應明確記錄無效日期的處理方式(例如記錄 warning),或在 Date.parse 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:在 `selectReleasesToDelete` 函式中,對於 `Date.parse` 無法解析的日期直接視為 `0`,這在資料清理邏輯中是一個隱晦的行為,未來維護者可能不清楚為什麼無效日期會優先被刪除。 **建議**:應明確記錄無效日期的處理方式(例如記錄 warning),或在 `Date.parse` 失敗時,應考慮給予一個明確的邏輯(例如拋出錯誤或放到特定排序位置),以減少不可預期的副作用。
/**
* 讀取成品清單,刪除超出保留數量的舊版本成品。
* @param {import('./gitea-client.js').GiteaClient} client
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Rogue
問題:為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。
建議:排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。

**嚴重等級**:🔵 建議 **審查員**:Rogue **問題**:為了排序而進行了多次 map 操作,先將陣列 map 為物件陣列,排序後又 map 回原始物件陣列。這在大數據集下會造成多次 O(n) 的陣列分配與垃圾回收壓力,浪費 CPU 與記憶體週期。 **建議**:排序時應嘗試減少陣列中間狀態的產生,例如考慮使用原地排序(若不介意原陣列變更)或簡化 mapping 的邏輯。
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 selectReleasesToDelete 的排序邏輯中,若多個成品具有完全相同的 created_at 時間戳,目前的排序行為依賴於 JavaScript 引擎對 sort() 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。
建議:建議在排序邏輯中加入次要的排序鍵值(如 idtag_name)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 `selectReleasesToDelete` 的排序邏輯中,若多個成品具有完全相同的 `created_at` 時間戳,目前的排序行為依賴於 JavaScript 引擎對 `sort()` 的實作(在某些情況下可能不穩定),導致保留與刪除的成品選擇具有不確定性。 **建議**:建議在排序邏輯中加入次要的排序鍵值(如 `id` 或 `tag_name`)作為比較依據(例如:若時間相同,則比較 ID 大小),以確保排序結果在時間相同時仍具有決定性。
* @param {ReturnType<import('./config.js').loadConfig>} config
*/
export async function cleanupReleases(client, config) {
section('取得成品資訊')
info(`GET ${config.releaseApiUrl}`)
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Bard
問題:在 cleanupReleases 函式中,參數 configReturnType<import('./config.js').loadConfig>,這種型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。
建議:建議在 app/config.js 定義並匯出型別註解(JSDoc @typedef),然後在此處直接使用該別名,提升整體程式碼的可讀性。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:在 `cleanupReleases` 函式中,參數 `config` 是 `ReturnType<import('./config.js').loadConfig>`,這種型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。 **建議**:建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在此處直接使用該別名,提升整體程式碼的可讀性。
Review

嚴重等級🔵 建議
審查員:Bard
問題:config 參數型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。
建議:建議在 app/config.js 定義並匯出型別註解(JSDoc @typedef),然後在各處直接使用該別名。

**嚴重等級**:🔵 建議 **審查員**:Bard **問題**:config 參數型別定義方式雖然準確,但過於冗長且與實作細節耦合過深,影響程式碼的可讀性與簡潔度。 **建議**:建議在 `app/config.js` 定義並匯出型別註解(JSDoc @typedef),然後在各處直接使用該別名。
const releases = await client.fetchAllPages(config.releaseApiUrl)
info(`RELEASE_COUNT=${releases.length}`)
info(`KEEP_COUNT=${config.keepCount}`)
if (releases.length <= config.keepCount) {
success('沒有需要清理的舊版本成品')
return
}
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Leo
問題:cleanupReleases 違反單一職責原則,同時處理資料獲取、邏輯判斷與副作用執行,不易測試。
建議:拆分邏輯與執行層,將刪除副作用抽象化為獨立函式,以利單獨測試。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:cleanupReleases 違反單一職責原則,同時處理資料獲取、邏輯判斷與副作用執行,不易測試。 **建議**:拆分邏輯與執行層,將刪除副作用抽象化為獨立函式,以利單獨測試。
Review

嚴重等級🟡 警告
審查員:Leo
問題:cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則,且未對 release 物件結構進行進一步驗證,可能導致資料正確性風險。
建議:拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。建議增加對於 release 物件結構的進一步驗證,並強化檢查邏輯。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:cleanupReleases 同時處理資料獲取、邏輯判斷與副作用執行,違反單一職責原則,且未對 release 物件結構進行進一步驗證,可能導致資料正確性風險。 **建議**:拆分邏輯與執行層,將刪除副作用抽象化為獨立函式。建議增加對於 release 物件結構的進一步驗證,並強化檢查邏輯。
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,導致容器無法確保後續清理的一致性與部分成功重試。
建議:引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略,確保清理任務具備健壯性。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:清理流程對網路請求依賴強,若 API 呼叫失敗,整個 main 流程中斷,導致容器無法確保後續清理的一致性與部分成功重試。 **建議**:引入更細緻的錯誤處理(如錯誤閾值機制)或部分清理成功後的重試策略,確保清理任務具備健壯性。
section('刪除舊版本成品')
const toDelete = selectReleasesToDelete(releases, config.keepCount)
for (const release of toDelete) {
const { id, tag_name: tag, name } = release
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Rogue
問題:在刪除舊成品時使用了序列化的 for...of 迴圈搭配 await,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。
建議:改用 Promise.all 搭配 map 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。 **建議**:改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。
Review

嚴重等級🟡 警告
審查員:Rogue
問題:在刪除舊成品時使用了序列化的 for...of 迴圈搭配 await,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。
建議:改用 Promise.all 搭配 map 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:在刪除舊成品時使用了序列化的 `for...of` 迴圈搭配 `await`,導致刪除請求一個個排隊等待 API 回應,浪費了寶貴的 I/O 等待時間。 **建議**:改用 `Promise.all` 搭配 `map` 將刪除請求並行化,讓所有請求同時發送,瞬間縮短總執行時間。
Review

嚴重等級🟡 警告
審查員:Rogue
問題:清理成品與刪除 tag 使用序列化迴圈,導致 API 請求逐一排隊,整體執行時間拉長。
建議:改用 Promise.all 搭配 map 將刪除請求並行化以縮短執行時間。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:清理成品與刪除 tag 使用序列化迴圈,導致 API 請求逐一排隊,整體執行時間拉長。 **建議**:改用 Promise.all 搭配 map 將刪除請求並行化以縮短執行時間。
if (isEmptyOrNull(id)) {
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Maya
問題:在 cleanupReleases 函數中,儘管對 id 進行了 isEmptyOrNull 檢查,但對於 releases 陣列中可能存在的 nullundefined 或非預期結構的 release 物件,測試覆蓋僅止於 id 為空的情況,缺少對 release 本身結構異常的測試案例。
建議:增加 cleanupReleases 的測試案例,模擬傳入包含異常結構(例如缺少 tag_name 或其他必要欄位)的 release 物件,確保在處理過程中不會拋出未捕捉的異常。

**嚴重等級**:🔴 嚴重 **審查員**:Maya **問題**:在 `cleanupReleases` 函數中,儘管對 `id` 進行了 `isEmptyOrNull` 檢查,但對於 `releases` 陣列中可能存在的 `null`、`undefined` 或非預期結構的 `release` 物件,測試覆蓋僅止於 `id` 為空的情況,缺少對 `release` 本身結構異常的測試案例。 **建議**:增加 `cleanupReleases` 的測試案例,模擬傳入包含異常結構(例如缺少 `tag_name` 或其他必要欄位)的 `release` 物件,確保在處理過程中不會拋出未捕捉的異常。
warn(`略過沒有 id 的成品: ${tag} (${name})`)
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 cleanupReleases 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除,且當前流程會因為失敗呼叫 fail 並繼續執行,可能導致後續刪除邏輯的不一致。
建議:對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff),而不是直接宣告刪除失敗。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 `cleanupReleases` 迴圈中執行 DELETE 請求時,未針對網路不穩定或暫時性服務錯誤(如 502, 503, 504)實作重試機制。若刪除過程中發生瞬間網路中斷,該 release 將不會被刪除,且當前流程會因為失敗呼叫 `fail` 並繼續執行,可能導致後續刪除邏輯的不一致。 **建議**:對於特定的 HTTP 狀態碼(502, 503, 504),建議引入簡單的指數退避重試機制(Exponential Backoff),而不是直接宣告刪除失敗。
Review

嚴重等級🟡 警告
審查員:Mage
問題:刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼 (502, 503, 504) 實作重試機制,且在遇到失敗時未停止後續請求,導致大量無意義錯誤。
建議:針對特定 HTTP 狀態碼實作指數退避重試機制,並引入錯誤閾值,當失敗次數過高時立即中斷流程。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼 (502, 503, 504) 實作重試機制,且在遇到失敗時未停止後續請求,導致大量無意義錯誤。 **建議**:針對特定 HTTP 狀態碼實作指數退避重試機制,並引入錯誤閾值,當失敗次數過高時立即中斷流程。
Review

嚴重等級🟡 警告
審查員:Mage
問題:刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。若 release 清單非常龐大,會佔用大量記憶體。
建議:針對特定 HTTP 狀態碼實作指數退避重試,並引入錯誤閾值。考慮在 fetchAllPages 中加入串流處理(Stream)或實作分批讀取機制,避免將所有資料一次性載入記憶體。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:刪除邏輯未針對網路不穩定或特定 HTTP 狀態碼(502, 503, 504)實作重試機制,且遇到失敗時未停止後續請求。若 release 清單非常龐大,會佔用大量記憶體。 **建議**:針對特定 HTTP 狀態碼實作指數退避重試,並引入錯誤閾值。考慮在 fetchAllPages 中加入串流處理(Stream)或實作分批讀取機制,避免將所有資料一次性載入記憶體。
Review

嚴重等級🟡 警告
審查員:Bard
問題:區段標題『取得成品資訊』與後續的 section('刪除舊版本成品') 使用了不同的區段層級。第一個區段內包含了 info 輸出,而第二個區段直接開始處理邏輯,視覺節奏上稍微不一致。
建議:建議在所有主要的操作階段前統一呼叫 section(),或在細部操作前使用更明確的層級標示,保持日誌格式的旋律一致性。

**嚴重等級**:🟡 警告 **審查員**:Bard **問題**:區段標題『取得成品資訊』與後續的 `section('刪除舊版本成品')` 使用了不同的區段層級。第一個區段內包含了 `info` 輸出,而第二個區段直接開始處理邏輯,視覺節奏上稍微不一致。 **建議**:建議在所有主要的操作階段前統一呼叫 `section()`,或在細部操作前使用更明確的層級標示,保持日誌格式的旋律一致性。
Review

嚴重等級🟡 警告
審查員:Maya
問題:在 cleanupReleases 中,若 client.fetchAllPages 拋出錯誤,流程會直接中斷且 cleanupOrphanTags 將不會被執行。雖然這符合嚴格的錯誤處理,但缺乏「部分失敗」後的清理與回報機制。
建議:考慮在 cleanupReleases 中加入 try...catch,若僅為該步驟失敗,記錄錯誤後仍嘗試執行 cleanupOrphanTags 或明確標示整個 Action 處於部分清理狀態。至少應確保在清理失敗時,日誌能明確指出是哪一步驟導致中斷。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:在 cleanupReleases 中,若 client.fetchAllPages 拋出錯誤,流程會直接中斷且 cleanupOrphanTags 將不會被執行。雖然這符合嚴格的錯誤處理,但缺乏「部分失敗」後的清理與回報機制。 **建議**:考慮在 cleanupReleases 中加入 try...catch,若僅為該步驟失敗,記錄錯誤後仍嘗試執行 cleanupOrphanTags 或明確標示整個 Action 處於部分清理狀態。至少應確保在清理失敗時,日誌能明確指出是哪一步驟導致中斷。
Review

嚴重等級🟡 警告
審查員:Maya
問題:cleanupReleases 中若 fetchAllPages 拋錯,cleanupOrphanTags 不會執行;缺乏部分失敗後的清理與回報機制。且當 fetchAllPages 成功取得列表後,若後續刪除操作發生異常(例如網路中斷、API 回應逾時),會拋出錯誤並導致整個 cleanupReleases 流程終止,無法保證「已讀取到的所有舊 release」都被嘗試清理。
建議:考慮在 cleanupReleases 加入 try/catch,並將逐筆刪除的迴圈包覆在 try-catch 中,即使單步驟或單筆刪除失敗,也應記錄錯誤後繼續嘗試執行後續步驟或刪除下一筆,僅該步驟失敗時記錄並仍嘗試 cleanupOrphanTags,或明確標示部分清理狀態。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:cleanupReleases 中若 fetchAllPages 拋錯,cleanupOrphanTags 不會執行;缺乏部分失敗後的清理與回報機制。且當 fetchAllPages 成功取得列表後,若後續刪除操作發生異常(例如網路中斷、API 回應逾時),會拋出錯誤並導致整個 cleanupReleases 流程終止,無法保證「已讀取到的所有舊 release」都被嘗試清理。 **建議**:考慮在 cleanupReleases 加入 try/catch,並將逐筆刪除的迴圈包覆在 try-catch 中,即使單步驟或單筆刪除失敗,也應記錄錯誤後繼續嘗試執行後續步驟或刪除下一筆,僅該步驟失敗時記錄並仍嘗試 cleanupOrphanTags,或明確標示部分清理狀態。
continue
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 cleanupReleases 迴圈中,對於每一個 release 都呼叫 await client.deleteResource(url)。若 release 數量眾多,這種依序刪除的方式非常耗時,且若 API 負載過高,容易導致部分請求逾時。
建議:如果 API 允許,建議採用併發刪除(例如使用 Promise.all 限制併發數),或者在日誌中增加處理進度,並考慮增加對 API 錯誤的重試機制。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 `cleanupReleases` 迴圈中,對於每一個 release 都呼叫 `await client.deleteResource(url)`。若 release 數量眾多,這種依序刪除的方式非常耗時,且若 API 負載過高,容易導致部分請求逾時。 **建議**:如果 API 允許,建議採用併發刪除(例如使用 `Promise.all` 限制併發數),或者在日誌中增加處理進度,並考慮增加對 API 錯誤的重試機制。
}
Ghost marked this conversation as resolved
Review

嚴重等級🔴 嚴重
審查員:Assassin
問題:攻擊者可以透過控制 GITEA_REPOSITORY 環境變數,在其名稱中包含路徑穿越序列(如 '../'),進而操控 API 的刪除目標,造成越權刪除其他儲存庫的成品。
建議:在將 id 拼接進 URL 前,務必使用 encodeURIComponent(id) 對 id 進行編碼,確保其僅被視為路徑的一部分,而非路徑控制字元。

**嚴重等級**:🔴 嚴重 **審查員**:Assassin **問題**:攻擊者可以透過控制 GITEA_REPOSITORY 環境變數,在其名稱中包含路徑穿越序列(如 '../'),進而操控 API 的刪除目標,造成越權刪除其他儲存庫的成品。 **建議**:在將 id 拼接進 URL 前,務必使用 encodeURIComponent(id) 對 id 進行編碼,確保其僅被視為路徑的一部分,而非路徑控制字元。
const url = `${config.releaseApiUrl}/${id}`
info(`DELETE ${tag} (${name})`)
const code = await client.deleteResource(url)
if (code === 204) {
success(`成功刪除: ${tag} (${name})`)
} else {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Assassin
問題:直接將 API 回傳的 id 與 tag 名稱拼接到 URL 中進行 DELETE 操作,未經驗證,存在路徑穿越或 SSRF 風險。
建議:在使用 id 或 tag 名稱構建 URL 前,必須嚴格驗證其字元組成(如僅允許特定格式或編碼處理)。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:直接將 API 回傳的 id 與 tag 名稱拼接到 URL 中進行 DELETE 操作,未經驗證,存在路徑穿越或 SSRF 風險。 **建議**:在使用 id 或 tag 名稱構建 URL 前,必須嚴格驗證其字元組成(如僅允許特定格式或編碼處理)。
fail(`刪除失敗: ${tag} (${name}), HTTP ${code}`)
}
}
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Mage
問題:使用字串模板直接拼接 API URL:const url = ${config.releaseApiUrl}/${id};。若 API 回傳的 id 包含 / 或特殊字元,可能導致拼接出錯誤的 API 路徑,甚至在某些處理器上導致路徑穿越,雖是 Gitea API 但應防禦性地進行 URL 編碼。
建議:建議使用 encodeURIComponent(id) 對 id 進行編碼後再拼接。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:使用字串模板直接拼接 API URL:`const url = `${config.releaseApiUrl}/${id}`;`。若 API 回傳的 id 包含 `/` 或特殊字元,可能導致拼接出錯誤的 API 路徑,甚至在某些處理器上導致路徑穿越,雖是 Gitea API 但應防禦性地進行 URL 編碼。 **建議**:建議使用 encodeURIComponent(id) 對 id 進行編碼後再拼接。
}
+66
View File
@@ -0,0 +1,66 @@
// 清理未指定 release 的 tag 的功能模組。
// 對應原本 entrypoint.sh 的「刪除未指定 release 的 tag」區段。
import { section, info, success, fail, warn } from './logger.js'
import { isEmptyOrNull } from './validate.js'
/**
* 將 tag 分類為保留、刪除或略過(無名稱)。
* 仍被任一 release 指定的 tag 予以保留,其餘視為孤立 tag 待刪除。
* 純函式,方便單元測試。
* @param {any[]} tags tag 清單
* @param {Iterable<string>} releaseTagNames 仍被 release 指定的 tag 名稱
* @returns {{ tag: any, action: 'keep' | 'delete' | 'skip' }[]}
*/
export function categorizeTags(tags, releaseTagNames) {
const keep = new Set(releaseTagNames)
return tags.map((tag) => {
if (isEmptyOrNull(tag.name)) {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Bard
問題:函式 categorizeTags 內部的判斷邏輯稍微複雜,且直接在 map 內部處理多種條件分支。
建議:建議將邏輯拆分為更小的判斷函式,提高可讀性。

**嚴重等級**:🟡 警告 **審查員**:Bard **問題**:函式 `categorizeTags` 內部的判斷邏輯稍微複雜,且直接在 map 內部處理多種條件分支。 **建議**:建議將邏輯拆分為更小的判斷函式,提高可讀性。
return { tag, action: 'skip' }
}
Ghost marked this conversation as resolved
Review

嚴重等級🔵 建議
審查員:Leo
問題:categorizeTags 內部頻繁轉換 Set,若此函式被頻繁呼叫,可能造成效能浪費。
建議:建議調整設計,讓 categorizeTags 接收已經轉好的 Set,將轉換開銷移至上層。

**嚴重等級**:🔵 建議 **審查員**:Leo **問題**:categorizeTags 內部頻繁轉換 Set,若此函式被頻繁呼叫,可能造成效能浪費。 **建議**:建議調整設計,讓 categorizeTags 接收已經轉好的 Set<string>,將轉換開銷移至上層。
if (keep.has(tag.name)) {
return { tag, action: 'keep' }
}
return { tag, action: 'delete' }
})
}
/**
* 重新讀取成品清單以取得仍被指定的 tag,再刪除未指定 release 的孤立 tag。
* @param {import('./gitea-client.js').GiteaClient} client
* @param {ReturnType<import('./config.js').loadConfig>} config
*/
export async function cleanupOrphanTags(client, config) {
section('刪除未指定 release 的 tag')
// 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品
const currentReleases = await client.fetchAllPages(config.releaseApiUrl)
const releaseTagNames = currentReleases.map((release) => release.tag_name)
info(`GET ${config.tagApiUrl}`)
const tags = await client.fetchAllPages(config.tagApiUrl)
info(`TAG_COUNT=${tags.length}`)
for (const { tag, action } of categorizeTags(tags, releaseTagNames)) {
if (action === 'skip') {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Rogue
問題:同樣在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。
建議:同樣改用 Promise.all 將刪除請求並行化,加快清理速度。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:同樣在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。 **建議**:同樣改用 `Promise.all` 將刪除請求並行化,加快清理速度。
Review

嚴重等級🟡 警告
審查員:Rogue
問題:在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。
建議:改用 Promise.all 將刪除請求並行化,加快清理速度。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:在刪除孤立 tag 時使用了序列化的迴圈,導致同樣的阻塞問題,造成不必要的總執行時間拉長。 **建議**:改用 `Promise.all` 將刪除請求並行化,加快清理速度。
Review

嚴重等級🟡 警告
審查員:Leo
問題:在 cleanupOrphanTags 中,直接在主流程中遍歷並執行刪除,同樣面臨未來如果需要對刪除失敗進行更複雜的處理(例如重試、批次處理)時,邏輯會變得難以維護。
建議:建議將 cleanupOrphanTags 參考 cleanupReleases 的結構,將「分類與過濾」與「執行刪除動作」進一步解耦,並對每個 tag 的刪除動作進行更細緻的錯誤處理。

**嚴重等級**:🟡 警告 **審查員**:Leo **問題**:在 `cleanupOrphanTags` 中,直接在主流程中遍歷並執行刪除,同樣面臨未來如果需要對刪除失敗進行更複雜的處理(例如重試、批次處理)時,邏輯會變得難以維護。 **建議**:建議將 `cleanupOrphanTags` 參考 `cleanupReleases` 的結構,將「分類與過濾」與「執行刪除動作」進一步解耦,並對每個 tag 的刪除動作進行更細緻的錯誤處理。
Review

嚴重等級🟡 警告
審查員:Rogue
問題:cleanupOrphanTags 在迴圈中重複掃描所有 release 名稱陣列,時間複雜度為 O(tags * releases),效能低落。
建議:應在進入 tag 迴圈前,先將所有 release 的 tag 名稱轉成一個 Set 物件,將查找複雜度降為 O(1)。

**嚴重等級**:🟡 警告 **審查員**:Rogue **問題**:cleanupOrphanTags 在迴圈中重複掃描所有 release 名稱陣列,時間複雜度為 O(tags * releases),效能低落。 **建議**:應在進入 tag 迴圈前,先將所有 release 的 tag 名稱轉成一個 Set 物件,將查找複雜度降為 O(1)。
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 cleanupOrphanTags 中,呼叫了兩次 API 分別獲取 releases 和 tags。若 API 在這兩次呼叫間發生變更(例如新的 release 剛好把某個舊 tag 關聯起來),categorizeTags 的結果可能會基於不一致的狀態,造成誤刪。
建議:這是一個典型的併發/時序問題。雖然 API 本身無交易機制,但建議在取得 releases 後,若 tags 數量巨大,應考慮是否存在原子性操作的需求,或者至少在日誌中明確標示兩次獲取資料的時間間距,以便除錯。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 cleanupOrphanTags 中,呼叫了兩次 API 分別獲取 releases 和 tags。若 API 在這兩次呼叫間發生變更(例如新的 release 剛好把某個舊 tag 關聯起來),categorizeTags 的結果可能會基於不一致的狀態,造成誤刪。 **建議**:這是一個典型的併發/時序問題。雖然 API 本身無交易機制,但建議在取得 releases 後,若 tags 數量巨大,應考慮是否存在原子性操作的需求,或者至少在日誌中明確標示兩次獲取資料的時間間距,以便除錯。
warn('略過沒有名稱的 tag')
continue
}
if (action === 'keep') {
info(`保留指定 release 的 tag: ${tag.name}`)
Review

嚴重等級🔴 嚴重
審查員:Mage
問題:在 cleanupOrphanTags 中,分兩次 API 呼叫取得 releases 與 tags,且過濾過程未保證原子性。若期間 Gitea 狀態變更,可能導致誤刪並非孤立的 tag。
建議:考量原子性需求,或在刪除操作前增加確認機制;並明確文件化此風險。

**嚴重等級**:🔴 嚴重 **審查員**:Mage **問題**:在 `cleanupOrphanTags` 中,分兩次 API 呼叫取得 releases 與 tags,且過濾過程未保證原子性。若期間 Gitea 狀態變更,可能導致誤刪並非孤立的 tag。 **建議**:考量原子性需求,或在刪除操作前增加確認機制;並明確文件化此風險。
continue
}
Review

嚴重等級🟡 警告
審查員:Maya
問題:在刪除 tag 的迴圈中,同樣缺少對 deleteResource 拋出例外的防禦,一旦發生網路錯誤,後續的 tag 將無法被清理。
建議:在 for 迴圈內增加 try...catch 區塊,記錄錯誤並 continue 以確保後續 tag 能正常被刪除;應比照 releases.js 新增測試驗證此行為。

**嚴重等級**:🟡 警告 **審查員**:Maya **問題**:在刪除 tag 的迴圈中,同樣缺少對 `deleteResource` 拋出例外的防禦,一旦發生網路錯誤,後續的 tag 將無法被清理。 **建議**:在 `for` 迴圈內增加 `try...catch` 區塊,記錄錯誤並 `continue` 以確保後續 tag 能正常被刪除;應比照 `releases.js` 新增測試驗證此行為。
const url = `${config.tagApiUrl}/${tag.name}`
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Mage
問題:在 cleanupOrphanTags 中,雖然先重新 fetch 了 release 清單,但 cleanupReleasescleanupOrphanTags 是非同步執行,且中間無確保一致性的機制。若在 cleanupReleases 刪除完成後到 cleanupOrphanTags 執行期間,Gitea 上有新的 release 被建立,則 releaseTagNames 的快照將會過時,導致正在使用的 tag 被錯誤刪除。
建議:考慮在兩個 cleanup 步驟之間,確保 API 狀態的一致性,或者在刪除 tag 前再次檢查該 tag 是否真的未被任何現存 release 使用。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:在 `cleanupOrphanTags` 中,雖然先重新 fetch 了 release 清單,但 `cleanupReleases` 和 `cleanupOrphanTags` 是非同步執行,且中間無確保一致性的機制。若在 `cleanupReleases` 刪除完成後到 `cleanupOrphanTags` 執行期間,Gitea 上有新的 release 被建立,則 `releaseTagNames` 的快照將會過時,導致正在使用的 tag 被錯誤刪除。 **建議**:考慮在兩個 cleanup 步驟之間,確保 API 狀態的一致性,或者在刪除 tag 前再次檢查該 tag 是否真的未被任何現存 release 使用。
Review

嚴重等級🔴 嚴重
審查員:Maya
問題:在 cleanupOrphanTags 中,雖有 categorizeTags 的邏輯測試,但對於 cleanupOrphanTags 本身與 GiteaClient 的整合互動,例如在 API 回傳異常 tag 列表時的處理,缺乏失敗路徑(如 API 請求失敗、JSON 解析失敗)的測試。
建議:補上 cleanupOrphanTags 的整合測試,模擬 client.fetchAllPages 拋出例外的情境,確認清理流程是否會優雅地處理或拋出預期的錯誤。

**嚴重等級**:🔴 嚴重 **審查員**:Maya **問題**:在 `cleanupOrphanTags` 中,雖有 `categorizeTags` 的邏輯測試,但對於 `cleanupOrphanTags` 本身與 `GiteaClient` 的整合互動,例如在 API 回傳異常 tag 列表時的處理,缺乏失敗路徑(如 API 請求失敗、JSON 解析失敗)的測試。 **建議**:補上 `cleanupOrphanTags` 的整合測試,模擬 `client.fetchAllPages` 拋出例外的情境,確認清理流程是否會優雅地處理或拋出預期的錯誤。
info(`DELETE tag ${tag.name}`)
const code = await client.deleteResource(url)
if (code === 204) {
success(`成功刪除未指定 release 的 tag: ${tag.name}`)
} else {
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Assassin
問題:直接將 tag 名稱拼接至 URL 是常見的 Injection 破口。儘管使用了 encodeURIComponent,但若 tag.name 包含某些在 Gitea API 邏輯中具特殊意義的字元,仍可能造成非預期的資源存取或路徑穿越。
建議:除了 encodeURIComponent 外,應在 config.js 或 tags.js 中對 tag.name 進行嚴格的白名單格式驗證(例如限制為英數字、點、破折號,並禁止 .. 或 /),這比單純編碼更安全。

**嚴重等級**:🟡 警告 **審查員**:Assassin **問題**:直接將 tag 名稱拼接至 URL 是常見的 Injection 破口。儘管使用了 encodeURIComponent,但若 tag.name 包含某些在 Gitea API 邏輯中具特殊意義的字元,仍可能造成非預期的資源存取或路徑穿越。 **建議**:除了 encodeURIComponent 外,應在 config.js 或 tags.js 中對 tag.name 進行嚴格的白名單格式驗證(例如限制為英數字、點、破折號,並禁止 .. 或 /),這比單純編碼更安全。
fail(`刪除 tag 失敗: ${tag.name}, HTTP ${code}`)
}
}
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Mage
問題:與 releases.js 相同問題,在拼接 tag URL 時:const url = ${config.tagApiUrl}/${tag.name}; 未對 tag 名稱進行 URL 編碼。Tag 名稱若包含 / 等特殊字元,會破壞 URL 結構。
建議:建議使用 encodeURIComponent(tag.name) 對 tag 名稱進行編碼。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:與 releases.js 相同問題,在拼接 tag URL 時:`const url = `${config.tagApiUrl}/${tag.name}`;` 未對 tag 名稱進行 URL 編碼。Tag 名稱若包含 `/` 等特殊字元,會破壞 URL 結構。 **建議**:建議使用 encodeURIComponent(tag.name) 對 tag 名稱進行編碼。
}
+36
View File
@@ -0,0 +1,36 @@
// 參數驗證,對應原本 entrypoint.sh 的 is_empty_or_null/require_value/require_integer。
// 驗證失敗時丟出 Error,由進入點統一捕捉後以非零狀態結束。
/**
* 判斷值是否視為「空」。使用嚴格相等,因此 `0`、`false`、字串 `"0"` 都不算空。
* @param {*} value 待判斷的值
* @returns {boolean} 當值為 `undefined`、`null`、空字串或字串 `"null"` 時回傳 true
*/
export function isEmptyOrNull(value) {
return value === undefined || value === null || value === '' || value === 'null'
}
/**
* 要求指定欄位有值,空值時丟出 Error 以中止流程。
Ghost marked this conversation as resolved
Review

嚴重等級🟡 警告
審查員:Mage
問題:isEmptyOrNull 判斷式包含 value === 'null',導致正確字串內容 'null' 被判定為空,破壞語義一致性。
建議:建議移除對 'null' 字串的特殊判定,改由呼叫端明確處理邏輯。

**嚴重等級**:🟡 警告 **審查員**:Mage **問題**:isEmptyOrNull 判斷式包含 value === 'null',導致正確字串內容 'null' 被判定為空,破壞語義一致性。 **建議**:建議移除對 'null' 字串的特殊判定,改由呼叫端明確處理邏輯。
* @param {string} name 欄位名稱,用於組出錯誤訊息
* @param {*} value 待檢查的值,空值判定委派給 [[isEmptyOrNull]]
* @throws {Error} 當 value 為空時丟出 `${name} is required`
*/
export function requireValue(name, value) {
if (isEmptyOrNull(value)) {
throw new Error(`${name} is required`)
}
}
/**
* 要求指定欄位為非負整數。會先轉成字串再以 `/^[0-9]+$/` 比對,因此可接受數字或純數字字串,
* 但拒絕負數、小數、空值與非數字內容。
* @param {string} name 欄位名稱,用於組出錯誤訊息
* @param {string|number} value 待檢查的值
* @throws {Error} 當 value 不是非負整數時丟出 `${name} must be a non-negative integer`
*/
export function requireInteger(name, value) {
if (!/^[0-9]+$/.test(String(value))) {
throw new Error(`${name} must be a non-negative integer`)
}
}
+12 -175
View File
@@ -1,177 +1,14 @@
#!/usr/bin/env bash
set -Eeuo pipefail
#!/usr/bin/env sh
# =============================================================================
# 用途: release-cleanup Action 的容器進入點 (entrypoint)。
# 專案已由 bash 改寫為 Node.js,本腳本僅負責啟動 /app/index.js,
# 由 Node.js 程式執行實際的 release 清理邏輯。
# 更新日期: 2026/06/26 10:28:36
# =============================================================================
separator() {
printf '\n%s\n' '=================================================='
}
# set -e: 任一指令失敗即中止; set -u: 使用未定義變數即報錯。確保失敗能即時暴露。
set -eu
section() {
separator
printf '%s\n' "$1"
printf '%s\n' '--------------------------------------------------'
}
info() {
printf '[INFO] %s\n' "$1"
}
success() {
printf '[OK] %s\n' "$1"
}
warn() {
printf '[WARN] %s\n' "$1"
}
fail() {
printf '[ERR] %s\n' "$1" >&2
}
is_empty_or_null() {
[ -z "${1:-}" ] || [ "${1:-}" = "null" ]
}
require_value() {
local name="$1"
local value="$2"
info "$name=$value"
if is_empty_or_null "$value"; then
fail "$name is required"
exit 1
fi
}
require_integer() {
local name="$1"
local value="$2"
if ! [[ "$value" =~ ^[0-9]+$ ]]; then
fail "$name must be a non-negative integer"
exit 1
fi
}
fetch_all_pages() {
local base_url="$1"
local all_json='[]'
local page=1
local page_url page_json
while :; do
page_url="$base_url?page=$page"
page_json="$(curl -fsS "${auth_header[@]}" "$page_url")"
if [ "$(jq 'length' <<<"$page_json")" -eq 0 ]; then
break
fi
all_json="$(jq -s 'add' <<<"$all_json"$'\n'"$page_json")"
page=$((page + 1))
done
printf '%s' "$all_json"
}
section "參數檢查"
require_value "GITEA_SERVER_URL" "$GITEA_SERVER_URL"
require_value "GITEA_REPOSITORY" "$GITEA_REPOSITORY"
require_value "KEEP_COUNT" "$KEEP_COUNT"
require_integer "KEEP_COUNT" "$KEEP_COUNT"
if is_empty_or_null "${GITEA_TOKEN:-}"; then
warn "GITEA_TOKEN is empty; release API calls will be anonymous"
else
info "GITEA_TOKEN=[redacted]"
fi
release_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases"
auth_header=()
if ! is_empty_or_null "${GITEA_TOKEN:-}"; then
auth_header=(-H "Authorization: token $GITEA_TOKEN")
fi
section "取得成品資訊"
info "GET $release_api_url"
release_json="$(fetch_all_pages "$release_api_url")"
release_json="$(jq -e 'sort_by(.created_at) | reverse' <<<"$release_json")"
release_count="$(jq 'length' <<<"$release_json")"
info "RELEASE_COUNT=$release_count"
info "KEEP_COUNT=$KEEP_COUNT"
if [ "$release_count" -le "$KEEP_COUNT" ]; then
success "沒有需要清理的舊版本成品"
else
section "刪除舊版本成品"
release_to_delete="$(jq -c ".[$KEEP_COUNT:]" <<<"$release_json")"
while IFS= read -r release_item; do
[ -z "$release_item" ] && continue
release_id="$(jq -r '.id' <<<"$release_item")"
release_tag="$(jq -r '.tag_name' <<<"$release_item")"
release_name="$(jq -r '.name' <<<"$release_item")"
if is_empty_or_null "$release_id"; then
warn "略過沒有 id 的成品: $release_tag ($release_name)"
continue
fi
delete_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases/$release_id"
info "DELETE $release_tag ($release_name)"
delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")"
if [ "$delete_code" -eq 204 ]; then
success "成功刪除: $release_tag ($release_name)"
else
fail "刪除失敗: $release_tag ($release_name), HTTP $delete_code"
fi
done < <(jq -c '.[]' <<<"$release_to_delete")
fi
section "刪除未指定 release 的 tag"
# 重新取得 release 清單,得到刪除舊版本後仍指定 tag 的成品
current_release_json="$(fetch_all_pages "$release_api_url")"
release_tags_json="$(jq -c '[.[].tag_name]' <<<"$current_release_json")"
tag_api_url="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/tags"
info "GET $tag_api_url"
tag_json="$(fetch_all_pages "$tag_api_url")"
tag_count="$(jq 'length' <<<"$tag_json")"
info "TAG_COUNT=$tag_count"
while IFS= read -r tag_item; do
[ -z "$tag_item" ] && continue
tag_name="$(jq -r '.name' <<<"$tag_item")"
if is_empty_or_null "$tag_name"; then
warn "略過沒有名稱的 tag"
continue
fi
if [ "$(jq --arg name "$tag_name" 'any(.[]; . == $name)' <<<"$release_tags_json")" = "true" ]; then
info "保留指定 release 的 tag: $tag_name"
continue
fi
delete_url="$tag_api_url/$tag_name"
info "DELETE tag $tag_name"
delete_code="$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE "${auth_header[@]}" "$delete_url")"
if [ "$delete_code" -eq 204 ]; then
success "成功刪除未指定 release 的 tag: $tag_name"
else
fail "刪除 tag 失敗: $tag_name, HTTP $delete_code"
fi
done < <(jq -c '.[]' <<<"$tag_json")
separator
# 進入點:實際邏輯改由 Node.js 實作,集中於 /app 目錄。
# 以 exec 取代當前 shell process,讓 node 成為容器的 PID 1,正確接收訊號 (SIGTERM 等)。
exec node /app/index.js