Files
tutti/apps/desktop/scripts/send-release-feishu-card.mjs
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

567 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { RELEASE_REPO_NAME, RELEASE_REPO_OWNER } from "./lib/releaseConfig.mjs";
function parseArgs(argv) {
const args = new Map();
for (let index = 2; index < argv.length; index += 1) {
const key = argv[index];
if (!key.startsWith("--")) {
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args.set(key.slice(2), "true");
continue;
}
args.set(key.slice(2), next);
index += 1;
}
return args;
}
function readOption(args, name, envName, fallback = "") {
return (args.get(name) ?? process.env[envName] ?? fallback).trim();
}
function requireOption(value, name) {
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function formatBuiltAt() {
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "medium",
timeZone: "Asia/Shanghai"
}).format(new Date());
}
function resolveDisplayValue(value, fallback = "unknown") {
return value || fallback;
}
function resolveReleaseKind(tag, publicationStatus = "published") {
if (/-rc\.(0|[1-9]\d*)$/i.test(tag)) {
return publicationStatus === "candidate"
? "Draft release candidate prerelease"
: "Release candidate prerelease";
}
if (/-beta\.(0|[1-9]\d*)$/i.test(tag)) {
return publicationStatus === "candidate"
? "Draft beta prerelease"
: "Beta prerelease";
}
return publicationStatus === "candidate"
? "Stable candidate"
: "Stable latest release";
}
function resolveIntroText(tag, publicationStatus = "published") {
if (publicationStatus === "candidate") {
return `**${tag}** 候选版本已构建完成,请体验安装包并确认更新说明;当前尚未向用户开放更新。`;
}
if (/-rc\.(0|[1-9]\d*)$/i.test(tag)) {
return `**${tag}** 已构建并同步到 RC 预览通道,可从下方入口下载安装包。`;
}
if (/-beta\.(0|[1-9]\d*)$/i.test(tag)) {
return `**${tag}** 已构建并同步到 Beta 预览通道,可从下方入口下载安装包。`;
}
return `**${tag}** 已构建并发布为 GitHub Release,可从下方入口下载安装包。`;
}
function normalizeBaseUrl(value) {
return value.replace(/\/+$/, "");
}
function resolveReleaseAssetBaseUrl({
bucket = "",
explicitBaseUrl = "",
prefix = ""
}) {
if (explicitBaseUrl) {
return normalizeBaseUrl(explicitBaseUrl);
}
if (!bucket || !prefix) {
return "";
}
return `https://${bucket}.s3-accelerate.amazonaws.com/${prefix.replace(/^\/+|\/+$/g, "")}`;
}
function resolveGithubToken() {
const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
if (envToken) {
return envToken;
}
try {
return execFileSync("gh", ["auth", "token"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"]
}).trim();
} catch {
return "";
}
}
async function loadRelease(repository, tag, githubToken) {
const headers = {
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28"
};
if (githubToken) {
headers.authorization = `Bearer ${githubToken}`;
}
const response = await fetch(
`https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`,
{
headers
}
);
if (response.ok) {
return response.json();
}
if (response.status !== 404) {
throw new Error(
`Unable to load release ${tag}: GitHub API returned ${response.status}`
);
}
// GitHub's release-by-tag endpoint does not return draft releases. The
// authenticated releases listing does, so fall back to it for draft-only,
// release-candidate, and beta notification paths.
for (let page = 1; ; page += 1) {
const releasesResponse = await fetch(
`https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`,
{
headers
}
);
if (!releasesResponse.ok) {
throw new Error(
`Unable to load release ${tag}: GitHub API returned ${releasesResponse.status}`
);
}
const releases = await releasesResponse.json();
const release = releases.find((candidate) => candidate.tag_name === tag);
if (release) {
return release;
}
if (releases.length < 100) {
break;
}
}
throw new Error(`Unable to load release ${tag}: release was not found`);
}
function findPreferredAssetName(assetNames, pattern) {
const matchingAssetNames = assetNames.filter((candidate) => {
pattern.lastIndex = 0;
return pattern.test(candidate);
});
return (
matchingAssetNames.find((candidate) =>
/-mac-universal\.dmg$/i.test(candidate)
) ?? matchingAssetNames[0]
);
}
function findAssetUrl(
release,
pattern,
releaseAssetBaseUrl = "",
releaseAssetPath = ""
) {
const assets = Array.isArray(release?.assets) ? release.assets : [];
const assetName = findPreferredAssetName(
assets.map((candidate) => candidate.name ?? ""),
pattern
);
const asset = assets.find((candidate) => candidate.name === assetName);
if (!asset?.browser_download_url) {
return "";
}
if (releaseAssetBaseUrl) {
const assetPath = releaseAssetPath || release.tag_name || "";
if (!assetPath) {
throw new Error(
"Release tag name is missing from the GitHub release payload"
);
}
const normalizedAssetPath = assetPath
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
return `${normalizeBaseUrl(releaseAssetBaseUrl)}/${normalizedAssetPath}/${encodeURIComponent(asset.name)}`;
}
return asset.browser_download_url;
}
async function listAssetNames(assetDirectory) {
if (!assetDirectory) {
return [];
}
return readdir(assetDirectory);
}
function resolveMirroredAssetUrl(
assetNames,
pattern,
releaseAssetBaseUrl,
assetPath
) {
if (!releaseAssetBaseUrl || assetNames.length === 0) {
return "";
}
const assetName = findPreferredAssetName(assetNames, pattern);
if (!assetName) {
return "";
}
const normalizedAssetPath = assetPath
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
return `${normalizeBaseUrl(releaseAssetBaseUrl)}/${normalizedAssetPath}/${encodeURIComponent(assetName)}`;
}
async function loadReleaseSummary(summaryPath) {
if (!summaryPath) {
return null;
}
try {
return JSON.parse(await readFile(summaryPath, "utf8"));
} catch {
return null;
}
}
function buildSummaryElements(summary) {
const zh = summary?.zh;
if (!zh?.headline || !Array.isArray(zh.sections)) {
return [];
}
const updateLines = [];
for (const section of zh.sections.slice(0, 4)) {
const items = Array.isArray(section.items) ? section.items : [];
for (const item of items) {
updateLines.push(`- ${section.title}:${item}`);
if (updateLines.length === 6) break;
}
if (updateLines.length === 6) break;
}
const content = [
`**本次更新**\n${zh.headline}`,
updateLines.length > 0 ? updateLines.join("\n") : ""
]
.filter(Boolean)
.join("\n");
return [
{
tag: "div",
text: {
content,
tag: "lark_md"
}
},
{ tag: "hr" }
];
}
function buildCandidatePromotionElements({ branch, promotionUrl, tag }) {
if (!promotionUrl) {
return [];
}
const branchInstruction = branch
? `- Branch 选择 \`${branch}\``
: "- Branch 选择本次候选版本的 release 分支";
return [
{
tag: "div",
text: {
content: [
"**发布审核操作**",
"1. 点击下方「打开发布审核流水线」",
"2. 点击 `Run workflow`",
branchInstruction,
`- \`release_tag\` 填写 \`${tag}\``,
"3. 启动后在 `Approve Stable Release` 完成审核"
].join("\n"),
tag: "lark_md"
}
},
{ tag: "hr" }
];
}
function buildCardPayload({
actor,
branch,
macUrl,
winUrl,
publicationStatus = "published",
promotionUrl,
releaseUrl,
runUrl,
summary,
tag
}) {
const deployActor = resolveDisplayValue(actor);
const candidate = publicationStatus === "candidate";
const actions = [
{ label: candidate ? "下载 macOS 体验版" : "下载 macOS", url: macUrl },
{
label: candidate ? "下载 Windows 候选版" : "下载 Windows",
url: winUrl
},
{
label: candidate ? "编辑更新说明" : "查看完整更新日志",
url: releaseUrl
},
...(candidate ? [{ label: "打开发布审核流水线", url: promotionUrl }] : []),
{ label: "查看技术详情", url: runUrl }
]
.filter((action) => action.url)
.map((action, index) => ({
tag: "button",
text: { content: action.label, tag: "plain_text" },
type: index === 0 ? "primary" : "default",
url: action.url
}));
return {
card: {
config: { wide_screen_mode: true },
elements: [
{
tag: "div",
text: {
content: resolveIntroText(tag, publicationStatus),
tag: "lark_md"
}
},
{ tag: "hr" },
...buildSummaryElements(summary),
...(candidate
? buildCandidatePromotionElements({ branch, promotionUrl, tag })
: []),
{
fields: [
{
is_short: true,
text: { content: `**版本号**\n${tag}`, tag: "lark_md" }
},
{
is_short: true,
text: {
content: `**构建类型**\n${resolveReleaseKind(tag, publicationStatus)}`,
tag: "lark_md"
}
},
{
is_short: true,
text: {
content: `**部署人**\n${deployActor}`,
tag: "lark_md"
}
},
{
is_short: true,
text: {
content: `**完成时间**\n${formatBuiltAt()} 北京时间`,
tag: "lark_md"
}
}
],
tag: "div"
},
{ actions, tag: "action" }
],
header: {
template: candidate ? "orange" : "blue",
title: {
content: candidate ? "Tutti 候选版本待确认" : "Tutti 发布完成",
tag: "plain_text"
}
}
},
msg_type: "interactive"
};
}
async function sendPayload(webhookUrl, payload) {
const response = await fetch(webhookUrl, {
body: JSON.stringify(payload),
headers: { "content-type": "application/json" },
method: "POST"
});
const text = await response.text();
if (!response.ok) {
throw new Error(`Feishu webhook returned ${response.status}: ${text}`);
}
return text;
}
async function main() {
const args = parseArgs(process.argv);
const dryRun =
args.has("dry-run") || process.env.TUTTI_DESKTOP_FEISHU_DRY_RUN === "true";
const repository = readOption(
args,
"repository",
"GITHUB_REPOSITORY",
`${RELEASE_REPO_OWNER}/${RELEASE_REPO_NAME}`
);
const tag = requireOption(
readOption(args, "tag", "RELEASE_TAG"),
"RELEASE_TAG"
);
const lookupTag = readOption(args, "lookup-tag", "RELEASE_LOOKUP_TAG", tag);
const target = readOption(args, "target", "RELEASE_TARGET");
const branch = readOption(args, "branch", "RELEASE_BRANCH");
const actor = readOption(args, "actor", "RELEASE_ACTOR");
const publicationStatus = readOption(
args,
"publication-status",
"RELEASE_PUBLICATION_STATUS",
"published"
);
if (publicationStatus !== "candidate" && publicationStatus !== "published") {
throw new Error(
"RELEASE_PUBLICATION_STATUS must be candidate or published"
);
}
const releaseUrl = readOption(
args,
"release-url",
"RELEASE_URL",
`https://github.com/${repository}/releases/tag/${encodeURIComponent(tag)}`
);
const runUrl = readOption(args, "run-url", "RUN_URL");
const promotionUrl = readOption(args, "promotion-url", "PROMOTION_URL");
const webhookUrl = readOption(args, "webhook-url", "FEISHU_WEBHOOK_URL");
const releaseAssetDirectory = readOption(
args,
"release-asset-directory",
"RELEASE_ASSET_DIRECTORY"
);
const releaseAssetBaseUrl = resolveReleaseAssetBaseUrl({
bucket: readOption(
args,
"release-asset-bucket",
"TUTTI_DESKTOP_RELEASE_ASSETS_S3_BUCKET"
),
explicitBaseUrl: readOption(
args,
"release-asset-base-url",
"TUTTI_DESKTOP_RELEASE_ASSETS_BASE_URL"
),
prefix: readOption(
args,
"release-asset-prefix",
"TUTTI_DESKTOP_RELEASE_ASSETS_S3_PREFIX"
)
});
const releaseAssetPath = readOption(
args,
"release-asset-path",
"RELEASE_ASSET_PATH",
tag
);
const mirroredAssetNames = await listAssetNames(releaseAssetDirectory);
const mirroredMacUrl = resolveMirroredAssetUrl(
mirroredAssetNames,
/\.dmg$/i,
releaseAssetBaseUrl,
releaseAssetPath
);
const mirroredWinUrl = resolveMirroredAssetUrl(
mirroredAssetNames,
/-win-x64\.exe$/i,
releaseAssetBaseUrl,
releaseAssetPath
);
const release =
mirroredMacUrl && mirroredWinUrl
? null
: await loadRelease(repository, lookupTag, resolveGithubToken());
const summary = await loadReleaseSummary(
readOption(args, "summary", "RELEASE_SUMMARY_PATH")
);
const payload = buildCardPayload({
actor,
branch,
macUrl:
mirroredMacUrl ||
findAssetUrl(release, /\.dmg$/i, releaseAssetBaseUrl, releaseAssetPath),
winUrl:
mirroredWinUrl ||
findAssetUrl(
release,
/-win-x64\.exe$/i,
releaseAssetBaseUrl,
releaseAssetPath
),
publicationStatus,
promotionUrl,
releaseUrl,
runUrl,
summary,
tag,
target
});
if (dryRun) {
console.log(JSON.stringify(payload, null, 2));
return;
}
requireOption(webhookUrl, "FEISHU_WEBHOOK_URL");
console.log(await sendPayload(webhookUrl, payload));
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
export {
buildCardPayload,
buildCandidatePromotionElements,
buildSummaryElements,
findPreferredAssetName,
loadRelease,
loadReleaseSummary,
listAssetNames,
resolveMirroredAssetUrl,
resolveIntroText,
resolveReleaseAssetBaseUrl,
resolveReleaseKind
};