refactor(calculate-version): 將版本計算邏輯由 bash 改寫為 Node.js 並依功能分檔
保留 entrypoint.sh 作為容器進入點,改為啟動 app/ 下的 Node.js 程式; 依功能拆分為 logger/config/version/releases/output/index,並更新 Dockerfile 改用 node:lts-alpine。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4983a72524
commit
bd6b6017ab
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 判斷環境變數值是否視為「未設定」。
|
||||
*
|
||||
* 下列任一情況皆視為未設定:undefined、null、空字串、字面字串 "null"。
|
||||
*
|
||||
* @param {*} value - 欲檢查的值(通常為環境變數字串)。
|
||||
* @returns {boolean} 視為未設定時回傳 true,否則回傳 false。
|
||||
*/
|
||||
function isUnset(value) {
|
||||
return value === undefined || value === null || value === '' || value === 'null';
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗證必填環境變數;未設定時拋出錯誤。
|
||||
*
|
||||
* @param {string} name - 環境變數名稱,用於組出錯誤訊息。
|
||||
* @param {*} value - 環境變數的值。
|
||||
* @returns {*} 驗證通過後原樣回傳的 value。
|
||||
* @throws {Error} 當 value 被視為未設定(undefined/null/空字串/"null")時拋出,訊息為 `${name} 未設定`。
|
||||
*/
|
||||
function requireEnv(name, value) {
|
||||
if (isUnset(value)) {
|
||||
throw new Error(`${name} 未設定`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 beta 旗標正規化為布林值。
|
||||
*
|
||||
* 未設定時預設為 false;僅當值嚴格等於字面字串 "true" 時回傳 true。
|
||||
*
|
||||
* @param {*} value - beta 旗標環境變數的值。
|
||||
* @returns {boolean} 啟用 beta 時回傳 true,否則回傳 false。
|
||||
*/
|
||||
function normalizeBetaFlag(value) {
|
||||
if (isUnset(value)) {
|
||||
return false;
|
||||
}
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* 從環境變數載入並驗證執行所需的設定。
|
||||
*
|
||||
* GITEA_SERVER_URL 與 GITEA_REPOSITORY 為必填,未設定時會拋出錯誤;
|
||||
* GITEA_TOKEN 為非必填,未設定時為 null;IS_BETA 會被正規化為布林值。
|
||||
*
|
||||
* @param {Object} [env=process.env] - 環境變數來源物件,預設為 process.env。
|
||||
* @returns {{ serverUrl: string, repository: string, token: (string|null), isBeta: boolean }} 已驗證的設定物件。
|
||||
* @throws {Error} 當 GITEA_SERVER_URL 或 GITEA_REPOSITORY 未設定時拋出。
|
||||
*/
|
||||
function loadConfig(env = process.env) {
|
||||
const serverUrl = requireEnv('GITEA_SERVER_URL', env.GITEA_SERVER_URL);
|
||||
const repository = requireEnv('GITEA_REPOSITORY', env.GITEA_REPOSITORY);
|
||||
const token = isUnset(env.GITEA_TOKEN) ? null : env.GITEA_TOKEN;
|
||||
const isBeta = normalizeBetaFlag(env.IS_BETA);
|
||||
|
||||
return { serverUrl, repository, token, isBeta };
|
||||
}
|
||||
|
||||
module.exports = { isUnset, requireEnv, normalizeBetaFlag, loadConfig };
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
|
||||
const logger = require('./logger');
|
||||
const { loadConfig } = require('./config');
|
||||
const { fetchReleases } = require('./releases');
|
||||
const { calculateVersion } = require('./version');
|
||||
const { writeOutput } = require('./output');
|
||||
|
||||
/**
|
||||
* Action 進入點:協調設定載入、release 取得、版本號計算與輸出寫出。
|
||||
*
|
||||
* 流程:印出「參數檢查」區段並呼叫 loadConfig() 載入設定(失敗則以 logger.fail
|
||||
* 回報並結束);印出設定值(token 以 *** 或「未提供」顯示);組出 release URL 並
|
||||
* 以 fetchReleases() 取得 release 清單(失敗則以 logger.fail 回報並結束);以
|
||||
* calculateVersion() 計算最新版與新版號,最後以 writeOutput('version', version)
|
||||
* 寫出 version 輸出。
|
||||
*
|
||||
* Side effects:透過 logger 輸出至 stdout/stderr、失敗時使 process 以失敗狀態
|
||||
* 結束、並寫入 GITHUB_OUTPUT。此函式於模組載入時即被呼叫。
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function main() {
|
||||
logger.section('參數檢查');
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig();
|
||||
} catch (error) {
|
||||
logger.fail(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`GITEA_SERVER_URL=${config.serverUrl}`);
|
||||
logger.info(`GITEA_REPOSITORY=${config.repository}`);
|
||||
logger.info(config.token ? 'GITEA_TOKEN=***' : 'GITEA_TOKEN=未提供');
|
||||
logger.info(`IS_BETA=${config.isBeta}`);
|
||||
|
||||
logger.section('取得版本資料');
|
||||
|
||||
const releaseUrl = `${config.serverUrl}/api/v1/repos/${config.repository}/releases`;
|
||||
logger.info(`RELEASE_URL=${releaseUrl}`);
|
||||
|
||||
let releases;
|
||||
try {
|
||||
releases = await fetchReleases(releaseUrl, { token: config.token, logger });
|
||||
} catch (error) {
|
||||
logger.fail(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const { latest, version } = calculateVersion(releases, config.isBeta);
|
||||
logger.info(`LATEST_VERSION=${latest}`);
|
||||
|
||||
logger.section('計算版本號');
|
||||
logger.info(`NEW_VERSION=${version}`);
|
||||
writeOutput('version', version);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
// 主分隔線與次分隔線寬度(沿用原 entrypoint.sh 的視覺樣式)
|
||||
const LINE = '='.repeat(50);
|
||||
const SUBLINE = '-'.repeat(50);
|
||||
|
||||
/**
|
||||
* 輸出帶標題的區塊段落至標準輸出,標題前後以分隔線包夾,
|
||||
* 用於在 log 中建立可視的段落區隔。
|
||||
*
|
||||
* 輸出格式為:換行 + 主分隔線(50 個 `=`)+ 標題 + 次分隔線(50 個 `-`)。
|
||||
*
|
||||
* @param {string} title - 區塊標題文字;會原樣輸出於兩條分隔線之間。
|
||||
* @returns {void}
|
||||
*/
|
||||
function section(title) {
|
||||
process.stdout.write(`\n${LINE}\n${title}\n${SUBLINE}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 輸出一般資訊層級的 log 訊息至標準輸出,自動加上 `[info]` 前綴與換行。
|
||||
*
|
||||
* @param {string} message - 要輸出的資訊內容。
|
||||
* @returns {void}
|
||||
*/
|
||||
function info(message) {
|
||||
process.stdout.write(`[info] ${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 輸出錯誤層級的 log 訊息至標準錯誤輸出(stderr),並以狀態碼 1
|
||||
* 立即終止整個行程。用於發生無法復原的錯誤時中止執行。
|
||||
*
|
||||
* 注意:此函式呼叫 `process.exit(1)`,正常情況下不會回傳給呼叫端,
|
||||
* 其後的程式碼不會被執行。
|
||||
*
|
||||
* @param {string} message - 要輸出的錯誤描述內容。
|
||||
* @returns {never} 不會正常回傳(行程會被終止)。
|
||||
*/
|
||||
function fail(message) {
|
||||
process.stderr.write(`[error] ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
module.exports = { section, info, fail };
|
||||
@@ -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,87 @@
|
||||
'use strict';
|
||||
|
||||
// 每頁取得的 release 筆數
|
||||
const RELEASES_PER_PAGE = 10;
|
||||
|
||||
/**
|
||||
* 以分頁方式取得指定 Gitea repo 的所有 release,並回傳合併後的陣列。
|
||||
*
|
||||
* 使用全域 fetch 逐頁請求,每頁 limit 為 10;當某頁回傳空資料、null
|
||||
* 或筆數少於每頁上限時即停止取得。
|
||||
*
|
||||
* @param {string} baseUrl - release API 的基底 URL(不含 query string)。
|
||||
* @param {object} [options={}] - 選用設定。
|
||||
* @param {string} [options.token] - 授權 token;有值時以 `Authorization: token <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})`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// 空字串或 null 代表已無更多資料
|
||||
if (!text || text === 'null') {
|
||||
break;
|
||||
}
|
||||
|
||||
let pageJson;
|
||||
try {
|
||||
pageJson = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`release API 回傳資料無法解析 (page=${page})`);
|
||||
}
|
||||
|
||||
if (pageJson === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!Array.isArray(pageJson)) {
|
||||
throw new Error(`release API 回傳非陣列資料 (page=${page})`);
|
||||
}
|
||||
|
||||
const count = pageJson.length;
|
||||
logger?.info(`第 ${page} 頁取得 ${count} 筆 release`);
|
||||
combined.push(...pageJson);
|
||||
|
||||
// 不足一頁代表已取完
|
||||
if (count < RELEASES_PER_PAGE) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
module.exports = { RELEASES_PER_PAGE, fetchReleases };
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user