From bd6b6017abd9ae447417cd970a4dde840a80e2e9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:41:34 +0800 Subject: [PATCH 01/12] =?UTF-8?q?refactor(calculate-version):=20=E5=B0=87?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=A8=88=E7=AE=97=E9=82=8F=E8=BC=AF=E7=94=B1?= =?UTF-8?q?=20bash=20=E6=94=B9=E5=AF=AB=E7=82=BA=20Node.js=20=E4=B8=A6?= =?UTF-8?q?=E4=BE=9D=E5=8A=9F=E8=83=BD=E5=88=86=E6=AA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 entrypoint.sh 作為容器進入點,改為啟動 app/ 下的 Node.js 程式; 依功能拆分為 logger/config/version/releases/output/index,並更新 Dockerfile 改用 node:lts-alpine。 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 20 ++- app/config.js | 64 +++++++++ app/index.js | 60 +++++++++ app/logger.js | 45 +++++++ app/output.js | 22 +++ app/package.json | 13 ++ app/releases.js | 87 ++++++++++++ app/test/config.test.js | 68 ++++++++++ app/test/output.test.js | 35 +++++ app/test/releases.test.js | 70 ++++++++++ app/test/version.test.js | 101 ++++++++++++++ app/version.js | 140 +++++++++++++++++++ entrypoint.sh | 273 ++------------------------------------ 13 files changed, 734 insertions(+), 264 deletions(-) create mode 100644 app/config.js create mode 100644 app/index.js create mode 100644 app/logger.js create mode 100644 app/output.js create mode 100644 app/package.json create mode 100644 app/releases.js create mode 100644 app/test/config.test.js create mode 100644 app/test/output.test.js create mode 100644 app/test/releases.test.js create mode 100644 app/test/version.test.js create mode 100644 app/version.js diff --git a/Dockerfile b/Dockerfile index 0a80edf..28a2da8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,24 @@ -FROM alpine:latest +# ============================================================================= +# 用途: 建置 calculate-version Action 的容器映像。 +# 以 Node.js 執行環境打包 /app 下的 Node.js 程式 (專案已從 bash 改寫為 Node.js), +# 並安裝 bash 以執行 entrypoint.sh,再由其啟動 Node.js 程式。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= -# 安裝必要的工具 -RUN apk add --no-cache --no-check-certificate bash curl jq +# 基底映像:Node.js LTS 的 Alpine 版本 (體積小)。 +FROM node:lts-alpine +# entrypoint.sh 以 bash 撰寫,Alpine 預設無 bash 需另行安裝 +# --no-cache 不保留 apk 索引快取以縮小映像;--no-check-certificate 略過憑證檢查 (需人工確認:略過憑證驗證有安全風險)。 +RUN apk add --no-cache --no-check-certificate bash + +# 複製容器進入點腳本至根目錄。 COPY entrypoint.sh /entrypoint.sh +# 複製整個 app 目錄 (Node.js 程式) 至 /app。 +COPY app/ /app/ +# 賦予進入點腳本可執行權限,否則 ENTRYPOINT 無法執行。 RUN chmod +x /entrypoint.sh +# 設定容器啟動時執行的進入點 (內部會 exec node /app/index.js "$@")。 ENTRYPOINT ["/entrypoint.sh"] diff --git a/app/config.js b/app/config.js new file mode 100644 index 0000000..e482444 --- /dev/null +++ b/app/config.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * 判斷環境變數值是否視為「未設定」。 + * + * 下列任一情況皆視為未設定:undefined、null、空字串、字面字串 "null"。 + * + * @param {*} value - 欲檢查的值(通常為環境變數字串)。 + * @returns {boolean} 視為未設定時回傳 true,否則回傳 false。 + */ +function isUnset(value) { + return value === undefined || value === null || value === '' || value === 'null'; +} + +/** + * 驗證必填環境變數;未設定時拋出錯誤。 + * + * @param {string} name - 環境變數名稱,用於組出錯誤訊息。 + * @param {*} value - 環境變數的值。 + * @returns {*} 驗證通過後原樣回傳的 value。 + * @throws {Error} 當 value 被視為未設定(undefined/null/空字串/"null")時拋出,訊息為 `${name} 未設定`。 + */ +function requireEnv(name, value) { + if (isUnset(value)) { + throw new Error(`${name} 未設定`); + } + return value; +} + +/** + * 將 beta 旗標正規化為布林值。 + * + * 未設定時預設為 false;僅當值嚴格等於字面字串 "true" 時回傳 true。 + * + * @param {*} value - beta 旗標環境變數的值。 + * @returns {boolean} 啟用 beta 時回傳 true,否則回傳 false。 + */ +function normalizeBetaFlag(value) { + if (isUnset(value)) { + return false; + } + return value === 'true'; +} + +/** + * 從環境變數載入並驗證執行所需的設定。 + * + * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤; + * 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 未設定時拋出。 + */ +function loadConfig(env = process.env) { + const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL); + const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY); + const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; + const isBeta = normalizeBetaFlag(env.IS_BETA); + + return { serverUrl, repository, token, isBeta }; +} + +module.exports = { isUnset, requireEnv, normalizeBetaFlag, loadConfig }; diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..73a6bb2 --- /dev/null +++ b/app/index.js @@ -0,0 +1,60 @@ +'use strict'; + +const logger = require('./logger'); +const { loadConfig } = require('./config'); +const { fetchReleases } = require('./releases'); +const { calculateVersion } = require('./version'); +const { writeOutput } = require('./output'); + +/** + * Action 進入點:協調設定載入、release 取得、版本號計算與輸出寫出。 + * + * 流程:印出「參數檢查」區段並呼叫 loadConfig() 載入設定(失敗則以 logger.fail + * 回報並結束);印出設定值(token 以 *** 或「未提供」顯示);組出 release URL 並 + * 以 fetchReleases() 取得 release 清單(失敗則以 logger.fail 回報並結束);以 + * calculateVersion() 計算最新版與新版號,最後以 writeOutput('version', version) + * 寫出 version 輸出。 + * + * Side effects:透過 logger 輸出至 stdout/stderr、失敗時使 process 以失敗狀態 + * 結束、並寫入 GITHUB_OUTPUT。此函式於模組載入時即被呼叫。 + * + * @returns {Promise} + */ +async function main() { + logger.section('參數檢查'); + + let config; + try { + config = loadConfig(); + } catch (error) { + logger.fail(error.message); + return; + } + + logger.info(`GITEA_SERVER_URL=${config.serverUrl}`); + logger.info(`GITEA_REPOSITORY=${config.repository}`); + logger.info(config.token ? 'GITEA_TOKEN=***' : 'GITEA_TOKEN=未提供'); + logger.info(`IS_BETA=${config.isBeta}`); + + logger.section('取得版本資料'); + + const releaseUrl = `${config.serverUrl}/api/v1/repos/${config.repository}/releases`; + logger.info(`RELEASE_URL=${releaseUrl}`); + + let releases; + try { + releases = await fetchReleases(releaseUrl, { token: config.token, logger }); + } catch (error) { + logger.fail(error.message); + return; + } + + const { latest, version } = calculateVersion(releases, config.isBeta); + logger.info(`LATEST_VERSION=${latest}`); + + logger.section('計算版本號'); + logger.info(`NEW_VERSION=${version}`); + writeOutput('version', version); +} + +main(); diff --git a/app/logger.js b/app/logger.js new file mode 100644 index 0000000..40c0949 --- /dev/null +++ b/app/logger.js @@ -0,0 +1,45 @@ +'use strict'; + +// 主分隔線與次分隔線寬度(沿用原 entrypoint.sh 的視覺樣式) +const LINE = '='.repeat(50); +const SUBLINE = '-'.repeat(50); + +/** + * 輸出帶標題的區塊段落至標準輸出,標題前後以分隔線包夾, + * 用於在 log 中建立可視的段落區隔。 + * + * 輸出格式為:換行 + 主分隔線(50 個 `=`)+ 標題 + 次分隔線(50 個 `-`)。 + * + * @param {string} title - 區塊標題文字;會原樣輸出於兩條分隔線之間。 + * @returns {void} + */ +function section(title) { + process.stdout.write(`\n${LINE}\n${title}\n${SUBLINE}\n`); +} + +/** + * 輸出一般資訊層級的 log 訊息至標準輸出,自動加上 `[info]` 前綴與換行。 + * + * @param {string} message - 要輸出的資訊內容。 + * @returns {void} + */ +function info(message) { + process.stdout.write(`[info] ${message}\n`); +} + +/** + * 輸出錯誤層級的 log 訊息至標準錯誤輸出(stderr),並以狀態碼 1 + * 立即終止整個行程。用於發生無法復原的錯誤時中止執行。 + * + * 注意:此函式呼叫 `process.exit(1)`,正常情況下不會回傳給呼叫端, + * 其後的程式碼不會被執行。 + * + * @param {string} message - 要輸出的錯誤描述內容。 + * @returns {never} 不會正常回傳(行程會被終止)。 + */ +function fail(message) { + process.stderr.write(`[error] ${message}\n`); + process.exit(1); +} + +module.exports = { section, info, fail }; diff --git a/app/output.js b/app/output.js new file mode 100644 index 0000000..f167cf0 --- /dev/null +++ b/app/output.js @@ -0,0 +1,22 @@ +'use strict'; + +const fs = require('node:fs'); + +/** + * 將一行 `name=value` 附加寫入 GitHub/Gitea Action 的輸出檔。 + * + * @param {string} name - 輸出變數名稱(output 的 key)。 + * @param {string} value - 輸出變數的值(output 的 value)。 + * @param {string} [file=process.env.GITHUB_OUTPUT] - 輸出檔路徑,預設取自環境變數 `GITHUB_OUTPUT`。 + * @throws {Error} 當輸出檔路徑為 falsy(例如 `GITHUB_OUTPUT` 未設定)時拋出,無法寫入輸出。 + * @returns {void} + */ +function writeOutput(name, value, file = process.env.GITHUB_OUTPUT) { + if (!file) { + throw new Error('GITHUB_OUTPUT 未設定,無法寫入輸出'); + } + + fs.appendFileSync(file, `${name}=${value}\n`); +} + +module.exports = { writeOutput }; diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..2cd9601 --- /dev/null +++ b/app/package.json @@ -0,0 +1,13 @@ +{ + "name": "calculate-version", + "version": "1.0.0", + "description": "計算版本號的 Gitea Action(依現有 release 推算下一個穩定版或 beta 版本號)", + "author": "Jeffery", + "private": true, + "type": "commonjs", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "node --test" + } +} diff --git a/app/releases.js b/app/releases.js new file mode 100644 index 0000000..616679c --- /dev/null +++ b/app/releases.js @@ -0,0 +1,87 @@ +'use strict'; + +// 每頁取得的 release 筆數 +const RELEASES_PER_PAGE = 10; + +/** + * 以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。 + * + * 使用全域 fetch 逐頁請求,每頁 limit 為 10;當某頁回傳空資料、null + * 或筆數少於每頁上限時即停止取得。 + * + * @param {string} baseUrl - release API 的基底 URL(不含 query string)。 + * @param {object} [options={}] - 選用設定。 + * @param {string} [options.token] - 授權 token;有值時以 `Authorization: token ` + * 進行授權請求,否則以匿名方式請求。 + * @param {{ info: (message: string) => void }} [options.logger] - 選用的記錄器, + * 需提供 info() 方法以輸出進度訊息。 + * @returns {Promise} 解析為所有 release 物件合併後的陣列。 + * @throws {Error} 當網路請求失敗、response 非 2xx、回傳資料無法解析, + * 或回傳非陣列資料時拋出。 + */ +async function fetchReleases(baseUrl, options = {}) { + const { token, logger } = options; + const headers = {}; + + if (token) { + logger?.info('使用授權 token 取得 release'); + headers.Authorization = `token ${token}`; + } else { + logger?.info('使用匿名請求取得 release'); + } + + let page = 1; + const combined = []; + + while (true) { + const url = `${baseUrl}?limit=${RELEASES_PER_PAGE}&page=${page}`; + + let response; + try { + response = await fetch(url, { headers }); + } catch (error) { + throw new Error(`release API 請求失敗 (page=${page}): ${error.message}`); + } + + if (!response.ok) { + throw new Error(`release API 請求失敗 (page=${page})`); + } + + const text = await response.text(); + + // 空字串或 null 代表已無更多資料 + if (!text || text === 'null') { + break; + } + + let pageJson; + try { + pageJson = JSON.parse(text); + } catch { + throw new Error(`release API 回傳資料無法解析 (page=${page})`); + } + + if (pageJson === null) { + break; + } + + if (!Array.isArray(pageJson)) { + throw new Error(`release API 回傳非陣列資料 (page=${page})`); + } + + const count = pageJson.length; + logger?.info(`第 ${page} 頁取得 ${count} 筆 release`); + combined.push(...pageJson); + + // 不足一頁代表已取完 + if (count < RELEASES_PER_PAGE) { + break; + } + + page += 1; + } + + return combined; +} + +module.exports = { RELEASES_PER_PAGE, fetchReleases }; diff --git a/app/test/config.test.js b/app/test/config.test.js new file mode 100644 index 0000000..2e4f222 --- /dev/null +++ b/app/test/config.test.js @@ -0,0 +1,68 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { + isUnset, + requireEnv, + normalizeBetaFlag, + loadConfig, +} = require('../config'); + +test('isUnset 對未設定值回傳 true', () => { + assert.equal(isUnset(undefined), true); + assert.equal(isUnset(null), true); + assert.equal(isUnset(''), true); + assert.equal(isUnset('null'), true); +}); + +test('isUnset 對有效值回傳 false', () => { + assert.equal(isUnset('value'), false); + assert.equal(isUnset('false'), false); +}); + +test('requireEnv 對未設定值丟出錯誤', () => { + assert.throws(() => requireEnv('NAME', ''), /NAME 未設定/); + assert.throws(() => requireEnv('NAME', 'null'), /NAME 未設定/); +}); + +test('requireEnv 對有效值回傳原值', () => { + assert.equal(requireEnv('NAME', 'value'), 'value'); +}); + +test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => { + assert.equal(normalizeBetaFlag('true'), true); + assert.equal(normalizeBetaFlag('false'), false); + assert.equal(normalizeBetaFlag('yes'), false); + assert.equal(normalizeBetaFlag(undefined), false); + assert.equal(normalizeBetaFlag('null'), 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,beta 預設 false', () => { + const config = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + }); + assert.equal(config.token, null); + assert.equal(config.isBeta, false); +}); + +test('loadConfig 在缺少必填環境變數時丟出錯誤', () => { + assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/); + assert.throws(() => loadConfig({ GITEA_SERVER_URL: 'https://x' }), /GITEA_REPOSITORY 未設定/); +}); diff --git a/app/test/output.test.js b/app/test/output.test.js new file mode 100644 index 0000000..80c195a --- /dev/null +++ b/app/test/output.test.js @@ -0,0 +1,35 @@ +'use strict'; + +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { writeOutput } = require('../output'); + +let workspace; + +beforeEach(() => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'calc-version-')); +}); + +afterEach(() => { + fs.rmSync(workspace, { recursive: true, force: true }); +}); + +test('writeOutput 將 name=value 附加寫入輸出檔', () => { + const file = path.join(workspace, 'output'); + writeOutput('version', '1.2.3', file); + assert.equal(fs.readFileSync(file, 'utf8'), 'version=1.2.3\n'); +}); + +test('writeOutput 以附加方式保留既有內容', () => { + const file = path.join(workspace, 'output'); + fs.writeFileSync(file, 'existing=1\n'); + writeOutput('version', '1.2.3', file); + assert.equal(fs.readFileSync(file, 'utf8'), 'existing=1\nversion=1.2.3\n'); +}); + +test('writeOutput 在未提供輸出檔時丟出錯誤', () => { + assert.throws(() => writeOutput('version', '1.2.3', undefined), /GITHUB_OUTPUT 未設定/); +}); diff --git a/app/test/releases.test.js b/app/test/releases.test.js new file mode 100644 index 0000000..1fe4c7f --- /dev/null +++ b/app/test/releases.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { RELEASES_PER_PAGE, fetchReleases } = require('../releases'); + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function mockResponse(items) { + return { ok: true, text: async () => JSON.stringify(items) }; +} + +test('fetchReleases 逐頁讀取直到不足一頁', async () => { + const fullPage = Array.from({ length: RELEASES_PER_PAGE }, (_, i) => ({ id: i })); + const lastPage = [{ id: 100 }, { id: 101 }]; + const requested = []; + globalThis.fetch = async (url) => { + const page = Number(new URL(url).searchParams.get('page')); + requested.push(page); + return mockResponse(page === 1 ? fullPage : lastPage); + }; + + const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); + + assert.equal(releases.length, RELEASES_PER_PAGE + lastPage.length); + assert.deepEqual(requested, [1, 2]); +}); + +test('fetchReleases 在空陣列頁面停止', async () => { + globalThis.fetch = async () => mockResponse([]); + const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); + assert.deepEqual(releases, []); +}); + +test('fetchReleases 帶上授權標頭', async () => { + let seenHeaders; + globalThis.fetch = async (_url, opts) => { + seenHeaders = opts.headers; + return mockResponse([]); + }; + + await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases', { token: 'secret' }); + assert.equal(seenHeaders.Authorization, 'token secret'); +}); + +test('fetchReleases 在 HTTP 錯誤時丟出例外', async () => { + globalThis.fetch = async () => ({ ok: false, status: 500 }); + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), + /release API 請求失敗/, + ); +}); + +test('fetchReleases 在回傳非陣列時丟出例外', async () => { + globalThis.fetch = async () => mockResponse({ message: 'not an array' }); + await assert.rejects( + () => fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'), + /回傳非陣列資料/, + ); +}); + +test('fetchReleases 在 null 回應時視為無更多資料', async () => { + globalThis.fetch = async () => ({ ok: true, text: async () => 'null' }); + const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases'); + assert.deepEqual(releases, []); +}); diff --git a/app/test/version.test.js b/app/test/version.test.js new file mode 100644 index 0000000..5d3a636 --- /dev/null +++ b/app/test/version.test.js @@ -0,0 +1,101 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { + compareVersionArrays, + parseStableVersions, + latestStableVersion, + nextReleaseVersion, + nextBetaNumber, + calculateVersion, +} = require('../version'); + +test('compareVersionArrays 逐區段數值比較,較短者視為較小', () => { + assert.ok(compareVersionArrays([1, 10, 0], [1, 9, 0]) > 0); + assert.ok(compareVersionArrays([1, 2], [1, 2, 0]) < 0); + assert.equal(compareVersionArrays([1, 2, 3], [1, 2, 3]), 0); + assert.ok(compareVersionArrays([2, 0, 0], [1, 9, 9]) > 0); +}); + +test('parseStableVersions 排除 beta、去除前綴 v 並驗證數字格式', () => { + const releases = [ + { tag_name: 'v1.2.3' }, + { tag_name: '2.0.0' }, + { tag_name: 'v1.3.0-beta.1' }, + { tag_name: 'nightly' }, + { tag_name: null }, + null, + ]; + assert.deepEqual(parseStableVersions(releases), [[1, 2, 3], [2, 0, 0]]); +}); + +test('parseStableVersions 對非陣列回傳空陣列', () => { + assert.deepEqual(parseStableVersions(null), []); + assert.deepEqual(parseStableVersions(undefined), []); +}); + +test('latestStableVersion 在無資料時回傳 0.0.0', () => { + assert.equal(latestStableVersion([]), '0.0.0'); + assert.equal(latestStableVersion([{ tag_name: 'v1.0.0-beta.1' }]), '0.0.0'); +}); + +test('latestStableVersion 取最新的穩定版(含跨區段比較)', () => { + assert.equal(latestStableVersion([ + { tag_name: 'v1.9.0' }, + { tag_name: 'v1.10.0' }, + { tag_name: 'v1.2.3' }, + ]), '1.10.0'); +}); + +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([], '1.2.4'), 1); + assert.equal(nextBetaNumber([{ tag_name: 'v9.9.9-beta.5' }], '1.2.4'), 1); + assert.equal(nextBetaNumber(null, '1.2.4'), 1); +}); + +test('nextBetaNumber 取對應 beta 流水號最大值 + 1', () => { + const releases = [ + { tag_name: 'v1.2.4-beta.1' }, + { tag_name: 'v1.2.4-beta.3' }, + { tag_name: 'v1.2.4-beta.2' }, + { tag_name: 'v1.2.5-beta.9' }, + ]; + assert.equal(nextBetaNumber(releases, '1.2.4'), 4); +}); + +test('calculateVersion 計算下一個穩定版', () => { + assert.deepEqual( + calculateVersion([{ tag_name: 'v1.2.3' }], false), + { latest: '1.2.3', version: '1.2.4' }, + ); +}); + +test('calculateVersion 計算下一個 beta 版', () => { + const releases = [ + { tag_name: 'v1.2.3' }, + { tag_name: 'v1.2.4-beta.2' }, + ]; + assert.deepEqual( + calculateVersion(releases, true), + { latest: '1.2.3', version: '1.2.4-beta.3' }, + ); +}); + +test('calculateVersion 在無任何 release 時的預設值', () => { + assert.deepEqual(calculateVersion([], false), { latest: '0.0.0', version: '0.0.1' }); + assert.deepEqual(calculateVersion([], true), { latest: '0.0.0', version: '0.0.1-beta.1' }); +}); diff --git a/app/version.js b/app/version.js new file mode 100644 index 0000000..f5562b4 --- /dev/null +++ b/app/version.js @@ -0,0 +1,140 @@ +'use strict'; + +// 每個版本號區段的進位上限:patch / minor 達到 10 即向上進位 +const SEGMENT_LIMIT = 10; + +/** + * 逐區段(element-wise)比較兩個版本號數值陣列,較短的陣列視為較小。 + * @param {number[]} a - 第一個版本號區段陣列,例如 [1, 2, 3]。 + * @param {number[]} b - 第二個版本號區段陣列,例如 [1, 2, 0]。 + * @returns {number} 大於 0 表示 a 大於 b;小於 0 表示 a 小於 b;0 表示兩者相等。 + */ +function compareVersionArrays(a, b) { + const length = Math.max(a.length, b.length); + + for (let i = 0; i < length; i += 1) { + if (a[i] === undefined) { + return -1; + } + if (b[i] === undefined) { + return 1; + } + if (a[i] !== b[i]) { + return a[i] - b[i]; + } + } + + return 0; +} + +/** + * 從 release 清單解析出所有穩定版(排除 beta、排除格式不合法者)的版本號數值陣列。 + * 會去除 tag 開頭的 'v',並將版本字串切成數字陣列。 + * @param {Array<{tag_name?: string}>} releases - release 物件清單;每個項目以 tag_name 表示版本標籤。 + * @returns {number[][]} 穩定版版本號的數值陣列清單,例如 [[1,2,3],[1,2,0]];輸入非陣列時回傳空陣列。 + */ +function parseStableVersions(releases) { + if (!Array.isArray(releases)) { + return []; + } + + return releases + .filter((release) => release + && typeof release.tag_name === 'string' + && !release.tag_name.includes('-beta.')) + .map((release) => release.tag_name.replace(/^v/, '')) + .filter((tag) => /^[0-9]+(\.[0-9]+)*$/.test(tag)) + .map((tag) => tag.split('.').map(Number)); +} + +/** + * 取得 release 清單中最新(最大)的穩定版版本號字串,固定格式化為三段 "major.minor.patch"。 + * @param {Array<{tag_name?: string}>} releases - release 物件清單。 + * @returns {string} 最新穩定版版本號字串;查無穩定版時回傳 "0.0.0"。 + */ +function latestStableVersion(releases) { + const versions = parseStableVersions(releases); + + if (versions.length === 0) { + return '0.0.0'; + } + + const max = versions.reduce( + (best, current) => (compareVersionArrays(current, best) > 0 ? current : best), + ); + + return `${max[0] ?? 0}.${max[1] ?? 0}.${max[2] ?? 0}`; +} + +/** + * 依最新穩定版字串計算下一個發行版本號:patch 加 1,patch 達 10 進位至 minor,minor 達 10 進位至 major。 + * @param {string} latest - 最新穩定版版本號字串,例如 "1.2.9"。 + * @returns {string} 下一個發行版本號字串,格式為 "major.minor.patch",例如 "1.3.0"。 + */ +function nextReleaseVersion(latest) { + const parts = String(latest).split('.').map((part) => Number(part) || 0); + let [major = 0, minor = 0, patch = 0] = parts; + + patch += 1; + if (patch >= SEGMENT_LIMIT) { + patch = 0; + minor += 1; + } + if (minor >= SEGMENT_LIMIT) { + minor = 0; + major += 1; + } + + return `${major}.${minor}.${patch}`; +} + +/** + * 計算指定版本號的下一個 beta 流水號:取現有相符 beta 標籤的最大序號加 1,查無時回傳 1。 + * @param {Array<{tag_name?: string}>} releases - release 物件清單。 + * @param {string} version - 不含前綴 'v' 的版本號字串,例如 "1.3.0";用於組出 "v-beta." 前綴比對。 + * @returns {number} 下一個 beta 流水號;查無對應 beta 時回傳 1。 + */ +function nextBetaNumber(releases, version) { + if (!Array.isArray(releases)) { + return 1; + } + + const prefix = `v${version}-beta.`; + const numbers = releases + .filter((release) => release + && typeof release.tag_name === 'string' + && release.tag_name.startsWith(prefix)) + .map((release) => Number(release.tag_name.slice(prefix.length))) + .filter((number) => Number.isFinite(number)); + + const max = numbers.length > 0 ? Math.max(...numbers) : 0; + return max + 1; +} + +/** + * 計算最新穩定版與下一個版本號;isBeta 為 true 時產生 beta 版本號。 + * @param {Array<{tag_name?: string}>} releases - release 物件清單。 + * @param {boolean} isBeta - 是否產生 beta 版本號;true 時 version 形如 "-beta."。 + * @returns {{latest: string, version: string}} 回傳物件: + * - latest: 現有最新穩定版版本號字串(查無時為 "0.0.0")。 + * - version: 本次要使用的版本號字串;非 beta 為下一個發行版(如 "1.3.0"),beta 為 "-beta."(如 "1.3.0-beta.1")。 + */ +function calculateVersion(releases, isBeta) { + const latest = latestStableVersion(releases); + const next = nextReleaseVersion(latest); + + const version = isBeta + ? `${next}-beta.${nextBetaNumber(releases, next)}` + : next; + + return { latest, version }; +} + +module.exports = { + compareVersionArrays, + parseStableVersions, + latestStableVersion, + nextReleaseVersion, + nextBetaNumber, + calculateVersion, +}; diff --git a/entrypoint.sh b/entrypoint.sh index 31afc19..617c5fb 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,265 +1,16 @@ #!/bin/bash +# ============================================================================= +# 用途: calculate-version Action 的容器進入點 (entrypoint)。 +# 專案已由 bash 改寫為 Node.js,本腳本僅負責啟動 /app/index.js, +# 並將容器收到的引數原封傳遞給 Node.js 程式執行版本計算邏輯。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= +# set -e: 任一指令失敗即中止; set -u: 使用未定義變數即報錯; +# set -o pipefail: 管線中任一指令失敗即視為整體失敗。確保錯誤能即時暴露。 set -euo pipefail -readonly LINE="==================================================" -readonly SUBLINE="--------------------------------------------------" -readonly RELEASES_PER_PAGE=10 - -section() { - printf '\n%s\n%s\n%s\n' "$LINE" "$1" "$SUBLINE" -} - -info() { - printf '[info] %s\n' "$1" -} - -fail() { - printf '[error] %s\n' "$1" >&2 - exit 1 -} - -require_env() { - local name="$1" - local value="$2" - - if [ -z "$value" ] || [ "$value" = "null" ]; then - fail "$name 未設定" - fi - - printf '%s=%s\n' "$name" "$value" -} - -write_output() { - printf 'version=%s\n' "$1" >> "$GITHUB_OUTPUT" -} - -normalize_beta_flag() { - local value="${1:-false}" - - if [ -z "$value" ] || [ "$value" = "null" ]; then - printf '%s\n' "false" - return - fi - - printf '%s\n' "$value" -} - -latest_stable_version() { - local release_json="$1" - - if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then - printf '%s\n' "0.0.0" - return - fi - - printf '%s' "$release_json" | jq -r ' - ( if type == "array" then . else [] end ) - | [ .[] - | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) - | .tag_name - | sub("^v"; "") - | select(test("^[0-9]+(\\.[0-9]+)*$")) - | split(".") - | map(tonumber) - ] - | if length == 0 then [0, 0, 0] else (sort | last) end - | "\(.[0] // 0).\(.[1] // 0).\(.[2] // 0)" - ' -} - -next_release_version() { - local latest_version="$1" - local major minor patch - - IFS='.' read -r major minor patch <<< "$latest_version" - major="${major:-0}" - minor="${minor:-0}" - patch="${patch:-0}" - - patch=$((patch + 1)) - if [ "$patch" -ge 10 ]; then - patch=0 - minor=$((minor + 1)) - fi - - if [ "$minor" -ge 10 ]; then - minor=0 - major=$((major + 1)) - fi - - printf '%s.%s.%s\n' "$major" "$minor" "$patch" -} - -next_beta_number() { - local release_json="$1" - local version="$2" - - if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then - printf '%s\n' "1" - return - fi - - printf '%s' "$release_json" | jq -r --arg prefix "v${version}-beta." ' - if type == "array" then - [ .[] - | select(.tag_name? and (.tag_name | startswith($prefix))) - | .tag_name - | ltrimstr($prefix) - | try tonumber catch empty - ] | if length > 0 then max else 0 end - else - 0 - end - ' | awk '{print $1 + 1}' -} - -calculate_version() { - local release_json="$1" - local is_beta="$2" - - if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then - if [ "$is_beta" = "true" ]; then - printf '%s\t%s\n' "0.0.0" "0.0.1-beta.1" - else - printf '%s\t%s\n' "0.0.0" "0.0.1" - fi - return - fi - - printf '%s' "$release_json" | jq -r --arg is_beta "$is_beta" ' - def stable_version: - ( if type == "array" then . else [] end ) - | [ .[] - | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) - | .tag_name - | sub("^v"; "") - | select(test("^[0-9]+(\\.[0-9]+)*$")) - | split(".") - | map(tonumber) - ] - | if length == 0 then [0, 0, 0] else (sort | last) end - | "\(.[0] // 0).\(.[1] // 0).\(.[2] // 0)"; - - def next_release($latest): - ($latest | split(".") | map(tonumber? // 0)) as $parts - | ($parts[0] // 0) as $major - | ($parts[1] // 0) as $minor - | ($parts[2] // 0) as $patch - | ($patch + 1) as $next_patch - | if $next_patch >= 10 then - if ($minor + 1) >= 10 then - "\(($major + 1)).0.0" - else - "\($major).\($minor + 1).0" - end - else - "\($major).\($minor).\($next_patch)" - end; - - def beta_max($prefix): - [ .[] - | select(.tag_name? and (.tag_name | startswith($prefix))) - | .tag_name - | ltrimstr($prefix) - | try tonumber catch empty - ] | if length > 0 then max else 0 end; - - (stable_version) as $base - | (next_release($base)) as $next - | if $is_beta == "true" then - $base + "\t" + ($next + "-beta." + ((beta_max("v" + $next + "-beta.") + 1) | tostring)) - else - $base + "\t" + $next - end - ' -} - -fetch_releases() { - local base_url="$1" - local -a auth_args=() - - if [ -n "${GITEA_TOKEN:-}" ] && [ "${GITEA_TOKEN:-}" != "null" ]; then - info "使用授權 token 取得 release" >&2 - auth_args=(-H "Authorization: token $GITEA_TOKEN") - else - info "使用匿名請求取得 release" >&2 - fi - - local page=1 - local combined="[]" - local page_json count - - while :; do - if ! page_json="$(curl -fsS ${auth_args[@]+"${auth_args[@]}"} "${base_url}?limit=${RELEASES_PER_PAGE}&page=${page}")"; then - fail "release API 請求失敗 (page=${page})" - fi - - # 空字串或 null 代表已無更多資料 - if [ -z "$page_json" ] || [ "$page_json" = "null" ]; then - break - fi - - if ! count="$(printf '%s' "$page_json" | jq 'if type == "array" then length else -1 end')"; then - fail "release API 回傳資料無法解析 (page=${page})" - fi - - if [ "$count" = "-1" ]; then - fail "release API 回傳非陣列資料 (page=${page})" - fi - - info "第 ${page} 頁取得 ${count} 筆 release" >&2 - - if ! combined="$(jq -n --argjson acc "$combined" --argjson page "$page_json" '$acc + $page')"; then - fail "合併 release 資料失敗 (page=${page})" - fi - - # 不足一頁代表已取完 - if [ "$count" -lt "$RELEASES_PER_PAGE" ]; then - break - fi - - page=$((page + 1)) - done - - printf '%s' "$combined" -} - -main() { - section "參數檢查" - - require_env "GITEA_SERVER_URL" "${GITEA_SERVER_URL:-}" - require_env "GITEA_REPOSITORY" "${GITEA_REPOSITORY:-}" - - if [ -n "${GITEA_TOKEN:-}" ] && [ "${GITEA_TOKEN:-}" != "null" ]; then - info "GITEA_TOKEN=***" - else - info "GITEA_TOKEN=未提供" - fi - - IS_BETA="$(normalize_beta_flag "${IS_BETA:-false}")" - info "IS_BETA=$IS_BETA" - - section "取得版本資料" - - RELEASE_URL="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases" - info "RELEASE_URL=$RELEASE_URL" - - if ! RELEASE_JSON="$(fetch_releases "$RELEASE_URL")"; then - fail "取得 release 資料失敗" - fi - - VERSION_INFO="$(calculate_version "$RELEASE_JSON" "$IS_BETA")" - IFS=$'\t' read -r LATEST_TAG NEW_VERSION <<< "$VERSION_INFO" - - info "LATEST_VERSION=$LATEST_TAG" - - section "計算版本號" - - info "NEW_VERSION=$NEW_VERSION" - write_output "$NEW_VERSION" -} - -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - main "$@" -fi +# 進入點:實際邏輯改以 Node.js 實作,置於 /app +# 以 exec 取代當前 shell,讓 node 成為 PID 1 正確接收訊號; +# "$@" 將容器接收到的所有引數原樣轉交給 Node.js 程式。 +exec node /app/index.js "$@" From 923c94d19813cf2e0f6cb1dee29f285b21bb9054 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:41:34 +0800 Subject: [PATCH 02/12] =?UTF-8?q?test(calculate-version):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20version/config/releases=20=E5=96=AE=E5=85=83?= =?UTF-8?q?=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以 node:test 為 app/ 模組補上 24 個單元測試,涵蓋版本計算、設定載入與 release 分頁抓取。 Co-Authored-By: Claude Opus 4.8 (1M context) --- test/config.test.js | 61 ++++++++++++++++++++++++++++ test/releases.test.js | 93 +++++++++++++++++++++++++++++++++++++++++++ test/version.test.js | 85 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 test/config.test.js create mode 100644 test/releases.test.js create mode 100644 test/version.test.js diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..e72d1de --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,61 @@ +'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 未設定/); +}); diff --git a/test/releases.test.js b/test/releases.test.js new file mode 100644 index 0000000..560957f --- /dev/null +++ b/test/releases.test.js @@ -0,0 +1,93 @@ +'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 對非陣列回應拋錯', 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 new file mode 100644 index 0000000..bfdccba --- /dev/null +++ b/test/version.test.js @@ -0,0 +1,85 @@ +'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' }); +}); From 6f9df6743c9395749e02b621ab4a18ae6cf0c0c5 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:41:34 +0800 Subject: [PATCH 03/12] =?UTF-8?q?docs(calculate-version):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20README=20=E4=B8=A6=E8=A3=9C=E4=B8=8A=20entrypoint/C?= =?UTF-8?q?I/CD=20=E8=A8=BB=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依 doc-funcs 產生 README(專案列表、功能列表、使用範例),並為 CI/CD workflow 補上逐行註解。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/cd.yaml | 15 +++ .gitea/workflows/ci.yaml | 21 +++ README.md | 274 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 README.md diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 30b1952..7ea67a4 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1,12 +1,27 @@ +# ============================================================================= +# 用途: calculate-version 專案的 CD workflow。 +# 在程式碼推送至 master 分支時,自動釋出並標註 (tag) 成品版本。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= + +# workflow 名稱,顯示於 Gitea Actions 介面。 name: CD +# 觸發條件設定。 on: + # 於 push 事件觸發。 push: + # 僅在推送至 master 分支時觸發。 branches: - master jobs: + # job 識別碼:釋出並標註版本。 release-tag-version: + # job 顯示名稱。 name: Release Tag Version + # 指定執行於 ubuntu runner。 runs-on: ubuntu steps: + # 呼叫共用的 release-tag-version composite action,自動產生版本標籤並釋出。 - name: 釋出並標註成品版本 + # 版本以變數 ACTION_RELEASE_TAG_VERSION 控制,便於統一升級。 uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }} diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b9d4eed..b90e67c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,19 +1,40 @@ +# ============================================================================= +# 用途: calculate-version 專案的 CI workflow。 +# 在 Pull Request 開啟或更新時,觸發 OpenCode AI 程式碼審查。 +# 更新日期: 2026/06/26 10:28:36 +# ============================================================================= + +# workflow 名稱,顯示於 Gitea Actions 介面。 name: CI +# 觸發條件設定。 on: + # 於 Pull Request 事件觸發。 pull_request: + # 忽略目標分支為 master 的 PR (master 走 CD 流程,不在此審查)。 branches-ignore: - master + # 僅在 PR 開啟 (opened) 或有新 commit 推送 (synchronize) 時觸發。 types: [opened, synchronize] jobs: + # job 識別碼:AI 程式碼審查。 ai-code-review: + # job 顯示名稱。 name: AI Code Review + # 指定執行於 ubuntu runner。 runs-on: ubuntu + # 此 job 所需的權限 (供 AI 寫回審查結果)。 permissions: + # 可寫入儲存庫內容。 contents: write + # 可在 PR 上留言/變更狀態。 pull-requests: write + # 可建立/更新 issue。 issues: write steps: + # 呼叫共用的 OpenCode 程式碼審查 composite action。 - name: AI 程式碼審查 by OpenCode + # 版本以變數 ACTION_OPENCODE_CODE_REVIEW_VERSION 控制,便於統一升級。 uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }} with: + # 傳入留言用 token (secret),供 action 在 PR 上發表審查留言。 comment_token: ${{ secrets.COMMENT_TOKEN }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..21fa9da --- /dev/null +++ b/README.md @@ -0,0 +1,274 @@ +# calculate-version + +> 更新時間:2026/06/26 10:37:28 + +計算版本號的 Gitea Action。依現有 release 推算下一個穩定版或 beta 版本號,並將結果寫入 Action output `version`。核心邏輯以 Node.js 實作,置於 `app/`,由 `entrypoint.sh` 作為容器進入點啟動。 + +版本進位規則:`patch + 1`;當 `patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`。beta 版本號形如 `-beta.`,其中 `` 為該版本既有 beta 標籤的最大序號加 1。 + +## 專案列表 + +### 專案描述 + +| 專案名稱 | 專案描述 | +| --- | --- | +| [calculate-version](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app) | 計算版本號的 Gitea Action:載入環境設定、分頁抓取 Gitea release、計算下一個穩定版或 beta 版本號,並寫出 Action output。 | + +### 參考專案 + +| 專案名稱 | 參考專案列表 | +| --- | --- | +| [calculate-version](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app) | 無 | + +### NuGet 套件 + +| 專案名稱 | NuGet 套件列表 | +| --- | --- | +| [calculate-version](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app) | 無(zero-dependency;僅使用 Node.js 內建模組與全域 fetch) | + +## 功能列表 + +### 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.fail](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L40) | [輸出錯誤訊息至 stderr 並以狀態碼 1 終止行程。](#loggerfail) | + +### config(app/config.js) + +| 功能名稱 | 功能描述 | +| --- | --- | +| [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#L55) | [從環境變數載入並驗證執行所需的設定。](#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) | + +### 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) | + +### 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) | + +## 使用範例 + + +### logger.section + +輸出帶標題的區塊段落至標準輸出:先一個換行,接著 50 個 `=` 的主分隔線、標題文字,最後 50 個 `-` 的次分隔線,用於在 log 中建立可視段落區隔。 + +```js +const logger = require('./logger'); + +logger.section('參數檢查'); +// 輸出: +// +// ================================================== +// 參數檢查 +// -------------------------------------------------- +``` + + +### logger.info + +輸出一般資訊層級訊息至標準輸出,自動加上 `[info]` 前綴與換行;不會結束行程。 + +```js +const logger = require('./logger'); + +logger.info('IS_BETA=false'); +// 輸出:[info] IS_BETA=false +``` + + +### logger.fail + +輸出錯誤訊息至標準錯誤輸出(stderr),並以狀態碼 1 立即終止整個行程。用於發生無法復原的錯誤時中止執行;呼叫後其下程式碼不會執行。 + +```js +const logger = require('./logger'); + +logger.fail('GITEA_SERVER_URL 未設定'); +// 對 stderr 輸出:[error] GITEA_SERVER_URL 未設定 +// 接著 process.exit(1) 終止行程 +``` + + +### config.isUnset + +判斷環境變數值是否視為「未設定」。`undefined`、`null`、空字串、字面字串 `"null"` 皆視為未設定。 + +```js +const { isUnset } = require('./config'); + +isUnset(undefined); // true +isUnset('null'); // true +isUnset('false'); // false +``` + + +### config.requireEnv + +驗證必填環境變數;未設定時拋出 `Error`(訊息為 `${name} 未設定`),已設定時原樣回傳值。 + +```js +const { requireEnv } = require('./config'); + +const url = requireEnv('GITEA_SERVER_URL', process.env.GITEA_SERVER_URL); +// 未設定時拋出:Error: GITEA_SERVER_URL 未設定 +``` + + +### config.normalizeBetaFlag + +將 beta 旗標正規化為布林值。未設定時預設為 `false`;僅當值嚴格等於字面字串 `"true"` 時回傳 `true`。 + +```js +const { normalizeBetaFlag } = require('./config'); + +normalizeBetaFlag('true'); // true +normalizeBetaFlag('TRUE'); // false(嚴格比較,不做大小寫轉換) +normalizeBetaFlag(undefined); // false +``` + + +### config.loadConfig + +從環境變數載入並驗證執行所需的設定。`GITEA_SERVER_URL` 與 `GITEA_REPOSITORY` 為必填(未設定即拋錯);`GITEA_TOKEN` 非必填(未設定為 `null`);`IS_BETA` 會正規化為布林值。 + +```js +const { loadConfig } = require('./config'); + +const config = loadConfig({ + GITEA_SERVER_URL: 'https://gitea.example.com', + GITEA_REPOSITORY: 'owner/repo', + IS_BETA: 'true', +}); +// => { serverUrl: 'https://gitea.example.com', repository: 'owner/repo', token: null, isBeta: true } +``` + + +### version.compareVersionArrays + +逐區段(element-wise)比較兩個版本號數值陣列,較短的陣列視為較小。回傳值 `> 0` 表示 a 大於 b、`< 0` 表示 a 小於 b、`0` 表示相等。 + +```js +const { compareVersionArrays } = require('./version'); + +compareVersionArrays([1, 10], [1, 2, 3]); // > 0(1.10 > 1.2.3) +compareVersionArrays([1, 2], [1, 2, 0]); // < 0(較短者較小) +``` + + +### version.parseStableVersions + +從 release 清單解析出所有穩定版(排除 beta、排除格式不合法者)的版本號數值陣列;會去除 tag 開頭的 `v`。 + +```js +const { parseStableVersions } = require('./version'); + +parseStableVersions([ + { tag_name: 'v1.2.3' }, + { tag_name: '2.0.0' }, + { tag_name: 'v1.0.0-beta.1' }, // 被排除 +]); +// => [[1, 2, 3], [2, 0, 0]] +``` + + +### version.latestStableVersion + +取得 release 清單中最新(最大)的穩定版版本號字串,固定格式化為三段 `major.minor.patch`;查無穩定版時回傳 `"0.0.0"`。 + +```js +const { latestStableVersion } = require('./version'); + +latestStableVersion([{ tag_name: 'v1.2.3' }, { tag_name: 'v1.9.9' }]); // "1.9.9" +latestStableVersion([]); // "0.0.0" +``` + + +### version.nextReleaseVersion + +依最新穩定版字串計算下一個發行版本號:`patch + 1`,`patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`。 + +```js +const { nextReleaseVersion } = require('./version'); + +nextReleaseVersion('1.2.8'); // "1.2.9" +nextReleaseVersion('1.2.9'); // "1.3.0" +nextReleaseVersion('1.9.9'); // "2.0.0" +``` + + +### version.nextBetaNumber + +計算指定版本號的下一個 beta 流水號:取現有相符 beta 標籤(`v-beta.`)的最大序號加 1,查無時回傳 `1`。`version` 參數不應含前綴 `v`。 + +```js +const { nextBetaNumber } = require('./version'); + +nextBetaNumber([{ tag_name: 'v1.3.0-beta.1' }, { tag_name: 'v1.3.0-beta.3' }], '1.3.0'); // 4 +nextBetaNumber([], '1.3.0'); // 1 +``` + + +### version.calculateVersion + +計算最新穩定版與下一個版本號;`isBeta` 為 `true` 時產生 beta 版本號。回傳物件 `{ latest, version }`。 + +```js +const { calculateVersion } = require('./version'); + +calculateVersion([{ tag_name: 'v1.2.3' }], false); +// => { latest: '1.2.3', version: '1.2.4' } + +calculateVersion([{ tag_name: 'v1.2.3' }], true); +// => { latest: '1.2.3', version: '1.2.4-beta.1' } +``` + + +### releases.fetchReleases + +以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。使用全域 `fetch` 逐頁請求(每頁 limit 為 10);當某頁回傳空資料、`null` 或筆數少於上限時即停止。提供 `options.token` 時以授權方式請求。網路失敗、非 2xx、無法解析或非陣列回應時拋出 `Error`。 + +```js +const { fetchReleases } = require('./releases'); +const logger = require('./logger'); + +const releases = await fetchReleases( + 'https://gitea.example.com/api/v1/repos/owner/repo/releases', + { token: process.env.GITEA_TOKEN, logger }, +); +// => 所有 release 物件合併後的陣列 +``` + + +### output.writeOutput + +將一行 `name=value` 附加寫入 GitHub/Gitea Action 的輸出檔(預設取 `process.env.GITHUB_OUTPUT`)。輸出檔路徑為 falsy 時拋出 `Error`。 + +```js +const { writeOutput } = require('./output'); + +writeOutput('version', '1.2.4'); +// 對 $GITHUB_OUTPUT 附加一行:version=1.2.4 +``` From f71d2cc35c58007765e755637ad4de3ed37994c8 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 02:43:30 +0000 Subject: [PATCH 04/12] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/findings.json | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 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..631774c --- /dev/null +++ b/.gitea/ai-review/findings.json @@ -0,0 +1,82 @@ +[ + { + "level": "critical", + "role": "Assassin", + "location": "Dockerfile:13", + "problem": "使用了 `--no-check-certificate` 參數下載軟體包,這會停用 TLS 憑證檢查。攻擊者若能進行中間人攻擊(MITM),即可攔截連線並將惡意的套件注入到映像檔中。", + "suggestion": "移除 `--no-check-certificate`。若確實有無法解決的憑證問題,應排查來源網域的信任鏈或安裝正確的憑證,而不是直接關閉安全機制。", + "is_new": true + }, + { + "level": "critical", + "role": "Assassin", + "location": "app/index.js:41", + "problem": "Action 未對 `GITEA_SERVER_URL` 進行格式驗證,攻擊者若能控制 CI/CD 環境變數,即可將該值設定為惡意 URL,進而誘使 Action 將 `GITEA_TOKEN` 傳送至攻擊者伺服器,導致敏感憑證外洩。", + "suggestion": "在 `app/config.js` 的 `loadConfig` 中,對 `GITEA_SERVER_URL` 進行嚴格驗證,確保其格式正確(例如開頭必須為 `https://`)且符合預期的網域白名單(若適用)。", + "is_new": true + }, + { + "level": "critical", + "role": "Leo", + "location": "app/logger.js:41", + "problem": "fail 函數在內部直接呼叫 process.exit(1),這會導致單元測試或呼叫此函數的程式無法攔截錯誤進行復原,且會直接終止整個 Node.js 行程,測試時會導致測試 runner 直接崩潰。", + "suggestion": "建議將 fail 函數改為只負責輸出錯誤訊息並拋出例外(throw Error),由最外層的 main 函數負責攔截並決定", + "is_new": true + }, + { + "level": "critical", + "role": "Maya", + "location": "app/index.js:18", + "problem": "主邏輯函式 `main` 處理了 `loadConfig` 與 `fetchReleases` 的例外情境,但缺乏針對這些失敗路徑的整合測試,無法確保錯誤發生時流程能正確終止。", + "suggestion": "建議編寫整合測試,透過 mock 相關依賴(如 `loadConfig`、`fetchReleases`)來模擬錯誤,並驗證 `main` 是否正確觸發錯誤處理機制。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "app/index.js:15", + "problem": "此函式註解過於詳盡描述執行流程(如 logger.fail、writeOutput),應專注於描述函式功能而非具體實作步驟。", + "suggestion": "簡化為:/** Action 進入點:執行版本計算並寫入輸出。 */", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "app/releases.js:52", + "problem": "fetch 的 response.text() 方法可能因連線中斷等原因拋出例外,且目前未被包裹在 try-catch 區塊中,若發生錯誤將無法提供明確的 API 頁碼上下文。", + "suggestion": "將 response.text() 以及後續的 JSON.parse 邏輯整合進現有的 try-catch 區塊,確保錯誤處理能準確捕捉並包含頁碼 (page) 資訊。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/releases.js:49", + "problem": "浪費 CPU 週期在手動處理 JSON 解析。`response.text()` 再 `JSON.parse()` 的效能比 `response.json()` 慢得多,且造成不必要的字串記憶體分配。", + "suggestion": "直接使用 `await response.json()`。", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "app/version.js:128", + "problem": "重複遍歷資料!在 `calculateVersion` 中,先呼叫 `latestStableVersion` 遍歷一次 release 清單,隨後又呼叫 `nextBetaNumber` 再遍歷一次。這在資料量大時是完全不必要的 O(N) 浪費。", + "suggestion": "應先在 `calculateVersion` 內將 `releases` 解析並篩選一次,將結果傳遞給後續函式,避免重複遍歷。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "app/version.js:77", + "problem": "String(latest) 呼叫顯得冗贅,因為 latest 在此處已明確為字串型別。", + "suggestion": "直接使用 latest.split('.') 即可。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "app/logger.js:34", + "problem": "註解提及 process.exit(1) 後續程式碼不會執行,此為程式語言基本常識,屬於冗餘註解。", + "suggestion": "移除此行註解,讓程式碼更精簡。", + "is_new": true + } +] From fdf33b41d8c8b64a8f7c395399fd4ca11028b50f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:52:19 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(calculate-version):=20=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9=20AI=20=E5=AF=A9=E6=9F=A5=E7=9A=84=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=88=87=E5=81=A5=E5=85=A8=E6=80=A7=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile 移除 --no-check-certificate,恢復 TLS 憑證檢查以防中間人攻擊 - config 對 GITEA_SERVER_URL 加入 http/https URL 格式驗證 - logger.fail 改為 logger.error(僅輸出不終止行程),退出移至 index 頂層處理 - index.main 改用相依注入並於頂層攔截錯誤後 exit 1,避免 library 內呼叫 process.exit - releases 將 response.text() 包入 try-catch 並附帶頁碼 Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 4 +-- app/config.js | 20 +++++++++++-- app/index.js | 79 ++++++++++++++++++++++++++----------------------- app/logger.js | 13 ++++---- app/releases.js | 7 ++++- 5 files changed, 72 insertions(+), 51 deletions(-) diff --git a/Dockerfile b/Dockerfile index 28a2da8..5057987 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,8 @@ FROM node:lts-alpine # entrypoint.sh 以 bash 撰寫,Alpine 預設無 bash 需另行安裝 -# --no-cache 不保留 apk 索引快取以縮小映像;--no-check-certificate 略過憑證檢查 (需人工確認:略過憑證驗證有安全風險)。 -RUN apk add --no-cache --no-check-certificate bash +# --no-cache 不保留 apk 索引快取以縮小映像(保留 TLS 憑證檢查以防中間人攻擊)。 +RUN apk add --no-cache bash # 複製容器進入點腳本至根目錄。 COPY entrypoint.sh /entrypoint.sh diff --git a/app/config.js b/app/config.js index e482444..bf57afe 100644 --- a/app/config.js +++ b/app/config.js @@ -42,18 +42,32 @@ function normalizeBetaFlag(value) { return value === 'true'; } +// 驗證字串為合法的 http/https URL,否則拋出錯誤(避免指向非預期協定或格式錯誤的位址) +function assertHttpUrl(name, value) { + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${name} 格式錯誤,必須為合法的 URL`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${name} 必須使用 http 或 https 協定`); + } +} + /** * 從環境變數載入並驗證執行所需的設定。 * - * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤; - * GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。 + * GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;GITEA_SERVER_URL + * 另需為合法的 http/https URL;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 未設定時拋出。 + * @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定,或 GITEA_SERVER_URL 非合法 http/https URL 時拋出。 */ 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); const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN; const isBeta = normalizeBetaFlag(env.IS_BETA); diff --git a/app/index.js b/app/index.js index 73a6bb2..688a404 100644 --- a/app/index.js +++ b/app/index.js @@ -9,52 +9,57 @@ const { writeOutput } = require('./output'); /** * Action 進入點:協調設定載入、release 取得、版本號計算與輸出寫出。 * - * 流程:印出「參數檢查」區段並呼叫 loadConfig() 載入設定(失敗則以 logger.fail - * 回報並結束);印出設定值(token 以 *** 或「未提供」顯示);組出 release URL 並 - * 以 fetchReleases() 取得 release 清單(失敗則以 logger.fail 回報並結束);以 - * calculateVersion() 計算最新版與新版號,最後以 writeOutput('version', version) - * 寫出 version 輸出。 + * 失敗時(loadConfig 或 fetchReleases 拋錯)直接向外拋出例外,由呼叫端決定如何結束, + * 以利單元測試覆蓋失敗路徑。相依模組可透過 deps 注入,預設使用各兄弟模組的實作。 * - * Side effects:透過 logger 輸出至 stdout/stderr、失敗時使 process 以失敗狀態 - * 結束、並寫入 GITHUB_OUTPUT。此函式於模組載入時即被呼叫。 - * - * @returns {Promise} + * @param {Object} [deps={}] - 可注入的相依,供測試替換。 + * @param {Function} [deps.loadConfig] - 載入設定的函式。 + * @param {Function} [deps.fetchReleases] - 取得 release 的函式。 + * @param {Function} [deps.calculateVersion] - 計算版本號的函式。 + * @param {Function} [deps.writeOutput] - 寫出 output 的函式。 + * @param {{section:Function, info:Function, error:Function}} [deps.log] - log 記錄器。 + * @returns {Promise} 計算出的版本號。 */ -async function main() { - logger.section('參數檢查'); +async function main(deps = {}) { + const { + loadConfig: loadConfigFn = loadConfig, + fetchReleases: fetchReleasesFn = fetchReleases, + calculateVersion: calculateVersionFn = calculateVersion, + writeOutput: writeOutputFn = writeOutput, + log = logger, + } = deps; - let config; - try { - config = loadConfig(); - } catch (error) { - logger.fail(error.message); - return; - } + log.section('參數檢查'); - logger.info(`GITEA_SERVER_URL=${config.serverUrl}`); - logger.info(`GITEA_REPOSITORY=${config.repository}`); - logger.info(config.token ? 'GITEA_TOKEN=***' : 'GITEA_TOKEN=未提供'); - logger.info(`IS_BETA=${config.isBeta}`); + const config = loadConfigFn(); + log.info(`GITEA_SERVER_URL=${config.serverUrl}`); + log.info(`GITEA_REPOSITORY=${config.repository}`); + log.info(config.token ? 'GITEA_TOKEN=***' : 'GITEA_TOKEN=未提供'); + log.info(`IS_BETA=${config.isBeta}`); - logger.section('取得版本資料'); + log.section('取得版本資料'); const releaseUrl = `${config.serverUrl}/api/v1/repos/${config.repository}/releases`; - logger.info(`RELEASE_URL=${releaseUrl}`); + log.info(`RELEASE_URL=${releaseUrl}`); - let releases; - try { - releases = await fetchReleases(releaseUrl, { token: config.token, logger }); - } catch (error) { - logger.fail(error.message); - return; - } + const releases = await fetchReleasesFn(releaseUrl, { token: config.token, logger: log }); - const { latest, version } = calculateVersion(releases, config.isBeta); - logger.info(`LATEST_VERSION=${latest}`); + const { latest, version } = calculateVersionFn(releases, config.isBeta); + log.info(`LATEST_VERSION=${latest}`); - logger.section('計算版本號'); - logger.info(`NEW_VERSION=${version}`); - writeOutput('version', version); + log.section('計算版本號'); + log.info(`NEW_VERSION=${version}`); + writeOutputFn('version', version); + + return version; } -main(); +// 僅在被直接執行時啟動(被 require/測試載入時不自動執行),失敗則在頂層回報並以狀態碼 1 結束 +if (require.main === module) { + main().catch((error) => { + logger.error(error.message); + process.exit(1); + }); +} + +module.exports = { main }; diff --git a/app/logger.js b/app/logger.js index 40c0949..bae031f 100644 --- a/app/logger.js +++ b/app/logger.js @@ -28,18 +28,15 @@ function info(message) { } /** - * 輸出錯誤層級的 log 訊息至標準錯誤輸出(stderr),並以狀態碼 1 - * 立即終止整個行程。用於發生無法復原的錯誤時中止執行。 + * 輸出錯誤層級的 log 訊息至標準錯誤輸出(stderr),自動加上 `[error]` 前綴與換行。 * - * 注意:此函式呼叫 `process.exit(1)`,正常情況下不會回傳給呼叫端, - * 其後的程式碼不會被執行。 + * 僅負責輸出,不終止行程;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。 * * @param {string} message - 要輸出的錯誤描述內容。 - * @returns {never} 不會正常回傳(行程會被終止)。 + * @returns {void} */ -function fail(message) { +function error(message) { process.stderr.write(`[error] ${message}\n`); - process.exit(1); } -module.exports = { section, info, fail }; +module.exports = { section, info, error }; diff --git a/app/releases.js b/app/releases.js index 616679c..e2b049d 100644 --- a/app/releases.js +++ b/app/releases.js @@ -47,7 +47,12 @@ async function fetchReleases(baseUrl, options = {}) { throw new Error(`release API 請求失敗 (page=${page})`); } - const text = await response.text(); + let text; + try { + text = await response.text(); + } catch (error) { + throw new Error(`release API 回應讀取失敗 (page=${page}): ${error.message}`); + } // 空字串或 null 代表已無更多資料 if (!text || text === 'null') { From 3c8704d1a392b21c21bb72a683126fbe5457fee7 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:52:19 +0800 Subject: [PATCH 06/12] =?UTF-8?q?test(calculate-version):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=20GITEA=5FSERVER=5FURL=20=E9=A9=97=E8=AD=89=E8=88=87?= =?UTF-8?q?=20main=20=E5=A4=B1=E6=95=97=E8=B7=AF=E5=BE=91=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 config 的 URL 格式/協定驗證測試,以及以相依注入覆蓋 main 成功與 loadConfig/fetchReleases 失敗路徑的整合測試。 Co-Authored-By: Claude Opus 4.8 (1M context) --- test/config.test.js | 14 +++++++++++ test/index.test.js | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 test/index.test.js diff --git a/test/config.test.js b/test/config.test.js index e72d1de..03a989d 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -59,3 +59,17 @@ test('loadConfig 缺少 token 視為匿名(null)', () => { 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/index.test.js b/test/index.test.js new file mode 100644 index 0000000..4309ba7 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,59 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { main } = require('../app/index'); + +// 安靜的 log 記錄器,避免測試輸出雜訊 +const silentLog = { section() {}, info() {}, error() {} }; + +const okConfig = { + serverUrl: 'https://gitea.example.com', + repository: 'owner/repo', + token: null, + isBeta: false, +}; + +test('main 成功時計算版本並寫出 output', async () => { + let written; + const version = await main({ + loadConfig: () => okConfig, + fetchReleases: async () => ([{ tag_name: 'v1.2.3' }]), + writeOutput: (name, value) => { written = { name, value }; }, + log: silentLog, + }); + + assert.equal(version, '1.2.4'); + assert.deepEqual(written, { name: 'version', value: '1.2.4' }); +}); + +test('main 將 release URL 與 token 傳給 fetchReleases', async () => { + let receivedUrl; + let receivedOptions; + await main({ + loadConfig: () => ({ ...okConfig, token: 'secret' }), + fetchReleases: async (url, options) => { receivedUrl = url; receivedOptions = options; return []; }, + writeOutput: () => {}, + log: silentLog, + }); + + assert.equal(receivedUrl, 'https://gitea.example.com/api/v1/repos/owner/repo/releases'); + assert.equal(receivedOptions.token, 'secret'); +}); + +test('main 於 loadConfig 失敗時向外拋出', async () => { + await assert.rejects(() => main({ + loadConfig: () => { throw new Error('GITEA_SERVER_URL 未設定'); }, + log: silentLog, + }), /GITEA_SERVER_URL 未設定/); +}); + +test('main 於 fetchReleases 失敗時向外拋出', async () => { + await assert.rejects(() => main({ + loadConfig: () => okConfig, + fetchReleases: async () => { throw new Error('release API 請求失敗 (page=1)'); }, + writeOutput: () => {}, + log: silentLog, + }), /release API 請求失敗/); +}); From fb760004fdaf99a8f38aaeabbef7db27cfe13223 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:52:19 +0800 Subject: [PATCH 07/12] =?UTF-8?q?docs(calculate-version):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20README=20=E5=B0=8D=E9=BD=8A=E9=8C=AF=E8=AA=A4?= =?UTF-8?q?=E8=99=95=E7=90=86=E9=87=8D=E6=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logger.fail 改為 logger.error、新增 index.main 公開函式說明,並更新對應行號與使用範例。 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 45 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 21fa9da..4ea412e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # calculate-version -> 更新時間:2026/06/26 10:37:28 +> 更新時間:2026/06/26 10:49:46 計算版本號的 Gitea Action。依現有 release 推算下一個穩定版或 beta 版本號,並將結果寫入 Action output `version`。核心邏輯以 Node.js 實作,置於 `app/`,由 `entrypoint.sh` 作為容器進入點啟動。 @@ -12,7 +12,7 @@ | 專案名稱 | 專案描述 | | --- | --- | -| [calculate-version](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app) | 計算版本號的 Gitea Action:載入環境設定、分頁抓取 Gitea release、計算下一個穩定版或 beta 版本號,並寫出 Action output。 | +| [calculate-version](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app) | 計算版本號的 Gitea Action:載入並驗證環境設定、分頁抓取 Gitea release、計算下一個穩定版或 beta 版本號,並寫出 Action output。 | ### 參考專案 @@ -28,13 +28,19 @@ ## 功能列表 +### index(app/index.js) + +| 功能名稱 | 功能描述 | +| --- | --- | +| [index.main](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/index.js#L23) | [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.fail](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L40) | [輸出錯誤訊息至 stderr 並以狀態碼 1 終止行程。](#loggerfail) | +| [logger.error](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L38) | [輸出錯誤訊息至 stderr(僅輸出,不終止行程)。](#loggererror) | ### config(app/config.js) @@ -43,7 +49,7 @@ | [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#L55) | [從環境變數載入並驗證執行所需的設定。](#configloadconfig) | +| [config.loadConfig](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/config.js#L68) | [從環境變數載入並驗證執行所需的設定。](#configloadconfig) | ### version(app/version.js) @@ -70,6 +76,24 @@ ## 使用範例 + +### index.main + +Action 進入點:依序協調設定載入、release 取得、版本計算與輸出寫出,回傳計算出的版本號。失敗時向外拋出例外(由進入點頂層攔截並以狀態碼 1 結束)。相依模組可透過 `deps` 注入,便於測試。模組僅在被直接執行(`node app/index.js`)時自動啟動。 + +```js +const { main } = require('./index'); + +// 測試或自訂情境:注入假的相依 +const version = await main({ + loadConfig: () => ({ serverUrl: 'https://gitea.example.com', repository: 'owner/repo', token: null, isBeta: false }), + fetchReleases: async () => ([{ tag_name: 'v1.2.3' }]), + writeOutput: () => {}, + log: { section() {}, info() {}, error() {} }, +}); +// version === '1.2.4' +``` + ### logger.section @@ -98,17 +122,16 @@ logger.info('IS_BETA=false'); // 輸出:[info] IS_BETA=false ``` - -### logger.fail + +### logger.error -輸出錯誤訊息至標準錯誤輸出(stderr),並以狀態碼 1 立即終止整個行程。用於發生無法復原的錯誤時中止執行;呼叫後其下程式碼不會執行。 +輸出錯誤訊息至標準錯誤輸出(stderr),自動加上 `[error]` 前綴與換行。僅負責輸出,**不終止行程**;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。 ```js const logger = require('./logger'); -logger.fail('GITEA_SERVER_URL 未設定'); +logger.error('GITEA_SERVER_URL 未設定'); // 對 stderr 輸出:[error] GITEA_SERVER_URL 未設定 -// 接著 process.exit(1) 終止行程 ``` @@ -152,7 +175,7 @@ normalizeBetaFlag(undefined); // false ### config.loadConfig -從環境變數載入並驗證執行所需的設定。`GITEA_SERVER_URL` 與 `GITEA_REPOSITORY` 為必填(未設定即拋錯);`GITEA_TOKEN` 非必填(未設定為 `null`);`IS_BETA` 會正規化為布林值。 +從環境變數載入並驗證執行所需的設定。`GITEA_SERVER_URL` 與 `GITEA_REPOSITORY` 為必填(未設定即拋錯),且 `GITEA_SERVER_URL` 須為合法的 http/https URL;`GITEA_TOKEN` 非必填(未設定為 `null`);`IS_BETA` 會正規化為布林值。 ```js const { loadConfig } = require('./config'); @@ -248,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` 逐頁請求(每頁 limit 為 10);當某頁回傳空資料、`null` 或筆數少於上限時即停止。提供 `options.token` 時以授權方式請求。網路失敗、回應讀取失敗、非 2xx、無法解析或非陣列回應時拋出 `Error`(含頁碼)。 ```js const { fetchReleases } = require('./releases'); From 43601350b03401761bfeab29c208e10157a9cb56 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:52:19 +0800 Subject: [PATCH 08/12] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings.json=20=E8=88=87=20exclusions.jso?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已解決 6 條(critical x4、warning x1、info x1),4 條判定為誤報寫入 exclusions(warning x3、info x1),findings 清空。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 26 ++++++++++ .gitea/ai-review/findings.json | 83 +------------------------------- 2 files changed, 27 insertions(+), 82 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..e299bde --- /dev/null +++ b/.gitea/ai-review/exclusions.json @@ -0,0 +1,26 @@ +[ + { + "location": "app/index.js:15", + "role": "Bard", + "original_finding": "此函式註解過於詳盡描述執行流程(如 logger.fail、writeOutput),應專注於描述函式功能而非具體實作步驟。", + "reason": "詳述行為的 JSDoc 為本專案 doc-funcs 文件慣例,全模組一致採用;main 的 JSDoc 另需說明可注入的 deps 參數,刻意保留必要細節。" + }, + { + "location": "app/releases.js:49", + "role": "Rogue", + "original_finding": "浪費 CPU 週期在手動處理 JSON 解析。response.text() 再 JSON.parse() 的效能比 response.json() 慢得多。建議直接使用 await response.json()。", + "reason": "text()+JSON.parse 為刻意設計:需先以字串判斷空 body 或字面 'null' 以決定分頁是否結束,改用 response.json() 會在空回應時拋錯而破壞分頁終止邏輯。" + }, + { + "location": "app/version.js:128", + "role": "Rogue", + "original_finding": "重複遍歷資料!在 calculateVersion 中先呼叫 latestStableVersion 遍歷一次,隨後又呼叫 nextBetaNumber 再遍歷一次。建議先解析並篩選一次再傳遞給後續函式。", + "reason": "latestStableVersion 篩選的是穩定版、nextBetaNumber 篩選的是對應 beta 標籤,兩者為不同子集無法共用單次解析;且 release 為分頁取得的小資料集,效益可忽略,現行設計以可組合的純函式換取可讀性。" + }, + { + "location": "app/version.js:77", + "role": "Bard", + "original_finding": "String(latest) 呼叫顯得冗贅,因為 latest 在此處已明確為字串型別。建議直接使用 latest.split('.')。", + "reason": "nextReleaseVersion 為對外公開(exported)函式,String() 為防禦性處理,確保被直接以非字串呼叫時也不致拋錯。" + } +] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 631774c..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,82 +1 @@ -[ - { - "level": "critical", - "role": "Assassin", - "location": "Dockerfile:13", - "problem": "使用了 `--no-check-certificate` 參數下載軟體包,這會停用 TLS 憑證檢查。攻擊者若能進行中間人攻擊(MITM),即可攔截連線並將惡意的套件注入到映像檔中。", - "suggestion": "移除 `--no-check-certificate`。若確實有無法解決的憑證問題,應排查來源網域的信任鏈或安裝正確的憑證,而不是直接關閉安全機制。", - "is_new": true - }, - { - "level": "critical", - "role": "Assassin", - "location": "app/index.js:41", - "problem": "Action 未對 `GITEA_SERVER_URL` 進行格式驗證,攻擊者若能控制 CI/CD 環境變數,即可將該值設定為惡意 URL,進而誘使 Action 將 `GITEA_TOKEN` 傳送至攻擊者伺服器,導致敏感憑證外洩。", - "suggestion": "在 `app/config.js` 的 `loadConfig` 中,對 `GITEA_SERVER_URL` 進行嚴格驗證,確保其格式正確(例如開頭必須為 `https://`)且符合預期的網域白名單(若適用)。", - "is_new": true - }, - { - "level": "critical", - "role": "Leo", - "location": "app/logger.js:41", - "problem": "fail 函數在內部直接呼叫 process.exit(1),這會導致單元測試或呼叫此函數的程式無法攔截錯誤進行復原,且會直接終止整個 Node.js 行程,測試時會導致測試 runner 直接崩潰。", - "suggestion": "建議將 fail 函數改為只負責輸出錯誤訊息並拋出例外(throw Error),由最外層的 main 函數負責攔截並決定", - "is_new": true - }, - { - "level": "critical", - "role": "Maya", - "location": "app/index.js:18", - "problem": "主邏輯函式 `main` 處理了 `loadConfig` 與 `fetchReleases` 的例外情境,但缺乏針對這些失敗路徑的整合測試,無法確保錯誤發生時流程能正確終止。", - "suggestion": "建議編寫整合測試,透過 mock 相關依賴(如 `loadConfig`、`fetchReleases`)來模擬錯誤,並驗證 `main` 是否正確觸發錯誤處理機制。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "app/index.js:15", - "problem": "此函式註解過於詳盡描述執行流程(如 logger.fail、writeOutput),應專注於描述函式功能而非具體實作步驟。", - "suggestion": "簡化為:/** Action 進入點:執行版本計算並寫入輸出。 */", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "app/releases.js:52", - "problem": "fetch 的 response.text() 方法可能因連線中斷等原因拋出例外,且目前未被包裹在 try-catch 區塊中,若發生錯誤將無法提供明確的 API 頁碼上下文。", - "suggestion": "將 response.text() 以及後續的 JSON.parse 邏輯整合進現有的 try-catch 區塊,確保錯誤處理能準確捕捉並包含頁碼 (page) 資訊。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/releases.js:49", - "problem": "浪費 CPU 週期在手動處理 JSON 解析。`response.text()` 再 `JSON.parse()` 的效能比 `response.json()` 慢得多,且造成不必要的字串記憶體分配。", - "suggestion": "直接使用 `await response.json()`。", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "app/version.js:128", - "problem": "重複遍歷資料!在 `calculateVersion` 中,先呼叫 `latestStableVersion` 遍歷一次 release 清單,隨後又呼叫 `nextBetaNumber` 再遍歷一次。這在資料量大時是完全不必要的 O(N) 浪費。", - "suggestion": "應先在 `calculateVersion` 內將 `releases` 解析並篩選一次,將結果傳遞給後續函式,避免重複遍歷。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "app/version.js:77", - "problem": "String(latest) 呼叫顯得冗贅,因為 latest 在此處已明確為字串型別。", - "suggestion": "直接使用 latest.split('.') 即可。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "app/logger.js:34", - "problem": "註解提及 process.exit(1) 後續程式碼不會執行,此為程式語言基本常識,屬於冗餘註解。", - "suggestion": "移除此行註解,讓程式碼更精簡。", - "is_new": true - } -] +[] From 66d2c4b35fa1b6eb116dfe4399f9480dbada2618 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 02:53:10 +0000 Subject: [PATCH 09/12] chore: update ai-review findings [ai-review-bot][failure] --- .gitea/ai-review/exclusions.json | 6 ++++ .gitea/ai-review/findings.json | 51 +++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index e299bde..f9a0b67 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -22,5 +22,11 @@ "role": "Bard", "original_finding": "String(latest) 呼叫顯得冗贅,因為 latest 在此處已明確為字串型別。建議直接使用 latest.split('.')。", "reason": "nextReleaseVersion 為對外公開(exported)函式,String() 為防禦性處理,確保被直接以非字串呼叫時也不致拋錯。" + }, + { + "location": "Dockerfile:13", + "role": "Assassin", + "original_finding": "移除 `--no-check-certificate`。若確實有無法解決的憑證問題,應排查來源網域的信任鏈或安裝正確的憑證,而不是直接關閉安全機制。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..8d1368f 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,50 @@ -[] +[ + { + "level": "critical", + "role": "Assassin", + "problem": "Action 未對 `GITEA_SERVER_URL` 進行格式驗證,攻擊者若能控制 CI/CD 環境變數,即可將該值設定為惡意 URL,進而誘使 Action 將 `GITEA_TOKEN` 傳送至攻擊者伺服器,導致敏感憑證外洩。", + "suggestion": "在 `app/config.js` 的 `loadConfig` 中,對 `GITEA_SERVER_URL` 進行嚴格驗證,確保其格式正確(例如開頭必須為 `https://`)且符合預期的網域白名單(若適用)。", + "location": "app/index.js:41", + "is_new": false + }, + { + "level": "critical", + "role": "Maya", + "problem": "主邏輯函式 `main` 處理了 `loadConfig` 與 `fetchReleases` 的例外情境,但缺乏針對這些失敗路徑的整合測試,無法確保錯誤發生時流程能正確終止。", + "suggestion": "建議編寫整合測試,透過 mock 相關依賴(如 `loadConfig`、`fetchReleases`)來模擬錯誤,並驗證 `main` 是否正確觸發錯誤處理機制。", + "location": "app/index.js:18", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/output.js:16", + "problem": "存在潛在的「任意檔案寫入」與「路徑穿越」風險。函式 `writeOutput` 直接接收 `file` 路徑並使用 `fs.appendFileSync` 進行寫入,且預設來源為環境變數 `GITHUB_OUTPUT`。若攻擊者能透過惡意配置篡改 CI/CD 環境變數,即可將任意資料寫入系統內的敏感檔案(如 /etc/passwd 或 SSH 授權金鑰),導致系統被入侵。", + "suggestion": "應對 `file` 路徑增加嚴格的驗證機制(Sanitization),確保其位於合法的臨時目錄或 CI/CD 授權的輸出路徑內,嚴禁寫入任意系統檔案路徑。", + "is_new": true + }, + { + "level": "warning", + "role": "Leo", + "location": "app/version.js:4", + "problem": "版本號區段的進位上限(10)被直接硬編碼在程式中。若未來業務需求需要調整進位規則,需要深入程式碼核心修改,容易遺漏或出錯。", + "suggestion": "建議將進位上限設為一個配置常數,或將其參數化,讓版本計算邏輯與進位策略分離。", + "is_new": true + }, + { + "level": "warning", + "role": "Maya", + "location": "app/releases.js:68", + "problem": "release API 解析 JSON 失敗時的例外路徑(Malformed JSON)未在測試中驗證,無法確認錯誤處理機制是否如預期運作。", + "suggestion": "在 test/releases.test.js 中新增一個測試案例,模擬 response.text() 回傳無法解析為 JSON 的字串,並驗證是否拋出預期的錯誤。", + "is_new": true + }, + { + "level": "info", + "role": "Leo", + "location": "app/logger.js:4", + "problem": "日誌的分隔線寬度與符號直接硬編碼在模組內。如果未來需要調整輸出風格,需要修改多個地方,且容易造成視覺不一致。", + "suggestion": "將分隔線寬度與符號定義為設定檔或在模組頂層集中管理,並考慮提供一個通用函數來產生這些分隔線。", + "is_new": true + } +] From e54805b241d8287acd2bcea68b1672c0726a2bdf Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:59:32 +0800 Subject: [PATCH 10/12] =?UTF-8?q?test(calculate-version):=20=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=20release=20JSON=20=E8=A7=A3=E6=9E=90=E5=A4=B1?= =?UTF-8?q?=E6=95=97=E8=B7=AF=E5=BE=91=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 fetchReleases 收到無法解析 JSON 時拋出「回傳資料無法解析」的測試案例。 Co-Authored-By: Claude Opus 4.8 (1M context) --- test/releases.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/releases.test.js b/test/releases.test.js index 560957f..c305bf9 100644 --- a/test/releases.test.js +++ b/test/releases.test.js @@ -65,6 +65,18 @@ test('fetchReleases 對非 2xx 回應拋錯', async () => { ); }); +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' }), From 6f95a3fbe6b1013a2a102a9d637348ec061ed05f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:59:32 +0800 Subject: [PATCH 11/12] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings.json=20=E8=88=87=20exclusions.jso?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已解決 1 條(warning:補 JSON 解析失敗測試),其餘 5 條判定為誤報寫入 exclusions(critical x2 重複項、warning x2、info x1),findings 清空。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/ai-review/exclusions.json | 30 +++++++++++++++++++ .gitea/ai-review/findings.json | 51 +------------------------------- 2 files changed, 31 insertions(+), 50 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index f9a0b67..e42650b 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -28,5 +28,35 @@ "role": "Assassin", "original_finding": "移除 `--no-check-certificate`。若確實有無法解決的憑證問題,應排查來源網域的信任鏈或安裝正確的憑證,而不是直接關閉安全機制。", "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "app/index.js:41", + "role": "Assassin", + "original_finding": "Action 未對 GITEA_SERVER_URL 進行格式驗證,攻擊者若能控制 CI/CD 環境變數,即可將該值設定為惡意 URL,導致 GITEA_TOKEN 外洩。建議嚴格驗證格式並符合網域白名單。", + "reason": "GITEA_SERVER_URL 由 Gitea Actions 平台注入(gitea.server_url),非一般威脅模型下使用者可控;loadConfig 已加入 http/https URL 格式驗證;網域白名單屬各部署環境特定決策,不宜硬寫進通用 action。" + }, + { + "location": "app/index.js:18", + "role": "Maya", + "original_finding": "主邏輯函式 main 處理了 loadConfig 與 fetchReleases 的例外情境,但缺乏針對這些失敗路徑的整合測試。", + "reason": "前提已不成立:test/index.test.js 已以相依注入新增 main 成功路徑,以及 loadConfig 失敗、fetchReleases 失敗兩條失敗路徑的整合測試。" + }, + { + "location": "app/output.js:16", + "role": "Assassin", + "original_finding": "writeOutput 直接接收 file 路徑並 fs.appendFileSync 寫入,預設來源為 GITHUB_OUTPUT,存在任意檔案寫入/路徑穿越風險。建議對路徑加入嚴格驗證。", + "reason": "GITHUB_OUTPUT 由 Action runner 注入並指向授權的暫存輸出檔,寫入它正是回傳 step output 的標準機制;對通用 action 而言硬性限制路徑會破壞功能,且該變數於正常威脅模型中非使用者可控。" + }, + { + "location": "app/version.js:4", + "role": "Leo", + "original_finding": "版本號區段的進位上限(10)被直接硬編碼在程式中,未來調整進位規則需深入核心修改。建議設為配置常數或參數化。", + "reason": "進位上限已抽出為模組頂層具名常數 SEGMENT_LIMIT,可單點調整;進位規則為本 action 固定的業務規則,參數化屬尚無需求的過度設計。" + }, + { + "location": "app/logger.js:4", + "role": "Leo", + "original_finding": "日誌分隔線寬度與符號直接硬編碼在模組內,調整風格需改多處。建議集中管理並提供通用產生函數。", + "reason": "分隔線已是模組頂層集中定義的常數 LINE/SUBLINE,單點即可調整;為固定的視覺樣式再加產生函數屬過度設計。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 8d1368f..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,50 +1 @@ -[ - { - "level": "critical", - "role": "Assassin", - "problem": "Action 未對 `GITEA_SERVER_URL` 進行格式驗證,攻擊者若能控制 CI/CD 環境變數,即可將該值設定為惡意 URL,進而誘使 Action 將 `GITEA_TOKEN` 傳送至攻擊者伺服器,導致敏感憑證外洩。", - "suggestion": "在 `app/config.js` 的 `loadConfig` 中,對 `GITEA_SERVER_URL` 進行嚴格驗證,確保其格式正確(例如開頭必須為 `https://`)且符合預期的網域白名單(若適用)。", - "location": "app/index.js:41", - "is_new": false - }, - { - "level": "critical", - "role": "Maya", - "problem": "主邏輯函式 `main` 處理了 `loadConfig` 與 `fetchReleases` 的例外情境,但缺乏針對這些失敗路徑的整合測試,無法確保錯誤發生時流程能正確終止。", - "suggestion": "建議編寫整合測試,透過 mock 相關依賴(如 `loadConfig`、`fetchReleases`)來模擬錯誤,並驗證 `main` 是否正確觸發錯誤處理機制。", - "location": "app/index.js:18", - "is_new": false - }, - { - "level": "warning", - "role": "Assassin", - "location": "app/output.js:16", - "problem": "存在潛在的「任意檔案寫入」與「路徑穿越」風險。函式 `writeOutput` 直接接收 `file` 路徑並使用 `fs.appendFileSync` 進行寫入,且預設來源為環境變數 `GITHUB_OUTPUT`。若攻擊者能透過惡意配置篡改 CI/CD 環境變數,即可將任意資料寫入系統內的敏感檔案(如 /etc/passwd 或 SSH 授權金鑰),導致系統被入侵。", - "suggestion": "應對 `file` 路徑增加嚴格的驗證機制(Sanitization),確保其位於合法的臨時目錄或 CI/CD 授權的輸出路徑內,嚴禁寫入任意系統檔案路徑。", - "is_new": true - }, - { - "level": "warning", - "role": "Leo", - "location": "app/version.js:4", - "problem": "版本號區段的進位上限(10)被直接硬編碼在程式中。若未來業務需求需要調整進位規則,需要深入程式碼核心修改,容易遺漏或出錯。", - "suggestion": "建議將進位上限設為一個配置常數,或將其參數化,讓版本計算邏輯與進位策略分離。", - "is_new": true - }, - { - "level": "warning", - "role": "Maya", - "location": "app/releases.js:68", - "problem": "release API 解析 JSON 失敗時的例外路徑(Malformed JSON)未在測試中驗證,無法確認錯誤處理機制是否如預期運作。", - "suggestion": "在 test/releases.test.js 中新增一個測試案例,模擬 response.text() 回傳無法解析為 JSON 的字串,並驗證是否拋出預期的錯誤。", - "is_new": true - }, - { - "level": "info", - "role": "Leo", - "location": "app/logger.js:4", - "problem": "日誌的分隔線寬度與符號直接硬編碼在模組內。如果未來需要調整輸出風格,需要修改多個地方,且容易造成視覺不一致。", - "suggestion": "將分隔線寬度與符號定義為設定檔或在模組頂層集中管理,並考慮提供一個通用函數來產生這些分隔線。", - "is_new": true - } -] +[] From 1f878199da54cd24b5a3f533526f3e70303bf0b0 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 26 Jun 2026 03:00:16 +0000 Subject: [PATCH 12/12] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..93d2ebe 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,26 @@ -[] +[ + { + "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 + } +]