calculate-version:多階段容器化、log 格式統一與 AI review 修復 #9
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 用途: calculate-version 專案的 CD workflow。
|
# 用途: calculate-version 專案的 CD workflow。
|
||||||
# 在程式碼推送至 master 分支時,自動釋出並標註 (tag) 成品版本。
|
# 在程式碼推送至 master 分支時,自動釋出並標註 (tag) 成品版本。
|
||||||
# 更新日期: 2026/06/26 10:28:36
|
# 更新日期: 2026/06/30 12:13:04
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# workflow 名稱,顯示於 Gitea Actions 介面。
|
# workflow 名稱,顯示於 Gitea Actions 介面。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 用途: calculate-version 專案的 CI workflow。
|
# 用途: calculate-version 專案的 CI workflow。
|
||||||
# 在 Pull Request 開啟或更新時,觸發 OpenCode AI 程式碼審查。
|
# 在 Pull Request 開啟或更新時,觸發 OpenCode AI 程式碼審查。
|
||||||
# 更新日期: 2026/06/26 10:28:36
|
# 更新日期: 2026/06/30 12:12:59
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# workflow 名稱,顯示於 Gitea Actions 介面。
|
# workflow 名稱,顯示於 Gitea Actions 介面。
|
||||||
|
|||||||
+33
-15
@@ -1,24 +1,42 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 用途: 建置 calculate-version Action 的容器映像。
|
# 用途: 建置 calculate-version Action 的容器映像。
|
||||||
# 以 Node.js 執行環境打包 /app 下的 Node.js 程式 (專案已從 bash 改寫為 Node.js),
|
# 採多階段建置:build 階段以最新 node 準備主程式 (/app) 與進入點腳本,
|
||||||
# 並安裝 bash 以執行 entrypoint.sh,再由其啟動 Node.js 程式。
|
# runtime 階段改用 node slim 基底縮小最終映像,再由 entrypoint.sh 啟動 Node.js 主程式。
|
||||||
# 更新日期: 2026/06/26 10:28:36
|
# 更新日期: 2026/06/30 12:12:52
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# 基底映像:Node.js LTS 的 Alpine 版本 (體積小)。
|
# 1. 參數處理:node 版本以 ARG 注入。預設使用最新版 (build: node:latest / runtime: node:slim);
|
||||||
FROM node:lts-alpine
|
# 可於建置時以 --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 需另行安裝
|
# ---- build 階段:安裝相依、準備主程式與進入點 ----
|
||||||
# --no-cache 不保留 apk 索引快取以縮小映像(保留 TLS 憑證檢查以防中間人攻擊)。
|
FROM node:${NODE_VERSION} AS build
|
||||||
RUN apk add --no-cache bash
|
WORKDIR /build
|
||||||
|
|
||||||
# 複製容器進入點腳本至根目錄。
|
# 2. 安裝套件:先單獨複製相依描述以利 layer 快取,再安裝 production 相依。
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
# 本專案目前無外部相依;有 package.json 時仍嘗試安裝 (有 lockfile 用 npm ci,否則 npm install),
|
||||||
# 複製整個 app 目錄 (Node.js 程式) 至 /app。
|
# 無相依時為 no-op,但保留此步可在未來新增相依時自動生效。
|
||||||
COPY app/ /app/
|
COPY app/package*.json ./app/
|
||||||
|
RUN if [ -f ./app/package.json ]; then \
|
||||||
|
cd ./app && (npm ci --omit=dev || npm install --omit=dev); \
|
||||||
|
fi
|
||||||
|
|
||||||
# 賦予進入點腳本可執行權限,否則 ENTRYPOINT 無法執行。
|
# 3. 複製檔案:複製主程式 (app/) 與容器進入點腳本。
|
||||||
RUN chmod +x /entrypoint.sh
|
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"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# calculate-version
|
# 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 版本號形如 `<next>-beta.<n>`,其中 `<n>` 為該版本既有 beta 標籤的最大序號加 1。
|
版本進位規則:`patch + 1`;當 `patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`。beta 版本號形如 `<next>-beta.<n>`,其中 `<n>` 為該版本既有 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(app/logger.js)
|
||||||
|
|
||||||
| 功能名稱 | 功能描述 |
|
| 功能名稱 | 功能描述 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [logger.section](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L16) | [輸出帶標題的區塊段落至標準輸出,標題前後以分隔線包夾。](#loggersection) |
|
| [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#L26) | [輸出一般資訊層級的 log 訊息,自動加上 `[info]` 前綴。](#loggerinfo) |
|
| [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#L38) | [輸出錯誤訊息至 stderr(僅輸出,不終止行程)。](#loggererror) |
|
| [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)
|
### 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.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.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.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(app/version.js)
|
||||||
|
|
||||||
| 功能名稱 | 功能描述 |
|
| 功能名稱 | 功能描述 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [version.compareVersionArrays](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/version.js#L12) | [逐區段比較兩個版本號數值陣列,較短者視為較小。](#versioncompareversionarrays) |
|
| [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#L36) | [從 release 清單解析出所有穩定版的版本號數值陣列。](#versionparsestableversions) |
|
| [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#L55) | [取得 release 清單中最新的穩定版版本號字串。](#versionlateststableversion) |
|
| [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#L74) | [依最新穩定版計算下一個發行版本號。](#versionnextreleaseversion) |
|
| [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#L97) | [計算指定版本號的下一個 beta 流水號。](#versionnextbetanumber) |
|
| [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#L122) | [計算最新穩定版與下一個版本號(支援 beta)。](#versioncalculateversion) |
|
| [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(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(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({
|
|||||||
<a id="loggersection"></a>
|
<a id="loggersection"></a>
|
||||||
### logger.section
|
### logger.section
|
||||||
|
|
||||||
輸出帶標題的區塊段落至標準輸出:先一個換行,接著 50 個 `=` 的主分隔線、標題文字,最後 50 個 `-` 的次分隔線,用於在 log 中建立可視段落區隔。
|
輸出帶標題的區塊段落至標準輸出:先一個換行,接著 50 個 `=` 的主分隔線、標題文字,最後 50 個 `-` 的次分隔線,用於在 log 中建立可視段落區隔。此為結構性段落標題,不套用 `[等級][時間]` 前綴。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
@@ -113,25 +113,25 @@ logger.section('參數檢查');
|
|||||||
<a id="loggerinfo"></a>
|
<a id="loggerinfo"></a>
|
||||||
### logger.info
|
### logger.info
|
||||||
|
|
||||||
輸出一般資訊層級訊息至標準輸出,自動加上 `[info]` 前綴與換行;不會結束行程。
|
輸出一般資訊(INF)層級訊息至標準輸出,格式統一為 `[INF][{時間}]: {訊息}`,時間使用台灣時區(Asia/Taipei)、格式 `yyyy/MM/dd HH:mm:ss`,並於結尾換行;不會結束行程。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
|
|
||||||
logger.info('IS_BETA=false');
|
logger.info('IS_BETA=false');
|
||||||
// 輸出:[info] IS_BETA=false
|
// 輸出:[INF][2026/06/30 12:00:00]: IS_BETA=false
|
||||||
```
|
```
|
||||||
|
|
||||||
<a id="loggererror"></a>
|
<a id="loggererror"></a>
|
||||||
### logger.error
|
### logger.error
|
||||||
|
|
||||||
輸出錯誤訊息至標準錯誤輸出(stderr),自動加上 `[error]` 前綴與換行。僅負責輸出,**不終止行程**;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。
|
輸出錯誤(ERR)層級訊息至標準錯誤輸出(stderr),格式統一為 `[ERR][{時間}]: {訊息}`(時間為台灣時區)。僅負責輸出,**不終止行程**;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
|
|
||||||
logger.error('GITEA_SERVER_URL 未設定');
|
logger.error('GITEA_SERVER_URL 未設定');
|
||||||
// 對 stderr 輸出:[error] GITEA_SERVER_URL 未設定
|
// 對 stderr 輸出:[ERR][2026/06/30 12:00:00]: GITEA_SERVER_URL 未設定
|
||||||
```
|
```
|
||||||
|
|
||||||
<a id="configisunset"></a>
|
<a id="configisunset"></a>
|
||||||
@@ -191,7 +191,7 @@ const config = loadConfig({
|
|||||||
<a id="versioncompareversionarrays"></a>
|
<a id="versioncompareversionarrays"></a>
|
||||||
### version.compareVersionArrays
|
### version.compareVersionArrays
|
||||||
|
|
||||||
逐區段(element-wise)比較兩個版本號數值陣列,較短的陣列視為較小。回傳值 `> 0` 表示 a 大於 b、`< 0` 表示 a 小於 b、`0` 表示相等。
|
逐區段(element-wise)比較兩個版本號數值陣列,較短的陣列視為較小。回傳值 `> 0` 表示 a 大於 b、`< 0` 表示 a 小於 b、`0` 表示相等;相異區段回傳的是該段差值(非固定 ±1)。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const { compareVersionArrays } = require('./version');
|
const { compareVersionArrays } = require('./version');
|
||||||
@@ -203,7 +203,7 @@ compareVersionArrays([1, 2], [1, 2, 0]); // < 0(較短者較小)
|
|||||||
<a id="versionparsestableversions"></a>
|
<a id="versionparsestableversions"></a>
|
||||||
### version.parseStableVersions
|
### version.parseStableVersions
|
||||||
|
|
||||||
從 release 清單解析出所有穩定版(排除 beta、排除格式不合法者)的版本號數值陣列;會去除 tag 開頭的 `v`。
|
從 release 清單解析出所有穩定版(排除 beta、排除格式不合法者)的版本號數值陣列;會去除 tag 開頭的 `v`。傳入非陣列時回傳空陣列。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const { parseStableVersions } = require('./version');
|
const { parseStableVersions } = require('./version');
|
||||||
@@ -231,7 +231,7 @@ latestStableVersion([]); // "0.0.0"
|
|||||||
<a id="versionnextreleaseversion"></a>
|
<a id="versionnextreleaseversion"></a>
|
||||||
### version.nextReleaseVersion
|
### version.nextReleaseVersion
|
||||||
|
|
||||||
依最新穩定版字串計算下一個發行版本號:`patch + 1`,`patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`。
|
依最新穩定版字串計算下一個發行版本號:`patch + 1`,`patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`(「逢 10 進位」為本專案自訂約定,非標準 SemVer)。
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const { nextReleaseVersion } = require('./version');
|
const { nextReleaseVersion } = require('./version');
|
||||||
@@ -271,7 +271,7 @@ calculateVersion([{ tag_name: 'v1.2.3' }], true);
|
|||||||
<a id="releasesfetchreleases"></a>
|
<a id="releasesfetchreleases"></a>
|
||||||
### releases.fetchReleases
|
### 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
|
```js
|
||||||
const { fetchReleases } = require('./releases');
|
const { fetchReleases } = require('./releases');
|
||||||
@@ -287,7 +287,7 @@ const releases = await fetchReleases(
|
|||||||
<a id="outputwriteoutput"></a>
|
<a id="outputwriteoutput"></a>
|
||||||
### output.writeOutput
|
### 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
|
```js
|
||||||
const { writeOutput } = require('./output');
|
const { writeOutput } = require('./output');
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ author: 'Jeffery'
|
|||||||
inputs:
|
inputs:
|
||||||
is_beta:
|
is_beta:
|
||||||
description: '是否為 beta 版本'
|
description: '是否為 beta 版本'
|
||||||
default: "false"
|
default: false
|
||||||
outputs:
|
outputs:
|
||||||
version:
|
version:
|
||||||
description: '計算出的版本號'
|
description: '計算出的版本號'
|
||||||
|
|||||||
+37
-3
@@ -42,7 +42,19 @@ function normalizeBetaFlag(value) {
|
|||||||
return value === 'true';
|
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) {
|
function assertHttpUrl(name, value) {
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
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
|
* 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。
|
* @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。
|
||||||
* @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。
|
* @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) {
|
function loadConfig(env = process.env) {
|
||||||
const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL);
|
const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL);
|
||||||
assertHttpUrl('GITEA_SERVER_URL', serverUrl);
|
assertHttpUrl('GITEA_SERVER_URL', serverUrl);
|
||||||
const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY);
|
const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY);
|
||||||
|
assertRepository('GITEA_REPOSITORY', repository);
|
||||||
const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN;
|
const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN;
|
||||||
const isBeta = normalizeBetaFlag(env.IS_BETA);
|
const isBeta = normalizeBetaFlag(env.IS_BETA);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const { writeOutput } = require('./output');
|
|||||||
* @param {Function} [deps.writeOutput] - 寫出 output 的函式。
|
* @param {Function} [deps.writeOutput] - 寫出 output 的函式。
|
||||||
* @param {{section:Function, info:Function, error:Function}} [deps.log] - log 記錄器。
|
* @param {{section:Function, info:Function, error:Function}} [deps.log] - log 記錄器。
|
||||||
* @returns {Promise<string>} 計算出的版本號。
|
* @returns {Promise<string>} 計算出的版本號。
|
||||||
|
* @throws {Error} 當任一注入相依(loadConfig/fetchReleases/calculateVersion/writeOutput)拋出例外時,
|
||||||
|
* 原樣向外傳播,由呼叫端決定如何結束。
|
||||||
|
* @remarks 由模組底部的 require.main 守衛在被直接執行時呼叫;失敗時頂層以模組層級 logger.error 輸出
|
||||||
|
* 並 exit(1)。單元測試可透過 deps 注入替身以覆蓋成功與各失敗路徑。
|
||||||
*/
|
*/
|
||||||
async function main(deps = {}) {
|
async function main(deps = {}) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
+43
-4
@@ -4,39 +4,78 @@
|
|||||||
const LINE = '='.repeat(50);
|
const LINE = '='.repeat(50);
|
||||||
const SUBLINE = '-'.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 中建立可視的段落區隔。
|
* 用於在 log 中建立可視的段落區隔。
|
||||||
*
|
*
|
||||||
* 輸出格式為:換行 + 主分隔線(50 個 `=`)+ 標題 + 次分隔線(50 個 `-`)。
|
* 輸出格式為:換行 + 主分隔線(50 個 `=`)+ 標題 + 次分隔線(50 個 `-`)。
|
||||||
|
* 此為結構性段落標題(非 INF/WRN/ERR 等級訊息),故不套用 `[{等級}][{時間}]` 前綴。
|
||||||
*
|
*
|
||||||
* @param {string} title - 區塊標題文字;會原樣輸出於兩條分隔線之間。
|
* @param {string} title - 區塊標題文字;會原樣輸出於兩條分隔線之間。
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @remarks 通常在進入一個處理階段前呼叫(例如「參數檢查」「取得版本資料」),
|
||||||
|
* 用以在 CI/容器 log 中分隔各階段輸出,便於閱讀與定位。
|
||||||
*/
|
*/
|
||||||
function section(title) {
|
function section(title) {
|
||||||
process.stdout.write(`\n${LINE}\n${title}\n${SUBLINE}\n`);
|
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 - 要輸出的資訊內容。
|
* @param {string} message - 要輸出的資訊內容。
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @remarks 用於回報正常流程進度(如設定值、URL、各頁取得筆數);此調整僅變更輸出前綴格式,
|
||||||
|
* 不改變訊息所反映的實際行為與內容。
|
||||||
*/
|
*/
|
||||||
function info(message) {
|
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 - 要輸出的錯誤描述內容。
|
* @param {string} message - 要輸出的錯誤描述內容。
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @remarks 由進入點在頂層捕捉到例外時呼叫,輸出至 stderr 後再由呼叫端決定 exit code;
|
||||||
|
* 此調整僅變更輸出前綴格式,不改變訊息所反映的實際行為與內容。
|
||||||
*/
|
*/
|
||||||
function error(message) {
|
function error(message) {
|
||||||
process.stderr.write(`[error] ${message}\n`);
|
process.stderr.write(`[ERR][${taipeiTimestamp()}]: ${message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { section, info, error };
|
module.exports = { section, info, error };
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const fs = require('node:fs');
|
|||||||
* @param {string} [file=process.env.GITHUB_OUTPUT] - 輸出檔路徑,預設取自環境變數 `GITHUB_OUTPUT`。
|
* @param {string} [file=process.env.GITHUB_OUTPUT] - 輸出檔路徑,預設取自環境變數 `GITHUB_OUTPUT`。
|
||||||
* @throws {Error} 當輸出檔路徑為 falsy(例如 `GITHUB_OUTPUT` 未設定)時拋出,無法寫入輸出。
|
* @throws {Error} 當輸出檔路徑為 falsy(例如 `GITHUB_OUTPUT` 未設定)時拋出,無法寫入輸出。
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
|
* @remarks 由 main 於計算出版本號後呼叫,將 `version` 寫入 Action output 供後續 step 取用;
|
||||||
|
* 以 append 方式寫入、不覆蓋既有內容,且 value 僅支援單行字串(未處理換行或 `=`)。
|
||||||
*/
|
*/
|
||||||
function writeOutput(name, value, file = process.env.GITHUB_OUTPUT) {
|
function writeOutput(name, value, file = process.env.GITHUB_OUTPUT) {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|||||||
+7
-3
@@ -15,9 +15,11 @@ const RELEASES_PER_PAGE = 10;
|
|||||||
* 進行授權請求,否則以匿名方式請求。
|
* 進行授權請求,否則以匿名方式請求。
|
||||||
* @param {{ info: (message: string) => void }} [options.logger] - 選用的記錄器,
|
* @param {{ info: (message: string) => void }} [options.logger] - 選用的記錄器,
|
||||||
* 需提供 info() 方法以輸出進度訊息。
|
* 需提供 info() 方法以輸出進度訊息。
|
||||||
* @returns {Promise<Array>} 解析為所有 release 物件合併後的陣列。
|
* @returns {Promise<Array>} 解析為所有 release 物件合併後的陣列;首頁即無資料時回傳空陣列。
|
||||||
* @throws {Error} 當網路請求失敗、response 非 2xx、回傳資料無法解析,
|
* @throws {Error} 當網路請求失敗、response 非 2xx、回應本文讀取失敗、回傳資料無法解析,
|
||||||
* 或回傳非陣列資料時拋出。
|
* 或回傳非陣列資料時拋出。
|
||||||
|
* @remarks 依賴 Node.js 18+ 的全域 fetch;baseUrl 不含 query string,函式自行附加
|
||||||
|
* `?limit=<RELEASES_PER_PAGE>&page=<n>` 逐頁請求,直到某頁空字串/`null`/不足一頁為止。
|
||||||
*/
|
*/
|
||||||
async function fetchReleases(baseUrl, options = {}) {
|
async function fetchReleases(baseUrl, options = {}) {
|
||||||
const { token, logger } = options;
|
const { token, logger } = options;
|
||||||
@@ -63,7 +65,9 @@ async function fetchReleases(baseUrl, options = {}) {
|
|||||||
try {
|
try {
|
||||||
pageJson = JSON.parse(text);
|
pageJson = JSON.parse(text);
|
||||||
} catch {
|
} 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) {
|
if (pageJson === null) {
|
||||||
|
|||||||
+42
-4
@@ -36,6 +36,7 @@ test('normalizeBetaFlag 僅將字面值 "true" 視為啟用', () => {
|
|||||||
assert.equal(normalizeBetaFlag('yes'), false);
|
assert.equal(normalizeBetaFlag('yes'), false);
|
||||||
assert.equal(normalizeBetaFlag(undefined), false);
|
assert.equal(normalizeBetaFlag(undefined), false);
|
||||||
assert.equal(normalizeBetaFlag('null'), false);
|
assert.equal(normalizeBetaFlag('null'), false);
|
||||||
|
assert.equal(normalizeBetaFlag('TRUE'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loadConfig 從環境變數組出設定', () => {
|
test('loadConfig 從環境變數組出設定', () => {
|
||||||
@@ -53,16 +54,53 @@ test('loadConfig 從環境變數組出設定', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loadConfig 將未提供的 token 正規化為 null,beta 預設 false', () => {
|
test('loadConfig 將未提供或字面 "null" 的 token 正規化為 null,beta 預設 false', () => {
|
||||||
const config = loadConfig({
|
const omitted = loadConfig({
|
||||||
GITEA_SERVER_URL: 'https://gitea.example.com',
|
GITEA_SERVER_URL: 'https://gitea.example.com',
|
||||||
GITEA_REPOSITORY: 'owner/repo',
|
GITEA_REPOSITORY: 'owner/repo',
|
||||||
});
|
});
|
||||||
assert.equal(config.token, null);
|
assert.equal(omitted.token, null);
|
||||||
assert.equal(config.isBeta, false);
|
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 在缺少必填環境變數時丟出錯誤', () => {
|
test('loadConfig 在缺少必填環境變數時丟出錯誤', () => {
|
||||||
assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/);
|
assert.throws(() => loadConfig({ GITEA_REPOSITORY: 'owner/repo' }), /GITEA_SERVER_URL 未設定/);
|
||||||
assert.throws(() => loadConfig({ GITEA_SERVER_URL: 'https://x' }), /GITEA_REPOSITORY 未設定/);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const { main } = require('../app/index');
|
const { main } = require('../index');
|
||||||
|
|
||||||
// 安靜的 log 記錄器,避免測試輸出雜訊
|
// 安靜的 log 記錄器,避免測試輸出雜訊
|
||||||
const silentLog = { section() {}, info() {}, error() {} };
|
const silentLog = { section() {}, info() {}, error() {} };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -68,3 +68,11 @@ test('fetchReleases 在 null 回應時視為無更多資料', async () => {
|
|||||||
const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases');
|
const releases = await fetchReleases('https://gitea.example.com/api/v1/repos/o/r/releases');
|
||||||
assert.deepEqual(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'),
|
||||||
|
/回傳資料無法解析/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const SEGMENT_LIMIT = 10;
|
|||||||
* @param {number[]} a - 第一個版本號區段陣列,例如 [1, 2, 3]。
|
* @param {number[]} a - 第一個版本號區段陣列,例如 [1, 2, 3]。
|
||||||
* @param {number[]} b - 第二個版本號區段陣列,例如 [1, 2, 0]。
|
* @param {number[]} b - 第二個版本號區段陣列,例如 [1, 2, 0]。
|
||||||
* @returns {number} 大於 0 表示 a 大於 b;小於 0 表示 a 小於 b;0 表示兩者相等。
|
* @returns {number} 大於 0 表示 a 大於 b;小於 0 表示 a 小於 b;0 表示兩者相等。
|
||||||
|
* 相異區段回傳的是該段差值(非固定 ±1)。
|
||||||
|
* @throws {TypeError} 當 a 或 b 非陣列(無 length 屬性)時拋出。
|
||||||
*/
|
*/
|
||||||
function compareVersionArrays(a, b) {
|
function compareVersionArrays(a, b) {
|
||||||
const length = Math.max(a.length, b.length);
|
const length = Math.max(a.length, b.length);
|
||||||
@@ -70,6 +72,8 @@ function latestStableVersion(releases) {
|
|||||||
* 依最新穩定版字串計算下一個發行版本號:patch 加 1,patch 達 10 進位至 minor,minor 達 10 進位至 major。
|
* 依最新穩定版字串計算下一個發行版本號:patch 加 1,patch 達 10 進位至 minor,minor 達 10 進位至 major。
|
||||||
* @param {string} latest - 最新穩定版版本號字串,例如 "1.2.9"。
|
* @param {string} latest - 最新穩定版版本號字串,例如 "1.2.9"。
|
||||||
* @returns {string} 下一個發行版本號字串,格式為 "major.minor.patch",例如 "1.3.0"。
|
* @returns {string} 下一個發行版本號字串,格式為 "major.minor.patch",例如 "1.3.0"。
|
||||||
|
* @remarks 「逢 10 進位」為本專案自訂約定(非標準 SemVer);輸入會先 String() 轉字串,
|
||||||
|
* 缺段或非數字段一律補 0(如 null/undefined → "0.0.1"),major 無進位上限。
|
||||||
*/
|
*/
|
||||||
function nextReleaseVersion(latest) {
|
function nextReleaseVersion(latest) {
|
||||||
const parts = String(latest).split('.').map((part) => Number(part) || 0);
|
const parts = String(latest).split('.').map((part) => Number(part) || 0);
|
||||||
|
|||||||
Regular → Executable
+15
-3
@@ -1,15 +1,27 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 用途: calculate-version Action 的容器進入點 (entrypoint)。
|
# 用途: calculate-version Action 的容器進入點 (entrypoint)。
|
||||||
# 專案已由 bash 改寫為 Node.js,本腳本僅負責啟動 /app/index.js,
|
# 專案已由 bash 改寫為 Node.js,本腳本先輸出 Action 資訊橫幅,
|
||||||
# 並將容器收到的引數原封傳遞給 Node.js 程式執行版本計算邏輯。
|
# 再啟動 /app/index.js,並將容器收到的引數原封傳遞給 Node.js 程式。
|
||||||
# 更新日期: 2026/06/26 10:28:36
|
# 更新日期: 2026/06/30 12:12:56
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# set -e: 任一指令失敗即中止; set -u: 使用未定義變數即報錯;
|
# set -e: 任一指令失敗即中止; set -u: 使用未定義變數即報錯;
|
||||||
# set -o pipefail: 管線中任一指令失敗即視為整體失敗。確保錯誤能即時暴露。
|
# set -o pipefail: 管線中任一指令失敗即視為整體失敗。確保錯誤能即時暴露。
|
||||||
set -euo 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
|
# 進入點:實際邏輯改以 Node.js 實作,置於 /app
|
||||||
# 以 exec 取代當前 shell,讓 node 成為 PID 1 正確接收訊號;
|
# 以 exec 取代當前 shell,讓 node 成為 PID 1 正確接收訊號;
|
||||||
# "$@" 將容器接收到的所有引數原樣轉交給 Node.js 程式。
|
# "$@" 將容器接收到的所有引數原樣轉交給 Node.js 程式。
|
||||||
|
|||||||
@@ -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/);
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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' });
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user