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
567 lines
14 KiB
JavaScript
567 lines
14 KiB
JavaScript
#!/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
|
||
};
|