mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
ci(release): build beta + notify Feishu on release push and daily (#3462)
Reuse the release-beta pipeline as a workflow_call and add two thin caller
workflows that build a full beta ("nightly") package and post a Feishu card
with download links and the changelog since the previous beta:
- notify-release-feishu: triggers on every push to release/**, debounced
(concurrency cancel-in-progress), notifies FEISHU_RELEASE_WEBHOOK.
- notify-daily-feishu: cron 01:00 UTC (09:00 Asia/Shanghai) + manual; resolves
the active release branch (highest-semver release/vX.Y.Z) and builds it via
the new ref input, notifies FEISHU_DAILY_WEBHOOK.
release-beta gains a workflow_call entry (enable_* booleans + optional ref +
outputs) so callers reuse one build source. Because scheduled callers run on
main but must build a release branch, every checkout takes inputs.ref and the
metadata/publish jobs pin GITHUB_SHA to the resolved build HEAD so the published
metadata.json records the real built commit (and the next run's changelog
baseline stays correct).
The changelog needs zero extra state: the baseline is the previous beta's commit
read from beta/latest/metadata.json before this run overwrites it; download links
come from this build's version metadata.json. notify.ts renders a Feishu
interactive card and supports optional signed bots (FEISHU_*_SIGN_SECRET).
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
// Posts a beta ("nightly") build notification to a Feishu (Lark) custom-bot
|
||||
// webhook as an interactive card: version, branch, commit, per-platform download
|
||||
// buttons, and the changelog since the previously published beta.
|
||||
//
|
||||
// Inputs (all via env):
|
||||
// FEISHU_WEBHOOK (required) custom-bot webhook URL
|
||||
// FEISHU_SIGN_SECRET (optional) signing secret when the bot enables 签名校验
|
||||
// VERSION_METADATA_URL (required) public R2 URL of this build's version metadata.json
|
||||
// BETA_VERSION (required) x.y.z-beta.N
|
||||
// BRANCH git branch that was built
|
||||
// COMMIT commit SHA that was built
|
||||
// PREVIOUS_COMMIT changelog baseline (last published beta); empty on cold start
|
||||
// CHANGELOG_FILE path to a file holding `git log` output (one commit per line)
|
||||
// RELEASE_STATE complete | partial | failed
|
||||
// STREAM_LABEL human label for the trigger (e.g. "release 分支推送" / "每日定时")
|
||||
// REPO owner/name
|
||||
// RUN_URL link back to the GitHub Actions run
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name];
|
||||
if (value == null || value.length === 0) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optional(name, fallback = "") {
|
||||
const value = process.env[name];
|
||||
return value == null || value.length === 0 ? fallback : value;
|
||||
}
|
||||
|
||||
const webhook = required("FEISHU_WEBHOOK");
|
||||
const signSecret = optional("FEISHU_SIGN_SECRET");
|
||||
const versionMetadataUrl = optional("VERSION_METADATA_URL");
|
||||
const betaVersion = required("BETA_VERSION");
|
||||
const branch = optional("BRANCH");
|
||||
const commit = optional("COMMIT");
|
||||
const previousCommit = optional("PREVIOUS_COMMIT");
|
||||
const changelogFile = optional("CHANGELOG_FILE");
|
||||
const releaseState = optional("RELEASE_STATE", "unknown");
|
||||
const streamLabel = optional("STREAM_LABEL", "构建");
|
||||
const repo = optional("REPO");
|
||||
const runUrl = optional("RUN_URL");
|
||||
|
||||
const MAX_CHANGELOG_LINES = 30;
|
||||
|
||||
// Primary downloadable artifact per platform key, in preference order.
|
||||
const PRIMARY_ARTIFACT = {
|
||||
mac: ["dmg", "zip"],
|
||||
win: ["installer"],
|
||||
linux: ["appImage"],
|
||||
macIntel: ["dmg", "zip"],
|
||||
};
|
||||
|
||||
async function fetchMetadata(url) {
|
||||
if (url.length === 0) return null;
|
||||
try {
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok) {
|
||||
console.warn(`[feishu] metadata fetch ${url} returned HTTP ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.warn(`[feishu] metadata fetch failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadButtons(metadata) {
|
||||
if (metadata == null || typeof metadata.platforms !== "object") return [];
|
||||
const buttons = [];
|
||||
for (const [key, platform] of Object.entries(metadata.platforms)) {
|
||||
if (platform == null || platform.status !== "published") continue;
|
||||
const artifacts = platform.artifacts ?? {};
|
||||
const order = PRIMARY_ARTIFACT[key] ?? Object.keys(artifacts);
|
||||
let chosen = null;
|
||||
for (const name of order) {
|
||||
if (artifacts[name]?.url) {
|
||||
chosen = artifacts[name].url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (chosen == null) continue;
|
||||
const label = platform.label ?? key;
|
||||
buttons.push({
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: `下载 ${label}` },
|
||||
type: buttons.length === 0 ? "primary" : "default",
|
||||
url: chosen,
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
|
||||
function readChangelog() {
|
||||
if (changelogFile.length === 0) return { lines: [], truncated: false };
|
||||
let raw = "";
|
||||
try {
|
||||
raw = readFileSync(changelogFile, "utf8");
|
||||
} catch {
|
||||
return { lines: [], truncated: false };
|
||||
}
|
||||
const all = raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const truncated = all.length > MAX_CHANGELOG_LINES;
|
||||
return { lines: truncated ? all.slice(0, MAX_CHANGELOG_LINES) : all, truncated, total: all.length };
|
||||
}
|
||||
|
||||
function changelogMarkdown() {
|
||||
const { lines, truncated, total } = readChangelog();
|
||||
if (previousCommit.length === 0) {
|
||||
return "首个 beta 包,无上个版本可对比。";
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
return "与上个 beta 包之间没有新增提交。";
|
||||
}
|
||||
const body = lines.map((line) => `- ${line}`).join("\n");
|
||||
if (truncated) {
|
||||
return `${body}\n- …还有 ${total - lines.length} 条提交(共 ${total} 条)`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function headerTemplate() {
|
||||
if (releaseState === "complete") return "blue";
|
||||
if (releaseState === "partial") return "orange";
|
||||
return "red";
|
||||
}
|
||||
|
||||
function buildCard() {
|
||||
const shortCommit = commit.length >= 7 ? commit.slice(0, 7) : commit;
|
||||
const fields = [];
|
||||
if (branch.length > 0) {
|
||||
fields.push({ is_short: true, text: { tag: "lark_md", content: `**分支**\n${branch}` } });
|
||||
}
|
||||
if (shortCommit.length > 0) {
|
||||
const link = repo.length > 0 ? `[\`${shortCommit}\`](https://github.com/${repo}/commit/${commit})` : `\`${shortCommit}\``;
|
||||
fields.push({ is_short: true, text: { tag: "lark_md", content: `**提交**\n${link}` } });
|
||||
}
|
||||
fields.push({ is_short: true, text: { tag: "lark_md", content: `**状态**\n${releaseState}` } });
|
||||
fields.push({ is_short: true, text: { tag: "lark_md", content: `**触发**\n${streamLabel}` } });
|
||||
|
||||
const elements = [{ tag: "div", fields }];
|
||||
elements.push({
|
||||
tag: "div",
|
||||
text: { tag: "lark_md", content: `**自上个 beta 新增提交**\n${changelogMarkdown()}` },
|
||||
});
|
||||
|
||||
const buttons = downloadButtons(currentMetadata);
|
||||
if (buttons.length > 0) {
|
||||
elements.push({ tag: "hr" });
|
||||
elements.push({ tag: "action", actions: buttons });
|
||||
}
|
||||
|
||||
if (runUrl.length > 0) {
|
||||
elements.push({
|
||||
tag: "note",
|
||||
elements: [{ tag: "lark_md", content: `[GitHub Actions run](${runUrl})` }],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config: { wide_screen_mode: true },
|
||||
header: {
|
||||
template: headerTemplate(),
|
||||
title: { tag: "plain_text", content: `🚀 Open Design Beta ${betaVersion}` },
|
||||
},
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function signedEnvelope(card) {
|
||||
const body = { msg_type: "interactive", card };
|
||||
if (signSecret.length === 0) return body;
|
||||
// Feishu signing: HMAC-SHA256 over empty bytes, keyed by `${timestamp}\n${secret}`.
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
const stringToSign = `${timestamp}\n${signSecret}`;
|
||||
const sign = createHmac("sha256", stringToSign).update("").digest("base64");
|
||||
return { timestamp, sign, ...body };
|
||||
}
|
||||
|
||||
async function post(body) {
|
||||
const attempts = 5;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(webhook, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[feishu] POST attempt ${attempt}/${attempts} threw: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (attempt === attempts) throw error;
|
||||
await sleep(attempt);
|
||||
continue;
|
||||
}
|
||||
const text = await res.text();
|
||||
let code = null;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
code = parsed.code ?? parsed.StatusCode ?? null;
|
||||
} catch {
|
||||
// non-JSON body
|
||||
}
|
||||
if (res.ok && (code === 0 || code === null)) {
|
||||
console.log(`[feishu] delivered (HTTP ${res.status}, code ${code ?? "n/a"})`);
|
||||
return;
|
||||
}
|
||||
console.warn(`[feishu] POST attempt ${attempt}/${attempts} HTTP ${res.status} code ${code}: ${text.slice(0, 500)}`);
|
||||
// Bot rate-limit (code 9499) and 5xx are retryable; a 4xx config error is not.
|
||||
const retryable = res.status === 429 || res.status >= 500 || code === 9499;
|
||||
if (!retryable || attempt === attempts) {
|
||||
throw new Error(`Feishu webhook failed: HTTP ${res.status} code ${code}`);
|
||||
}
|
||||
await sleep(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(attempt) {
|
||||
const ms = Math.min(1000 * 2 ** (attempt - 1), 15000);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const currentMetadata = await fetchMetadata(versionMetadataUrl);
|
||||
const card = buildCard();
|
||||
await post(signedEnvelope(card));
|
||||
Reference in New Issue
Block a user