Merge pull request 'refactor: calculate-version 由 bash 改寫為 Node.js' (#6) from develop into master
CD / Release Tag Version (push) Successful in 4s
CD / Release Tag Version (push) Successful in 4s
Reviewed-on: docker-actions/calculate-version#6 Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"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() 為防禦性處理,確保被直接以非字串呼叫時也不致拋錯。"
|
||||
},
|
||||
{
|
||||
"location": "Dockerfile:13",
|
||||
"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,單點即可調整;為固定的視覺樣式再加產生函數屬過度設計。"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +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
|
||||
}
|
||||
]
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+17
-3
@@ -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 索引快取以縮小映像(保留 TLS 憑證檢查以防中間人攻擊)。
|
||||
RUN apk add --no-cache 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"]
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# calculate-version
|
||||
|
||||
> 更新時間:2026/06/26 10:49:46
|
||||
|
||||
計算版本號的 Gitea Action。依現有 release 推算下一個穩定版或 beta 版本號,並將結果寫入 Action output `version`。核心邏輯以 Node.js 實作,置於 `app/`,由 `entrypoint.sh` 作為容器進入點啟動。
|
||||
|
||||
版本進位規則:`patch + 1`;當 `patch` 達 10 進位至 `minor`,`minor` 達 10 進位至 `major`。beta 版本號形如 `<next>-beta.<n>`,其中 `<n>` 為該版本既有 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) |
|
||||
|
||||
## 功能列表
|
||||
|
||||
### 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.error](https://gitea.jsc.idv.tw/docker-actions/calculate-version/src/branch/develop/app/logger.js#L38) | [輸出錯誤訊息至 stderr(僅輸出,不終止行程)。](#loggererror) |
|
||||
|
||||
### 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#L68) | [從環境變數載入並驗證執行所需的設定。](#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) |
|
||||
|
||||
## 使用範例
|
||||
|
||||
<a id="indexmain"></a>
|
||||
### 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'
|
||||
```
|
||||
|
||||
<a id="loggersection"></a>
|
||||
### logger.section
|
||||
|
||||
輸出帶標題的區塊段落至標準輸出:先一個換行,接著 50 個 `=` 的主分隔線、標題文字,最後 50 個 `-` 的次分隔線,用於在 log 中建立可視段落區隔。
|
||||
|
||||
```js
|
||||
const logger = require('./logger');
|
||||
|
||||
logger.section('參數檢查');
|
||||
// 輸出:
|
||||
//
|
||||
// ==================================================
|
||||
// 參數檢查
|
||||
// --------------------------------------------------
|
||||
```
|
||||
|
||||
<a id="loggerinfo"></a>
|
||||
### logger.info
|
||||
|
||||
輸出一般資訊層級訊息至標準輸出,自動加上 `[info]` 前綴與換行;不會結束行程。
|
||||
|
||||
```js
|
||||
const logger = require('./logger');
|
||||
|
||||
logger.info('IS_BETA=false');
|
||||
// 輸出:[info] IS_BETA=false
|
||||
```
|
||||
|
||||
<a id="loggererror"></a>
|
||||
### logger.error
|
||||
|
||||
輸出錯誤訊息至標準錯誤輸出(stderr),自動加上 `[error]` 前綴與換行。僅負責輸出,**不終止行程**;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。
|
||||
|
||||
```js
|
||||
const logger = require('./logger');
|
||||
|
||||
logger.error('GITEA_SERVER_URL 未設定');
|
||||
// 對 stderr 輸出:[error] GITEA_SERVER_URL 未設定
|
||||
```
|
||||
|
||||
<a id="configisunset"></a>
|
||||
### config.isUnset
|
||||
|
||||
判斷環境變數值是否視為「未設定」。`undefined`、`null`、空字串、字面字串 `"null"` 皆視為未設定。
|
||||
|
||||
```js
|
||||
const { isUnset } = require('./config');
|
||||
|
||||
isUnset(undefined); // true
|
||||
isUnset('null'); // true
|
||||
isUnset('false'); // false
|
||||
```
|
||||
|
||||
<a id="configrequireenv"></a>
|
||||
### config.requireEnv
|
||||
|
||||
驗證必填環境變數;未設定時拋出 `Error`(訊息為 `${name} 未設定`),已設定時原樣回傳值。
|
||||
|
||||
```js
|
||||
const { requireEnv } = require('./config');
|
||||
|
||||
const url = requireEnv('GITEA_SERVER_URL', process.env.GITEA_SERVER_URL);
|
||||
// 未設定時拋出:Error: GITEA_SERVER_URL 未設定
|
||||
```
|
||||
|
||||
<a id="confignormalizebetaflag"></a>
|
||||
### config.normalizeBetaFlag
|
||||
|
||||
將 beta 旗標正規化為布林值。未設定時預設為 `false`;僅當值嚴格等於字面字串 `"true"` 時回傳 `true`。
|
||||
|
||||
```js
|
||||
const { normalizeBetaFlag } = require('./config');
|
||||
|
||||
normalizeBetaFlag('true'); // true
|
||||
normalizeBetaFlag('TRUE'); // false(嚴格比較,不做大小寫轉換)
|
||||
normalizeBetaFlag(undefined); // false
|
||||
```
|
||||
|
||||
<a id="configloadconfig"></a>
|
||||
### config.loadConfig
|
||||
|
||||
從環境變數載入並驗證執行所需的設定。`GITEA_SERVER_URL` 與 `GITEA_REPOSITORY` 為必填(未設定即拋錯),且 `GITEA_SERVER_URL` 須為合法的 http/https URL;`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 }
|
||||
```
|
||||
|
||||
<a id="versioncompareversionarrays"></a>
|
||||
### 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(較短者較小)
|
||||
```
|
||||
|
||||
<a id="versionparsestableversions"></a>
|
||||
### 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]]
|
||||
```
|
||||
|
||||
<a id="versionlateststableversion"></a>
|
||||
### 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"
|
||||
```
|
||||
|
||||
<a id="versionnextreleaseversion"></a>
|
||||
### 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"
|
||||
```
|
||||
|
||||
<a id="versionnextbetanumber"></a>
|
||||
### version.nextBetaNumber
|
||||
|
||||
計算指定版本號的下一個 beta 流水號:取現有相符 beta 標籤(`v<version>-beta.<n>`)的最大序號加 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
|
||||
```
|
||||
|
||||
<a id="versioncalculateversion"></a>
|
||||
### 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' }
|
||||
```
|
||||
|
||||
<a id="releasesfetchreleases"></a>
|
||||
### 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 物件合併後的陣列
|
||||
```
|
||||
|
||||
<a id="outputwriteoutput"></a>
|
||||
### 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
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
'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';
|
||||
}
|
||||
|
||||
// 驗證字串為合法的 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_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 未設定,或 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);
|
||||
|
||||
return { serverUrl, repository, token, isBeta };
|
||||
}
|
||||
|
||||
module.exports = { isUnset, requireEnv, normalizeBetaFlag, loadConfig };
|
||||
@@ -0,0 +1,65 @@
|
||||
'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 或 fetchReleases 拋錯)直接向外拋出例外,由呼叫端決定如何結束,
|
||||
* 以利單元測試覆蓋失敗路徑。相依模組可透過 deps 注入,預設使用各兄弟模組的實作。
|
||||
*
|
||||
* @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<string>} 計算出的版本號。
|
||||
*/
|
||||
async function main(deps = {}) {
|
||||
const {
|
||||
loadConfig: loadConfigFn = loadConfig,
|
||||
fetchReleases: fetchReleasesFn = fetchReleases,
|
||||
calculateVersion: calculateVersionFn = calculateVersion,
|
||||
writeOutput: writeOutputFn = writeOutput,
|
||||
log = logger,
|
||||
} = deps;
|
||||
|
||||
log.section('參數檢查');
|
||||
|
||||
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}`);
|
||||
|
||||
log.section('取得版本資料');
|
||||
|
||||
const releaseUrl = `${config.serverUrl}/api/v1/repos/${config.repository}/releases`;
|
||||
log.info(`RELEASE_URL=${releaseUrl}`);
|
||||
|
||||
const releases = await fetchReleasesFn(releaseUrl, { token: config.token, logger: log });
|
||||
|
||||
const { latest, version } = calculateVersionFn(releases, config.isBeta);
|
||||
log.info(`LATEST_VERSION=${latest}`);
|
||||
|
||||
log.section('計算版本號');
|
||||
log.info(`NEW_VERSION=${version}`);
|
||||
writeOutputFn('version', version);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
// 僅在被直接執行時啟動(被 require/測試載入時不自動執行),失敗則在頂層回報並以狀態碼 1 結束
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
logger.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -0,0 +1,42 @@
|
||||
'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),自動加上 `[error]` 前綴與換行。
|
||||
*
|
||||
* 僅負責輸出,不終止行程;是否結束由呼叫端(進入點)決定,以利測試與錯誤復原。
|
||||
*
|
||||
* @param {string} message - 要輸出的錯誤描述內容。
|
||||
* @returns {void}
|
||||
*/
|
||||
function error(message) {
|
||||
process.stderr.write(`[error] ${message}\n`);
|
||||
}
|
||||
|
||||
module.exports = { section, info, error };
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'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 <token>`
|
||||
* 進行授權請求,否則以匿名方式請求。
|
||||
* @param {{ info: (message: string) => void }} [options.logger] - 選用的記錄器,
|
||||
* 需提供 info() 方法以輸出進度訊息。
|
||||
* @returns {Promise<Array>} 解析為所有 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})`);
|
||||
}
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = await response.text();
|
||||
} catch (error) {
|
||||
throw new Error(`release API 回應讀取失敗 (page=${page}): ${error.message}`);
|
||||
}
|
||||
|
||||
// 空字串或 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 };
|
||||
@@ -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 未設定/);
|
||||
});
|
||||
@@ -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 未設定/);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
+140
@@ -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<version>-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 形如 "<next>-beta.<n>"。
|
||||
* @returns {{latest: string, version: string}} 回傳物件:
|
||||
* - latest: 現有最新穩定版版本號字串(查無時為 "0.0.0")。
|
||||
* - version: 本次要使用的版本號字串;非 beta 為下一個發行版(如 "1.3.0"),beta 為 "<next>-beta.<n>"(如 "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,
|
||||
};
|
||||
+12
-261
@@ -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 "$@"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'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/);
|
||||
});
|
||||
@@ -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 請求失敗/);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
'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');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
Reference in New Issue
Block a user