From fdf33b41d8c8b64a8f7c395399fd4ca11028b50f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 10:52:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(calculate-version):=20=E4=BF=AE=E5=BE=A9=20?= =?UTF-8?q?AI=20=E5=AF=A9=E6=9F=A5=E7=9A=84=E5=AE=89=E5=85=A8=E8=88=87?= =?UTF-8?q?=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') {