441 lines
12 KiB
JavaScript
441 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const { URL } = require('url');
|
|
|
|
const RELEASES_PER_PAGE = 10;
|
|
let currentStage = null;
|
|
|
|
/**
|
|
* 設定目前訊息輸出的階段名稱。
|
|
*
|
|
* @param {string} title 階段名稱,通常是流程區塊名稱。
|
|
* @remarks
|
|
* 供 `info()` 與錯誤輸出使用,讓訊息可以帶上統一前綴。
|
|
*/
|
|
function section(title) {
|
|
currentStage = title;
|
|
}
|
|
|
|
/**
|
|
* 產生統一格式的時間戳記字串。
|
|
*
|
|
* @returns {string} Asia/Taipei 時區格式化後的時間戳記。
|
|
* @remarks
|
|
* 由 `info()` 與錯誤輸出共用,避免不同輸出路徑各自拼出不同格式。
|
|
*/
|
|
function formatTimestamp() {
|
|
return new Date()
|
|
.toLocaleString('sv-SE', { timeZone: 'Asia/Taipei', hour12: false })
|
|
.replace(/-/g, '/');
|
|
}
|
|
|
|
/**
|
|
* 以統一格式組裝單行 log。
|
|
*
|
|
* @param {string} level 訊息等級。
|
|
* @param {string} message 要輸出的訊息內容。
|
|
* @returns {string} 已格式化完成且含換行的 log 字串。
|
|
* @remarks
|
|
* 讓一般資訊與錯誤訊息共用同一套前綴格式,降低未來調整格式時的維護成本。
|
|
*/
|
|
function formatLogLine(level, message) {
|
|
const stagePrefix = currentStage ? `[${currentStage}]` : '';
|
|
|
|
return `${stagePrefix}[${level}][${formatTimestamp()}]: ${message}\n`;
|
|
}
|
|
|
|
/**
|
|
* 以統一格式輸出資訊訊息。
|
|
*
|
|
* @param {string} message 要輸出的訊息內容。
|
|
* @remarks
|
|
* 會附加目前階段與 Asia/Taipei 時區時間戳記,適合用於流程進度與狀態輸出。
|
|
*/
|
|
function info(message) {
|
|
process.stdout.write(formatLogLine('INF', message));
|
|
}
|
|
|
|
/**
|
|
* 以例外方式終止流程。
|
|
*
|
|
* @param {string} message 錯誤訊息。
|
|
* @remarks
|
|
* 當輸入參數缺失或流程無法繼續時使用,呼叫後會直接拋出 `Error`。
|
|
*/
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
/**
|
|
* 驗證必要環境變數是否有值。
|
|
*
|
|
* @param {string} name 環境變數名稱,用於錯誤訊息。
|
|
* @param {string} value 環境變數值。
|
|
* @returns {string} 驗證通過後的原始值。
|
|
* @remarks
|
|
* 適合用在 action 啟動時檢查 GITEA 相關必要設定。若值缺失,會直接拋出例外。
|
|
*/
|
|
function requireEnv(name, value) {
|
|
if (!value || value === 'null') {
|
|
fail(`${name} 未設定`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* 將 beta 旗標正規化成字串。
|
|
*
|
|
* @param {string} [value='false'] 原始 beta 旗標。
|
|
* @returns {string} 正規化後的字串,空值會變成 `false`。
|
|
* @remarks
|
|
* 用於 action 入口參數,避免 `undefined`、空字串或字串 `null` 進入後續判斷。
|
|
*/
|
|
function normalizeBetaFlag(value = 'false') {
|
|
if (!value || value === 'null') {
|
|
return 'false';
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
/**
|
|
* 將計算結果寫入 action output。
|
|
*
|
|
* @param {string} version 要輸出的版本號。
|
|
* @remarks
|
|
* 若執行環境提供 `GITHUB_OUTPUT`,會寫入檔案;否則退回標準輸出,方便本機除錯。
|
|
*/
|
|
function writeOutput(version) {
|
|
const line = `version=${version}\n`;
|
|
const outputPath = process.env.GITHUB_OUTPUT;
|
|
|
|
if (outputPath) {
|
|
fs.appendFileSync(outputPath, line);
|
|
return;
|
|
}
|
|
|
|
process.stdout.write(line);
|
|
}
|
|
|
|
/**
|
|
* 將版本字串拆成數字陣列。
|
|
*
|
|
* @param {string|number} version 版本字串。
|
|
* @returns {number[]} 由 major、minor、patch 等片段組成的數字陣列。
|
|
* @remarks
|
|
* 非數字片段會被轉成 `0`,適合用於後續比較與遞增計算。
|
|
*/
|
|
function parseVersionParts(version) {
|
|
return String(version)
|
|
.split('.')
|
|
.map((part) => {
|
|
const value = Number.parseInt(part, 10);
|
|
return Number.isFinite(value) ? value : 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 比較兩組版本片段的大小。
|
|
*
|
|
* @param {number[]} left 左側版本片段。
|
|
* @param {number[]} right 右側版本片段。
|
|
* @returns {number} 左小於右回傳負值,左大於右回傳正值,相等回傳 0。
|
|
* @remarks
|
|
* 可直接搭配 `Array.prototype.sort()` 使用,用來找出最新穩定版本。
|
|
*/
|
|
function compareVersionParts(left, right) {
|
|
const maxLength = Math.max(left.length, right.length);
|
|
|
|
for (let index = 0; index < maxLength; index += 1) {
|
|
const a = left[index] ?? 0;
|
|
const b = right[index] ?? 0;
|
|
|
|
if (a !== b) {
|
|
return a - b;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* 從 release tag 解析穩定版版本資訊。
|
|
*
|
|
* @param {string} tagName release tag 名稱。
|
|
* @returns {number[]|null} 解析成功時回傳版本片段,否則回傳 `null`。
|
|
* @remarks
|
|
* 只接受純數字與點號組成的穩定版標籤,例如 `v1.2.3` 或 `1.2.3`。
|
|
*/
|
|
function stableVersionFromTag(tagName) {
|
|
if (typeof tagName !== 'string' || tagName.includes('-beta.')) {
|
|
return null;
|
|
}
|
|
|
|
const versionText = tagName.startsWith('v') ? tagName.slice(1) : tagName;
|
|
if (!/^[0-9]+(\.[0-9]+)*$/.test(versionText)) {
|
|
return null;
|
|
}
|
|
|
|
return parseVersionParts(versionText);
|
|
}
|
|
|
|
/**
|
|
* 取得最新的穩定版版本號。
|
|
*
|
|
* @param {Array<object>} releaseJson release API 回傳資料。
|
|
* @returns {string} 最新穩定版版本號;找不到時回傳 `0.0.0`。
|
|
* @remarks
|
|
* 主要用於計算下一版版本號的基礎值,會忽略 beta 標籤與不合法版本字串。
|
|
*/
|
|
function latestStableVersion(releaseJson) {
|
|
if (!Array.isArray(releaseJson) || releaseJson.length === 0) {
|
|
return '0.0.0';
|
|
}
|
|
|
|
const stableVersions = releaseJson
|
|
.map((release) => stableVersionFromTag(release && release.tag_name))
|
|
.filter(Boolean);
|
|
|
|
if (stableVersions.length === 0) {
|
|
return '0.0.0';
|
|
}
|
|
|
|
stableVersions.sort(compareVersionParts);
|
|
const latest = stableVersions[stableVersions.length - 1];
|
|
return `${latest[0] ?? 0}.${latest[1] ?? 0}.${latest[2] ?? 0}`;
|
|
}
|
|
|
|
/**
|
|
* 計算下一個正式版版本號。
|
|
*
|
|
* @param {string} latestVersion 目前最新的正式版版本號。
|
|
* @returns {string} 下一個正式版版本號。
|
|
* @remarks
|
|
* 進位規則以 10 為界,適合與 `latestStableVersion()` 搭配使用。
|
|
*/
|
|
function nextReleaseVersion(latestVersion) {
|
|
const [majorRaw, minorRaw, patchRaw] = parseVersionParts(latestVersion);
|
|
let major = majorRaw ?? 0;
|
|
let minor = minorRaw ?? 0;
|
|
let patch = patchRaw ?? 0;
|
|
|
|
patch += 1;
|
|
if (patch >= 10) {
|
|
patch = 0;
|
|
minor += 1;
|
|
}
|
|
|
|
if (minor >= 10) {
|
|
minor = 0;
|
|
major += 1;
|
|
}
|
|
|
|
return `${major}.${minor}.${patch}`;
|
|
}
|
|
|
|
/**
|
|
* 計算下一個 beta 編號。
|
|
*
|
|
* @param {Array<object>} releaseJson release API 回傳資料。
|
|
* @param {string} version 目標正式版版本號。
|
|
* @returns {number} 下一個 beta 編號,最小為 1。
|
|
* @remarks
|
|
* 用於 beta 發版流程,會忽略不符合命名格式或無法解析成整數的 tag。
|
|
*/
|
|
function nextBetaNumber(releaseJson, version) {
|
|
if (!Array.isArray(releaseJson) || releaseJson.length === 0) {
|
|
return 1;
|
|
}
|
|
|
|
const prefix = `v${version}-beta.`;
|
|
const values = releaseJson
|
|
.map((release) => (release && typeof release.tag_name === 'string' ? release.tag_name : ''))
|
|
.filter((tagName) => tagName.startsWith(prefix))
|
|
.map((tagName) => Number.parseInt(tagName.slice(prefix.length), 10))
|
|
.filter((value) => Number.isInteger(value));
|
|
|
|
if (values.length === 0) {
|
|
return 1;
|
|
}
|
|
|
|
return Math.max(...values) + 1;
|
|
}
|
|
|
|
/**
|
|
* 計算目前最新版本與下一個版本。
|
|
*
|
|
* @param {Array<object>} releaseJson release API 回傳資料。
|
|
* @param {string} isBeta 是否計算 beta 版本,`true` 代表 beta。
|
|
* @returns {string[]} 回傳 `[latestVersion, nextVersion]`。
|
|
* @remarks
|
|
* 這是 action 的核心邏輯,通常由 `main()` 取得 release 後再呼叫。
|
|
*/
|
|
function calculateVersion(releaseJson, isBeta) {
|
|
if (!Array.isArray(releaseJson) || releaseJson.length === 0) {
|
|
return isBeta === 'true'
|
|
? ['0.0.0', '0.0.1-beta.1']
|
|
: ['0.0.0', '0.0.1'];
|
|
}
|
|
|
|
const baseVersion = latestStableVersion(releaseJson);
|
|
const nextVersion = nextReleaseVersion(baseVersion);
|
|
|
|
if (isBeta === 'true') {
|
|
const betaNumber = nextBetaNumber(releaseJson, nextVersion);
|
|
return [baseVersion, `${nextVersion}-beta.${betaNumber}`];
|
|
}
|
|
|
|
return [baseVersion, nextVersion];
|
|
}
|
|
|
|
/**
|
|
* 以 HTTP/HTTPS 取得 JSON 回應。
|
|
*
|
|
* @param {URL} url 請求目標。
|
|
* @param {string} token 驗證 token;空值或字串 `null` 會被忽略。
|
|
* @returns {Promise<unknown>} 成功時解析為 JSON 物件或空字串。
|
|
* @remarks
|
|
* 失敗時會拋出錯誤,適合拿來包裝 release API 的低階請求。
|
|
*/
|
|
function requestJson(url, token) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = url.protocol === 'http:' ? http : https;
|
|
const headers = {
|
|
Accept: 'application/json',
|
|
};
|
|
|
|
if (token && token !== 'null') {
|
|
headers.Authorization = `token ${token}`;
|
|
}
|
|
|
|
const request = client.request(url, { method: 'GET', headers }, (response) => {
|
|
let body = '';
|
|
|
|
response.setEncoding('utf8');
|
|
response.on('data', (chunk) => {
|
|
body += chunk;
|
|
});
|
|
response.on('end', () => {
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
reject(new Error(`release API 請求失敗 (${response.statusCode})`));
|
|
return;
|
|
}
|
|
|
|
if (body === '') {
|
|
resolve('');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
resolve(JSON.parse(body));
|
|
} catch (error) {
|
|
reject(new Error(`release API 回傳資料無法解析: ${error.message}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
request.on('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 逐頁抓取 repository 的 release 清單。
|
|
*
|
|
* @param {string|URL} baseUrl Gitea API 基底網址。
|
|
* @param {string} repository repository 路徑。
|
|
* @param {string} token API 存取 token。
|
|
* @returns {Promise<Array<object>>} 合併後的 release 清單。
|
|
* @remarks
|
|
* 會依 `RELEASES_PER_PAGE` 分頁,當 API 回傳空資料或最後一頁不足數量時停止。
|
|
*/
|
|
async function fetchReleases(baseUrl, repository, token) {
|
|
const combined = [];
|
|
let page = 1;
|
|
|
|
while (true) {
|
|
const pageUrl = new URL(`/api/v1/repos/${repository}/releases`, baseUrl);
|
|
pageUrl.searchParams.set('limit', String(RELEASES_PER_PAGE));
|
|
pageUrl.searchParams.set('page', String(page));
|
|
|
|
const pageJson = await requestJson(pageUrl, token);
|
|
|
|
if (pageJson === '' || pageJson === null) {
|
|
break;
|
|
}
|
|
|
|
if (!Array.isArray(pageJson)) {
|
|
throw new Error(`release API 回傳非陣列資料 (page=${page})`);
|
|
}
|
|
|
|
info(`第 ${page} 頁取得 ${pageJson.length} 筆 release`);
|
|
combined.push(...pageJson);
|
|
|
|
if (pageJson.length < RELEASES_PER_PAGE) {
|
|
break;
|
|
}
|
|
|
|
page += 1;
|
|
}
|
|
|
|
return combined;
|
|
}
|
|
|
|
/**
|
|
* 執行 calculate-version action 的主流程。
|
|
*
|
|
* @returns {Promise<void>} 完成時不回傳值,失敗時會拋出例外。
|
|
* @remarks
|
|
* 需要在 Gitea action 環境中執行,並依賴 `GITEA_SERVER_URL`、`GITEA_REPOSITORY` 與 token 相關環境變數。
|
|
*/
|
|
async function main() {
|
|
section('參數檢查');
|
|
|
|
const giteaServerUrl = requireEnv('GITEA_SERVER_URL', process.env.GITEA_SERVER_URL || '');
|
|
const giteaRepository = requireEnv('GITEA_REPOSITORY', process.env.GITEA_REPOSITORY || '');
|
|
const authToken = process.env.RUNNER_TOKEN || process.env.GITEA_TOKEN || '';
|
|
const isBeta = normalizeBetaFlag(process.env.IS_BETA || 'false');
|
|
|
|
info(`RUNNER_TOKEN=${authToken && authToken !== 'null' ? '***' : '未提供'}`);
|
|
info(`IS_BETA=${isBeta}`);
|
|
|
|
section('取得版本資料');
|
|
|
|
const releaseUrl = new URL(`/api/v1/repos/${giteaRepository}/releases`, `${giteaServerUrl.replace(/\/$/, '')}/`);
|
|
info(`RELEASE_URL=${releaseUrl.toString()}`);
|
|
|
|
const releaseJson = await fetchReleases(giteaServerUrl, giteaRepository, authToken);
|
|
const [latestTag, newVersion] = calculateVersion(releaseJson, isBeta);
|
|
|
|
info(`LATEST_VERSION=${latestTag}`);
|
|
|
|
section('計算版本號');
|
|
info(`NEW_VERSION=${newVersion}`);
|
|
writeOutput(newVersion);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((error) => {
|
|
process.stderr.write(formatLogLine('ERR', error.message));
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
calculateVersion,
|
|
compareVersionParts,
|
|
fetchReleases,
|
|
latestStableVersion,
|
|
main,
|
|
nextBetaNumber,
|
|
nextReleaseVersion,
|
|
normalizeBetaFlag,
|
|
parseVersionParts,
|
|
stableVersionFromTag,
|
|
};
|