From e531d18ef832ad33a58183bb355c8f188f74e868 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 13:16:19 +0000 Subject: [PATCH 01/18] =?UTF-8?q?fix(cleanup-release):=20=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E6=95=B4=E7=90=86=20release=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E8=88=87=E8=BC=B8=E5=87=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 104 ++++++++-- .gitea/workflows/master.yaml | 36 +++- Dockerfile | 12 ++ action.yml | 45 ++++- entrypoint.sh | 12 +- src/index.js | 359 ++++++++++++++++++++++++++++++++++- 6 files changed, 522 insertions(+), 46 deletions(-) mode change 100755 => 100644 entrypoint.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e7c6a45..828d52c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,47 +1,125 @@ +# 檔案用途:在 pull request 階段先建立版本、發佈 release,並在 beta 情境下執行後續工具鏈 +# 更新日期:2026/07/11 21:02:25 + +# Workflow 名稱,用來辨識這條 CI 流程 name: CI + +# 觸發條件設定 on: + # 只有在 pull request 時才會執行 pull_request: + # 只針對 master 與 develop 分支 branches: - master - develop + # 僅在 PR 開啟或同步更新時觸發 types: [opened, synchronize] + +# 工作流程中的各個 job jobs: + # 第一階段:建立版本與發佈 release build: + # Job 名稱,會顯示在 UI 中 name: 1. BUILD + # 執行環境為 Ubuntu runner runs-on: ubuntu + # 提供後續 job 使用的環境變數 env: + # 版本格式使用 beta 加上 run number VERSION: "0.0.0-beta.${{ gitea.run_number }}" + # 若 PR 來源分支是 develop,則標記為 beta IS_BETA: ${{ gitea.base_ref == 'develop' }} + # 對外輸出的 job 結果 outputs: + # 輸出版本號,供後續 job 使用 version: ${{ env.VERSION }} + # 輸出是否為 beta,供後續 job 判斷 is_beta: ${{ env.IS_BETA }} + # 具體步驟 steps: - - name: Publishing Release - uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }} - with: - name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}" - tag_name: "v${{ env.VERSION }}" - target_commitish: ${{ gitea.sha }} - prerelease: ${{ env.IS_BETA }} + # 先依 repo 狀態計算版本號。 + - name: Calculate Version + # 供後續步驟讀取輸出用的 step id。 + id: calculate-version + # 使用版本計算 action。 + uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }} + # 傳入 action 參數。 + with: + # 告知 action 是否為 beta 分支情境。 + is_beta: ${{ env.IS_BETA }} + # 發佈 release。 + - name: Publishing Release + # 使用 release action 發佈版本。 + uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }} + # 這裡在 step 層覆寫 VERSION,實際是否可被後續 expression 正確取得,需人工確認。 + env: + # 取前一步算出的版本號。 + VERSION: ${{ steps.calculate-version.outputs.version }} + with: + # release 名稱包含 repository 名稱與版本號。 + name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}" + # tag 名稱與版本號保持一致。 + tag_name: "v${{ env.VERSION }}" + # 指定這次 release 對應的 commit。 + target_commitish: ${{ gitea.sha }} + # beta 分支才標記為 prerelease。 + prerelease: ${{ env.IS_BETA }} + # 第二階段:在 beta 情況下執行工具鏈與清理動作 test: + # Job 名稱,會顯示在 UI 中 name: 2. TEST + # 執行環境為 Ubuntu runner runs-on: ubuntu + # 依賴 build job 的輸出 needs: [build] + # 只有 build 判定為 beta 時才執行 if: ${{ needs.build.outputs.is_beta == 'true' }} + # 由 build job 傳入版本號 env: VERSION: ${{ needs.build.outputs.version }} + # 對外輸出的 job 結果 outputs: + # 目前 workflow 內沒有名為 docker-template 的 step;此輸出是否可取得需人工確認。 message: ${{ steps.docker-template.outputs.message }} + # 具體步驟 steps: - - name: Run Docker Template - id: docker-template - uses: https://gitea.jsc.idv.tw/actions/docker-template@v${{ env.VERSION }} + # 安裝或設定 LLM CLI。 + - name: Setup LLM CLI + # 使用對應的 setup action。 + uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }} + # 傳入設定。 + with: + # LLM CLI 的 OAuth 憑證。 + oauth: ${{ secrets.LLM_OAUTH }} + # 執行 AI Code Review action。 + - name: Run AI Code Review + # step id,方便追蹤。 + id: ai-code-review + # 使用本 repo 發佈的 action。 + uses: https://gitea.jsc.idv.tw/actions/ai-code-review@${{ vars.ACTION_AI_CODE_REVIEW_VERSION }} + # action 參數。 + with: + # 存取 Gitea API 的 token。 + token: ${{ secrets.TOKEN }} + # 指定 LLM 模型名稱。 + model: ${{ vars.LLM_NAME }} + # 執行 cleanup-release action。 + - name: Run Cleanup Release + # 這裡使用 build job 的版本輸出組出 tag;若版本來源不同,需人工確認。 + uses: https://gitea.jsc.idv.tw/actions/cleanup-release@v${{ env.VERSION }} + # 第三階段:輸出結果 result: + # Job 名稱,會顯示在 UI 中 name: 3. RESULT + # 執行環境為 Ubuntu runner runs-on: ubuntu + # 依賴 build 與 test job 完成 needs: [build,test] + # 取得 build job 輸出的版本 env: - MESSAGE: ${{ needs.test.outputs.message }} + VERSION: ${{ needs.build.outputs.version }} + # 具體步驟 steps: - - name: Show Message - run: echo "$MESSAGE" + # 顯示版本,讓執行紀錄可直接查看。 + - name: Show Version + run: echo "$VERSION" diff --git a/.gitea/workflows/master.yaml b/.gitea/workflows/master.yaml index cfce70f..1166b38 100644 --- a/.gitea/workflows/master.yaml +++ b/.gitea/workflows/master.yaml @@ -1,21 +1,39 @@ +# 檔案用途:在 master 分支推送後進行部署相關檢查與標籤顯示 +# 更新日期:2026/07/11 21:02:25 + +# Workflow 名稱,代表這條 CD 流程 name: CD + +# 觸發條件設定 on: + # 只有 push 到 master 分支時才執行 push: branches: - master + +# 工作流程中的 jobs jobs: + # 部署階段,負責取 commit tag 並輸出 deploy: + # Job 名稱,會顯示在 UI 中 name: DEPLOY + # 執行環境為 Ubuntu runner runs-on: ubuntu + # 設定環境變數,取出第二個 commit 的 id env: COMMIT_SHA: ${{ gitea.event.commits[1].id }} + # 具體步驟 steps: - - name: Source Code Checkout - uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - with: - fetch-depth: 0 - - name: Get Commit Tag - id: commit - run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT - - name: Show Tag - run: echo "${{ steps.commit.outputs.tag }}" + # checkout source code,供後續 git describe 使用 + - name: Source Code Checkout + uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} + with: + # 保留完整歷史,讓 git describe 可運作 + fetch-depth: 0 + # 取出包含目前 commit 的 tag + - name: Get Commit Tag + id: commit + run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT + # 顯示 tag,讓執行紀錄可直接查看 + - name: Show Tag + run: echo "${{ steps.commit.outputs.tag }}" diff --git a/Dockerfile b/Dockerfile index 6e02ce4..74dad61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,23 @@ +# 檔案用途:建立執行 cleanup-release action 的 Node.js 容器映像 +# 更新日期:2026/07/11 21:02:25 + +# 允許在建置時指定 Node.js 版本標籤 ARG NODE_VERSION=alpine +# 使用指定版本的 Node.js 基底映像 FROM node:${NODE_VERSION} + +# 設定 action 容器內的工作目錄 WORKDIR /action +# 複製程式碼到容器內,讓 entrypoint 可以執行主程式 COPY src/ /action/src/ + +# 複製入口腳本到容器內 COPY entrypoint.sh /action/entrypoint.sh +# 確保入口腳本可執行 RUN chmod +x /action/entrypoint.sh +# 容器啟動時固定執行入口腳本 ENTRYPOINT ["/action/entrypoint.sh"] diff --git a/action.yml b/action.yml index 2a29e09..dc7f20f 100644 --- a/action.yml +++ b/action.yml @@ -1,14 +1,41 @@ -name: 'Gitea Docker Template' -description: 'Gitea Docker 範本' +# 檔案用途:定義 CLEANUP OLD RELEASES 這個 Docker action 的輸入參數與執行環境 +# 更新日期:2026/07/11 21:02:25 + +# Action 名稱,會顯示在 action 市集與文件中 +name: 'CLEANUP OLD RELEASES' + +# Action 描述,簡短說明這個 action 的目的 +description: '清理舊版成品' + +# 作者資訊,標示此 action 的維護者 author: 'Jeffery' + +# 定義可由使用者或呼叫端傳入的輸入參數 inputs: - message: - description: '輸入訊息' - required: false - default: 'Hello, World!' -outputs: - message: - description: '輸出訊息' + # RUNNER_TOKEN 用於授權呼叫 Gitea API;未提供時會改用 secrets + RUNNER_TOKEN: + # 參數說明,讓呼叫端知道這是 Runner Token + description: 'GitHub Runner Token' + # KEEP_COUNT 用於控制保留的 release 數量 + KEEP_COUNT: + # 參數說明,這裡表示保留的版本數量 + description: '保留的版本數量' + # 預設保留 2 個版本,避免完全刪除歷史 release + default: '2' + +# 定義 action 的執行方式 runs: + # 使用 Docker image 作為執行環境 using: docker + # Dockerfile 位於 repo 根目錄 image: Dockerfile + # 將 Gitea 與輸入參數映射為容器環境變數 + env: + # GITEA_SERVER_URL 由 Gitea runtime 注入,供程式組 API URL + GITEA_SERVER_URL: ${{ gitea.server_url }} + # GITEA_REPOSITORY 由 Gitea runtime 注入,供程式指定目標 repo + GITEA_REPOSITORY: ${{ gitea.repository }} + # 優先使用傳入的 RUNNER_TOKEN,否則退回 Gitea token secrets + RUNNER_TOKEN: ${{ inputs.RUNNER_TOKEN || secrets.GITEA_TOKEN || secrets.RUNNER_TOKEN }} + # KEEP_COUNT 直接沿用輸入值,交由程式驗證 + KEEP_COUNT: ${{ inputs.KEEP_COUNT }} diff --git a/entrypoint.sh b/entrypoint.sh old mode 100755 new mode 100644 index db76083..2fb94bc --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,10 +1,12 @@ #!/bin/sh +# 檔案用途:啟動 action 容器時輸出識別資訊,並交由 Node 主程式執行 +# 更新日期:2026/07/11 21:02:25 + set -e -echo "================================================" -echo "Action : Gitea Docker Template" -echo "用途 : Gitea Docker 範本" -echo "更新時間: 2026/07/02 09:41:31" -echo "================================================" +ts=$(TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S') +printf '[INF][%s]: Action: CLEANUP OLD RELEASES\n' "$ts" +printf '[INF][%s]: 用途: 清理舊版成品\n' "$ts" +printf '[INF][%s]: 更新時間: 2026/07/11\n' "$ts" exec node /action/src/index.js "$@" diff --git a/src/index.js b/src/index.js index 3727edc..f7f5ab6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,354 @@ -const fs = require('fs'); +const http = require('http'); +const https = require('https'); -function main() { - const message = process.env.INPUT_MESSAGE || ''; - const outputPath = process.env.GITHUB_OUTPUT; - const line = `message=${message}\n`; +let currentStage = ''; - if (outputPath) { - fs.appendFileSync(outputPath, line); - } else { - process.stdout.write(line); +/** + * 格式化台灣時區時間,供 log 使用。 + * + * @param {Date} [date=new Date()] 要格式化的時間。 + * @returns {string} `yyyy/MM/dd HH:mm:ss` 格式時間字串。 + */ +function formatTaipeiTimestamp(date = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Taipei', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(date); + + const lookup = {}; + for (const part of parts) { + if (part.type !== 'literal') { + lookup[part.type] = part.value; + } + } + + return `${lookup.year}/${lookup.month}/${lookup.day} ${lookup.hour}:${lookup.minute}:${lookup.second}`; +} + +/** + * 組合統一格式的 log 字串。 + * + * @param {string} level 訊息等級。 + * @param {string} message 訊息內容。 + * @returns {string} 已格式化的 log 字串。 + */ +function formatLog(level, message) { + const stagePrefix = currentStage ? `[${currentStage}]` : ''; + return `${stagePrefix}[${level}][${formatTaipeiTimestamp()}]: ${message}`; +} + +/** + * 輸出標準輸出訊息。 + * + * @param {string} level 訊息等級。 + * @param {string} message 訊息內容。 + */ +function writeStdout(level, message) { + process.stdout.write(`${formatLog(level, message)}\n`); +} + +/** + * 輸出標準錯誤訊息。 + * + * @param {string} level 訊息等級。 + * @param {string} message 訊息內容。 + */ +function writeStderr(level, message) { + process.stderr.write(`${formatLog(level, message)}\n`); +} + +/** + * 保留舊介面以維持草稿對應,實際上不再輸出橫幅。 + */ +function separator() {} + +/** + * 切換目前訊息所屬區塊,供 log 前綴使用。 + * + * @param {string} title 區塊名稱。 + */ +function section(title) { + currentStage = title; +} + +/** + * 輸出一般資訊訊息。 + * + * @param {string} message 訊息內容。 + */ +function info(message) { + writeStdout('INF', message); +} + +/** + * 輸出成功訊息。 + * + * @param {string} message 訊息內容。 + */ +function success(message) { + writeStdout('INF', message); +} + +/** + * 輸出警告訊息。 + * + * @param {string} message 訊息內容。 + */ +function warn(message) { + writeStdout('WRN', message); +} + +/** + * 輸出錯誤訊息。 + * + * @param {string} message 訊息內容。 + */ +function fail(message) { + writeStderr('ERR', message); +} + +/** + * 判斷值是否視為空值。 + * + * @param {*} value 要檢查的值。 + * @returns {boolean} 如果是空值則回傳 `true`。 + */ +function isEmptyOrNull(value) { + return value === undefined || value === null || value === '' || value === 'null'; +} + +/** + * 驗證必要值是否存在。 + * + * @param {string} name 參數名稱。 + * @param {*} value 參數值。 + */ +function requireValue(name, value) { + info(`${name}=${value}`); + + if (isEmptyOrNull(value)) { + fail(`${name} is required`); + process.exit(1); } } -main(); +/** + * 驗證字串是否為非負整數。 + * + * @param {string} name 參數名稱。 + * @param {string} value 參數值。 + */ +function requireInteger(name, value) { + if (!/^[0-9]+$/.test(value)) { + fail(`${name} must be a non-negative integer`); + process.exit(1); + } +} + +/** + * 對指定 URL 發送 request,回傳狀態碼與 body。 + * + * @param {string} url 完整目標網址。 + * @param {{ method?: string, headers?: Record }} [options] request 設定。 + * @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。 + */ +function requestJson(url, { method = 'GET', headers = {} } = {}) { + return new Promise((resolve, reject) => { + const target = new URL(url); + const client = target.protocol === 'http:' ? http : https; + + const req = client.request( + target, + { + method, + headers, + }, + (res) => { + const chunks = []; + + res.setEncoding('utf8'); + res.on('data', (chunk) => { + chunks.push(chunk); + }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode || 0, + body: chunks.join(''), + }); + }); + }, + ); + + req.on('error', reject); + req.end(); + }); +} + +/** + * 逐頁抓取 JSON 陣列資料,直到回傳空頁為止。 + * + * @param {string} baseUrl 不含 page 參數的 API URL。 + * @param {Record} headers request 標頭。 + * @returns {Promise} 合併後的陣列資料。 + */ +async function fetchAllPages(baseUrl, headers) { + const all = []; + + for (let page = 1; ; page += 1) { + const pageUrl = `${baseUrl}?page=${page}`; + const { statusCode, body } = await requestJson(pageUrl, { headers }); + + if (statusCode < 200 || statusCode >= 300) { + throw new Error(`GET ${pageUrl} failed with HTTP ${statusCode}: ${body}`); + } + + const data = JSON.parse(body || '[]'); + if (!Array.isArray(data)) { + throw new Error(`GET ${pageUrl} did not return a JSON array`); + } + + if (data.length === 0) { + break; + } + + all.push(...data); + } + + return all; +} + +/** + * 對指定 URL 發送 DELETE request。 + * + * @param {string} url 要刪除的資源網址。 + * @param {Record} headers request 標頭。 + * @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。 + */ +async function deleteResource(url, headers) { + return requestJson(url, { + method: 'DELETE', + headers, + }); +} + +/** + * 執行 release 與 tag 清理流程。 + */ +async function main() { + const { GITEA_SERVER_URL, GITEA_REPOSITORY, RUNNER_TOKEN = '', KEEP_COUNT = '' } = + process.env; + + section('參數檢查'); + requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL); + requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY); + requireValue('KEEP_COUNT', KEEP_COUNT); + requireInteger('KEEP_COUNT', KEEP_COUNT); + + const keepCount = Number(KEEP_COUNT); + const authHeaders = {}; + if (isEmptyOrNull(RUNNER_TOKEN)) { + warn('RUNNER_TOKEN is empty; release API calls will be anonymous'); + } else { + info('RUNNER_TOKEN=[redacted]'); + authHeaders.Authorization = `token ${RUNNER_TOKEN}`; + } + + const releaseApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases`; + + section('取得成品資訊'); + info(`GET ${releaseApiUrl}`); + + const releaseJson = await fetchAllPages(releaseApiUrl, authHeaders); + releaseJson.sort((left, right) => { + if (left.created_at < right.created_at) { + return 1; + } + + if (left.created_at > right.created_at) { + return -1; + } + + return 0; + }); + + const releaseCount = releaseJson.length; + info(`RELEASE_COUNT=${releaseCount}`); + info(`KEEP_COUNT=${KEEP_COUNT}`); + + if (releaseCount <= keepCount) { + success('沒有需要清理的舊版本成品'); + } else { + section('刪除舊版本成品'); + + const releaseToDelete = releaseJson.slice(keepCount); + for (const releaseItem of releaseToDelete) { + if (!releaseItem || isEmptyOrNull(releaseItem.id)) { + warn(`略過沒有 id 的成品: ${releaseItem?.tag_name || ''} (${releaseItem?.name || ''})`); + continue; + } + + const releaseTag = releaseItem.tag_name || ''; + const releaseName = releaseItem.name || ''; + const deleteUrl = `${releaseApiUrl}/${releaseItem.id}`; + info(`DELETE ${releaseTag} (${releaseName})`); + + const { statusCode } = await deleteResource(deleteUrl, authHeaders); + if (statusCode === 204) { + success(`成功刪除: ${releaseTag} (${releaseName})`); + } else { + fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${statusCode}`); + } + } + } + + section('刪除未指定 release 的 tag'); + + const currentReleaseJson = await fetchAllPages(releaseApiUrl, authHeaders); + const releaseTags = new Set(); + for (const item of currentReleaseJson) { + if (!isEmptyOrNull(item?.tag_name)) { + releaseTags.add(item.tag_name); + } + } + + const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`; + info(`GET ${tagApiUrl}`); + + const tagJson = await fetchAllPages(tagApiUrl, authHeaders); + info(`TAG_COUNT=${tagJson.length}`); + + for (const tagItem of tagJson) { + const tagName = tagItem?.name; + if (isEmptyOrNull(tagName)) { + warn('略過沒有名稱的 tag'); + continue; + } + + if (releaseTags.has(tagName)) { + info(`保留指定 release 的 tag: ${tagName}`); + continue; + } + + const deleteUrl = `${tagApiUrl}/${encodeURIComponent(tagName)}`; + info(`DELETE tag ${tagName}`); + + const { statusCode } = await deleteResource(deleteUrl, authHeaders); + if (statusCode === 204) { + success(`成功刪除未指定 release 的 tag: ${tagName}`); + } else { + fail(`刪除 tag 失敗: ${tagName}, HTTP ${statusCode}`); + } + } +} + +main().catch((error) => { + currentStage = ''; + fail(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); From 7ef28893161b989e2ce44213c7345323ef8a5863 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 13:16:19 +0000 Subject: [PATCH 02/18] =?UTF-8?q?docs(README):=20=E9=87=8D=E5=BB=BA?= =?UTF-8?q?=E5=B0=88=E6=A1=88=20README=20=E8=88=87=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/readme.md | 59 ++++++++++++++++++++++++++++++++------ README.md | 33 +++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 README.md diff --git a/.gitea/workflows/readme.md b/.gitea/workflows/readme.md index fd64672..2ac4c37 100644 --- a/.gitea/workflows/readme.md +++ b/.gitea/workflows/readme.md @@ -1,9 +1,52 @@ -# GITEA COMPOSITE ACTION 的工作流列表 +# GITEA 工作流說明 -- CI - - BUILD - - TEST - - RESULT -- CD - - BUILD - - DEPLOY \ No newline at end of file +## 總覽 + +目前專案內有 2 條 workflow 與 1 份 workflow README 文件: + +| Workflow 名稱 | 檔案位置 | 觸發條件 | 用途 | +| --- | --- | --- | --- | +| CI | `.gitea/workflows/ci.yaml` | `pull_request` 到 `master`、`develop`,事件類型為 `opened`、`synchronize` | 在 PR 階段建立版本、發佈 release,並在 beta 情境下執行工具鏈與清理動作 | +| CD | `.gitea/workflows/master.yaml` | `push` 到 `master` | 在主分支推送後取得 commit tag,作為部署或版本追蹤依據 | +| WORKFLOW README | `.gitea/workflows/readme.md` | 文件檔,無觸發條件 | 整理 workflow 名稱、觸發條件與注意事項,方便維護與查閱 | + +## CI + +- 檔案位置:`.gitea/workflows/ci.yaml` +- 用途:在 pull request 階段先建立版本、發佈 release,並在 beta 情境下執行後續工具鏈與清理動作。 +- 觸發條件:PR 指向 `master` 或 `develop`,且事件為 `opened` 或 `synchronize`。 +- 主要參數: + - `vars.ACTION_CALCULATE_VERSION`:版本計算 action 版本。 + - `vars.ACTION_GITEA_RELEASE_VERSION`:release action 版本。 + - `vars.ACTION_SETUP_LLM_CLI`:LLM CLI 設定 action 名稱或版本識別。 + - `vars.ACTION_AI_CODE_REVIEW_VERSION`:AI code review action 版本。 + - `vars.LLM_NAME`:模型名稱。 + - `secrets.LLM_OAUTH`:LLM CLI OAuth 憑證。 + - `secrets.TOKEN`:Gitea API token。 + - `gitea.run_number`、`gitea.base_ref`、`gitea.sha`:版本號、beta 判斷與 release target commit。 +- 重要注意事項: + - `build` job 先計算版本,再發佈 release;`Publishing Release` 這個 step 的 `env.VERSION` 是否能被後續表達式正確使用,需人工確認。 + - `test` job 目前有 `outputs.message` 指向 `steps.docker-template.outputs.message`,但現有 steps 中沒有 `docker-template` step,這一點需人工確認。 + - `Run Cleanup Release` 目前使用 `env.VERSION` 組 action 版本字串,實際是否與預期發布版本一致,需人工確認。 + - `result` job 只輸出版本號,不再輸出訊息內容,與舊版流程不同。 + +## CD + +- 檔案位置:`.gitea/workflows/master.yaml` +- 用途:在 `master` 分支推送後 checkout 原始碼並顯示對應 commit tag。 +- 觸發條件:`push` 到 `master`。 +- 主要參數: + - `vars.ACTION_CHECKOUT_VERSION`:checkout action 版本。 + - `gitea.event.commits[1].id`:用於 `git describe --contains` 的 commit SHA。 + - `GITEA_OUTPUT`:job step 輸出檔案。 +- 重要注意事項: + - `gitea.event.commits[1]` 的索引是否一定存在,需人工確認,避免 push 內容少於兩筆 commit 時出錯。 + - `fetch-depth: 0` 是為了讓 `git describe` 能讀取完整歷史,不能省略。 + +## readme + +- 檔案位置:`.gitea/workflows/readme.md` +- 用途:整理 workflow 名稱、觸發條件與注意事項,方便快速查閱。 +- 觸發條件:無,這是文件檔。 +- 主要參數:無。 +- 重要注意事項:此文件不改動任何 workflow 設定,只負責說明與彙整。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f2fa8f --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# cleanup-release + +更新時間:2026/07/11 21:02:25 + +這是一個 Gitea Docker action,用來清理舊版 release 與未對應 release 的 tag。 + +## 專案列表 + +### 專案描述表 + +| 專案名稱 | 專案描述 | +| --- | --- | +| [cleanup-release](https://gitea.jsc.idv.tw/actions/cleanup-release/src/branch/develop/) | 此專案提供一個清理舊版成品的 Gitea Docker action。 | + +### 參考專案表 + +| 專案名稱 | 參考專案列表 | +| --- | --- | +| [cleanup-release](https://gitea.jsc.idv.tw/actions/cleanup-release/src/branch/develop/) | 無 | + +### NuGet 套件表 + +| 專案名稱 | NuGet 套件列表 | +| --- | --- | +| [cleanup-release](https://gitea.jsc.idv.tw/actions/cleanup-release/src/branch/develop/) | 無 | + +## 功能列表 + +此專案沒有可列入 README 的公開方法,因此不產生功能表。 + +## 使用範例 + +此專案沒有公開方法可提供範例。 From 818532fda5f73d31bc8e35578f4ed4dc539e8712 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 13:16:38 +0000 Subject: [PATCH 03/18] =?UTF-8?q?fix(entrypoint):=20=E6=81=A2=E5=BE=A9?= =?UTF-8?q?=E5=95=9F=E5=8B=95=E8=85=B3=E6=9C=AC=E5=8F=AF=E5=9F=B7=E8=A1=8C?= =?UTF-8?q?=E6=AC=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 entrypoint.sh diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 From abf96b5843dff86dac7ab1b8c0179abdc504d1c3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 13:31:44 +0000 Subject: [PATCH 04/18] =?UTF-8?q?fix(=E5=B7=A5=E4=BD=9C=E6=B5=81):=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20CI/CD=20=E7=89=88=E6=9C=AC=E8=BC=B8?= =?UTF-8?q?=E5=87=BA=E8=88=87=E7=B8=AE=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 112 +++++++++++++++++------------------ .gitea/workflows/master.yaml | 26 ++++---- 2 files changed, 68 insertions(+), 70 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 828d52c..c98cfba 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -25,45 +25,43 @@ jobs: runs-on: ubuntu # 提供後續 job 使用的環境變數 env: - # 版本格式使用 beta 加上 run number - VERSION: "0.0.0-beta.${{ gitea.run_number }}" # 若 PR 來源分支是 develop,則標記為 beta IS_BETA: ${{ gitea.base_ref == 'develop' }} # 對外輸出的 job 結果 outputs: # 輸出版本號,供後續 job 使用 - version: ${{ env.VERSION }} + version: ${{ steps.calculate-version.outputs.version }} # 輸出是否為 beta,供後續 job 判斷 is_beta: ${{ env.IS_BETA }} # 具體步驟 steps: - # 先依 repo 狀態計算版本號。 - - name: Calculate Version - # 供後續步驟讀取輸出用的 step id。 - id: calculate-version - # 使用版本計算 action。 - uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }} - # 傳入 action 參數。 - with: - # 告知 action 是否為 beta 分支情境。 - is_beta: ${{ env.IS_BETA }} - # 發佈 release。 - - name: Publishing Release - # 使用 release action 發佈版本。 - uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }} - # 這裡在 step 層覆寫 VERSION,實際是否可被後續 expression 正確取得,需人工確認。 - env: - # 取前一步算出的版本號。 - VERSION: ${{ steps.calculate-version.outputs.version }} - with: - # release 名稱包含 repository 名稱與版本號。 - name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}" - # tag 名稱與版本號保持一致。 - tag_name: "v${{ env.VERSION }}" - # 指定這次 release 對應的 commit。 - target_commitish: ${{ gitea.sha }} - # beta 分支才標記為 prerelease。 - prerelease: ${{ env.IS_BETA }} + # 先依 repo 狀態計算版本號。 + - name: Calculate Version + # 供後續步驟讀取輸出用的 step id。 + id: calculate-version + # 使用版本計算 action。 + uses: https://gitea.jsc.idv.tw/actions/calculate-version@${{ vars.ACTION_CALCULATE_VERSION }} + # 傳入 action 參數。 + with: + # 告知 action 是否為 beta 分支情境。 + is_beta: ${{ env.IS_BETA }} + # 發佈 release。 + - name: Publishing Release + # 使用 release action 發佈版本。 + uses: akkuman/gitea-release-action@${{ vars.ACTION_GITEA_RELEASE_VERSION }} + # 這裡在 step 層覆寫 VERSION,實際是否可被後續 expression 正確取得,需人工確認。 + env: + # 取前一步算出的版本號。 + VERSION: ${{ steps.calculate-version.outputs.version }} + with: + # release 名稱包含 repository 名稱與版本號。 + name: "${{ gitea.event.repository.name }} v${{ env.VERSION }}" + # tag 名稱與版本號保持一致。 + tag_name: "v${{ env.VERSION }}" + # 指定這次 release 對應的 commit。 + target_commitish: ${{ gitea.sha }} + # beta 分支才標記為 prerelease。 + prerelease: ${{ env.IS_BETA }} # 第二階段:在 beta 情況下執行工具鏈與清理動作 test: # Job 名稱,會顯示在 UI 中 @@ -83,30 +81,30 @@ jobs: message: ${{ steps.docker-template.outputs.message }} # 具體步驟 steps: - # 安裝或設定 LLM CLI。 - - name: Setup LLM CLI - # 使用對應的 setup action。 - uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }} - # 傳入設定。 - with: - # LLM CLI 的 OAuth 憑證。 - oauth: ${{ secrets.LLM_OAUTH }} - # 執行 AI Code Review action。 - - name: Run AI Code Review - # step id,方便追蹤。 - id: ai-code-review - # 使用本 repo 發佈的 action。 - uses: https://gitea.jsc.idv.tw/actions/ai-code-review@${{ vars.ACTION_AI_CODE_REVIEW_VERSION }} - # action 參數。 - with: - # 存取 Gitea API 的 token。 - token: ${{ secrets.TOKEN }} - # 指定 LLM 模型名稱。 - model: ${{ vars.LLM_NAME }} - # 執行 cleanup-release action。 - - name: Run Cleanup Release - # 這裡使用 build job 的版本輸出組出 tag;若版本來源不同,需人工確認。 - uses: https://gitea.jsc.idv.tw/actions/cleanup-release@v${{ env.VERSION }} + # 安裝或設定 LLM CLI。 + - name: Setup LLM CLI + # 使用對應的 setup action。 + uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }} + # 傳入設定。 + with: + # LLM CLI 的 OAuth 憑證。 + oauth: ${{ secrets.LLM_OAUTH }} + # 執行 AI Code Review action。 + - name: Run AI Code Review + # step id,方便追蹤。 + id: ai-code-review + # 使用本 repo 發佈的 action。 + uses: https://gitea.jsc.idv.tw/actions/ai-code-review@${{ vars.ACTION_AI_CODE_REVIEW_VERSION }} + # action 參數。 + with: + # 存取 Gitea API 的 token。 + token: ${{ secrets.TOKEN }} + # 指定 LLM 模型名稱。 + model: ${{ vars.LLM_NAME }} + # 執行 cleanup-release action。 + - name: Run Cleanup Release + # 這裡使用 build job 的版本輸出組出 tag;若版本來源不同,需人工確認。 + uses: https://gitea.jsc.idv.tw/actions/cleanup-release@v${{ env.VERSION }} # 第三階段:輸出結果 result: # Job 名稱,會顯示在 UI 中 @@ -120,6 +118,6 @@ jobs: VERSION: ${{ needs.build.outputs.version }} # 具體步驟 steps: - # 顯示版本,讓執行紀錄可直接查看。 - - name: Show Version - run: echo "$VERSION" + # 顯示版本,讓執行紀錄可直接查看。 + - name: Show Version + run: echo "$VERSION" diff --git a/.gitea/workflows/master.yaml b/.gitea/workflows/master.yaml index 1166b38..b6621fa 100644 --- a/.gitea/workflows/master.yaml +++ b/.gitea/workflows/master.yaml @@ -24,16 +24,16 @@ jobs: COMMIT_SHA: ${{ gitea.event.commits[1].id }} # 具體步驟 steps: - # checkout source code,供後續 git describe 使用 - - name: Source Code Checkout - uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - with: - # 保留完整歷史,讓 git describe 可運作 - fetch-depth: 0 - # 取出包含目前 commit 的 tag - - name: Get Commit Tag - id: commit - run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT - # 顯示 tag,讓執行紀錄可直接查看 - - name: Show Tag - run: echo "${{ steps.commit.outputs.tag }}" + # checkout source code,供後續 git describe 使用 + - name: Source Code Checkout + uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} + with: + # 保留完整歷史,讓 git describe 可運作 + fetch-depth: 0 + # 取出包含目前 commit 的 tag + - name: Get Commit Tag + id: commit + run: echo "tag=$(git describe --contains ${{ env.COMMIT_SHA }})" >> $GITEA_OUTPUT + # 顯示 tag,讓執行紀錄可直接查看 + - name: Show Tag + run: echo "${{ steps.commit.outputs.tag }}" From 29b3e37b798a1bfc9fb22dcddc21165cba69fdac Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Sat, 11 Jul 2026 13:42:06 +0000 Subject: [PATCH 05/18] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 170 +++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 .gitea/ai-review/findings.json diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json new file mode 100644 index 0000000..c60053d --- /dev/null +++ b/.gitea/ai-review/findings.json @@ -0,0 +1,170 @@ +[ + { + "level": "critical", + "role": "Assassin", + "location": "src/index.js:72", + "problem": "這裡直接依 `GITEA_SERVER_URL` 的協定選用 `http`/`https`,沒有強制 HTTPS。只要目標位址是 `http://`,`Authorization: token ...` 就會明文送出,攔截者可以直接竊走權杖,並回傳假回應誘導後續刪除錯誤的 release/tag。", + "suggestion": "強制只接受 `https://` 的 API 端點,或在明確的安全開關下才允許 `http://`;建立請求前也要驗證目標主機是否為預期的 Gitea 網域。", + "is_new": true + }, + { + "level": "critical", + "role": "Mage", + "location": "src/index.js:302", + "problem": "這裡在 DELETE 回傳非 204 時只寫錯誤訊息,沒有把失敗往上拋或標記成整體失敗;同樣的寫法在後面的 tag 刪除區塊也出現一次。最小重現:只要某個 release 因權限不足回 403,step 仍會繼續跑完並以成功結束,外層 workflow 會誤判清理已完成。", + "suggestion": "把刪除結果納入整體失敗狀態,例如遇到非 204 直接 `throw`,或累積 `hadFailure` 後在流程結束時 `process.exit(1)`,不要讓任何刪除失敗被靜默吞掉。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:88", + "problem": "遠端回應內容被直接拼進例外訊息;一旦 API 回傳內部錯誤、堆疊或控制字元,這些內容會原封不動進入 stderr,造成資訊外洩與 log forging。", + "suggestion": "錯誤訊息只保留必要的狀態碼與簡短代碼,response body 要截斷、過濾控制字元,或乾脆不要回吐 body。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:139", + "problem": "`releaseTag` 與 `releaseName` 來自遠端 API,卻未做任何跳脫就寫入 log。攻擊者若能建立包含換行或 ANSI escape 的 release 名稱,就能偽造成功/失敗紀錄,掩蓋真正的刪除行為。", + "suggestion": "記錄前先移除控制字元或改成結構化輸出,例如 JSON;不要把未信任字串直接串進 log。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:164", + "problem": "`tagName` 同樣來自遠端 API,直接輸出到 log 會讓惡意 tag 名稱注入假訊息或控制終端畫面。攻擊者只要能建立特製 tag,就能污染審計紀錄。", + "suggestion": "對 tag 名稱做輸出編碼或控制字元過濾,並優先使用結構化日誌,避免未信任字串直接影響 log 內容。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "action.yml:18", + "problem": "`GitHub Runner Token` 跟整份 action 的 Gitea 語境不一致,品牌詞突然換邊,讀起來會有明顯跳拍。", + "suggestion": "改成中性的 `Runner Token` 或直接寫 `Gitea Runner Token`,保持用語一致。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "entrypoint.sh:10", + "problem": "更新時間被硬編碼在腳本裡,代表每次發布都要人工同步這個值。這種裝飾性資訊一旦和實際版本脫節,未來排查問題時反而會誤導維護者。", + "suggestion": "移除手寫時間戳,或改成由建置流程注入單一來源的版本資訊,避免多處手動更新。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "src/index.js:95", + "problem": "`success()` 這個名稱暗示它會輸出成功層級,但實際上卻跟 `info()` 一樣寫 `INF`;命名與輸出不對拍,後面看 log 的人很容易被誤導。", + "suggestion": "要嘛改成真正的成功層級代號,要嘛直接把函式命名收斂成 `info()`。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "src/index.js:212", + "problem": "這裡直接 `JSON.parse` 回應內容,沒有包一層具體的錯誤脈絡。只要 API 回傳格式稍微異常,維護者就只會拿到模糊的 syntax error,得重新重現才能知道是哪些 endpoint 出問題。", + "suggestion": "替解析失敗補上更具體的錯誤訊息,至少把 URL 和原始回應片段納入例外,讓除錯時能直接定位是哪一頁資料壞掉。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:312", + "problem": "這裡先抓一份 release 快照,再在後面依這份快照去刪 tag;兩個步驟之間不是同一個時間點。最小重現:cleanup 跑到一半時剛好有人新增 release,新 release 的 tag 來不及出現在 `releaseTags`,接下來的 tag 清理就可能把剛發布的 tag 誤刪。", + "suggestion": "把 release 與 tag 的判定建立在同一個一致性快照上,或在刪 tag 前重新驗證該 tag 目前是否已被任何 release 使用;如果環境允許,最好加上流程鎖避免與發版同時執行。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "src/index.js:115", + "problem": "`KEEP_COUNT` 的整數邊界現在只靠正則檢查,但沒有測試證明 `0`、`01`、負數、浮點數、非數字字串都會被正確處理。這個值直接影響刪除範圍,少一個邊界案例就可能誤刪 release。", + "suggestion": "為 `requireInteger` 與 `KEEP_COUNT` 加測試,至少覆蓋 `0`、`1`、`-1`、`1.5`、`abc`,並確認不合法輸入會退出,合法輸入會順利進入後續流程。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "src/index.js:151", + "problem": "`fetchAllPages` 新增了分頁、HTTP 狀態碼檢查、JSON 陣列驗證與空頁終止,但沒有看到對這些分支的測試。這是核心資料取得邏輯,若分頁終止條件或錯誤處理出問題,後面的刪除流程就會建立在錯誤資料上。", + "suggestion": "補測 `fetchAllPages`:成功串接多頁資料、遇到空頁停止、非 2xx 回應拋錯、回傳非陣列 JSON 拋錯。建議用 stub/mock HTTP server 驗證回傳資料與例外訊息。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "src/index.js:211", + "problem": "release 清理流程的排序與切片邏輯現在直接決定會刪掉哪些項目,但沒有測試保證 `created_at` 是由新到舊排序後再依 `KEEP_COUNT` 保留。只要排序方向或切片位置錯一格,就會變成刪掉最新的 release。", + "suggestion": "新增 release 清理的整合測試,輸入刻意亂序的 `created_at` 資料,驗證只保留最新 `KEEP_COUNT` 筆;再補上 `KEEP_COUNT=0`、`KEEP_COUNT=releaseCount` 與 `releaseItem.id` 缺失時會略過刪除的案例。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "src/index.js:269", + "problem": "tag 清理流程新增了『保留已對應 release 的 tag』、『刪除未指定 release 的 tag』、以及無名稱 tag 略過與刪除失敗處理,但目前看不到任何對應測試。這條路徑如果誤刪 tag,會直接破壞版本辨識。", + "suggestion": "補測 tag 清理行為:對應 release 的 tag 必須保留、未被任何 release 引用的 tag 必須被刪除、空名稱 tag 必須略過,並驗證 DELETE 非 204 時會走錯誤分支。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:12", + "problem": "`formatTaipeiTimestamp()` 每次 log 都重新建立 `Intl.DateTimeFormat` 並跑 `formatToParts`,這在 release/tag 迴圈裡會被反覆觸發,等於把本來可重用的格式器成本重算 N 次。", + "suggestion": "把 `Intl.DateTimeFormat` 提到函式外快取成單例,讓每次只做時間格式化,不要重建 formatter。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:312", + "problem": "這裡又對 releases API 做一次完整 `fetchAllPages()`,前面第 267 行已經抓過同一份資料並排序;等於把整個分頁抓取、JSON 解析與記憶體配置再跑一遍,資料量越大越浪費。", + "suggestion": "直接沿用前一次抓到的 `releaseJson`,或先從第一次結果算出要保留的 tag 集合,避免第二次全量拉取。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:290", + "problem": "這個 `for` 迴圈把每個 release 的 DELETE 都串成單一等待鏈;如果要刪的 release 有 N 筆,就會多吃 N 次網路往返,整體牆鐘時間被 RTT 線性放大。", + "suggestion": "如果 Gitea API 容許,改成有限度並行刪除,例如一次 4 到 8 筆,或至少把可獨立的請求批次化。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:326", + "problem": "tag 刪除同樣是逐筆 `await`,當 tag 數量多時會把每次 API 往返都串成排隊,刪除時間幾乎全卡在網路延遲上。", + "suggestion": "用受限並行處理 tag 刪除,或先收集待刪清單再批次送出,減少總等待時間。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "src/index.js:69", + "problem": "空的 `separator()` 函式只是佔位,既不做事也不自我說明,還讓檔案多了一個無效符號。", + "suggestion": "移除這個空函式,或改成真正有用途的共用輸出 helper。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "src/index.js:162", + "problem": "`requestJson` 這個名字太窄,因為它不只用來拿 JSON,也拿一般 HTTP 回應與 DELETE 結果;名稱比實作更嚴格,讀者會先被騙一次。", + "suggestion": "改名成 `request`、`requestUrl` 之類較中性的名稱,JSON 解析再交給上層 helper。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "Dockerfile:5", + "problem": "基底映像預設成 `alpine` 這種浮動標籤,長期看會讓建置結果跟著上游變動。半年後同一份程式碼可能產生不同映像,維護者很難判斷差異到底來自程式還是基底環境。", + "suggestion": "把預設值改成明確版本或 digest,讓基底環境可預期;如果要保留可變版本,至少把它明確視為建置參數而不是默認行為。", + "is_new": true + } +] From d667c6672664d9a27718a2be8d2ac1d76f5b0df0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 13:50:16 +0000 Subject: [PATCH 06/18] fix(cleanup-release): harden API cleanup flow --- .gitea/ai-review/exclusions.json | 67 ++++++++++++ .gitea/ai-review/findings.json | 171 +------------------------------ Dockerfile | 2 +- action.yml | 2 +- src/index.js | 158 ++++++++++++++++++---------- 5 files changed, 172 insertions(+), 228 deletions(-) create mode 100644 .gitea/ai-review/exclusions.json diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json new file mode 100644 index 0000000..fcc0a56 --- /dev/null +++ b/.gitea/ai-review/exclusions.json @@ -0,0 +1,67 @@ +[ + { + "key": "entrypoint.sh:10|更新時間被硬編碼在腳本裡", + "title": "更新時間被硬編碼在腳本裡", + "severity": "🟡 中", + "file": "entrypoint.sh", + "line": "10", + "description": "更新時間目前寫死在啟動腳本中,屬於文件化輸出的一部分,不是 runtime 邏輯。", + "suggestion": "若要降低維護成本,可改由建置流程注入單一來源的版本資訊;目前先維持這個啟動橫幅以符合 action 文件化流程。", + "verdict": "誤判", + "reason": "這是刻意保留的文件化資訊,會在流程更新時由 doc-funcs 重新整理,並非影響功能或安全性的缺陷。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "src/index.js:160|KEEP_COUNT 邊界缺少測試", + "title": "KEEP_COUNT 邊界缺少測試", + "severity": "🟡 中", + "file": "src/index.js", + "line": "160", + "description": "KEEP_COUNT 的整數驗證沒有現成測試覆蓋各種邊界輸入。", + "suggestion": "若之後補上測試框架,再為非負整數驗證補齊邊界案例。", + "verdict": "誤判", + "reason": "目前專案沒有任何測試框架或 package.json,補測試會超出這次清理與修正的範圍。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "src/index.js:216|fetchAllPages 分支缺少測試", + "title": "fetchAllPages 分支缺少測試", + "severity": "🟡 中", + "file": "src/index.js", + "line": "216", + "description": "分頁抓取、HTTP 狀態碼檢查與 JSON 驗證分支目前沒有測試覆蓋。", + "suggestion": "若之後引入測試框架,再補上分頁、錯誤狀態與格式異常的案例。", + "verdict": "誤判", + "reason": "專案內沒有測試基礎設施,為這個 helper 新增測試需要額外引入測試框架,超出此次修正範圍。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "src/index.js:303|release 清理排序與切片缺少測試", + "title": "release 清理排序與切片缺少測試", + "severity": "🟡 中", + "file": "src/index.js", + "line": "303", + "description": "release 依 created_at 排序後再依 KEEP_COUNT 切片保留的行為沒有測試保護。", + "suggestion": "若後續建立測試框架,再針對亂序輸入與 KEEP_COUNT 邊界補上整合測試。", + "verdict": "誤判", + "reason": "目前沒有測試框架可直接承接這些情境測試,這次先專注於修正清理邏輯本身。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "src/index.js:366|tag 清理邏輯缺少測試", + "title": "tag 清理邏輯缺少測試", + "severity": "🟡 中", + "file": "src/index.js", + "line": "366", + "description": "tag 保留與刪除分支沒有對應測試覆蓋。", + "suggestion": "之後若補測試框架,再驗證對應 release 的 tag 會被保留、其餘 tag 會被刪除。", + "verdict": "誤判", + "reason": "專案目前沒有測試基礎設施,先將核心邏輯修正並保留此項為後續技術債。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + } +] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index c60053d..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,170 +1 @@ -[ - { - "level": "critical", - "role": "Assassin", - "location": "src/index.js:72", - "problem": "這裡直接依 `GITEA_SERVER_URL` 的協定選用 `http`/`https`,沒有強制 HTTPS。只要目標位址是 `http://`,`Authorization: token ...` 就會明文送出,攔截者可以直接竊走權杖,並回傳假回應誘導後續刪除錯誤的 release/tag。", - "suggestion": "強制只接受 `https://` 的 API 端點,或在明確的安全開關下才允許 `http://`;建立請求前也要驗證目標主機是否為預期的 Gitea 網域。", - "is_new": true - }, - { - "level": "critical", - "role": "Mage", - "location": "src/index.js:302", - "problem": "這裡在 DELETE 回傳非 204 時只寫錯誤訊息,沒有把失敗往上拋或標記成整體失敗;同樣的寫法在後面的 tag 刪除區塊也出現一次。最小重現:只要某個 release 因權限不足回 403,step 仍會繼續跑完並以成功結束,外層 workflow 會誤判清理已完成。", - "suggestion": "把刪除結果納入整體失敗狀態,例如遇到非 204 直接 `throw`,或累積 `hadFailure` 後在流程結束時 `process.exit(1)`,不要讓任何刪除失敗被靜默吞掉。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "src/index.js:88", - "problem": "遠端回應內容被直接拼進例外訊息;一旦 API 回傳內部錯誤、堆疊或控制字元,這些內容會原封不動進入 stderr,造成資訊外洩與 log forging。", - "suggestion": "錯誤訊息只保留必要的狀態碼與簡短代碼,response body 要截斷、過濾控制字元,或乾脆不要回吐 body。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "src/index.js:139", - "problem": "`releaseTag` 與 `releaseName` 來自遠端 API,卻未做任何跳脫就寫入 log。攻擊者若能建立包含換行或 ANSI escape 的 release 名稱,就能偽造成功/失敗紀錄,掩蓋真正的刪除行為。", - "suggestion": "記錄前先移除控制字元或改成結構化輸出,例如 JSON;不要把未信任字串直接串進 log。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "src/index.js:164", - "problem": "`tagName` 同樣來自遠端 API,直接輸出到 log 會讓惡意 tag 名稱注入假訊息或控制終端畫面。攻擊者只要能建立特製 tag,就能污染審計紀錄。", - "suggestion": "對 tag 名稱做輸出編碼或控制字元過濾,並優先使用結構化日誌,避免未信任字串直接影響 log 內容。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "action.yml:18", - "problem": "`GitHub Runner Token` 跟整份 action 的 Gitea 語境不一致,品牌詞突然換邊,讀起來會有明顯跳拍。", - "suggestion": "改成中性的 `Runner Token` 或直接寫 `Gitea Runner Token`,保持用語一致。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "entrypoint.sh:10", - "problem": "更新時間被硬編碼在腳本裡,代表每次發布都要人工同步這個值。這種裝飾性資訊一旦和實際版本脫節,未來排查問題時反而會誤導維護者。", - "suggestion": "移除手寫時間戳,或改成由建置流程注入單一來源的版本資訊,避免多處手動更新。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "src/index.js:95", - "problem": "`success()` 這個名稱暗示它會輸出成功層級,但實際上卻跟 `info()` 一樣寫 `INF`;命名與輸出不對拍,後面看 log 的人很容易被誤導。", - "suggestion": "要嘛改成真正的成功層級代號,要嘛直接把函式命名收斂成 `info()`。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "src/index.js:212", - "problem": "這裡直接 `JSON.parse` 回應內容,沒有包一層具體的錯誤脈絡。只要 API 回傳格式稍微異常,維護者就只會拿到模糊的 syntax error,得重新重現才能知道是哪些 endpoint 出問題。", - "suggestion": "替解析失敗補上更具體的錯誤訊息,至少把 URL 和原始回應片段納入例外,讓除錯時能直接定位是哪一頁資料壞掉。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/index.js:312", - "problem": "這裡先抓一份 release 快照,再在後面依這份快照去刪 tag;兩個步驟之間不是同一個時間點。最小重現:cleanup 跑到一半時剛好有人新增 release,新 release 的 tag 來不及出現在 `releaseTags`,接下來的 tag 清理就可能把剛發布的 tag 誤刪。", - "suggestion": "把 release 與 tag 的判定建立在同一個一致性快照上,或在刪 tag 前重新驗證該 tag 目前是否已被任何 release 使用;如果環境允許,最好加上流程鎖避免與發版同時執行。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "src/index.js:115", - "problem": "`KEEP_COUNT` 的整數邊界現在只靠正則檢查,但沒有測試證明 `0`、`01`、負數、浮點數、非數字字串都會被正確處理。這個值直接影響刪除範圍,少一個邊界案例就可能誤刪 release。", - "suggestion": "為 `requireInteger` 與 `KEEP_COUNT` 加測試,至少覆蓋 `0`、`1`、`-1`、`1.5`、`abc`,並確認不合法輸入會退出,合法輸入會順利進入後續流程。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "src/index.js:151", - "problem": "`fetchAllPages` 新增了分頁、HTTP 狀態碼檢查、JSON 陣列驗證與空頁終止,但沒有看到對這些分支的測試。這是核心資料取得邏輯,若分頁終止條件或錯誤處理出問題,後面的刪除流程就會建立在錯誤資料上。", - "suggestion": "補測 `fetchAllPages`:成功串接多頁資料、遇到空頁停止、非 2xx 回應拋錯、回傳非陣列 JSON 拋錯。建議用 stub/mock HTTP server 驗證回傳資料與例外訊息。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "src/index.js:211", - "problem": "release 清理流程的排序與切片邏輯現在直接決定會刪掉哪些項目,但沒有測試保證 `created_at` 是由新到舊排序後再依 `KEEP_COUNT` 保留。只要排序方向或切片位置錯一格,就會變成刪掉最新的 release。", - "suggestion": "新增 release 清理的整合測試,輸入刻意亂序的 `created_at` 資料,驗證只保留最新 `KEEP_COUNT` 筆;再補上 `KEEP_COUNT=0`、`KEEP_COUNT=releaseCount` 與 `releaseItem.id` 缺失時會略過刪除的案例。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "src/index.js:269", - "problem": "tag 清理流程新增了『保留已對應 release 的 tag』、『刪除未指定 release 的 tag』、以及無名稱 tag 略過與刪除失敗處理,但目前看不到任何對應測試。這條路徑如果誤刪 tag,會直接破壞版本辨識。", - "suggestion": "補測 tag 清理行為:對應 release 的 tag 必須保留、未被任何 release 引用的 tag 必須被刪除、空名稱 tag 必須略過,並驗證 DELETE 非 204 時會走錯誤分支。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:12", - "problem": "`formatTaipeiTimestamp()` 每次 log 都重新建立 `Intl.DateTimeFormat` 並跑 `formatToParts`,這在 release/tag 迴圈裡會被反覆觸發,等於把本來可重用的格式器成本重算 N 次。", - "suggestion": "把 `Intl.DateTimeFormat` 提到函式外快取成單例,讓每次只做時間格式化,不要重建 formatter。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:312", - "problem": "這裡又對 releases API 做一次完整 `fetchAllPages()`,前面第 267 行已經抓過同一份資料並排序;等於把整個分頁抓取、JSON 解析與記憶體配置再跑一遍,資料量越大越浪費。", - "suggestion": "直接沿用前一次抓到的 `releaseJson`,或先從第一次結果算出要保留的 tag 集合,避免第二次全量拉取。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:290", - "problem": "這個 `for` 迴圈把每個 release 的 DELETE 都串成單一等待鏈;如果要刪的 release 有 N 筆,就會多吃 N 次網路往返,整體牆鐘時間被 RTT 線性放大。", - "suggestion": "如果 Gitea API 容許,改成有限度並行刪除,例如一次 4 到 8 筆,或至少把可獨立的請求批次化。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:326", - "problem": "tag 刪除同樣是逐筆 `await`,當 tag 數量多時會把每次 API 往返都串成排隊,刪除時間幾乎全卡在網路延遲上。", - "suggestion": "用受限並行處理 tag 刪除,或先收集待刪清單再批次送出,減少總等待時間。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "src/index.js:69", - "problem": "空的 `separator()` 函式只是佔位,既不做事也不自我說明,還讓檔案多了一個無效符號。", - "suggestion": "移除這個空函式,或改成真正有用途的共用輸出 helper。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "src/index.js:162", - "problem": "`requestJson` 這個名字太窄,因為它不只用來拿 JSON,也拿一般 HTTP 回應與 DELETE 結果;名稱比實作更嚴格,讀者會先被騙一次。", - "suggestion": "改名成 `request`、`requestUrl` 之類較中性的名稱,JSON 解析再交給上層 helper。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "Dockerfile:5", - "problem": "基底映像預設成 `alpine` 這種浮動標籤,長期看會讓建置結果跟著上游變動。半年後同一份程式碼可能產生不同映像,維護者很難判斷差異到底來自程式還是基底環境。", - "suggestion": "把預設值改成明確版本或 digest,讓基底環境可預期;如果要保留可變版本,至少把它明確視為建置參數而不是默認行為。", - "is_new": true - } -] +[] diff --git a/Dockerfile b/Dockerfile index 74dad61..8e6e942 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # 更新日期:2026/07/11 21:02:25 # 允許在建置時指定 Node.js 版本標籤 -ARG NODE_VERSION=alpine +ARG NODE_VERSION=22-alpine # 使用指定版本的 Node.js 基底映像 FROM node:${NODE_VERSION} diff --git a/action.yml b/action.yml index dc7f20f..dd34818 100644 --- a/action.yml +++ b/action.yml @@ -15,7 +15,7 @@ inputs: # RUNNER_TOKEN 用於授權呼叫 Gitea API;未提供時會改用 secrets RUNNER_TOKEN: # 參數說明,讓呼叫端知道這是 Runner Token - description: 'GitHub Runner Token' + description: 'Gitea Runner Token' # KEEP_COUNT 用於控制保留的 release 數量 KEEP_COUNT: # 參數說明,這裡表示保留的版本數量 diff --git a/src/index.js b/src/index.js index f7f5ab6..a833b02 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,16 @@ -const http = require('http'); const https = require('https'); +const taipeiFormatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Taipei', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', +}); + let currentStage = ''; /** @@ -10,16 +20,7 @@ let currentStage = ''; * @returns {string} `yyyy/MM/dd HH:mm:ss` 格式時間字串。 */ function formatTaipeiTimestamp(date = new Date()) { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Taipei', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - }).formatToParts(date); + const parts = taipeiFormatter.formatToParts(date); const lookup = {}; for (const part of parts) { @@ -31,6 +32,32 @@ function formatTaipeiTimestamp(date = new Date()) { return `${lookup.year}/${lookup.month}/${lookup.day} ${lookup.hour}:${lookup.minute}:${lookup.second}`; } +/** + * 將未信任內容整理成適合記錄到 log 或錯誤訊息的文字。 + * + * @param {*} value 原始值。 + * @returns {string} 已去除控制字元的文字。 + */ +function sanitizeLogText(value) { + return String(value).replace(/[\u0000-\u001f\u007f]/g, ' '); +} + +/** + * 將回應內容整理成適合放進例外訊息的摘要。 + * + * @param {*} body 回應內容。 + * @param {number} [maxLength=200] 最長保留長度。 + * @returns {string} 已整理的回應摘要。 + */ +function summarizeResponseBody(body, maxLength = 200) { + const text = sanitizeLogText(body).replace(/\s+/g, ' ').trim(); + if (text.length <= maxLength) { + return text; + } + + return `${text.slice(0, maxLength)}…`; +} + /** * 組合統一格式的 log 字串。 * @@ -39,8 +66,8 @@ function formatTaipeiTimestamp(date = new Date()) { * @returns {string} 已格式化的 log 字串。 */ function formatLog(level, message) { - const stagePrefix = currentStage ? `[${currentStage}]` : ''; - return `${stagePrefix}[${level}][${formatTaipeiTimestamp()}]: ${message}`; + const stagePrefix = currentStage ? `[${sanitizeLogText(currentStage)}]` : ''; + return `${stagePrefix}[${level}][${formatTaipeiTimestamp()}]: ${sanitizeLogText(message)}`; } /** @@ -63,11 +90,6 @@ function writeStderr(level, message) { process.stderr.write(`${formatLog(level, message)}\n`); } -/** - * 保留舊介面以維持草稿對應,實際上不再輸出橫幅。 - */ -function separator() {} - /** * 切換目前訊息所屬區塊,供 log 前綴使用。 * @@ -86,15 +108,6 @@ function info(message) { writeStdout('INF', message); } -/** - * 輸出成功訊息。 - * - * @param {string} message 訊息內容。 - */ -function success(message) { - writeStdout('INF', message); -} - /** * 輸出警告訊息。 * @@ -158,12 +171,15 @@ function requireInteger(name, value) { * @param {{ method?: string, headers?: Record }} [options] request 設定。 * @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。 */ -function requestJson(url, { method = 'GET', headers = {} } = {}) { +function request(url, { method = 'GET', headers = {} } = {}) { return new Promise((resolve, reject) => { const target = new URL(url); - const client = target.protocol === 'http:' ? http : https; + if (target.protocol !== 'https:') { + reject(new Error(`Refusing to send request to non-HTTPS URL: ${target.origin}`)); + return; + } - const req = client.request( + const req = https.request( target, { method, @@ -202,13 +218,19 @@ async function fetchAllPages(baseUrl, headers) { for (let page = 1; ; page += 1) { const pageUrl = `${baseUrl}?page=${page}`; - const { statusCode, body } = await requestJson(pageUrl, { headers }); + const { statusCode, body } = await request(pageUrl, { headers }); if (statusCode < 200 || statusCode >= 300) { - throw new Error(`GET ${pageUrl} failed with HTTP ${statusCode}: ${body}`); + throw new Error(`GET ${pageUrl} failed with HTTP ${statusCode}: ${summarizeResponseBody(body)}`); + } + + let data; + try { + data = JSON.parse(body || '[]'); + } catch (error) { + throw new Error(`GET ${pageUrl} returned invalid JSON: ${summarizeResponseBody(body)}`); } - const data = JSON.parse(body || '[]'); if (!Array.isArray(data)) { throw new Error(`GET ${pageUrl} did not return a JSON array`); } @@ -231,12 +253,26 @@ async function fetchAllPages(baseUrl, headers) { * @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。 */ async function deleteResource(url, headers) { - return requestJson(url, { + return request(url, { method: 'DELETE', headers, }); } +/** + * 以固定批次大小處理項目,降低逐筆等待造成的延遲。 + * + * @param {any[]} items 要處理的項目。 + * @param {number} batchSize 每批同時處理的數量。 + * @param {(item: any) => Promise} handler 單筆處理函式。 + */ +async function processInBatches(items, batchSize, handler) { + for (let index = 0; index < items.length; index += batchSize) { + const batch = items.slice(index, index + batchSize); + await Promise.all(batch.map((item) => handler(item))); + } +} + /** * 執行 release 與 tag 清理流程。 */ @@ -281,41 +317,45 @@ async function main() { info(`RELEASE_COUNT=${releaseCount}`); info(`KEEP_COUNT=${KEEP_COUNT}`); + let hadFailure = false; + if (releaseCount <= keepCount) { - success('沒有需要清理的舊版本成品'); + info('沒有需要清理的舊版本成品'); } else { section('刪除舊版本成品'); const releaseToDelete = releaseJson.slice(keepCount); - for (const releaseItem of releaseToDelete) { + await processInBatches(releaseToDelete, 4, async (releaseItem) => { if (!releaseItem || isEmptyOrNull(releaseItem.id)) { - warn(`略過沒有 id 的成品: ${releaseItem?.tag_name || ''} (${releaseItem?.name || ''})`); - continue; + warn( + `略過沒有 id 的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`, + ); + return; } - const releaseTag = releaseItem.tag_name || ''; - const releaseName = releaseItem.name || ''; + const releaseTag = sanitizeLogText(releaseItem.tag_name || ''); + const releaseName = sanitizeLogText(releaseItem.name || ''); const deleteUrl = `${releaseApiUrl}/${releaseItem.id}`; info(`DELETE ${releaseTag} (${releaseName})`); const { statusCode } = await deleteResource(deleteUrl, authHeaders); if (statusCode === 204) { - success(`成功刪除: ${releaseTag} (${releaseName})`); + info(`成功刪除: ${releaseTag} (${releaseName})`); } else { + hadFailure = true; fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${statusCode}`); } - } + }); } section('刪除未指定 release 的 tag'); - const currentReleaseJson = await fetchAllPages(releaseApiUrl, authHeaders); - const releaseTags = new Set(); - for (const item of currentReleaseJson) { - if (!isEmptyOrNull(item?.tag_name)) { - releaseTags.add(item.tag_name); - } - } + const releaseTags = new Set( + releaseJson + .slice(0, keepCount) + .map((item) => item?.tag_name) + .filter((tag) => !isEmptyOrNull(tag)), + ); const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`; info(`GET ${tagApiUrl}`); @@ -323,27 +363,33 @@ async function main() { const tagJson = await fetchAllPages(tagApiUrl, authHeaders); info(`TAG_COUNT=${tagJson.length}`); - for (const tagItem of tagJson) { + await processInBatches(tagJson, 4, async (tagItem) => { const tagName = tagItem?.name; if (isEmptyOrNull(tagName)) { warn('略過沒有名稱的 tag'); - continue; + return; } + const safeTagName = sanitizeLogText(tagName); if (releaseTags.has(tagName)) { - info(`保留指定 release 的 tag: ${tagName}`); - continue; + info(`保留指定 release 的 tag: ${safeTagName}`); + return; } const deleteUrl = `${tagApiUrl}/${encodeURIComponent(tagName)}`; - info(`DELETE tag ${tagName}`); + info(`DELETE tag ${safeTagName}`); const { statusCode } = await deleteResource(deleteUrl, authHeaders); if (statusCode === 204) { - success(`成功刪除未指定 release 的 tag: ${tagName}`); + info(`成功刪除未指定 release 的 tag: ${safeTagName}`); } else { - fail(`刪除 tag 失敗: ${tagName}, HTTP ${statusCode}`); + hadFailure = true; + fail(`刪除 tag 失敗: ${safeTagName}, HTTP ${statusCode}`); } + }); + + if (hadFailure) { + throw new Error('至少有一筆 release 或 tag 刪除失敗'); } } From 3109aa79230c41e8c44c61645064fcbd7660f1fe Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Sat, 11 Jul 2026 13:57:27 +0000 Subject: [PATCH 07/18] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 91 +++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..692ef5f 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,90 @@ -[] +[ + { + "level": "critical", + "role": "Mage", + "problem": "這裡在 DELETE 回傳非 204 時只寫錯誤訊息,沒有把失敗往上拋或標記成整體失敗;同樣的寫法在後面的 tag 刪除區塊也出現一次。最小重現:只要某個 release 因權限不足回 403,step 仍會繼續跑完並以成功結束,外層 workflow 會誤判清理已完成。", + "suggestion": "把刪除結果納入整體失敗狀態,例如遇到非 204 直接 `throw`,或累積 `hadFailure` 後在流程結束時 `process.exit(1)`,不要讓任何刪除失敗被靜默吞掉。", + "location": "src/index.js:302", + "is_new": false + }, + { + "level": "critical", + "role": "Assassin", + "location": "src/index.js:299", + "problem": "這裡把 `GITEA_SERVER_URL` 直接拼進帶有 `Authorization: token ...` 的 API 請求,只檢查是不是 `https` 並不能防止攻擊者把環境變數指到自己的 HTTPS 主機。只要外部能影響這個值,就能把 runner token 一起送出,等於把這個 action 變成可用來外洩憑證的 SSRF 入口。", + "suggestion": "不要只驗證協定,必須把目標主機固定在預期的 Gitea 來源;改成解析 `URL` 後比對 `origin`/host 白名單,拒絕任何非預期網域,並且只在確認是可信任的 Gitea 站台時才附加 `Authorization` header。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "problem": "這裡先抓一份 release 快照,再在後面依這份快照去刪 tag;兩個步驟之間不是同一個時間點。最小重現:cleanup 跑到一半時剛好有人新增 release,新 release 的 tag 來不及出現在 `releaseTags`,接下來的 tag 清理就可能把剛發布的 tag 誤刪。", + "suggestion": "把 release 與 tag 的判定建立在同一個一致性快照上,或在刪 tag 前重新驗證該 tag 目前是否已被任何 release 使用;如果環境允許,最好加上流程鎖避免與發版同時執行。", + "location": "src/index.js:312", + "is_new": false + }, + { + "level": "warning", + "role": "Rogue", + "problem": "這裡又對 releases API 做一次完整 `fetchAllPages()`,前面第 267 行已經抓過同一份資料並排序;等於把整個分頁抓取、JSON 解析與記憶體配置再跑一遍,資料量越大越浪費。", + "suggestion": "直接沿用前一次抓到的 `releaseJson`,或先從第一次結果算出要保留的 tag 集合,避免第二次全量拉取。", + "location": "src/index.js:312", + "is_new": false + }, + { + "level": "warning", + "role": "Rogue", + "problem": "tag 刪除同樣是逐筆 `await`,當 tag 數量多時會把每次 API 往返都串成排隊,刪除時間幾乎全卡在網路延遲上。", + "suggestion": "用受限並行處理 tag 刪除,或先收集待刪清單再批次送出,減少總等待時間。", + "location": "src/index.js:326", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "location": "Dockerfile:8", + "problem": "這裡仍然使用可浮動的 `node:22-alpine` 標籤,沒有鎖定到不可變的 digest。攻擊者只要污染上游映像或讓標籤漂移,就可能在 action 啟動前先取得執行權,進而竊取後續流程中的 token 與 repo 資料。", + "suggestion": "把基底映像改成固定 digest,例如 `node:22-alpine@sha256:...`,並定期以受控流程更新;不要依賴會隨時間變動的映像標籤。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "src/index.js:1", + "problem": "這個檔案同時承擔 log 格式化、輸入驗證、HTTP 呼叫、分頁抓取、刪除流程與錯誤彙總,責任切得太散。半年後只要想改一個 API 規則,維護者就得在同一個大檔裡來回跳,單元測試也很難把純邏輯跟 I/O 分開。", + "suggestion": "把共用基礎能力拆成獨立模組,例如 `logger`、`gitea client`、`cleanup workflow`,並讓主程式只負責組裝依賴與啟動流程。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:177", + "problem": "這裡直接拒絕 `http:` 連線,導致任何使用 `http://` 的 Gitea 部署都會在第一個 API 請求就失敗。最小重現情境是把 `GITEA_SERVER_URL` 設成內網常見的 `http://gitea.local`,整個清理流程會完全無法執行。", + "suggestion": "若這個 action 需要支援常見的自架環境,應移除固定只允許 HTTPS 的限制,或把協定限制做成可配置;若確實只支援 HTTPS,也要在 action 說明中明確標示,避免使用者在 `http` 環境下直接踩雷。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:353", + "problem": "前面只要有任何 release 刪除失敗,這裡仍然會繼續做 tag 清理,而且 `releaseTags` 是依照刪除前的清單算出來的。最小重現情境是某個待刪 release 因權限不足或暫時性網路錯誤沒刪掉,接著它對應的 tag 仍可能被刪除,最後變成 release 還在、tag 卻被移除的半套狀態。", + "suggestion": "在進入 tag 清理前先檢查 release 刪除是否有失敗;只要有失敗就應中止後續 tag 刪除,或改成只把實際成功刪除的 release 對應 tag 納入待刪集合,避免留下不一致狀態。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:25", + "problem": "每次輸出一條 log 都要跑 `Intl.DateTimeFormat.formatToParts()`,還額外建立 `lookup` 物件再組字串;這條熱路徑會在每個 release、tag 與錯誤訊息上重複消耗 CPU,訊息一多就很浪費。", + "suggestion": "把時間格式改成可快取的字串產生方式,例如同一秒共用結果,或改用較便宜的 formatter,不要每條 log 都做 `formatToParts()` 拆解。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:184", + "problem": "這裡先把每一頁資料全部塞進 `all`,等於把整個 API 結果完整具現化;release/tag 數量一大時,記憶體會吃到 O(n),而且陣列反覆擴容與拷貝也會多耗 CPU。", + "suggestion": "改成邊抓邊處理,不要先合併成單一大陣列;如果 API 支援,順便加大每頁筆數,減少往返次數。", + "is_new": true + } +] From bfeea4c2a9cf3f522205026f5a558d22169dd670 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 14:00:52 +0000 Subject: [PATCH 08/18] =?UTF-8?q?fix(cleanup-release):=20=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E5=A4=B1=E6=95=97=E5=BE=8C=E7=9A=84=20tag=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E4=B8=A6=E5=BF=AB=E5=8F=96=E6=99=82=E9=96=93=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index a833b02..5fad040 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,8 @@ const taipeiFormatter = new Intl.DateTimeFormat('en-CA', { }); let currentStage = ''; +let cachedTimestampKey = ''; +let cachedTimestampValue = ''; /** * 格式化台灣時區時間,供 log 使用。 @@ -20,6 +22,11 @@ let currentStage = ''; * @returns {string} `yyyy/MM/dd HH:mm:ss` 格式時間字串。 */ function formatTaipeiTimestamp(date = new Date()) { + const timestampKey = date.toISOString().slice(0, 19); + if (timestampKey === cachedTimestampKey) { + return cachedTimestampValue; + } + const parts = taipeiFormatter.formatToParts(date); const lookup = {}; @@ -29,7 +36,9 @@ function formatTaipeiTimestamp(date = new Date()) { } } - return `${lookup.year}/${lookup.month}/${lookup.day} ${lookup.hour}:${lookup.minute}:${lookup.second}`; + cachedTimestampKey = timestampKey; + cachedTimestampValue = `${lookup.year}/${lookup.month}/${lookup.day} ${lookup.hour}:${lookup.minute}:${lookup.second}`; + return cachedTimestampValue; } /** @@ -348,6 +357,10 @@ async function main() { }); } + if (hadFailure) { + throw new Error('至少有一筆 release 或 tag 刪除失敗'); + } + section('刪除未指定 release 的 tag'); const releaseTags = new Set( @@ -388,9 +401,6 @@ async function main() { } }); - if (hadFailure) { - throw new Error('至少有一筆 release 或 tag 刪除失敗'); - } } main().catch((error) => { From 98abcf236394a8ada1b404b6ff5dd44c04d7083c Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 14:00:55 +0000 Subject: [PATCH 09/18] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings=20=E8=88=87=20exclusions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 65 ++++++++++++++++++++++++++++++++ .gitea/ai-review/findings.json | 48 ----------------------- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index fcc0a56..f7f3086 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -63,5 +63,70 @@ "reason": "專案目前沒有測試基礎設施,先將核心邏輯修正並保留此項為後續技術債。", "source": "develop...ai-review-resolve/develop-20260711-131608", "date": "2026-07-11" + }, + { + "key": "src/index.js:299|GITEA_SERVER_URL 未限制可信主機", + "title": "GITEA_SERVER_URL 未限制可信主機", + "severity": "🟠 高", + "file": "src/index.js", + "line": "299", + "description": "在發送帶有授權標頭的請求前,沒有額外比對 GITEA_SERVER_URL 是否屬於固定白名單。", + "suggestion": "若之後有明確的可信主機清單,再補上 origin/host 白名單檢查;目前先依 Gitea runtime 注入的 server_url 運作。", + "verdict": "誤判", + "reason": "這個 action 只會在 Gitea runtime 提供的 `gitea.server_url` 上執行,沒有額外可用的可信來源來建立另一層主機清單,因此這項告警屬於泛化風險而非本專案可落地的缺陷。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "Dockerfile:8|基底映像未鎖定 digest", + "title": "基底映像未鎖定 digest", + "severity": "🟡 中", + "file": "Dockerfile", + "line": "8", + "description": "Dockerfile 目前以可浮動的 Node.js 標籤作為基底映像。", + "suggestion": "若日後改採嚴格供應鏈控管,再將基底映像鎖定為 digest;目前先維持版本標籤以便跟進 Node 版本。", + "verdict": "誤判", + "reason": "這個 repo 的 Dockerfile 以版本標籤控管 Node 大版本,符合目前簡潔維護的目標;將其固定到 digest 會增加後續更新成本,屬於部署政策取捨而非立即缺陷。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "src/index.js:1|檔案責任切分過於集中", + "title": "檔案責任切分過於集中", + "severity": "🟡 中", + "file": "src/index.js", + "line": "1", + "description": "單一檔案同時承擔 logger、驗證、HTTP client 與清理流程。", + "suggestion": "若之後擴充出更大的功能,再考慮拆分成獨立模組。", + "verdict": "誤判", + "reason": "目前程式規模仍小,拆模組只會增加檔案跳轉與維護成本;在這個階段保持單檔能更直接地追蹤 action 行為。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "src/index.js:177|HTTP 連線被直接拒絕", + "title": "HTTP 連線被直接拒絕", + "severity": "🟡 中", + "file": "src/index.js", + "line": "177", + "description": "request helper 只允許 HTTPS,遇到 HTTP 端點會直接失敗。", + "suggestion": "若之後需要支援 HTTP 自架環境,再把協定限制做成可配置;目前先維持 HTTPS-only。", + "verdict": "誤判", + "reason": "這個 action 會帶著授權標頭呼叫 API,強制 HTTPS 是刻意的安全限制,不是缺陷;若要支援 HTTP,應另行評估風險後再開放。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" + }, + { + "key": "src/index.js:184|分頁結果先完整收集到陣列", + "title": "分頁結果先完整收集到陣列", + "severity": "🟡 中", + "file": "src/index.js", + "line": "184", + "description": "fetchAllPages 會把所有頁面合併到單一陣列後再交給後續流程。", + "suggestion": "若未來資料量大幅成長,再考慮改成串流處理或分段消耗。", + "verdict": "誤判", + "reason": "目前 release/tag 數量預期不大,而且後續需要排序與切片,完整收集資料是最直接也最清楚的實作。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-11" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 692ef5f..197e21a 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,12 +1,4 @@ [ - { - "level": "critical", - "role": "Mage", - "problem": "這裡在 DELETE 回傳非 204 時只寫錯誤訊息,沒有把失敗往上拋或標記成整體失敗;同樣的寫法在後面的 tag 刪除區塊也出現一次。最小重現:只要某個 release 因權限不足回 403,step 仍會繼續跑完並以成功結束,外層 workflow 會誤判清理已完成。", - "suggestion": "把刪除結果納入整體失敗狀態,例如遇到非 204 直接 `throw`,或累積 `hadFailure` 後在流程結束時 `process.exit(1)`,不要讓任何刪除失敗被靜默吞掉。", - "location": "src/index.js:302", - "is_new": false - }, { "level": "critical", "role": "Assassin", @@ -15,30 +7,6 @@ "suggestion": "不要只驗證協定,必須把目標主機固定在預期的 Gitea 來源;改成解析 `URL` 後比對 `origin`/host 白名單,拒絕任何非預期網域,並且只在確認是可信任的 Gitea 站台時才附加 `Authorization` header。", "is_new": true }, - { - "level": "warning", - "role": "Mage", - "problem": "這裡先抓一份 release 快照,再在後面依這份快照去刪 tag;兩個步驟之間不是同一個時間點。最小重現:cleanup 跑到一半時剛好有人新增 release,新 release 的 tag 來不及出現在 `releaseTags`,接下來的 tag 清理就可能把剛發布的 tag 誤刪。", - "suggestion": "把 release 與 tag 的判定建立在同一個一致性快照上,或在刪 tag 前重新驗證該 tag 目前是否已被任何 release 使用;如果環境允許,最好加上流程鎖避免與發版同時執行。", - "location": "src/index.js:312", - "is_new": false - }, - { - "level": "warning", - "role": "Rogue", - "problem": "這裡又對 releases API 做一次完整 `fetchAllPages()`,前面第 267 行已經抓過同一份資料並排序;等於把整個分頁抓取、JSON 解析與記憶體配置再跑一遍,資料量越大越浪費。", - "suggestion": "直接沿用前一次抓到的 `releaseJson`,或先從第一次結果算出要保留的 tag 集合,避免第二次全量拉取。", - "location": "src/index.js:312", - "is_new": false - }, - { - "level": "warning", - "role": "Rogue", - "problem": "tag 刪除同樣是逐筆 `await`,當 tag 數量多時會把每次 API 往返都串成排隊,刪除時間幾乎全卡在網路延遲上。", - "suggestion": "用受限並行處理 tag 刪除,或先收集待刪清單再批次送出,減少總等待時間。", - "location": "src/index.js:326", - "is_new": false - }, { "level": "warning", "role": "Assassin", @@ -63,22 +31,6 @@ "suggestion": "若這個 action 需要支援常見的自架環境,應移除固定只允許 HTTPS 的限制,或把協定限制做成可配置;若確實只支援 HTTPS,也要在 action 說明中明確標示,避免使用者在 `http` 環境下直接踩雷。", "is_new": true }, - { - "level": "warning", - "role": "Mage", - "location": "src/index.js:353", - "problem": "前面只要有任何 release 刪除失敗,這裡仍然會繼續做 tag 清理,而且 `releaseTags` 是依照刪除前的清單算出來的。最小重現情境是某個待刪 release 因權限不足或暫時性網路錯誤沒刪掉,接著它對應的 tag 仍可能被刪除,最後變成 release 還在、tag 卻被移除的半套狀態。", - "suggestion": "在進入 tag 清理前先檢查 release 刪除是否有失敗;只要有失敗就應中止後續 tag 刪除,或改成只把實際成功刪除的 release 對應 tag 納入待刪集合,避免留下不一致狀態。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:25", - "problem": "每次輸出一條 log 都要跑 `Intl.DateTimeFormat.formatToParts()`,還額外建立 `lookup` 物件再組字串;這條熱路徑會在每個 release、tag 與錯誤訊息上重複消耗 CPU,訊息一多就很浪費。", - "suggestion": "把時間格式改成可快取的字串產生方式,例如同一秒共用結果,或改用較便宜的 formatter,不要每條 log 都做 `formatToParts()` 拆解。", - "is_new": true - }, { "level": "warning", "role": "Rogue", From 73c2e5d17159e8db065e8db0be98ae72052eaac0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 11 Jul 2026 14:01:38 +0000 Subject: [PATCH 10/18] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E5=B7=B2=E8=99=95=E7=90=86=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/findings.json | 43 +--------------------------------- 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 197e21a..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,42 +1 @@ -[ - { - "level": "critical", - "role": "Assassin", - "location": "src/index.js:299", - "problem": "這裡把 `GITEA_SERVER_URL` 直接拼進帶有 `Authorization: token ...` 的 API 請求,只檢查是不是 `https` 並不能防止攻擊者把環境變數指到自己的 HTTPS 主機。只要外部能影響這個值,就能把 runner token 一起送出,等於把這個 action 變成可用來外洩憑證的 SSRF 入口。", - "suggestion": "不要只驗證協定,必須把目標主機固定在預期的 Gitea 來源;改成解析 `URL` 後比對 `origin`/host 白名單,拒絕任何非預期網域,並且只在確認是可信任的 Gitea 站台時才附加 `Authorization` header。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "Dockerfile:8", - "problem": "這裡仍然使用可浮動的 `node:22-alpine` 標籤,沒有鎖定到不可變的 digest。攻擊者只要污染上游映像或讓標籤漂移,就可能在 action 啟動前先取得執行權,進而竊取後續流程中的 token 與 repo 資料。", - "suggestion": "把基底映像改成固定 digest,例如 `node:22-alpine@sha256:...`,並定期以受控流程更新;不要依賴會隨時間變動的映像標籤。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "src/index.js:1", - "problem": "這個檔案同時承擔 log 格式化、輸入驗證、HTTP 呼叫、分頁抓取、刪除流程與錯誤彙總,責任切得太散。半年後只要想改一個 API 規則,維護者就得在同一個大檔裡來回跳,單元測試也很難把純邏輯跟 I/O 分開。", - "suggestion": "把共用基礎能力拆成獨立模組,例如 `logger`、`gitea client`、`cleanup workflow`,並讓主程式只負責組裝依賴與啟動流程。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/index.js:177", - "problem": "這裡直接拒絕 `http:` 連線,導致任何使用 `http://` 的 Gitea 部署都會在第一個 API 請求就失敗。最小重現情境是把 `GITEA_SERVER_URL` 設成內網常見的 `http://gitea.local`,整個清理流程會完全無法執行。", - "suggestion": "若這個 action 需要支援常見的自架環境,應移除固定只允許 HTTPS 的限制,或把協定限制做成可配置;若確實只支援 HTTPS,也要在 action 說明中明確標示,避免使用者在 `http` 環境下直接踩雷。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:184", - "problem": "這裡先把每一頁資料全部塞進 `all`,等於把整個 API 結果完整具現化;release/tag 數量一大時,記憶體會吃到 O(n),而且陣列反覆擴容與拷貝也會多耗 CPU。", - "suggestion": "改成邊抓邊處理,不要先合併成單一大陣列;如果 API 支援,順便加大每頁筆數,減少往返次數。", - "is_new": true - } -] +[] From 4ea57ae1a9276fff8bfb5c3c5d84364cb7559122 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Sat, 11 Jul 2026 14:07:52 +0000 Subject: [PATCH 11/18] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 43 +++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..a17786a 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,42 @@ -[] +[ + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:169", + "problem": "這個驗證只擋掉非數字,`0` 仍然會通過;若攻擊者能控制 `KEEP_COUNT`,就能把保留數設成 0,後續流程會把所有 release 刪光,還會把所有未被保留的 tag 一併清掉。", + "suggestion": "把下限改成至少 `1`,並在進入刪除流程前再做一次保護檢查;如果真的需要全清,應該改成獨立的高風險開關,而不是混在一般輸入參數裡。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:233", + "problem": "這裡把 API 回應 body 直接拼進例外訊息,任何錯誤回應都可能被寫進 action log;如果伺服器或中間層回傳內部路徑、設定值或其他敏感內容,就會被一起外洩。", + "suggestion": "錯誤訊息只保留狀態碼與必要識別資訊,回應內容改成固定摘要或更嚴格的截斷與紅字處理,不要把完整 body 直接丟進例外。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "src/index.js:107", + "problem": "`section()` 與 `currentStage` 混用兩套詞彙,一個像段落、一個像階段,語意不夠統一。這種命名會讓人讀到一半還要猜:它到底是在切 log 區塊,還是在切執行階段。", + "suggestion": "統一成同一套語彙,例如把 `section()` 改成 `setLogSection()`,並讓相關變數名稱也跟著一致。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:282", + "problem": "`Promise.all` 只要其中一個刪除任務拋出 reject,整個 batch 會立刻失敗,外層 `main().catch(...)` 也會直接結束。這代表只要某一筆 release 或 tag 遇到網路中斷、DNS 失敗、連線逾時,後續同 batch 的項目就不會再處理,清理流程會在半途中停住。", + "suggestion": "把每個 item 的刪除包在個別 `try/catch`,或改用 `Promise.allSettled` 後統一彙總失敗;至少要確保單筆失敗不會中止同批其他清理工作。", + "is_new": true + }, + { + "level": "info", + "role": "Mage", + "location": "src/index.js:146", + "problem": "這個 helper 把字串 `'null'` 也當成空值。若真的存在名稱剛好是 `null` 的 tag、repo 名稱或其他合法輸入,就會被誤判成缺值而跳過或拒絕,造成清理邏輯和實際資料不一致。", + "suggestion": "只把 `undefined`、`null` 和空字串視為空值;若需要處理來自環境變數的字面字串 `'null'`,應該在特定參數的解析層單獨處理,不要放進通用空值判斷。", + "is_new": true + } +] From fb26de18d0cddea254ffc3df634fb70349554d8f Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Sat, 11 Jul 2026 14:08:34 +0000 Subject: [PATCH 12/18] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 62 +++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index a17786a..8e1ef5b 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,42 +1,66 @@ [ { - "level": "warning", - "role": "Assassin", - "location": "src/index.js:169", - "problem": "這個驗證只擋掉非數字,`0` 仍然會通過;若攻擊者能控制 `KEEP_COUNT`,就能把保留數設成 0,後續流程會把所有 release 刪光,還會把所有未被保留的 tag 一併清掉。", - "suggestion": "把下限改成至少 `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:233", - "problem": "這裡把 API 回應 body 直接拼進例外訊息,任何錯誤回應都可能被寫進 action log;如果伺服器或中間層回傳內部路徑、設定值或其他敏感內容,就會被一起外洩。", - "suggestion": "錯誤訊息只保留狀態碼與必要識別資訊,回應內容改成固定摘要或更嚴格的截斷與紅字處理,不要把完整 body 直接丟進例外。", + "location": "src/index.js:155", + "problem": "這裡把 `GITEA_SERVER_URL` 原樣寫進 log,若 URL 內含 userinfo、查詢字串或被惡意塞入敏感資訊,這些內容會直接落到 action log,形成可被讀取的資料外洩點。", + "suggestion": "不要記錄完整 URL;只輸出必要的非敏感資訊,例如遮罩後的主機名,或改成只記錄是否存在。", "is_new": true }, { "level": "warning", - "role": "Bard", - "location": "src/index.js:107", - "problem": "`section()` 與 `currentStage` 混用兩套詞彙,一個像段落、一個像階段,語意不夠統一。這種命名會讓人讀到一半還要猜:它到底是在切 log 區塊,還是在切執行階段。", - "suggestion": "統一成同一套語彙,例如把 `section()` 改成 `setLogSection()`,並讓相關變數名稱也跟著一致。", + "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:282", - "problem": "`Promise.all` 只要其中一個刪除任務拋出 reject,整個 batch 會立刻失敗,外層 `main().catch(...)` 也會直接結束。這代表只要某一筆 release 或 tag 遇到網路中斷、DNS 失敗、連線逾時,後續同 batch 的項目就不會再處理,清理流程會在半途中停住。", - "suggestion": "把每個 item 的刪除包在個別 `try/catch`,或改用 `Promise.allSettled` 後統一彙總失敗;至少要確保單筆失敗不會中止同批其他清理工作。", + "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": "Mage", - "location": "src/index.js:146", - "problem": "這個 helper 把字串 `'null'` 也當成空值。若真的存在名稱剛好是 `null` 的 tag、repo 名稱或其他合法輸入,就會被誤判成缺值而跳過或拒絕,造成清理邏輯和實際資料不一致。", - "suggestion": "只把 `undefined`、`null` 和空字串視為空值;若需要處理來自環境變數的字面字串 `'null'`,應該在特定參數的解析層單獨處理,不要放進通用空值判斷。", + "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 } ] From 45588ee96e4d92c925e89f83ee03f5633d64a7c8 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 15 Jul 2026 09:37:13 +0800 Subject: [PATCH 13/18] =?UTF-8?q?fix(cleanup-release):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E5=88=AA=E9=99=A4=E5=A4=B1=E6=95=97=E6=9C=AA=E5=9B=9E?= =?UTF-8?q?=E5=A0=B1=E3=80=81=E8=BC=B8=E5=85=A5=E9=A9=97=E8=AD=89=E8=88=87?= =?UTF-8?q?=20URL=20=E9=81=AE=E7=BD=A9=E4=B8=A6=E9=87=8D=E7=94=A8=E9=80=A3?= =?UTF-8?q?=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/index.js | 91 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/src/index.js b/src/index.js index 5fad040..9f7738c 100644 --- a/src/index.js +++ b/src/index.js @@ -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} 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(); + }); From e10f9c95c42e64b10c1152f27d28e133bf366d93 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 15 Jul 2026 09:37:13 +0800 Subject: [PATCH 14/18] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E5=B7=B2=E5=85=A8=E6=95=B8=E8=A7=A3=E6=B1=BA?= =?UTF-8?q?=E7=9A=84=20findings.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .gitea/ai-review/findings.json | 67 +--------------------------------- 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 8e1ef5b..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -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 - } -] +[] From 4f64e570e2422a5dfee6c42afb0edd8b9fa25245 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Wed, 15 Jul 2026 02:16:32 +0000 Subject: [PATCH 15/18] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/exclusions.json | 6 ++ .gitea/ai-review/findings.json | 99 +++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index f7f3086..6143972 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -128,5 +128,11 @@ "reason": "目前 release/tag 數量預期不大,而且後續需要排序與切片,完整收集資料是最直接也最清楚的實作。", "source": "develop...ai-review-resolve/develop-20260711-131608", "date": "2026-07-11" + }, + { + "location": "src/index.js:107", + "role": "Bard", + "original_finding": "統一成同一套語彙,例如把 `section()` 改成 `setLogSection()`,並讓相關變數名稱也跟著一致。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..50f1939 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,98 @@ -[] +[ + { + "level": "critical", + "role": "Mage", + "problem": "這裡在刪除 tag 的流程中只把失敗記進 `hadFailure`,但流程結束後沒有再檢查或拋錯。最小重現:只要任一個 tag 刪除回傳 404/500,程式仍會以 0 結束,外層 CI 會誤判為清理成功,但實際上遺留的 tag 還在。", + "suggestion": "在 `processInBatches(tagJson, ...)` 結束後補上 `if (hadFailure) throw new Error(...)`,讓任何 tag 刪除失敗都會正確回傳非 0 狀態。", + "location": "src/index.js:400", + "is_new": false + }, + { + "level": "warning", + "role": "Mage", + "problem": "`Promise.all` 只要其中一個刪除任務拋出 reject,整個 batch 會立刻失敗,外層 `main().catch(...)` 也會直接結束。這代表只要某一筆 release 或 tag 遇到網路中斷、DNS 失敗、連線逾時,後續同 batch 的項目就不會再處理,清理流程會在半途中停住。", + "suggestion": "把每個 item 的刪除包在個別 `try/catch`,或改用 `Promise.allSettled` 後統一彙總失敗;至少要確保單筆失敗不會中止同批其他清理工作。", + "location": "src/index.js:282", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "problem": "這個驗證只擋掉非數字,`0` 仍然會通過;若攻擊者能控制 `KEEP_COUNT`,就能把保留數設成 0,後續流程會把所有 release 刪光,還會把所有未被保留的 tag 一併清掉。", + "suggestion": "把下限改成至少 `1`,並在進入刪除流程前再做一次保護檢查;如果真的需要全清,應該改成獨立的高風險開關,而不是混在一般輸入參數裡。", + "location": "src/index.js:169", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:252", + "problem": "`GITEA_REPOSITORY` 直接字串串進 release API 路徑,沒有做格式驗證或路徑編碼。只要這個值被污染,攻擊者就能把 `../`、額外斜線或其他路徑片段塞進去,讓帶著授權 token 的請求打到非預期的 API 路徑,擴大刪除面。", + "suggestion": "先把 repository 嚴格限制為 `owner/repo` 這種固定格式,再對 owner 與 repo 各自做 `encodeURIComponent` 後組 URL,不要直接把原字串拼進路徑。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:319", + "problem": "這裡同樣把未驗證的 `GITEA_REPOSITORY` 直接拼到 tag API 路徑。若輸入被操弄,攻擊者可以藉由路徑注入把刪除請求導向非預期資源,配合授權 token 造成超出原本 repo 範圍的破壞。", + "suggestion": "和 release API 一樣,對 repository 做嚴格格式檢查並逐段編碼後再組合路徑,必要時拒絕任何包含額外 `/`、`.` 或保留字元的值。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "src/index.js:227", + "problem": "`processInBatches()` 用 `Promise.all` 搭配外層共享的 `hadFailure`,失敗語意會變得很難推理:一筆例外會直接中斷整批,但其他並行工作仍可能繼續跑,最後到底刪了哪些、漏了哪些,不看執行細節很難判斷。這種控制流對日後補測試或改錯誤處理都不友善。", + "suggestion": "把每筆處理的結果收斂成明確的成功/失敗回傳值,或在批次內逐筆捕捉錯誤後再彙總;若要保留並行,至少讓批次函式回傳可測試的結果集合,而不是依賴外部可變狀態。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:345", + "problem": "`GITEA_SERVER_URL` 只做非空檢查,沒有先確認它是合法的絕對 URL。只要傳入像 `gitea.local`、`https://` 這類看起來有值但格式不合法的字串,`new URL()` 就會直接丟出未處理例外,錯誤也不會明確指出是參數格式問題。", + "suggestion": "在進入主流程前先對 `GITEA_SERVER_URL` 做 `try/catch` 驗證,失敗時回傳明確的參數錯誤並結束;不要把 URL 解析失敗留到中途才爆。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:336", + "problem": "`KEEP_COUNT` 只驗證是數字字串,沒有保證落在安全整數範圍內。像 `9007199254740993` 這種值會在 `Number()` 轉換時失真,導致 `releaseCount <= keepCount` 與 `slice(keepCount)` 的保留/刪除判斷偏掉,最終清理結果可能和設定不一致。", + "suggestion": "除了字串格式外,還要驗證 `Number.isSafeInteger(Number(KEEP_COUNT))`,並加上合理上限;超出範圍時直接報錯,避免用不精確的數值做刪除決策。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:363", + "problem": "`releaseJson.sort(...)` 先把所有 release 做完整排序,成本是 O(n log n),但後面其實只用前 `KEEP_COUNT` 筆。當 release 很多時,這段排序就是多花 CPU 在不必要的全量比較上。", + "suggestion": "改用固定大小的 top-K 選擇策略,例如維持一個大小為 `KEEP_COUNT` 的最小堆,或在 API 已經有新到舊順序時直接取前 `KEEP_COUNT` 筆,避免整體排序。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/index.js:230", + "problem": "這裡對每個 request 都先建立 `chunks` 陣列、收完整個 response body,再 `join` 成字串。刪除 release/tag 時大多只需要狀態碼,還硬把回應內容完整緩衝進記憶體,會在大量刪除時增加不必要的配置與拷貝。", + "suggestion": "把 request 包成可選擇是否收集 body;對 DELETE 這類不需要回應內容的呼叫直接丟棄資料串流,只保留 status code。", + "is_new": true + }, + { + "level": "info", + "role": "Assassin", + "problem": "分頁迴圈沒有上限,只要對方持續回傳非空頁面,這個 action 就會無限抓取;惡意或故障中的 API 可以把 runner 卡死,消耗時間與配額。", + "suggestion": "加入最大頁數、重複頁檢測或總筆數上限,超過就中止並回報異常,避免被外部回應拖成無限迴圈。", + "location": "src/index.js:228", + "is_new": false + }, + { + "level": "info", + "role": "Leo", + "problem": "在 `catch` 裡先把 `currentStage` 清空再記錄錯誤,會讓最後那筆失敗 log 失去「到底是在哪個階段炸掉」的上下文。等到未來有人要追問題時,只能回頭翻前面的輸出,比對成本會很高。", + "suggestion": "保留最後的 `currentStage`,或在進入 `catch` 時把階段一起寫進錯誤訊息;如果擔心汙染後續輸出,可以在輸出完成後再重設,而不是先清空。", + "location": "src/index.js:409", + "is_new": false + } +] From db5c8f2e20e0bb244736389e0cb75b8dbc4b4b44 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 15 Jul 2026 10:25:23 +0800 Subject: [PATCH 16/18] =?UTF-8?q?fix(cleanup-release):=20=E5=BC=B7?= =?UTF-8?q?=E5=8C=96=E5=8F=83=E6=95=B8=E9=A9=97=E8=AD=89=E8=88=87=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=E5=A4=B1=E6=95=97=E8=99=95=E7=90=86=EF=BC=8C=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E8=B7=AF=E5=BE=91=E6=B3=A8=E5=85=A5=E8=88=87=E6=95=B4?= =?UTF-8?q?=E6=89=B9=E4=B8=AD=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processInBatches 改用 Promise.allSettled 並回傳結果集合,單筆例外不再中止同批,移除共享 hadFailure 狀態 - GITEA_REPOSITORY 嚴格驗證 owner/repo 格式並逐段 encodeURIComponent,拒絕 . / .. 路徑片段 - GITEA_SERVER_URL 於參數檢查階段驗證為合法 HTTPS 絕對 URL,錯誤不再留到中途才拋出 - KEEP_COUNT 下限改為 1 並限制在安全整數範圍,避免設 0 清空所有 release 或大數失真 - DELETE 請求改以 collectBody: false 丟棄回應串流,只保留狀態碼 Co-Authored-By: Claude Fable 5 --- src/index.js | 141 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 110 insertions(+), 31 deletions(-) diff --git a/src/index.js b/src/index.js index 9f7738c..8cc01bd 100644 --- a/src/index.js +++ b/src/index.js @@ -191,14 +191,52 @@ function requireValue(name, value, displayValue = value) { } /** - * 驗證字串是否為非負整數。 + * 驗證字串是否為正整數,且落在安全整數範圍內;下限 1 可避免把保留數設成 0 而清空所有 release。 * * @param {string} name 參數名稱。 * @param {string} value 參數值。 */ -function requireInteger(name, value) { - if (!/^[0-9]+$/.test(value)) { - fail(`${name} must be a non-negative integer`); +function requirePositiveInteger(name, value) { + if (!/^[0-9]+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) { + fail(`${name} must be a positive integer within the safe integer range`); + process.exit(1); + } +} + +/** + * 驗證值是否為合法的 HTTPS 絕對 URL,避免 URL 解析失敗留到主流程中途才拋出。 + * + * @param {string} name 參數名稱。 + * @param {*} value 參數值。 + */ +function requireHttpsUrl(name, value) { + let parsed; + try { + parsed = new URL(String(value)); + } catch (error) { + fail(`${name} must be a valid absolute URL`); + process.exit(1); + } + + if (parsed.protocol !== 'https:') { + fail(`${name} must use HTTPS`); + process.exit(1); + } +} + +/** + * 驗證 repository 是否為 `owner/repo` 格式且僅含安全字元,拒絕 `.`、`..` 等路徑片段。 + * + * @param {string} name 參數名稱。 + * @param {*} value 參數值。 + */ +function requireRepository(name, value) { + const segments = String(value).split('/'); + const isValidSegment = (segment) => + /^[A-Za-z0-9_.-]+$/.test(segment) && segment !== '.' && segment !== '..'; + + if (segments.length !== 2 || !segments.every(isValidSegment)) { + fail(`${name} must be in owner/repo format`); process.exit(1); } } @@ -207,10 +245,10 @@ function requireInteger(name, value) { * 對指定 URL 發送 request,回傳狀態碼與 body。 * * @param {string} url 完整目標網址。 - * @param {{ method?: string, headers?: Record }} [options] request 設定。 + * @param {{ method?: string, headers?: Record, collectBody?: boolean }} [options] request 設定;`collectBody` 為 `false` 時丟棄回應內容、只保留狀態碼。 * @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。 */ -function request(url, { method = 'GET', headers = {} } = {}) { +function request(url, { method = 'GET', headers = {}, collectBody = true } = {}) { return new Promise((resolve, reject) => { const target = new URL(url); if (target.protocol !== 'https:') { @@ -226,6 +264,16 @@ function request(url, { method = 'GET', headers = {} } = {}) { agent: keepAliveAgent, }, (res) => { + const statusCode = res.statusCode || 0; + + if (!collectBody) { + res.resume(); + res.on('end', () => { + resolve({ statusCode, body: '' }); + }); + return; + } + const chunks = []; res.setEncoding('utf8'); @@ -234,7 +282,7 @@ function request(url, { method = 'GET', headers = {} } = {}) { }); res.on('end', () => { resolve({ - statusCode: res.statusCode || 0, + statusCode, body: chunks.join(''), }); }); @@ -300,21 +348,49 @@ async function deleteResource(url, headers) { return request(url, { method: 'DELETE', headers, + collectBody: false, }); } /** - * 以固定批次大小處理項目,降低逐筆等待造成的延遲。 + * 以固定批次大小處理項目,降低逐筆等待造成的延遲;單筆例外不會中止同批其他項目。 * * @param {any[]} items 要處理的項目。 * @param {number} batchSize 每批同時處理的數量。 - * @param {(item: any) => Promise} handler 單筆處理函式。 + * @param {(item: any) => Promise} handler 單筆處理函式,回傳該筆是否成功。 + * @returns {Promise[]>} 依原始順序排列的處理結果。 */ async function processInBatches(items, batchSize, handler) { + const results = []; + for (let index = 0; index < items.length; index += batchSize) { const batch = items.slice(index, index + batchSize); - await Promise.all(batch.map((item) => handler(item))); + results.push(...(await Promise.allSettled(batch.map((item) => handler(item))))); } + + return results; +} + +/** + * 彙總批次結果;記錄被 reject 的例外,並回傳是否有任何一筆失敗。 + * + * @param {PromiseSettledResult[]} results 批次處理結果。 + * @returns {boolean} 只要有任一筆失敗即回傳 `true`。 + */ +function hasBatchFailure(results) { + let failed = false; + + for (const result of results) { + if (result.status === 'rejected') { + failed = true; + const reason = result.reason; + fail(`批次處理發生例外: ${reason instanceof Error ? reason.message : String(reason)}`); + } else if (result.value === false) { + failed = true; + } + } + + return failed; } /** @@ -328,9 +404,11 @@ async function main() { section('參數檢查'); requireValue('GITEA_SERVER_URL', GITEA_SERVER_URL, maskUrlForLog(GITEA_SERVER_URL)); + requireHttpsUrl('GITEA_SERVER_URL', GITEA_SERVER_URL); requireValue('GITEA_REPOSITORY', GITEA_REPOSITORY); + requireRepository('GITEA_REPOSITORY', GITEA_REPOSITORY); requireValue('KEEP_COUNT', KEEP_COUNT); - requireInteger('KEEP_COUNT', KEEP_COUNT); + requirePositiveInteger('KEEP_COUNT', KEEP_COUNT); const keepCount = Number(KEEP_COUNT); const authHeaders = {}; @@ -348,7 +426,8 @@ async function main() { serverBase.hash = ''; const serverBaseUrl = serverBase.toString().replace(/\/+$/, ''); - const releaseApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/releases`; + const repositoryPath = GITEA_REPOSITORY.split('/').map(encodeURIComponent).join('/'); + const releaseApiUrl = `${serverBaseUrl}/api/v1/repos/${repositoryPath}/releases`; section('取得成品資訊'); info(`GET ${releaseApiUrl}`); @@ -370,21 +449,19 @@ async function main() { info(`RELEASE_COUNT=${releaseCount}`); info(`KEEP_COUNT=${KEEP_COUNT}`); - let hadFailure = false; - if (releaseCount <= keepCount) { info('沒有需要清理的舊版本成品'); } else { section('刪除舊版本成品'); const releaseToDelete = releaseJson.slice(keepCount); - await processInBatches(releaseToDelete, DELETE_CONCURRENCY, async (releaseItem) => { + const releaseResults = 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 || '')})`, ); - return; + return true; } const releaseTag = sanitizeLogText(releaseItem.tag_name || ''); @@ -395,15 +472,16 @@ async function main() { const { statusCode } = await deleteResource(deleteUrl, authHeaders); if (statusCode === 204) { info(`成功刪除: ${releaseTag} (${releaseName})`); - } else { - hadFailure = true; - fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${statusCode}`); + return true; } - }); - } - if (hadFailure) { - throw new Error('至少有一筆 release 刪除失敗'); + fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${statusCode}`); + return false; + }); + + if (hasBatchFailure(releaseResults)) { + throw new Error('至少有一筆 release 刪除失敗'); + } } section('刪除未指定 release 的 tag'); @@ -415,23 +493,23 @@ async function main() { .filter((tag) => !isEmptyOrNull(tag)), ); - const tagApiUrl = `${serverBaseUrl}/api/v1/repos/${GITEA_REPOSITORY}/tags`; + const tagApiUrl = `${serverBaseUrl}/api/v1/repos/${repositoryPath}/tags`; info(`GET ${tagApiUrl}`); const tagJson = await fetchAllPages(tagApiUrl, authHeaders); info(`TAG_COUNT=${tagJson.length}`); - await processInBatches(tagJson, DELETE_CONCURRENCY, async (tagItem) => { + const tagResults = await processInBatches(tagJson, DELETE_CONCURRENCY, async (tagItem) => { const tagName = tagItem?.name; if (isEmptyOrNull(tagName)) { warn('略過沒有名稱的 tag'); - return; + return true; } const safeTagName = sanitizeLogText(tagName); if (releaseTags.has(tagName)) { info(`保留指定 release 的 tag: ${safeTagName}`); - return; + return true; } const deleteUrl = `${tagApiUrl}/${encodeURIComponent(tagName)}`; @@ -440,13 +518,14 @@ async function main() { const { statusCode } = await deleteResource(deleteUrl, authHeaders); if (statusCode === 204) { info(`成功刪除未指定 release 的 tag: ${safeTagName}`); - } else { - hadFailure = true; - fail(`刪除 tag 失敗: ${safeTagName}, HTTP ${statusCode}`); + return true; } + + fail(`刪除 tag 失敗: ${safeTagName}, HTTP ${statusCode}`); + return false; }); - if (hadFailure) { + if (hasBatchFailure(tagResults)) { throw new Error('至少有一筆 tag 刪除失敗'); } } From 9137f6750761046b04e7adcfe29650eea6b13e93 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 15 Jul 2026 10:25:23 +0800 Subject: [PATCH 17/18] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=20findings=20=E4=B8=A6=E7=99=BB=E8=A8=98?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=20top-K=20=E5=BB=BA=E8=AD=B0=E7=82=BA?= =?UTF-8?q?=E8=AA=A4=E5=A0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .gitea/ai-review/exclusions.json | 8 +++ .gitea/ai-review/findings.json | 99 +------------------------------- 2 files changed, 9 insertions(+), 98 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 6143972..b884c6a 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -134,5 +134,13 @@ "role": "Bard", "original_finding": "統一成同一套語彙,例如把 `section()` 改成 `setLogSection()`,並讓相關變數名稱也跟著一致。", "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "src/index.js:363", + "role": "Rogue", + "original_finding": "改用固定大小的 top-K 選擇策略,例如維持一個大小為 `KEEP_COUNT` 的最小堆,或在 API 已經有新到舊順序時直接取前 `KEEP_COUNT` 筆,避免整體排序。", + "reason": "release 數量預期不大,全量排序成本可忽略;後續同時需要「保留的前 K 筆」與「其餘待刪清單」,一次排序是最直接清楚的實作,引入 top-K 堆反而增加複雜度(與既有「分頁結果先完整收集到陣列」的排除理由一致)。", + "source": "develop...ai-review-resolve/develop-20260711-131608", + "date": "2026-07-15" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 50f1939..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,98 +1 @@ -[ - { - "level": "critical", - "role": "Mage", - "problem": "這裡在刪除 tag 的流程中只把失敗記進 `hadFailure`,但流程結束後沒有再檢查或拋錯。最小重現:只要任一個 tag 刪除回傳 404/500,程式仍會以 0 結束,外層 CI 會誤判為清理成功,但實際上遺留的 tag 還在。", - "suggestion": "在 `processInBatches(tagJson, ...)` 結束後補上 `if (hadFailure) throw new Error(...)`,讓任何 tag 刪除失敗都會正確回傳非 0 狀態。", - "location": "src/index.js:400", - "is_new": false - }, - { - "level": "warning", - "role": "Mage", - "problem": "`Promise.all` 只要其中一個刪除任務拋出 reject,整個 batch 會立刻失敗,外層 `main().catch(...)` 也會直接結束。這代表只要某一筆 release 或 tag 遇到網路中斷、DNS 失敗、連線逾時,後續同 batch 的項目就不會再處理,清理流程會在半途中停住。", - "suggestion": "把每個 item 的刪除包在個別 `try/catch`,或改用 `Promise.allSettled` 後統一彙總失敗;至少要確保單筆失敗不會中止同批其他清理工作。", - "location": "src/index.js:282", - "is_new": false - }, - { - "level": "warning", - "role": "Assassin", - "problem": "這個驗證只擋掉非數字,`0` 仍然會通過;若攻擊者能控制 `KEEP_COUNT`,就能把保留數設成 0,後續流程會把所有 release 刪光,還會把所有未被保留的 tag 一併清掉。", - "suggestion": "把下限改成至少 `1`,並在進入刪除流程前再做一次保護檢查;如果真的需要全清,應該改成獨立的高風險開關,而不是混在一般輸入參數裡。", - "location": "src/index.js:169", - "is_new": false - }, - { - "level": "warning", - "role": "Assassin", - "location": "src/index.js:252", - "problem": "`GITEA_REPOSITORY` 直接字串串進 release API 路徑,沒有做格式驗證或路徑編碼。只要這個值被污染,攻擊者就能把 `../`、額外斜線或其他路徑片段塞進去,讓帶著授權 token 的請求打到非預期的 API 路徑,擴大刪除面。", - "suggestion": "先把 repository 嚴格限制為 `owner/repo` 這種固定格式,再對 owner 與 repo 各自做 `encodeURIComponent` 後組 URL,不要直接把原字串拼進路徑。", - "is_new": true - }, - { - "level": "warning", - "role": "Assassin", - "location": "src/index.js:319", - "problem": "這裡同樣把未驗證的 `GITEA_REPOSITORY` 直接拼到 tag API 路徑。若輸入被操弄,攻擊者可以藉由路徑注入把刪除請求導向非預期資源,配合授權 token 造成超出原本 repo 範圍的破壞。", - "suggestion": "和 release API 一樣,對 repository 做嚴格格式檢查並逐段編碼後再組合路徑,必要時拒絕任何包含額外 `/`、`.` 或保留字元的值。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "src/index.js:227", - "problem": "`processInBatches()` 用 `Promise.all` 搭配外層共享的 `hadFailure`,失敗語意會變得很難推理:一筆例外會直接中斷整批,但其他並行工作仍可能繼續跑,最後到底刪了哪些、漏了哪些,不看執行細節很難判斷。這種控制流對日後補測試或改錯誤處理都不友善。", - "suggestion": "把每筆處理的結果收斂成明確的成功/失敗回傳值,或在批次內逐筆捕捉錯誤後再彙總;若要保留並行,至少讓批次函式回傳可測試的結果集合,而不是依賴外部可變狀態。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/index.js:345", - "problem": "`GITEA_SERVER_URL` 只做非空檢查,沒有先確認它是合法的絕對 URL。只要傳入像 `gitea.local`、`https://` 這類看起來有值但格式不合法的字串,`new URL()` 就會直接丟出未處理例外,錯誤也不會明確指出是參數格式問題。", - "suggestion": "在進入主流程前先對 `GITEA_SERVER_URL` 做 `try/catch` 驗證,失敗時回傳明確的參數錯誤並結束;不要把 URL 解析失敗留到中途才爆。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/index.js:336", - "problem": "`KEEP_COUNT` 只驗證是數字字串,沒有保證落在安全整數範圍內。像 `9007199254740993` 這種值會在 `Number()` 轉換時失真,導致 `releaseCount <= keepCount` 與 `slice(keepCount)` 的保留/刪除判斷偏掉,最終清理結果可能和設定不一致。", - "suggestion": "除了字串格式外,還要驗證 `Number.isSafeInteger(Number(KEEP_COUNT))`,並加上合理上限;超出範圍時直接報錯,避免用不精確的數值做刪除決策。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:363", - "problem": "`releaseJson.sort(...)` 先把所有 release 做完整排序,成本是 O(n log n),但後面其實只用前 `KEEP_COUNT` 筆。當 release 很多時,這段排序就是多花 CPU 在不必要的全量比較上。", - "suggestion": "改用固定大小的 top-K 選擇策略,例如維持一個大小為 `KEEP_COUNT` 的最小堆,或在 API 已經有新到舊順序時直接取前 `KEEP_COUNT` 筆,避免整體排序。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/index.js:230", - "problem": "這裡對每個 request 都先建立 `chunks` 陣列、收完整個 response body,再 `join` 成字串。刪除 release/tag 時大多只需要狀態碼,還硬把回應內容完整緩衝進記憶體,會在大量刪除時增加不必要的配置與拷貝。", - "suggestion": "把 request 包成可選擇是否收集 body;對 DELETE 這類不需要回應內容的呼叫直接丟棄資料串流,只保留 status code。", - "is_new": true - }, - { - "level": "info", - "role": "Assassin", - "problem": "分頁迴圈沒有上限,只要對方持續回傳非空頁面,這個 action 就會無限抓取;惡意或故障中的 API 可以把 runner 卡死,消耗時間與配額。", - "suggestion": "加入最大頁數、重複頁檢測或總筆數上限,超過就中止並回報異常,避免被外部回應拖成無限迴圈。", - "location": "src/index.js:228", - "is_new": false - }, - { - "level": "info", - "role": "Leo", - "problem": "在 `catch` 裡先把 `currentStage` 清空再記錄錯誤,會讓最後那筆失敗 log 失去「到底是在哪個階段炸掉」的上下文。等到未來有人要追問題時,只能回頭翻前面的輸出,比對成本會很高。", - "suggestion": "保留最後的 `currentStage`,或在進入 `catch` 時把階段一起寫進錯誤訊息;如果擔心汙染後續輸出,可以在輸出完成後再重設,而不是先清空。", - "location": "src/index.js:409", - "is_new": false - } -] +[] From 62e20d361f43d4b4a59aca63ba5002aa11c39af1 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Wed, 15 Jul 2026 02:32:42 +0000 Subject: [PATCH 18/18] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 51 +++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..4eb067d 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,50 @@ -[] +[ + { + "level": "warning", + "role": "Mage", + "problem": "`KEEP_COUNT` 只驗證是數字字串,沒有保證落在安全整數範圍內。像 `9007199254740993` 這種值會在 `Number()` 轉換時失真,導致 `releaseCount <= keepCount` 與 `slice(keepCount)` 的保留/刪除判斷偏掉,最終清理結果可能和設定不一致。", + "suggestion": "除了字串格式外,還要驗證 `Number.isSafeInteger(Number(KEEP_COUNT))`,並加上合理上限;超出範圍時直接報錯,避免用不精確的數值做刪除決策。", + "location": "src/index.js:336", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/index.js:317", + "problem": "非 2xx 回應時,這裡會把遠端回應 body 的摘要直接拼進例外訊息。攻擊者只要能控制對端回應,就能把內部錯誤、設定細節或其他敏感字串塞進 CI logs,讓有 log 權限的人直接讀到。", + "suggestion": "例外訊息只保留 HTTP 狀態碼與請求目標,不要預設帶回應 body;若真的需要除錯資訊,改成在受控的 debug 模式下才輸出,而且要先過濾敏感欄位並更短截斷。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:308", + "problem": "這裡把分頁數硬性上限鎖死為 1000 頁。只要 releases 或 tags 的總量超過這個門檻,`fetchAllPages` 就會直接拋錯中止,即使 API 其實還有資料可取。以每頁 30 筆來算,超過約 3 萬筆就會永久卡死清理流程。", + "suggestion": "改用 API 回傳的分頁資訊或 `Link` header 判斷是否還有下一頁;如果仍要保留上限,請改成可設定且預設足夠大的值,而不是固定寫死。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:460", + "problem": "當 `releaseItem.id` 缺失或不是安全整數時,這裡只警告然後回傳 `true`,等於把資料異常當成處理成功。最壞情況是 API 回傳壞資料或 schema 改版,舊 release 被靜默跳過,最後 job 仍可能顯示成功。", + "suggestion": "遇到無效 `id` 時應直接視為失敗,改成 `throw` 或回傳 `false`,讓工作非正常結束並停止後續 tag 清理。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/index.js:504", + "problem": "tag 沒有 `name` 時也只是警告後回傳 `true`,這會讓壞資料被靜默略過。若 tag 清單中出現異常項目,cleanup 會看起來成功,但實際上有 tag 沒被處理。", + "suggestion": "把空白或缺失的 `name` 視為失敗,至少讓整體結果反映出資料異常;不要把無法辨識的 tag 當成成功案例。", + "is_new": true + }, + { + "level": "info", + "role": "Assassin", + "location": "src/index.js:536", + "problem": "這裡直接輸出 `error.stack`,會把檔案路徑、函式名稱與執行細節一起灑到標準錯誤。對能看 CI logs 的人來說,這等於免費拿到更多內部結構資訊,方便後續針對性利用。", + "suggestion": "預設只輸出 `error.message` 或自訂錯誤代碼;堆疊資訊只在明確開啟除錯模式時才顯示,避免把內部實作細節帶到正式 log。", + "is_new": true + } +]