feat: add staged release updates
TallyNote release / linux-x64 (push) Failing after 2m51s

This commit is contained in:
Qiufeng
2026-09-01 14:46:32 +08:00
parent 4395317651
commit a268eb5fe9
18 changed files with 452 additions and 92 deletions
+68 -1
View File
@@ -35,7 +35,11 @@ export type ReleaseAsset = {
export type ReleaseMetadata = {
version: string;
tagName?: string;
releaseName?: string;
publishedAt?: string;
/** Plain-text release notes, bounded to keep API/cache payloads small. */
notes?: string;
releaseUrl?: string;
assets: ReleaseAsset[];
};
@@ -143,6 +147,57 @@ function metadataError(): Error {
}
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
export const RELEASE_NOTES_MAX_BYTES = 64 * 1024;
function releaseNotesText(value: unknown): string | undefined {
if (typeof value !== "string" || value.length === 0) return undefined;
// Gitea exposes both Markdown (body/body_html) and releaseNotes depending on
// endpoint/version. Keep the browser contract text-only and bounded.
const text = value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p\s*>/gi, "\n\n")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/\r\n?/g, "\n")
.trim();
const bytes = Buffer.from(text, "utf8");
if (bytes.length <= RELEASE_NOTES_MAX_BYTES) return text;
return bytes.subarray(0, RELEASE_NOTES_MAX_BYTES).toString("utf8").replace(/\uFFFD$/u, "") + "\n[内容已截断]";
}
function releaseNameText(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
return text.length > 0 ? text.slice(0, 200) : undefined;
}
/** Gitea installations behind a reverse proxy sometimes emit internal HTTP
* asset URLs. Rebind those URLs to the already trusted HTTPS release origin,
* while continuing to reject arbitrary HTTPS hosts and credentials. */
function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): string {
let candidate: URL;
try {
candidate = new URL(value, current);
} catch {
throw new Error("更新地址无效");
}
if (candidate.username || candidate.password) throw new Error("更新地址不允许携带凭据");
try {
return validateHttpsUrl(candidate, { ...options, baseUrl: current }).toString();
} catch {
if (candidate.protocol !== "http:") throw new Error("更新地址必须使用 HTTPS");
const rebound = new URL(current);
rebound.pathname = candidate.pathname;
rebound.search = candidate.search;
rebound.hash = "";
return validateHttpsUrl(rebound, { ...options, baseUrl: current }).toString();
}
}
/** Read a fetch body without ever buffering more than the caller's bound. */
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise<Buffer> {
@@ -227,12 +282,24 @@ export async function fetchReleaseMetadata(
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
}
assets.push({ name, url: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
}
const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html);
const releaseName = releaseNameText(item.name ?? item.releaseName);
let releaseUrl: string | undefined;
if (typeof item.html_url === "string" || typeof item.url === "string") {
try {
const candidate = typeof item.html_url === "string" ? item.html_url : item.url as string;
releaseUrl = releaseResourceUrl(candidate, current, options);
} catch { /* omit invalid optional release page URL */ }
}
return {
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
...(releaseName ? { releaseName } : {}),
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
...(notes ? { notes } : {}),
...(releaseUrl ? { releaseUrl } : {}),
assets,
};
}