fix(cleanup-release): harden API cleanup flow
This commit is contained in:
+102
-56
@@ -1,6 +1,16 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const taipeiFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
let currentStage = '';
|
||||
|
||||
/**
|
||||
@@ -10,16 +20,7 @@ let currentStage = '';
|
||||
* @returns {string} `yyyy/MM/dd HH:mm:ss` 格式時間字串。
|
||||
*/
|
||||
function formatTaipeiTimestamp(date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
const parts = taipeiFormatter.formatToParts(date);
|
||||
|
||||
const lookup = {};
|
||||
for (const part of parts) {
|
||||
@@ -31,6 +32,32 @@ function formatTaipeiTimestamp(date = new Date()) {
|
||||
return `${lookup.year}/${lookup.month}/${lookup.day} ${lookup.hour}:${lookup.minute}:${lookup.second}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將未信任內容整理成適合記錄到 log 或錯誤訊息的文字。
|
||||
*
|
||||
* @param {*} value 原始值。
|
||||
* @returns {string} 已去除控制字元的文字。
|
||||
*/
|
||||
function sanitizeLogText(value) {
|
||||
return String(value).replace(/[\u0000-\u001f\u007f]/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 將回應內容整理成適合放進例外訊息的摘要。
|
||||
*
|
||||
* @param {*} body 回應內容。
|
||||
* @param {number} [maxLength=200] 最長保留長度。
|
||||
* @returns {string} 已整理的回應摘要。
|
||||
*/
|
||||
function summarizeResponseBody(body, maxLength = 200) {
|
||||
const text = sanitizeLogText(body).replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, maxLength)}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 組合統一格式的 log 字串。
|
||||
*
|
||||
@@ -39,8 +66,8 @@ function formatTaipeiTimestamp(date = new Date()) {
|
||||
* @returns {string} 已格式化的 log 字串。
|
||||
*/
|
||||
function formatLog(level, message) {
|
||||
const stagePrefix = currentStage ? `[${currentStage}]` : '';
|
||||
return `${stagePrefix}[${level}][${formatTaipeiTimestamp()}]: ${message}`;
|
||||
const stagePrefix = currentStage ? `[${sanitizeLogText(currentStage)}]` : '';
|
||||
return `${stagePrefix}[${level}][${formatTaipeiTimestamp()}]: ${sanitizeLogText(message)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,11 +90,6 @@ function writeStderr(level, message) {
|
||||
process.stderr.write(`${formatLog(level, message)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留舊介面以維持草稿對應,實際上不再輸出橫幅。
|
||||
*/
|
||||
function separator() {}
|
||||
|
||||
/**
|
||||
* 切換目前訊息所屬區塊,供 log 前綴使用。
|
||||
*
|
||||
@@ -86,15 +108,6 @@ function info(message) {
|
||||
writeStdout('INF', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 輸出成功訊息。
|
||||
*
|
||||
* @param {string} message 訊息內容。
|
||||
*/
|
||||
function success(message) {
|
||||
writeStdout('INF', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 輸出警告訊息。
|
||||
*
|
||||
@@ -158,12 +171,15 @@ function requireInteger(name, value) {
|
||||
* @param {{ method?: string, headers?: Record<string, string> }} [options] request 設定。
|
||||
* @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。
|
||||
*/
|
||||
function requestJson(url, { method = 'GET', headers = {} } = {}) {
|
||||
function request(url, { method = 'GET', headers = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = new URL(url);
|
||||
const client = target.protocol === 'http:' ? http : https;
|
||||
if (target.protocol !== 'https:') {
|
||||
reject(new Error(`Refusing to send request to non-HTTPS URL: ${target.origin}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const req = client.request(
|
||||
const req = https.request(
|
||||
target,
|
||||
{
|
||||
method,
|
||||
@@ -202,13 +218,19 @@ async function fetchAllPages(baseUrl, headers) {
|
||||
|
||||
for (let page = 1; ; page += 1) {
|
||||
const pageUrl = `${baseUrl}?page=${page}`;
|
||||
const { statusCode, body } = await requestJson(pageUrl, { headers });
|
||||
const { statusCode, body } = await request(pageUrl, { headers });
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new Error(`GET ${pageUrl} failed with HTTP ${statusCode}: ${body}`);
|
||||
throw new Error(`GET ${pageUrl} failed with HTTP ${statusCode}: ${summarizeResponseBody(body)}`);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(body || '[]');
|
||||
} catch (error) {
|
||||
throw new Error(`GET ${pageUrl} returned invalid JSON: ${summarizeResponseBody(body)}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(body || '[]');
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`GET ${pageUrl} did not return a JSON array`);
|
||||
}
|
||||
@@ -231,12 +253,26 @@ async function fetchAllPages(baseUrl, headers) {
|
||||
* @returns {Promise<{ statusCode: number, body: string }>} 回應狀態碼與內容。
|
||||
*/
|
||||
async function deleteResource(url, headers) {
|
||||
return requestJson(url, {
|
||||
return request(url, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 以固定批次大小處理項目,降低逐筆等待造成的延遲。
|
||||
*
|
||||
* @param {any[]} items 要處理的項目。
|
||||
* @param {number} batchSize 每批同時處理的數量。
|
||||
* @param {(item: any) => Promise<void>} handler 單筆處理函式。
|
||||
*/
|
||||
async function processInBatches(items, batchSize, handler) {
|
||||
for (let index = 0; index < items.length; index += batchSize) {
|
||||
const batch = items.slice(index, index + batchSize);
|
||||
await Promise.all(batch.map((item) => handler(item)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 release 與 tag 清理流程。
|
||||
*/
|
||||
@@ -281,41 +317,45 @@ async function main() {
|
||||
info(`RELEASE_COUNT=${releaseCount}`);
|
||||
info(`KEEP_COUNT=${KEEP_COUNT}`);
|
||||
|
||||
let hadFailure = false;
|
||||
|
||||
if (releaseCount <= keepCount) {
|
||||
success('沒有需要清理的舊版本成品');
|
||||
info('沒有需要清理的舊版本成品');
|
||||
} else {
|
||||
section('刪除舊版本成品');
|
||||
|
||||
const releaseToDelete = releaseJson.slice(keepCount);
|
||||
for (const releaseItem of releaseToDelete) {
|
||||
await processInBatches(releaseToDelete, 4, async (releaseItem) => {
|
||||
if (!releaseItem || isEmptyOrNull(releaseItem.id)) {
|
||||
warn(`略過沒有 id 的成品: ${releaseItem?.tag_name || ''} (${releaseItem?.name || ''})`);
|
||||
continue;
|
||||
warn(
|
||||
`略過沒有 id 的成品: ${sanitizeLogText(releaseItem?.tag_name || '')} (${sanitizeLogText(releaseItem?.name || '')})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseTag = releaseItem.tag_name || '';
|
||||
const releaseName = releaseItem.name || '';
|
||||
const releaseTag = sanitizeLogText(releaseItem.tag_name || '');
|
||||
const releaseName = sanitizeLogText(releaseItem.name || '');
|
||||
const deleteUrl = `${releaseApiUrl}/${releaseItem.id}`;
|
||||
info(`DELETE ${releaseTag} (${releaseName})`);
|
||||
|
||||
const { statusCode } = await deleteResource(deleteUrl, authHeaders);
|
||||
if (statusCode === 204) {
|
||||
success(`成功刪除: ${releaseTag} (${releaseName})`);
|
||||
info(`成功刪除: ${releaseTag} (${releaseName})`);
|
||||
} else {
|
||||
hadFailure = true;
|
||||
fail(`刪除失敗: ${releaseTag} (${releaseName}), HTTP ${statusCode}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
section('刪除未指定 release 的 tag');
|
||||
|
||||
const currentReleaseJson = await fetchAllPages(releaseApiUrl, authHeaders);
|
||||
const releaseTags = new Set();
|
||||
for (const item of currentReleaseJson) {
|
||||
if (!isEmptyOrNull(item?.tag_name)) {
|
||||
releaseTags.add(item.tag_name);
|
||||
}
|
||||
}
|
||||
const releaseTags = new Set(
|
||||
releaseJson
|
||||
.slice(0, keepCount)
|
||||
.map((item) => item?.tag_name)
|
||||
.filter((tag) => !isEmptyOrNull(tag)),
|
||||
);
|
||||
|
||||
const tagApiUrl = `${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/tags`;
|
||||
info(`GET ${tagApiUrl}`);
|
||||
@@ -323,27 +363,33 @@ async function main() {
|
||||
const tagJson = await fetchAllPages(tagApiUrl, authHeaders);
|
||||
info(`TAG_COUNT=${tagJson.length}`);
|
||||
|
||||
for (const tagItem of tagJson) {
|
||||
await processInBatches(tagJson, 4, async (tagItem) => {
|
||||
const tagName = tagItem?.name;
|
||||
if (isEmptyOrNull(tagName)) {
|
||||
warn('略過沒有名稱的 tag');
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTagName = sanitizeLogText(tagName);
|
||||
if (releaseTags.has(tagName)) {
|
||||
info(`保留指定 release 的 tag: ${tagName}`);
|
||||
continue;
|
||||
info(`保留指定 release 的 tag: ${safeTagName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteUrl = `${tagApiUrl}/${encodeURIComponent(tagName)}`;
|
||||
info(`DELETE tag ${tagName}`);
|
||||
info(`DELETE tag ${safeTagName}`);
|
||||
|
||||
const { statusCode } = await deleteResource(deleteUrl, authHeaders);
|
||||
if (statusCode === 204) {
|
||||
success(`成功刪除未指定 release 的 tag: ${tagName}`);
|
||||
info(`成功刪除未指定 release 的 tag: ${safeTagName}`);
|
||||
} else {
|
||||
fail(`刪除 tag 失敗: ${tagName}, HTTP ${statusCode}`);
|
||||
hadFailure = true;
|
||||
fail(`刪除 tag 失敗: ${safeTagName}, HTTP ${statusCode}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (hadFailure) {
|
||||
throw new Error('至少有一筆 release 或 tag 刪除失敗');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user