fix(cleanup-release): harden API cleanup flow
CI / 1. BUILD (pull_request) Successful in 4s
CI / 2. TEST (pull_request) Failing after 6m52s
CI / 3. RESULT (pull_request) Has been skipped

This commit is contained in:
2026-07-11 13:50:16 +00:00
parent 29b3e37b79
commit d667c66726
5 changed files with 172 additions and 228 deletions
+102 -56
View File
@@ -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 刪除失敗');
}
}