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:
Jeffery
2026-06-26 10:41:34 +08:00
co-authored by Claude Opus 4.8
parent 4983a72524
commit bd6b6017ab
13 changed files with 734 additions and 264 deletions
+68
View File
@@ -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 正規化為 nullbeta 預設 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 未設定/);
});
+35
View File
@@ -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 未設定/);
});
+70
View File
@@ -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, []);
});
+101
View File
@@ -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' });
});