From e23f222ffa67db87d4b97a74048ebce4f8bc0874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B3=BB=E7=B5=B1=E7=AE=A1=E7=90=86=E5=93=A1?= <1+admin@noreply.localhost> Date: Tue, 30 Jun 2026 02:51:50 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20action.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index 347f706..c1140b8 100644 --- a/action.yaml +++ b/action.yaml @@ -4,7 +4,7 @@ author: 'Jeffery' inputs: is_beta: description: '是否為 beta 版本' - default: "false" + default: false outputs: version: description: '計算出的版本號' -- 2.53.0 From b14b676d0c3bd5154dd7419b5cdc683586e12f35 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:42:11 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(=E8=A8=AD=E5=AE=9A=E8=88=87=20release?= =?UTF-8?q?=20=E5=8F=96=E5=BE=97):=20=E8=A3=9C=20GITEA=5FREPOSITORY=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E9=A9=97=E8=AD=89=E4=B8=A6=E8=AE=93=20JSON?= =?UTF-8?q?=20=E8=A7=A3=E6=9E=90=E9=8C=AF=E8=AA=A4=E5=B8=B6=E5=9B=9E?= =?UTF-8?q?=E5=82=B3=E5=85=A7=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.js | 40 +++++++++++++++++++++++++++++++++++++--- app/releases.js | 10 +++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/config.js b/app/config.js index bf57afe..6b5de7f 100644 --- a/app/config.js +++ b/app/config.js @@ -42,7 +42,19 @@ function normalizeBetaFlag(value) { return value === 'true'; } -// 驗證字串為合法的 http/https URL,否則拋出錯誤(避免指向非預期協定或格式錯誤的位址) +/** + * 驗證字串為合法的 http/https URL,否則拋出錯誤(避免指向非預期協定或格式錯誤的位址)。 + * + * 先以 `new URL(value)` 解析,無法解析時視為格式錯誤;解析成功後再檢查協定, + * 僅允許 `http:` 與 `https:`,其餘協定(如 ftp)一律拒絕。 + * + * @param {string} name - 設定項名稱,用於組出錯誤訊息(例如 `GITEA_SERVER_URL`)。 + * @param {string} value - 待驗證的 URL 字串。 + * @returns {void} 驗證通過時不回傳值;不通過時改以拋出例外表示。 + * @throws {Error} 當 value 無法解析為合法 URL 時拋出 `${name} 格式錯誤,必須為合法的 URL`。 + * @throws {Error} 當 value 協定非 http/https 時拋出 `${name} 必須使用 http 或 https 協定`。 + * @remarks 由 loadConfig 於驗證 GITEA_SERVER_URL 時呼叫,確保後續組出的 release API URL 指向合法位址。 + */ function assertHttpUrl(name, value) { let parsed; try { @@ -55,20 +67,42 @@ function assertHttpUrl(name, value) { } } +/** + * 驗證字串為合法的 `owner/repo` 形式,否則拋出錯誤。 + * + * 此值會直接拼接於 release API URL(`.../repos/{repository}/releases`),故須限制為單一斜線 + * 分隔的合法 repo 字元,並排除含 `..` 等可能造成路徑穿越或非預期端點存取的輸入。 + * + * @param {string} name - 設定項名稱,用於組出錯誤訊息(例如 `GITEA_REPOSITORY`)。 + * @param {string} value - 待驗證的 `owner/repo` 字串。 + * @returns {void} 驗證通過時不回傳值;不通過時改以拋出例外表示。 + * @throws {Error} 當 value 非 `owner/repo` 形式、含非法字元或含 `..` 路徑穿越片段時拋出。 + * @remarks 由 loadConfig 於驗證 GITEA_REPOSITORY 時呼叫;允許 repo 名稱含 `.`(如 `org/my.repo`), + * 但拒絕任何 `..` 片段與多餘斜線,避免拼接出非預期的 API 路徑。 + */ +function assertRepository(name, value) { + if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(value) || value.includes('..')) { + throw new Error(`${name} 格式錯誤,必須為 owner/repo 形式`); + } +} + /** * 從環境變數載入並驗證執行所需的設定。 * * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL - * 另需為合法的 http/https URL;GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 + * 另需為合法的 http/https URL;GITEA_REPOSITORY 另需為合法的 `owner/repo` 形式(排除路徑穿越); + * GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 * * @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。 * @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。 - * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定,或 GITEA_SERVER_URL 非合法 http/https URL 時拋出。 + * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定、GITEA_SERVER_URL 非合法 http/https URL, + * 或 GITEA_REPOSITORY 非合法 owner/repo 形式時拋出。 */ function loadConfig(env = process.env) { const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); assertHttpUrl('GITEA_SERVER_URL', serverUrl); const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); + assertRepository('GITEA_REPOSITORY', repository); const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const isBeta = normalizeBetaFlag(env.IS_BETA); diff --git a/app/releases.js b/app/releases.js index e2b049d..db64c1b 100644 --- a/app/releases.js +++ b/app/releases.js @@ -15,9 +15,11 @@ const RELEASES_PER_PAGE = 10; * 進行授權請求,否則以匿名方式請求。 * @param {{ info: (message: string) => void }} [options.logger] - 選用的記錄器, * 需提供 info() 方法以輸出進度訊息。 - * @returns {Promise} 解析為所有 release 物件合併後的陣列。 - * @throws {Error} 當網路請求失敗、response 非 2xx、回傳資料無法解析, + * @returns {Promise} 解析為所有 release 物件合併後的陣列;首頁即無資料時回傳空陣列。 + * @throws {Error} 當網路請求失敗、response 非 2xx、回應本文讀取失敗、回傳資料無法解析, * 或回傳非陣列資料時拋出。 + * @remarks 依賴 Node.js 18+ 的全域 fetch;baseUrl 不含 query string,函式自行附加 + * `?limit=&page=` 逐頁請求,直到某頁空字串/`null`/不足一頁為止。 */ async function fetchReleases(baseUrl, options = {}) { const { token, logger } = options; @@ -63,7 +65,9 @@ async function fetchReleases(baseUrl, options = {}) { try { pageJson = JSON.parse(text); } catch { - throw new Error(`release API 回傳資料無法解析 (page=${page})`); + // 附上截斷的回傳內容(至多 200 字元)以利定位回傳格式異常的確切原因 + const snippet = text.length > 200 ? `${text.slice(0, 200)}…` : text; + throw new Error(`release API 回傳資料無法解析 (page=${page}):${snippet}`); } if (pageJson === null) { -- 2.53.0 From 2e5c46b8932e859d33b0c24465ddf0d5a3b93a53 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:42:11 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(=E5=95=9F=E5=8B=95=E6=A9=AB=E5=B9=85?= =?UTF-8?q?=E8=88=87=20log=20=E6=A0=BC=E5=BC=8F):=20=E5=95=9F=E5=8B=95?= =?UTF-8?q?=E8=BC=B8=E5=87=BA=20Action=20=E8=B3=87=E8=A8=8A=E6=A9=AB?= =?UTF-8?q?=E5=B9=85=E4=B8=A6=E7=B5=B1=E4=B8=80=20log=20=E8=A8=8A=E6=81=AF?= =?UTF-8?q?=E7=82=BA=20[=E7=AD=89=E7=B4=9A][=E6=99=82=E9=96=93]=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/logger.js | 47 +++++++++++++++++++++++++++++++++++++++++++---- entrypoint.sh | 18 +++++++++++++++--- 2 files changed, 58 insertions(+), 7 deletions(-) mode change 100644 => 100755 entrypoint.sh diff --git a/app/logger.js b/app/logger.js index bae031f..5aba8fb 100644 --- a/app/logger.js +++ b/app/logger.js @@ -4,39 +4,78 @@ const LINE = '='.repeat(50); const SUBLINE = '-'.repeat(50); +/** + * 產生台灣時區(Asia/Taipei)的時間戳,格式固定為 `yyyy/MM/dd HH:mm:ss`。 + * + * 供 log 訊息統一格式 `[{等級}][{時間}]: {訊息}` 的時間欄位使用。 + * + * @returns {string} 台灣時區當下時間字串,例如 `2026/06/30 12:13:04`。 + */ +function taipeiTimestamp() { + const parts = new Intl.DateTimeFormat('zh-TW', { + timeZone: 'Asia/Taipei', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(new Date()); + + const get = (type) => parts.find((part) => part.type === type)?.value ?? ''; + // 部分執行環境會把 24 時制的午夜輸出為 "24",統一正規化為 "00" + const hour = get('hour') === '24' ? '00' : get('hour'); + + return `${get('year')}/${get('month')}/${get('day')} ${hour}:${get('minute')}:${get('second')}`; +} + /** * 輸出帶標題的區塊段落至標準輸出,標題前後以分隔線包夾, * 用於在 log 中建立可視的段落區隔。 * * 輸出格式為:換行 + 主分隔線(50 個 `=`)+ 標題 + 次分隔線(50 個 `-`)。 + * 此為結構性段落標題(非 INF/WRN/ERR 等級訊息),故不套用 `[{等級}][{時間}]` 前綴。 * * @param {string} title - 區塊標題文字;會原樣輸出於兩條分隔線之間。 * @returns {void} + * @remarks 通常在進入一個處理階段前呼叫(例如「參數檢查」「取得版本資料」), + * 用以在 CI/容器 log 中分隔各階段輸出,便於閱讀與定位。 */ function section(title) { process.stdout.write(`\n${LINE}\n${title}\n${SUBLINE}\n`); } /** - * 輸出一般資訊層級的 log 訊息至標準輸出,自動加上 `[info]` 前綴與換行。 + * 輸出一般資訊(INF)層級的 log 訊息至標準輸出。 + * + * 訊息格式統一為 `[INF][{台灣時間}]: {訊息}`,時間使用台灣時區(Asia/Taipei), + * 格式為 `yyyy/MM/dd HH:mm:ss`,並於結尾換行。 * * @param {string} message - 要輸出的資訊內容。 * @returns {void} + * @remarks 用於回報正常流程進度(如設定值、URL、各頁取得筆數);此調整僅變更輸出前綴格式, + * 不改變訊息所反映的實際行為與內容。 */ function info(message) { - process.stdout.write(`[info] ${message}\n`); + process.stdout.write(`[INF][${taipeiTimestamp()}]: ${message}\n`); } /** - * 輸出錯誤層級的 log 訊息至標準錯誤輸出(stderr),自動加上 `[error]` 前綴與換行。 + * 輸出錯誤(ERR)層級的 log 訊息至標準錯誤輸出(stderr)。 + * + * 訊息格式統一為 `[ERR][{台灣時間}]: {訊息}`,時間使用台灣時區(Asia/Taipei), + * 格式為 `yyyy/MM/dd HH:mm:ss`,並於結尾換行。 * * 僅負責輸出,不終止行程;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。 * * @param {string} message - 要輸出的錯誤描述內容。 * @returns {void} + * @remarks 由進入點在頂層捕捉到例外時呼叫,輸出至 stderr 後再由呼叫端決定 exit code; + * 此調整僅變更輸出前綴格式,不改變訊息所反映的實際行為與內容。 */ function error(message) { - process.stderr.write(`[error] ${message}\n`); + process.stderr.write(`[ERR][${taipeiTimestamp()}]: ${message}\n`); } module.exports = { section, info, error }; diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index 617c5fb..1b431db --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,15 +1,27 @@ #!/bin/bash # ============================================================================= # 用途: calculate-version Action 的容器進入點 (entrypoint)。 -# 專案已由 bash 改寫為 Node.js,本腳本僅負責啟動 /app/index.js, -# 並將容器收到的引數原封傳遞給 Node.js 程式執行版本計算邏輯。 -# 更新日期: 2026/06/26 10:28:36 +# 專案已由 bash 改寫為 Node.js,本腳本先輸出 Action 資訊橫幅, +# 再啟動 /app/index.js,並將容器收到的引數原封傳遞給 Node.js 程式。 +# 更新日期: 2026/06/30 12:12:56 # ============================================================================= # set -e: 任一指令失敗即中止; set -u: 使用未定義變數即報錯; # set -o pipefail: 管線中任一指令失敗即視為整體失敗。確保錯誤能即時暴露。 set -euo pipefail +# Action 基本資訊:與 action.yaml 的 name/description 一致;更新時間為本腳本最後異動的台灣時間。 +ACTION_NAME='Calculate Version' +ACTION_DESC='計算版本號' +ACTION_UPDATED='2026/06/30 12:12:56' + +# 啟動時印出 Action 名稱 / 用途 / 更新時間橫幅,方便在 CI/容器 log 中辨識本次執行。 +printf '\n%s\n' '==================================================' +printf '名稱 : %s\n' "$ACTION_NAME" +printf '用途 : %s\n' "$ACTION_DESC" +printf '更新 : %s\n' "$ACTION_UPDATED" +printf '%s\n' '==================================================' + # 進入點:實際邏輯改以 Node.js 實作,置於 /app # 以 exec 取代當前 shell,讓 node 成為 PID 1 正確接收訊號; # "$@" 將容器接收到的所有引數原樣轉交給 Node.js 程式。 -- 2.53.0 From fe299cac64024f14a512204ea51cdc8aa0c94e22 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:42:11 +0800 Subject: [PATCH 4/7] =?UTF-8?q?refactor(Dockerfile):=20=E6=94=B9=E7=82=BA?= =?UTF-8?q?=E6=9C=80=E6=96=B0=20node=20=E5=A4=9A=E9=9A=8E=E6=AE=B5?= =?UTF-8?q?=E5=BB=BA=E7=BD=AE=E4=BB=A5=E7=B8=AE=E5=B0=8F=E6=9C=80=E7=B5=82?= =?UTF-8?q?=E6=98=A0=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 48 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5057987..7c596d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,42 @@ +# syntax=docker/dockerfile:1 # ============================================================================= # 用途: 建置 calculate-version Action 的容器映像。 -# 以 Node.js 執行環境打包 /app 下的 Node.js 程式 (專案已從 bash 改寫為 Node.js), -# 並安裝 bash 以執行 entrypoint.sh,再由其啟動 Node.js 程式。 -# 更新日期: 2026/06/26 10:28:36 +# 採多階段建置:build 階段以最新 node 準備主程式 (/app) 與進入點腳本, +# runtime 階段改用 node slim 基底縮小最終映像,再由 entrypoint.sh 啟動 Node.js 主程式。 +# 更新日期: 2026/06/30 12:12:52 # ============================================================================= -# 基底映像:Node.js LTS 的 Alpine 版本 (體積小)。 -FROM node:lts-alpine +# 1. 參數處理:node 版本以 ARG 注入。預設使用最新版 (build: node:latest / runtime: node:slim); +# 可於建置時以 --build-arg NODE_VERSION=22 --build-arg NODE_RUNTIME=22-slim 覆寫。 +# 注意:最新版 slim 一律為 node:slim,無 node:latest-slim 這個 tag,故以獨立 NODE_RUNTIME 帶入。 +ARG NODE_VERSION=latest +ARG NODE_RUNTIME=slim -# entrypoint.sh 以 bash 撰寫,Alpine 預設無 bash 需另行安裝 -# --no-cache 不保留 apk 索引快取以縮小映像(保留 TLS 憑證檢查以防中間人攻擊)。 -RUN apk add --no-cache bash +# ---- build 階段:安裝相依、準備主程式與進入點 ---- +FROM node:${NODE_VERSION} AS build +WORKDIR /build -# 複製容器進入點腳本至根目錄。 -COPY entrypoint.sh /entrypoint.sh -# 複製整個 app 目錄 (Node.js 程式) 至 /app。 -COPY app/ /app/ +# 2. 安裝套件:先單獨複製相依描述以利 layer 快取,再安裝 production 相依。 +# 本專案目前無外部相依;有 package.json 時仍嘗試安裝 (有 lockfile 用 npm ci,否則 npm install), +# 無相依時為 no-op,但保留此步可在未來新增相依時自動生效。 +COPY app/package*.json ./app/ +RUN if [ -f ./app/package.json ]; then \ + cd ./app && (npm ci --omit=dev || npm install --omit=dev); \ + fi -# 賦予進入點腳本可執行權限,否則 ENTRYPOINT 無法執行。 -RUN chmod +x /entrypoint.sh +# 3. 複製檔案:複製主程式 (app/) 與容器進入點腳本。 +COPY app/ ./app/ +COPY entrypoint.sh ./entrypoint.sh -# 設定容器啟動時執行的進入點 (內部會 exec node /app/index.js "$@")。 +# 4. 執行程序:賦予進入點腳本可執行權限。本專案為純 JS,無需 build/transpile,故此步僅設定權限。 +RUN chmod +x ./entrypoint.sh + +# ---- runtime 階段:以 slim 基底縮小最終映像 ---- +# 5. 縮小映像檔:多階段建置,runtime 僅自 build 階段帶入執行真正需要的檔案 (app/ 與 entrypoint.sh), +# 不含 build 階段的快取。node slim 為 Debian 基底,內建 bash,足以執行 entrypoint.sh。 +FROM node:${NODE_RUNTIME} AS runtime +COPY --from=build /build/app /app +COPY --from=build /build/entrypoint.sh /entrypoint.sh + +# 6. 設定入口:以 entrypoint.sh 為容器進入點 (其內以 exec node /app/index.js "$@" 啟動主程式)。 ENTRYPOINT ["/entrypoint.sh"] -- 2.53.0 From 7954fda31188d75fb0210826a91c11f0d0d9c6f4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:42:11 +0800 Subject: [PATCH 5/7] =?UTF-8?q?docs(calculate-version):=20=E8=A3=9C?= =?UTF-8?q?=E9=BD=8A=20function=20JSDoc=20=E8=88=87=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E6=AA=94=E9=80=90=E8=A1=8C=E8=A8=BB=E8=A7=A3=E4=B8=A6=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/cd.yaml | 2 +- .gitea/workflows/ci.yaml | 2 +- README.md | 50 ++++++++++++++++++++-------------------- app/index.js | 4 ++++ app/output.js | 2 ++ app/version.js | 4 ++++ 6 files changed, 37 insertions(+), 27 deletions(-) diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 7ea67a4..2eb53fa 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1,7 +1,7 @@ # ============================================================================= # 用途: calculate-version 專案的 CD workflow。 # 在程式碼推送至 master 分支時,自動釋出並標註 (tag) 成品版本。 -# 更新日期: 2026/06/26 10:28:36 +# 更新日期: 2026/06/30 12:13:04 # ============================================================================= # workflow 名稱,顯示於 Gitea Actions 介面。 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b90e67c..8013bc9 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,7 +1,7 @@ # ============================================================================= # 用途: calculate-version 專案的 CI workflow。 # 在 Pull Request 開啟或更新時,觸發 OpenCode AI 程式碼審查。 -# 更新日期: 2026/06/26 10:28:36 +# 更新日期: 2026/06/30 12:12:59 # ============================================================================= # workflow 名稱,顯示於 Gitea Actions 介面。 diff --git a/README.md b/README.md index 4ea412e..8d0b1a4 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # calculate-version -> 更新時間:2026/06/26 10:49:46 +> 更新時間:2026/06/30 12:32:40 -計算版本號的 Gitea Action。依現有 release 推算下一個穩定版或 beta 版本號,並將結果寫入 Action output `version`。核心邏輯以 Node.js 實作,置於 `app/`,由 `entrypoint.sh` 作為容器進入點啟動。 +計算版本號的 Gitea Action。依現有 release 推算下一個穩定版或 beta 版本號,並將結果寫入 Action output `version`。核心邏輯以 Node.js 實作,置於 `app/`,由 `entrypoint.sh` 作為容器進入點啟動;容器映像採多階段建置(build 階段 `node:latest`、runtime 階段 `node:slim`)。 版本進位規則:`patch + 1`;當 `patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`。beta 版本號形如 `-beta.`,其中 `` 為該版本既有 beta 標籤的最大序號加 1。 @@ -32,15 +32,15 @@ | 功能名稱 | 功能描述 | | --- | --- | -| [index.main](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/index.js#L23) | [Action 進入點:協調設定載入、release 取得、版本計算與輸出(支援相依注入)。](#indexmain) | +| [index.main](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/index.js#L27) | [Action 進入點:協調設定載入、release 取得、版本計算與輸出(支援相依注入)。](#indexmain) | ### logger(app/logger.js) | 功能名稱 | 功能描述 | | --- | --- | -| [logger.section](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L16) | [輸出帶標題的區塊段落至標準輸出,標題前後以分隔線包夾。](#loggersection) | -| [logger.info](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L26) | [輸出一般資訊層級的 log 訊息,自動加上 `[info]` 前綴。](#loggerinfo) | -| [logger.error](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L38) | [輸出錯誤訊息至 stderr(僅輸出,不終止行程)。](#loggererror) | +| [logger.section](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L45) | [輸出帶標題的區塊段落至標準輸出,標題前後以分隔線包夾。](#loggersection) | +| [logger.info](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L60) | [輸出 INF 層級 log 訊息,格式為 `[INF][時間]: 訊息`。](#loggerinfo) | +| [logger.error](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L77) | [輸出 ERR 層級訊息至 stderr(格式為 `[ERR][時間]: 訊息`,僅輸出不終止行程)。](#loggererror) | ### config(app/config.js) @@ -49,30 +49,30 @@ | [config.isUnset](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/config.js#L11) | [判斷環境變數值是否視為「未設定」。](#configisunset) | | [config.requireEnv](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/config.js#L23) | [驗證必填環境變數,未設定時拋出錯誤。](#configrequireenv) | | [config.normalizeBetaFlag](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/config.js#L38) | [將 beta 旗標正規化為布林值。](#confignormalizebetaflag) | -| [config.loadConfig](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/config.js#L68) | [從環境變數載入並驗證執行所需的設定。](#configloadconfig) | +| [config.loadConfig](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/config.js#L80) | [從環境變數載入並驗證執行所需的設定。](#configloadconfig) | ### version(app/version.js) | 功能名稱 | 功能描述 | | --- | --- | -| [version.compareVersionArrays](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L12) | [逐區段比較兩個版本號數值陣列,較短者視為較小。](#versioncompareversionarrays) | -| [version.parseStableVersions](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L36) | [從 release 清單解析出所有穩定版的版本號數值陣列。](#versionparsestableversions) | -| [version.latestStableVersion](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L55) | [取得 release 清單中最新的穩定版版本號字串。](#versionlateststableversion) | -| [version.nextReleaseVersion](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L74) | [依最新穩定版計算下一個發行版本號。](#versionnextreleaseversion) | -| [version.nextBetaNumber](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L97) | [計算指定版本號的下一個 beta 流水號。](#versionnextbetanumber) | -| [version.calculateVersion](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L122) | [計算最新穩定版與下一個版本號(支援 beta)。](#versioncalculateversion) | +| [version.compareVersionArrays](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L14) | [逐區段比較兩個版本號數值陣列,較短者視為較小。](#versioncompareversionarrays) | +| [version.parseStableVersions](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L38) | [從 release 清單解析出所有穩定版的版本號數值陣列。](#versionparsestableversions) | +| [version.latestStableVersion](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L57) | [取得 release 清單中最新的穩定版版本號字串。](#versionlateststableversion) | +| [version.nextReleaseVersion](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L78) | [依最新穩定版計算下一個發行版本號。](#versionnextreleaseversion) | +| [version.nextBetaNumber](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L101) | [計算指定版本號的下一個 beta 流水號。](#versionnextbetanumber) | +| [version.calculateVersion](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L126) | [計算最新穩定版與下一個版本號(支援 beta)。](#versioncalculateversion) | ### releases(app/releases.js) | 功能名稱 | 功能描述 | | --- | --- | -| [releases.fetchReleases](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/releases.js#L22) | [以分頁方式取得指定 Gitea repo 的所有 release。](#releasesfetchreleases) | +| [releases.fetchReleases](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/releases.js#L24) | [以分頁方式取得指定 Gitea repo 的所有 release。](#releasesfetchreleases) | ### output(app/output.js) | 功能名稱 | 功能描述 | | --- | --- | -| [output.writeOutput](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/output.js#L14) | [將一行 `name=value` 附加寫入 Action 的輸出檔。](#outputwriteoutput) | +| [output.writeOutput](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/output.js#L16) | [將一行 `name=value` 附加寫入 Action 的輸出檔。](#outputwriteoutput) | ## 使用範例 @@ -97,7 +97,7 @@ const version = await main({ ### logger.section -輸出帶標題的區塊段落至標準輸出:先一個換行,接著 50 個 `=` 的主分隔線、標題文字,最後 50 個 `-` 的次分隔線,用於在 log 中建立可視段落區隔。 +輸出帶標題的區塊段落至標準輸出:先一個換行,接著 50 個 `=` 的主分隔線、標題文字,最後 50 個 `-` 的次分隔線,用於在 log 中建立可視段落區隔。此為結構性段落標題,不套用 `[等級][時間]` 前綴。 ```js const logger = require('./logger'); @@ -113,25 +113,25 @@ logger.section('參數檢查'); ### logger.info -輸出一般資訊層級訊息至標準輸出,自動加上 `[info]` 前綴與換行;不會結束行程。 +輸出一般資訊(INF)層級訊息至標準輸出,格式統一為 `[INF][{時間}]: {訊息}`,時間使用台灣時區(Asia/Taipei)、格式 `yyyy/MM/dd HH:mm:ss`,並於結尾換行;不會結束行程。 ```js const logger = require('./logger'); logger.info('IS_BETA=false'); -// 輸出:[info] IS_BETA=false +// 輸出:[INF][2026/06/30 12:00:00]: IS_BETA=false ``` ### logger.error -輸出錯誤訊息至標準錯誤輸出(stderr),自動加上 `[error]` 前綴與換行。僅負責輸出,**不終止行程**;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。 +輸出錯誤(ERR)層級訊息至標準錯誤輸出(stderr),格式統一為 `[ERR][{時間}]: {訊息}`(時間為台灣時區)。僅負責輸出,**不終止行程**;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。 ```js const logger = require('./logger'); logger.error('GITEA_SERVER_URL 未設定'); -// 對 stderr 輸出:[error] GITEA_SERVER_URL 未設定 +// 對 stderr 輸出:[ERR][2026/06/30 12:00:00]: GITEA_SERVER_URL 未設定 ``` @@ -191,7 +191,7 @@ const config = loadConfig({ ### version.compareVersionArrays -逐區段(element-wise)比較兩個版本號數值陣列,較短的陣列視為較小。回傳值 `> 0` 表示 a 大於 b、`< 0` 表示 a 小於 b、`0` 表示相等。 +逐區段(element-wise)比較兩個版本號數值陣列,較短的陣列視為較小。回傳值 `> 0` 表示 a 大於 b、`< 0` 表示 a 小於 b、`0` 表示相等;相異區段回傳的是該段差值(非固定 ±1)。 ```js const { compareVersionArrays } = require('./version'); @@ -203,7 +203,7 @@ compareVersionArrays([1, 2], [1, 2, 0]); // < 0(較短者較小) ### version.parseStableVersions -從 release 清單解析出所有穩定版(排除 beta、排除格式不合法者)的版本號數值陣列;會去除 tag 開頭的 `v`。 +從 release 清單解析出所有穩定版(排除 beta、排除格式不合法者)的版本號數值陣列;會去除 tag 開頭的 `v`。傳入非陣列時回傳空陣列。 ```js const { parseStableVersions } = require('./version'); @@ -231,7 +231,7 @@ latestStableVersion([]); // "0.0.0" ### version.nextReleaseVersion -依最新穩定版字串計算下一個發行版本號:`patch + 1`,`patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`。 +依最新穩定版字串計算下一個發行版本號:`patch + 1`,`patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`(「逢 10 進位」為本專案自訂約定,非標準 SemVer)。 ```js const { nextReleaseVersion } = require('./version'); @@ -271,7 +271,7 @@ calculateVersion([{ tag_name: 'v1.2.3' }], true); ### releases.fetchReleases -以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。使用全域 `fetch` 逐頁請求(每頁 limit 為 10);當某頁回傳空資料、`null` 或筆數少於上限時即停止。提供 `options.token` 時以授權方式請求。網路失敗、回應讀取失敗、非 2xx、無法解析或非陣列回應時拋出 `Error`(含頁碼)。 +以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。使用全域 `fetch`(Node.js 18+)逐頁請求(每頁 limit 為 10);當某頁回傳空資料、`null` 或筆數少於上限時即停止。提供 `options.token` 時以授權方式請求。網路失敗、回應讀取失敗、非 2xx、無法解析或非陣列回應時拋出 `Error`(含頁碼)。 ```js const { fetchReleases } = require('./releases'); @@ -287,7 +287,7 @@ const releases = await fetchReleases( ### output.writeOutput -將一行 `name=value` 附加寫入 GitHub/Gitea Action 的輸出檔(預設取 `process.env.GITHUB_OUTPUT`)。輸出檔路徑為 falsy 時拋出 `Error`。 +將一行 `name=value` 附加寫入 GitHub/Gitea Action 的輸出檔(預設取 `process.env.GITHUB_OUTPUT`)。以 append 方式寫入、不覆蓋既有內容;輸出檔路徑為 falsy 時拋出 `Error`。 ```js const { writeOutput } = require('./output'); diff --git a/app/index.js b/app/index.js index 688a404..9cf09b7 100644 --- a/app/index.js +++ b/app/index.js @@ -19,6 +19,10 @@ const { writeOutput } = require('./output'); * @param {Function} [deps.writeOutput] - 寫出 output 的函式。 * @param {{section:Function, info:Function, error:Function}} [deps.log] - log 記錄器。 * @returns {Promise} 計算出的版本號。 + * @throws {Error} 當任一注入相依(loadConfig/fetchReleases/calculateVersion/writeOutput)拋出例外時, + * 原樣向外傳播,由呼叫端決定如何結束。 + * @remarks 由模組底部的 require.main 守衛在被直接執行時呼叫;失敗時頂層以模組層級 logger.error 輸出 + * 並 exit(1)。單元測試可透過 deps 注入替身以覆蓋成功與各失敗路徑。 */ async function main(deps = {}) { const { diff --git a/app/output.js b/app/output.js index f167cf0..44a2f9c 100644 --- a/app/output.js +++ b/app/output.js @@ -10,6 +10,8 @@ const fs = require('node:fs'); * @param {string} [file=process.env.GITHUB_OUTPUT] - 輸出檔路徑,預設取自環境變數 `GITHUB_OUTPUT`。 * @throws {Error} 當輸出檔路徑為 falsy(例如 `GITHUB_OUTPUT` 未設定)時拋出,無法寫入輸出。 * @returns {void} + * @remarks 由 main 於計算出版本號後呼叫,將 `version` 寫入 Action output 供後續 step 取用; + * 以 append 方式寫入、不覆蓋既有內容,且 value 僅支援單行字串(未處理換行或 `=`)。 */ function writeOutput(name, value, file = process.env.GITHUB_OUTPUT) { if (!file) { diff --git a/app/version.js b/app/version.js index f5562b4..49c973a 100644 --- a/app/version.js +++ b/app/version.js @@ -8,6 +8,8 @@ const SEGMENT_LIMIT = 10; * @param {number[]} a - 第一個版本號區段陣列,例如 [1, 2, 3]。 * @param {number[]} b - 第二個版本號區段陣列,例如 [1, 2, 0]。 * @returns {number} 大於 0 表示 a 大於 b;小於 0 表示 a 小於 b;0 表示兩者相等。 + * 相異區段回傳的是該段差值(非固定 ±1)。 + * @throws {TypeError} 當 a 或 b 非陣列(無 length 屬性)時拋出。 */ function compareVersionArrays(a, b) { const length = Math.max(a.length, b.length); @@ -70,6 +72,8 @@ function latestStableVersion(releases) { * 依最新穩定版字串計算下一個發行版本號:patch 加 1,patch 達 10 進位至 minor,minor 達 10 進位至 major。 * @param {string} latest - 最新穩定版版本號字串,例如 "1.2.9"。 * @returns {string} 下一個發行版本號字串,格式為 "major.minor.patch",例如 "1.3.0"。 + * @remarks 「逢 10 進位」為本專案自訂約定(非標準 SemVer);輸入會先 String() 轉字串, + * 缺段或非數字段一律補 0(如 null/undefined → "0.0.1"),major 無進位上限。 */ function nextReleaseVersion(latest) { const parts = String(latest).split('.').map((part) => Number(part) || 0); -- 2.53.0 From 04e1ada80a0beb302ffa4dae9c188579f5035313 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:42:11 +0800 Subject: [PATCH 6/7] =?UTF-8?q?test(calculate-version):=20=E6=95=B4?= =?UTF-8?q?=E4=BD=B5=E9=87=8D=E8=A4=87=E6=B8=AC=E8=A9=A6=E8=87=B3=20app/te?= =?UTF-8?q?st=20=E4=B8=A6=E6=96=B0=E5=A2=9E=20logger=20=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/test/config.test.js | 46 ++++++++++++-- {test => app/test}/index.test.js | 2 +- app/test/logger.test.js | 53 ++++++++++++++++ app/test/releases.test.js | 8 +++ test/config.test.js | 75 ---------------------- test/releases.test.js | 105 ------------------------------- test/version.test.js | 85 ------------------------- 7 files changed, 104 insertions(+), 270 deletions(-) rename {test => app/test}/index.test.js (97%) create mode 100644 app/test/logger.test.js delete mode 100644 test/config.test.js delete mode 100644 test/releases.test.js delete mode 100644 test/version.test.js diff --git a/app/test/config.test.js b/app/test/config.test.js index 2e4f222..2fb1adb 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -36,6 +36,7 @@ test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => { assert.equal(normalizeBetaFlag('yes'), false); assert.equal(normalizeBetaFlag(undefined), false); assert.equal(normalizeBetaFlag('null'), false); + assert.equal(normalizeBetaFlag('TRUE'), false); }); test('loadConfig 從環境變數組出設定', () => { @@ -53,16 +54,53 @@ test('loadConfig 從環境變數組出設定', () => { }); }); -test('loadConfig 將未提供的 token 正規化為 null,beta 預設 false', () => { - const config = loadConfig({ +test('loadConfig 將未提供或字面 "null" 的 token 正規化為 null,beta 預設 false', () => { + const omitted = loadConfig({ GITEA_SERVER_URL: 'https://gitea.example.com', GITEA_REPOSITORY: 'owner/repo', }); - assert.equal(config.token, null); - assert.equal(config.isBeta, false); + assert.equal(omitted.token, null); + assert.equal(omitted.isBeta, false); + + const literalNull = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + GITEA_TOKEN: 'null', + }); + assert.equal(literalNull.token, null); }); test('loadConfig 在缺少必填環境變數時丟出錯誤', () => { assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); assert.throws(() => loadConfig({ GITEA_SERVER_URL: 'https://x' }), /GITEA_REPOSITORY 未設定/); }); + +test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'not-a-url', + GITEA_REPOSITORY: 'owner/repo', + }), /GITEA_SERVER_URL 格式錯誤/); +}); + +test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => { + assert.throws(() => loadConfig({ + GITEA_SERVER_URL: 'ftp://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + }), /必須使用 http 或 https/); +}); + +test('loadConfig 於 GITEA_REPOSITORY 非 owner/repo 形式或含路徑穿越時拋錯', () => { + const base = { GITEA_SERVER_URL: 'https://gitea.example.com' }; + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'no-slash' }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'owner/repo/extra' }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: '../owner/repo' }), /GITEA_REPOSITORY 格式錯誤/); + assert.throws(() => loadConfig({ ...base, GITEA_REPOSITORY: 'owner/..' }), /GITEA_REPOSITORY 格式錯誤/); +}); + +test('loadConfig 接受含點號的合法 owner/repo', () => { + const config = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'my.org/my.repo', + }); + assert.equal(config.repository, 'my.org/my.repo'); +}); diff --git a/test/index.test.js b/app/test/index.test.js similarity index 97% rename from test/index.test.js rename to app/test/index.test.js index 4309ba7..c9210b1 100644 --- a/test/index.test.js +++ b/app/test/index.test.js @@ -3,7 +3,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { main } = require('../app/index'); +const { main } = require('../index'); // 安靜的 log 記錄器,避免測試輸出雜訊 const silentLog = { section() {}, info() {}, error() {} }; diff --git a/app/test/logger.test.js b/app/test/logger.test.js new file mode 100644 index 0000000..b466e12 --- /dev/null +++ b/app/test/logger.test.js @@ -0,0 +1,53 @@ +'use strict'; + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const logger = require('../logger'); + +const realStdout = process.stdout.write; +const realStderr = process.stderr.write; + +afterEach(() => { + process.stdout.write = realStdout; + process.stderr.write = realStderr; +}); + +// 同時攔截 stdout 與 stderr,回傳本次 run() 內各自寫出的字串 +function capture(run) { + const out = []; + const err = []; + process.stdout.write = (chunk) => { out.push(String(chunk)); return true; }; + process.stderr.write = (chunk) => { err.push(String(chunk)); return true; }; + try { + run(); + } finally { + process.stdout.write = realStdout; + process.stderr.write = realStderr; + } + return { out: out.join(''), err: err.join('') }; +} + +const TIME = /\[\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}\]/; + +test('section 於 stdout 輸出標題與 50 寬分隔線區塊', () => { + const { out, err } = capture(() => logger.section('參數檢查')); + assert.equal(out, `\n${'='.repeat(50)}\n參數檢查\n${'-'.repeat(50)}\n`); + assert.equal(err, ''); +}); + +test('info 以 [INF][時間]: 格式輸出至 stdout', () => { + const { out, err } = capture(() => logger.info('IS_BETA=false')); + assert.match(out, new RegExp(`^\\[INF\\]${TIME.source}: IS_BETA=false\\n$`)); + assert.equal(err, ''); +}); + +test('error 以 [ERR][時間]: 格式輸出至 stderr,且不寫入 stdout', () => { + const { out, err } = capture(() => logger.error('GITEA_SERVER_URL 未設定')); + assert.match(err, new RegExp(`^\\[ERR\\]${TIME.source}: GITEA_SERVER_URL 未設定\\n$`)); + assert.equal(out, ''); +}); + +test('info/error 的時間戳為 yyyy/MM/dd HH:mm:ss 格式', () => { + const { out } = capture(() => logger.info('x')); + assert.match(out, TIME); +}); diff --git a/app/test/releases.test.js b/app/test/releases.test.js index 1fe4c7f..25a1099 100644 --- a/app/test/releases.test.js +++ b/app/test/releases.test.js @@ -68,3 +68,11 @@ test('fetchReleases 在 null 回應時視為無更多資料', async () => { const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); assert.deepEqual(releases, []); }); + +test('fetchReleases 在回傳無法解析的 JSON 時丟出例外', async () => { + globalThis.fetch = async () => ({ ok: true, text: async () => '{ this is not valid json' }); + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), + /回傳資料無法解析/, + ); +}); diff --git a/test/config.test.js b/test/config.test.js deleted file mode 100644 index 03a989d..0000000 --- a/test/config.test.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { - isUnset, - requireEnv, - normalizeBetaFlag, - loadConfig, -} = require('../app/config'); - -test('isUnset 將 undefined/空字串/null 字面值視為未設定', () => { - assert.equal(isUnset(undefined), true); - assert.equal(isUnset(''), true); - assert.equal(isUnset('null'), true); - assert.equal(isUnset('value'), false); -}); - -test('requireEnv 未設定時拋錯,有值時回傳原值', () => { - assert.throws(() => requireEnv('FOO', ''), /FOO 未設定/); - assert.equal(requireEnv('FOO', 'bar'), 'bar'); -}); - -test('normalizeBetaFlag 僅 "true" 視為啟用', () => { - assert.equal(normalizeBetaFlag('true'), true); - assert.equal(normalizeBetaFlag('false'), false); - assert.equal(normalizeBetaFlag(undefined), false); - assert.equal(normalizeBetaFlag('TRUE'), false); -}); - -test('loadConfig 解析完整設定', () => { - const config = loadConfig({ - GITEA_SERVER_URL: 'https://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - GITEA_TOKEN: 'secret', - IS_BETA: 'true', - }); - - assert.deepEqual(config, { - serverUrl: 'https://gitea.example.com', - repository: 'owner/repo', - token: 'secret', - isBeta: true, - }); -}); - -test('loadConfig 缺少 token 視為匿名(null)', () => { - const config = loadConfig({ - GITEA_SERVER_URL: 'https://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - GITEA_TOKEN: 'null', - }); - - assert.equal(config.token, null); - assert.equal(config.isBeta, false); -}); - -test('loadConfig 缺少必填項目時拋錯', () => { - assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); -}); - -test('loadConfig 於 GITEA_SERVER_URL 非合法 URL 時拋錯', () => { - assert.throws(() => loadConfig({ - GITEA_SERVER_URL: 'not-a-url', - GITEA_REPOSITORY: 'owner/repo', - }), /GITEA_SERVER_URL 格式錯誤/); -}); - -test('loadConfig 於 GITEA_SERVER_URL 使用非 http(s) 協定時拋錯', () => { - assert.throws(() => loadConfig({ - GITEA_SERVER_URL: 'ftp://gitea.example.com', - GITEA_REPOSITORY: 'owner/repo', - }), /必須使用 http 或 https/); -}); diff --git a/test/releases.test.js b/test/releases.test.js deleted file mode 100644 index c305bf9..0000000 --- a/test/releases.test.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { fetchReleases } = require('../app/releases'); - -// 以可控的假回應替換全域 fetch,並於結束後還原 -function withFetch(handler, run) { - const original = globalThis.fetch; - globalThis.fetch = handler; - return Promise.resolve() - .then(run) - .finally(() => { - globalThis.fetch = original; - }); -} - -// 建立模擬 Response 物件 -const jsonResponse = (body, ok = true) => ({ - ok, - text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), -}); - -test('fetchReleases 單頁(不足一頁即停止)', async () => { - await withFetch( - async () => jsonResponse([{ tag_name: 'v1.0.0' }]), - async () => { - const releases = await fetchReleases('https://gitea.example.com/api'); - assert.deepEqual(releases, [{ tag_name: 'v1.0.0' }]); - }, - ); -}); - -test('fetchReleases 跨頁合併直到不足一頁', async () => { - const pages = { - 1: Array.from({ length: 10 }, (_, i) => ({ tag_name: `v1.0.${i}` })), - 2: [{ tag_name: 'v1.1.0' }], - }; - const seen = []; - - await withFetch( - async (url) => { - const page = Number(new URL(url).searchParams.get('page')); - seen.push(page); - return jsonResponse(pages[page]); - }, - async () => { - const releases = await fetchReleases('https://gitea.example.com/api'); - assert.equal(releases.length, 11); - assert.deepEqual(seen, [1, 2]); - }, - ); -}); - -test('fetchReleases 對非 2xx 回應拋錯', async () => { - await withFetch( - async () => jsonResponse('Not Found', false), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /release API 請求失敗/, - ); - }, - ); -}); - -test('fetchReleases 對無法解析的 JSON 回應拋錯', async () => { - await withFetch( - async () => jsonResponse('{ this is not valid json', true), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /回傳資料無法解析/, - ); - }, - ); -}); - -test('fetchReleases 對非陣列回應拋錯', async () => { - await withFetch( - async () => jsonResponse({ message: 'oops' }), - async () => { - await assert.rejects( - () => fetchReleases('https://gitea.example.com/api'), - /回傳非陣列資料/, - ); - }, - ); -}); - -test('fetchReleases 有 token 時帶入授權標頭', async () => { - let captured; - - await withFetch( - async (url, options) => { - captured = options; - return jsonResponse([]); - }, - async () => { - await fetchReleases('https://gitea.example.com/api', { token: 'secret' }); - assert.equal(captured.headers.Authorization, 'token secret'); - }, - ); -}); diff --git a/test/version.test.js b/test/version.test.js deleted file mode 100644 index bfdccba..0000000 --- a/test/version.test.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); - -const { - compareVersionArrays, - parseStableVersions, - latestStableVersion, - nextReleaseVersion, - nextBetaNumber, - calculateVersion, -} = require('../app/version'); - -// 便於建構測試資料的小工具 -const rel = (tag) => ({ tag_name: tag }); - -test('compareVersionArrays 逐區段比較且較短者較小', () => { - assert.ok(compareVersionArrays([1, 10], [1, 2, 3]) > 0); - assert.ok(compareVersionArrays([1, 2], [1, 2, 0]) < 0); - assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0); -}); - -test('parseStableVersions 排除 beta 與非法 tag 並去除 v 前綴', () => { - const releases = [ - rel('v1.2.3'), - rel('2.0.0'), - rel('v1.0.0-beta.1'), - rel('latest'), - { name: '沒有 tag_name' }, - ]; - - assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]); -}); - -test('latestStableVersion 無資料時回傳 0.0.0', () => { - assert.equal(latestStableVersion([]), '0.0.0'); - assert.equal(latestStableVersion(null), '0.0.0'); -}); - -test('latestStableVersion 取出最大穩定版', () => { - const releases = [rel('v1.2.3'), rel('v1.9.9'), rel('v1.2.9'), rel('v0.5.0-beta.2')]; - assert.equal(latestStableVersion(releases), '1.9.9'); -}); - -test('nextReleaseVersion patch 進位', () => { - assert.equal(nextReleaseVersion('0.0.0'), '0.0.1'); - assert.equal(nextReleaseVersion('1.2.3'), '1.2.4'); -}); - -test('nextReleaseVersion patch 達 10 進位至 minor', () => { - assert.equal(nextReleaseVersion('1.2.9'), '1.3.0'); -}); - -test('nextReleaseVersion minor 達 10 進位至 major', () => { - assert.equal(nextReleaseVersion('1.9.9'), '2.0.0'); -}); - -test('nextBetaNumber 無對應 beta 時回傳 1', () => { - assert.equal(nextBetaNumber([], '0.0.1'), 1); - assert.equal(nextBetaNumber([rel('v0.0.1')], '0.0.1'), 1); -}); - -test('nextBetaNumber 取最大流水號加一', () => { - const releases = [rel('v1.3.0-beta.1'), rel('v1.3.0-beta.3'), rel('v1.3.0-beta.2')]; - assert.equal(nextBetaNumber(releases, '1.3.0'), 4); -}); - -test('calculateVersion 無 release 的穩定版', () => { - assert.deepEqual(calculateVersion([], false), { latest: '0.0.0', version: '0.0.1' }); -}); - -test('calculateVersion 無 release 的 beta 版', () => { - assert.deepEqual(calculateVersion([], true), { latest: '0.0.0', version: '0.0.1-beta.1' }); -}); - -test('calculateVersion 既有穩定版推下一個穩定版', () => { - const releases = [rel('v1.2.3'), rel('v1.2.2')]; - assert.deepEqual(calculateVersion(releases, false), { latest: '1.2.3', version: '1.2.4' }); -}); - -test('calculateVersion 既有穩定版推下一個 beta 版', () => { - const releases = [rel('v1.2.3'), rel('v1.2.4-beta.1')]; - assert.deepEqual(calculateVersion(releases, true), { latest: '1.2.3', version: '1.2.4-beta.2' }); -}); -- 2.53.0 From 1ba092bbf44d4197d53bcd9397c90a9653daddb3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 30 Jun 2026 12:42:11 +0800 Subject: [PATCH 7/7] =?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=A7=A3=E6=B1=BA=E7=9A=84=20findin?= =?UTF-8?q?gs.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/findings.json | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 93d2ebe..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,26 +1 @@ -[ - { - "level": "warning", - "role": "Assassin", - "location": "app/config.js:71", - "problem": "對 GITEA_REPOSITORY 環境變數缺乏輸入驗證。由於此值會直接拼接於 API URL 中(見 app/index.js:37),若攻擊者傳入特殊字元或路徑穿越字元(如 `../`),可能導致 API 請求路徑異常,甚至造成非預期的 API 端點存取。", - "suggestion": "增加格式驗證機制,使用嚴格的正則表達式限制 GITEA_REPOSITORY 格式(例如確保只包含合法的 repo 名稱字元:`^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$`),拒絕任何不符合規範的輸入。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "app/releases.js:77", - "problem": "JSON.parse 失敗時,僅拋出通用錯誤訊息,未來除錯時無法得知具體回傳內容,將導致除錯時浪費大量時間追查。", - "suggestion": "建議將錯誤訊息擴充,納入部分的 response body 內容,以利於快速定位回傳格式異常的確切原因。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/logger.js:1", - "problem": "整個 logger.js 模組完全沒有測試,無法確保 section、info 與 error 函式是否正確將訊息格式化並寫入標準輸出與標準錯誤。", - "suggestion": "為 app/logger.js 新增測試,模擬 process.stdout 與 process.stderr,驗證輸出的字串格式是否符合預期(例如分隔線寬度、前綴是否正確)。", - "is_new": true - } -] +[] -- 2.53.0