From 6ffa75aa5b024653c77f13a0bd2b8446db5e2945 Mon Sep 17 00:00:00 2001 From: lefarcen <935902669@qq.com> Date: Tue, 2 Jun 2026 12:41:50 +0800 Subject: [PATCH] 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). --- .github/scripts/release/feishu/notify.ts | 233 ++++++++++++++++++++ .github/workflows/notify-daily-feishu.yml | 123 +++++++++++ .github/workflows/notify-release-feishu.yml | 76 +++++++ .github/workflows/release-beta.yml | 92 ++++++++ 4 files changed, 524 insertions(+) create mode 100644 .github/scripts/release/feishu/notify.ts create mode 100644 .github/workflows/notify-daily-feishu.yml create mode 100644 .github/workflows/notify-release-feishu.yml diff --git a/.github/scripts/release/feishu/notify.ts b/.github/scripts/release/feishu/notify.ts new file mode 100644 index 0000000000..d0767e3138 --- /dev/null +++ b/.github/scripts/release/feishu/notify.ts @@ -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)); diff --git a/.github/workflows/notify-daily-feishu.yml b/.github/workflows/notify-daily-feishu.yml new file mode 100644 index 0000000000..ae22a49684 --- /dev/null +++ b/.github/workflows/notify-daily-feishu.yml @@ -0,0 +1,123 @@ +name: notify-daily-feishu + +# Every morning (09:00 Asia/Shanghai) build a full beta ("nightly") package off +# the currently active release branch (highest-semver release/vX.Y.Z) by reusing +# release-beta, then post a Feishu card with download links and the changelog +# since the previously published beta — to a separate Feishu group from the +# per-push release stream. +# +# NOTE: schedule triggers only fire from the default branch (main), so this file +# must live on main to run on its cron. + +on: + schedule: + # 01:00 UTC = 09:00 Asia/Shanghai + - cron: "0 1 * * *" + workflow_dispatch: + inputs: + ref: + description: "Override the release branch to build (e.g. release/v0.9.0). Empty auto-selects the highest-semver release branch." + required: false + type: string + default: "" + +permissions: + contents: read + actions: write + +concurrency: + group: feishu-daily + cancel-in-progress: false + +jobs: + resolve: + name: Resolve active release branch + if: github.repository == 'nexu-io/open-design' + runs-on: ubuntu-latest + outputs: + ref: ${{ steps.pick.outputs.ref }} + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Pick highest-semver release branch + id: pick + env: + OVERRIDE_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + if [ -n "${OVERRIDE_REF:-}" ]; then + echo "using override ref: $OVERRIDE_REF" + echo "ref=$OVERRIDE_REF" >> "$GITHUB_OUTPUT" + exit 0 + fi + latest="$(git ls-remote --heads origin 'refs/heads/release/v*' \ + | sed 's#.*refs/heads/##' \ + | grep -E '^release/v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V \ + | tail -1)" + if [ -z "$latest" ]; then + echo "no release/vX.Y.Z branch found" >&2 + exit 1 + fi + echo "active release branch: $latest" + echo "ref=$latest" >> "$GITHUB_OUTPUT" + + build: + name: Build beta from active release branch + needs: resolve + uses: ./.github/workflows/release-beta.yml + secrets: inherit + with: + enable_mac: true + enable_win: true + ref: ${{ needs.resolve.outputs.ref }} + + notify: + name: Notify Feishu (daily) + needs: + - resolve + - build + if: ${{ !cancelled() && needs.build.outputs.beta_version != '' }} + runs-on: ubuntu-latest + steps: + - name: Checkout built ref + uses: actions/checkout@v6.0.2 + with: + ref: ${{ needs.resolve.outputs.ref }} + fetch-depth: 0 + + - name: Compute changelog since last beta + id: changelog + env: + PREV: ${{ needs.build.outputs.previous_commit }} + CUR: ${{ needs.build.outputs.commit }} + run: | + set -euo pipefail + file="$RUNNER_TEMP/changelog.txt" + : > "$file" + if [ -n "$PREV" ] && git cat-file -e "${PREV}^{commit}" 2>/dev/null && git cat-file -e "${CUR}^{commit}" 2>/dev/null; then + git log --no-merges --pretty=format:'%s (%h)' "${PREV}..${CUR}" > "$file" || true + fi + echo "file=$file" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Post Feishu card + env: + FEISHU_WEBHOOK: ${{ secrets.FEISHU_DAILY_WEBHOOK }} + FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_DAILY_SIGN_SECRET }} + VERSION_METADATA_URL: ${{ needs.build.outputs.version_metadata_url }} + BETA_VERSION: ${{ needs.build.outputs.beta_version }} + BRANCH: ${{ needs.build.outputs.branch }} + COMMIT: ${{ needs.build.outputs.commit }} + PREVIOUS_COMMIT: ${{ needs.build.outputs.previous_commit }} + CHANGELOG_FILE: ${{ steps.changelog.outputs.file }} + RELEASE_STATE: ${{ needs.build.outputs.release_state }} + STREAM_LABEL: "每日定时" + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node --experimental-strip-types .github/scripts/release/feishu/notify.ts diff --git a/.github/workflows/notify-release-feishu.yml b/.github/workflows/notify-release-feishu.yml new file mode 100644 index 0000000000..9ae6d580ca --- /dev/null +++ b/.github/workflows/notify-release-feishu.yml @@ -0,0 +1,76 @@ +name: notify-release-feishu + +# On every push to a release/** branch, build a full beta ("nightly") package by +# reusing release-beta, then post a Feishu card with the download links and the +# changelog since the previously published beta. Consecutive pushes to the same +# branch are debounced: a newer push cancels the in-flight build. + +on: + push: + branches: + - "release/**" + +permissions: + contents: read + actions: write + +concurrency: + group: feishu-release-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build beta from release branch + if: github.repository == 'nexu-io/open-design' + uses: ./.github/workflows/release-beta.yml + secrets: inherit + with: + enable_mac: true + enable_win: true + + notify: + name: Notify Feishu (release) + needs: build + if: ${{ !cancelled() && needs.build.outputs.beta_version != '' }} + runs-on: ubuntu-latest + steps: + - name: Checkout built ref + uses: actions/checkout@v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Compute changelog since last beta + id: changelog + env: + PREV: ${{ needs.build.outputs.previous_commit }} + CUR: ${{ needs.build.outputs.commit }} + run: | + set -euo pipefail + file="$RUNNER_TEMP/changelog.txt" + : > "$file" + if [ -n "$PREV" ] && git cat-file -e "${PREV}^{commit}" 2>/dev/null && git cat-file -e "${CUR}^{commit}" 2>/dev/null; then + git log --no-merges --pretty=format:'%s (%h)' "${PREV}..${CUR}" > "$file" || true + fi + echo "file=$file" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Post Feishu card + env: + FEISHU_WEBHOOK: ${{ secrets.FEISHU_RELEASE_WEBHOOK }} + FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_RELEASE_SIGN_SECRET }} + VERSION_METADATA_URL: ${{ needs.build.outputs.version_metadata_url }} + BETA_VERSION: ${{ needs.build.outputs.beta_version }} + BRANCH: ${{ needs.build.outputs.branch }} + COMMIT: ${{ needs.build.outputs.commit }} + PREVIOUS_COMMIT: ${{ needs.build.outputs.previous_commit }} + CHANGELOG_FILE: ${{ steps.changelog.outputs.file }} + RELEASE_STATE: ${{ needs.build.outputs.release_state }} + STREAM_LABEL: "release 分支推送" + REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: node --experimental-strip-types .github/scripts/release/feishu/notify.ts diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 27b6c08f66..657f0c56f8 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -23,6 +23,57 @@ on: required: true type: boolean default: false + ref: + description: "Optional git ref (branch/tag/SHA) to build. Empty builds the ref this workflow was dispatched on." + required: false + type: string + default: "" + workflow_call: + inputs: + enable_mac: + description: "Build and publish mac arm64 beta artifacts." + required: false + type: boolean + default: true + enable_win: + description: "Build and publish Windows x64 beta artifacts." + required: false + type: boolean + default: true + enable_mac_intel: + description: "Build and publish macOS Intel x64 (unsigned) beta artifacts." + required: false + type: boolean + default: false + enable_linux: + description: "Build and publish Linux x64 AppImage/checksum to R2 only; no updater feed is published yet." + required: false + type: boolean + default: false + ref: + description: "Optional git ref (branch/tag/SHA) to build. Empty builds the caller's ref." + required: false + type: string + default: "" + outputs: + beta_version: + description: "The beta version that was built (x.y.z-beta.N)." + value: ${{ jobs.metadata.outputs.beta_version }} + branch: + description: "The branch that was built." + value: ${{ jobs.metadata.outputs.branch }} + commit: + description: "The commit SHA that was built." + value: ${{ jobs.metadata.outputs.commit }} + previous_commit: + description: "Commit SHA of the previously published beta (changelog baseline); empty on cold start." + value: ${{ jobs.metadata.outputs.previous_commit }} + release_state: + description: "complete | partial | failed." + value: ${{ jobs.publish.outputs.release_state }} + version_metadata_url: + description: "Public R2 URL of this build's version metadata.json." + value: ${{ jobs.publish.outputs.version_metadata_url }} permissions: actions: write @@ -62,12 +113,14 @@ jobs: beta_version: ${{ steps.beta.outputs.beta_version }} branch: ${{ steps.beta.outputs.branch }} commit: ${{ steps.beta.outputs.commit }} + previous_commit: ${{ steps.prev.outputs.previous_commit }} release_name: ${{ steps.beta.outputs.release_name }} state_source: ${{ steps.beta.outputs.state_source }} steps: - name: Checkout uses: actions/checkout@v6.0.2 with: + ref: ${{ inputs.ref }} fetch-depth: 0 - name: Setup Node.js @@ -75,6 +128,24 @@ jobs: with: node-version: 24 + # Under workflow_call the runner's GITHUB_SHA/GITHUB_REF_NAME describe the + # caller's ref (e.g. main for the scheduled daily build), not the ref we + # actually checked out. Resolve the real built HEAD so the published + # metadata.json records the branch we built, not the caller. + - name: Resolve built commit + run: echo "BUILT_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + + # Capture the previously published beta commit BEFORE this run overwrites + # beta/latest/metadata.json, so the notification can diff the changelog + # against the last package. Empty on cold start (HTTP 404). + - name: Capture previous beta commit + id: prev + run: | + set -euo pipefail + prev="$(curl -fsSL "$OPEN_DESIGN_BETA_METADATA_URL" | jq -r '.github.commit // ""' 2>/dev/null || true)" + echo "previous beta commit: ${prev:-}" + echo "previous_commit=$prev" >> "$GITHUB_OUTPUT" + - name: Validate beta publish inputs run: | set -euo pipefail @@ -98,6 +169,9 @@ jobs: - name: Prepare beta release metadata id: beta + env: + GITHUB_SHA: ${{ env.BUILT_SHA }} + GITHUB_REF_NAME: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} run: node --experimental-strip-types ./scripts/release-beta.ts build_mac: @@ -108,6 +182,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -333,6 +409,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -401,6 +479,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -573,6 +653,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -625,6 +707,7 @@ jobs: - name: Smoke beta linux AppImage runtime working-directory: e2e env: + GITHUB_SHA: ${{ needs.metadata.outputs.commit }} OD_PACKAGED_E2E_LINUX_APPIMAGE: "1" OD_PACKAGED_E2E_NAMESPACE: release-beta-linux OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux/screenshots/open-design-linux-smoke.png @@ -701,8 +784,15 @@ jobs: (inputs.enable_mac || inputs.enable_win || inputs.enable_mac_intel || inputs.enable_linux) }} runs-on: ubuntu-latest + outputs: + version_metadata_url: ${{ steps.r2.outputs.version_metadata_url }} + release_state: ${{ steps.r2.outputs.release_state }} env: GH_TOKEN: ${{ github.token }} + # Record the real built commit in metadata.json. Under workflow_call the + # runner's GITHUB_SHA is the caller's ref, so pin it to the resolved build + # commit; the next run reads this back as its changelog baseline. + GITHUB_SHA: ${{ needs.metadata.outputs.commit }} AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} AWS_DEFAULT_REGION: auto @@ -729,6 +819,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.ref }} - name: Download mac publish manifest if: ${{ inputs.enable_mac && needs.build_mac.result == 'success' }}