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:
lefarcen
2026-06-02 04:41:50 +00:00
committed by GitHub
parent 66522f0bc5
commit 6ffa75aa5b
4 changed files with 524 additions and 0 deletions
+233
View File
@@ -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));