fix release smoke gating and notifications (#6709)

This commit is contained in:
Ray Xi
2026-08-10 12:37:29 +00:00
committed by GitHub
parent 8477b20b43
commit ca894ab862
7 changed files with 207 additions and 50 deletions
+4 -11
View File
@@ -11,8 +11,8 @@ name: cut-patch-release
# — i.e. release-stable ran `gh release edit --draft=false --latest`). If it is
# not published yet, do NOT cut a branch or launch a prerelease publication;
# instead post a Feishu notice that the patch cut was skipped, and stop. The
# independent Windows canary still runs first so every release-cut attempt
# leaves a current product-smoke signal.
# independent scheduled Windows canary remains an advisory product-smoke
# signal and does not block this branch cut or its prerelease package.
# 3. On the happy path it is otherwise identical to cut-release: create
# release/vX.Y.(Z+1), bump apps/packaged/package.json, push with the App token
# (which triggers notify-release-feishu -> prerelease build + Feishu card), and
@@ -41,7 +41,6 @@ on:
default: false
permissions:
actions: write
contents: read # branch/label writes still go through the App token below
concurrency:
@@ -49,13 +48,7 @@ concurrency:
cancel-in-progress: false
jobs:
prerelease_win_smoke:
if: github.repository == 'nexu-io/open-design'
uses: ./.github/workflows/main-prerelease-win-smoke.yml
secrets: inherit
cut:
needs: prerelease_win_smoke
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
steps:
@@ -67,8 +60,8 @@ jobs:
- uses: actions/checkout@v4
with:
# Cut the exact main commit that passed the Windows packaged smoke.
ref: ${{ needs.prerelease_win_smoke.outputs.commit }}
# Smoke is advisory; release cuts always use the current main tip.
ref: main
fetch-depth: 0
token: ${{ steps.app.outputs.token }}
+7 -13
View File
@@ -1,9 +1,10 @@
name: cut-release
# Every Tuesday 09:00 Asia/Shanghai, cut the next minor release branch from main:
# 1. Build + smoke main's Windows prerelease package and capture the passing SHA
# 2. Compute version (highest release/vX.Y.Z -> bump minor -> X.(Y+1).0; e.g. 0.11.0 -> 0.12.0)
# 3. Create release/vX.Y.Z from that SHA, bump apps/packaged/package.json, push
# 4. Create the "backport release/vX.Y.Z" label
# 1. Compute version (highest release/vX.Y.Z -> bump minor -> X.(Y+1).0; e.g. 0.11.0 -> 0.12.0)
# 2. Create release/vX.Y.Z from main, bump apps/packaged/package.json, push
# 3. Create the "backport release/vX.Y.Z" label
# The independent main prerelease Windows canary remains an advisory signal: its
# smoke result is notified separately and never blocks a branch cut or package.
# The push uses the App token (not GITHUB_TOKEN), so it triggers notify-release-feishu
# -> builds a nightly package and posts the Feishu card.
@@ -18,17 +19,10 @@ on:
default: ''
permissions:
actions: write
contents: read # branch/label writes still go through the App token below
jobs:
prerelease_win_smoke:
if: github.repository == 'nexu-io/open-design'
uses: ./.github/workflows/main-prerelease-win-smoke.yml
secrets: inherit
cut:
needs: prerelease_win_smoke
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
steps:
@@ -40,8 +34,8 @@ jobs:
- uses: actions/checkout@v4
with:
# Cut the exact main commit that passed the Windows packaged smoke.
ref: ${{ needs.prerelease_win_smoke.outputs.commit }}
# Smoke is advisory; release cuts always use the current main tip.
ref: main
fetch-depth: 0
token: ${{ steps.app.outputs.token }}
@@ -10,7 +10,7 @@ on:
workflow_call:
outputs:
commit:
description: "The exact commit that passed the Windows packaged smoke."
description: "The exact commit that was exercised by the Windows packaged smoke."
value: ${{ jobs.smoke.outputs.commit }}
permissions:
@@ -256,6 +256,6 @@ jobs:
**目标 ref**: `main`
release cut 会被阻止,直到同一条 smoke 通过。
该 smoke 是独立质量信号,不阻塞 release cut 或 prerelease 打包 / 发布。
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node --experimental-strip-types tools/release/src/notifications/feishu-notice.ts
+8 -11
View File
@@ -42,7 +42,7 @@ on:
type: string
default: ""
win_x64_smoke_mode:
description: "Windows x64 packaged smoke coverage (skip/core/full). Empty uses the per-branch default: skip on release/v0.18.0 to bypass the known packaged-win smoke flake that gates publish, otherwise release-prerelease's own default."
description: "Windows x64 packaged smoke coverage (skip/core/full). Empty uses core. Smoke failures are reported on the download card but do not block publication."
required: false
type: string
default: ""
@@ -85,22 +85,17 @@ jobs:
# (This replaces a temporary `test` default on release/v0.18.0 that
# existed only while prod's resource-hub storage was unconfigured.)
amr_profile: ${{ inputs.amr_profile || 'prod' }}
# Windows packaged smoke is a hard publish gate (publish needs build_win
# success) but flaked on release/v0.18.0 ("did not reach main app shell"),
# skip-blocking EVERY platform's publish + the Feishu card. TEMPORARY:
# skip it on release/v0.18.0 so the workspace-team prerelease can actually
# ship its mac/win/linux packages + post its card. Manual dispatch overrides.
# The root cause is fixed on main (#6481); remove this branch default once
# release/v0.18.0 no longer builds.
#
# The final arm must name a real profile, NOT ''. A workflow_call
# Windows packaged smoke is advisory to publication: a failed smoke is
# recorded in the report and annotated on the full download card without
# blocking platform or metadata publication. The final arm must name a
# real profile, NOT ''. A workflow_call
# `default:` applies only when an input is omitted, so forwarding an empty
# string defeats the callee's declared `core` default; the empty value
# then reaches the spec, reads as "not core", and selects the updater path
# — which demands a fixture only a genuine `full` request wires up, so the
# job dies before the smoke starts. That is how release/v0.18.1's first
# prerelease failed on its branch-cut commit.
win_x64_smoke_mode: ${{ inputs.win_x64_smoke_mode || (github.ref_name == 'release/v0.18.0' && 'skip') || 'core' }}
win_x64_smoke_mode: ${{ inputs.win_x64_smoke_mode || 'core' }}
notify:
name: Notify Feishu (release)
@@ -153,6 +148,8 @@ jobs:
PREVIOUS_COMMIT: ${{ needs.build.outputs.previous_commit }}
CHANGELOG_FILE: ${{ steps.changelog.outputs.file }}
BUILD_STATE: ${{ needs.build.result }}
MAC_ARM64_SMOKE_RESULT: ${{ needs.build.outputs.mac_arm64_smoke_result }}
WIN_X64_SMOKE_RESULT: ${{ needs.build.outputs.win_x64_smoke_result }}
MAC_ARM64_URL: ${{ needs.build.outputs.mac_arm64_url }}
MAC_INTEL_URL: ${{ needs.build.outputs.mac_intel_url }}
WIN_URL: ${{ needs.build.outputs.win_url }}
+15
View File
@@ -84,6 +84,12 @@ on:
previous_commit:
description: "Commit SHA of the previously published prerelease; empty on cold start."
value: ${{ jobs.metadata.outputs.previous_commit }}
mac_arm64_smoke_result:
description: "macOS arm64 packaged smoke outcome; advisory to publication."
value: ${{ jobs.build_mac.outputs.smoke_result }}
win_x64_smoke_result:
description: "Windows x64 packaged smoke outcome; advisory to publication."
value: ${{ jobs.build_win.outputs.smoke_result }}
version_metadata_url:
description: "Public R2 URL of this build's version metadata.json."
value: ${{ jobs.publish.outputs.version_metadata_url }}
@@ -241,6 +247,8 @@ jobs:
name: Build prerelease mac arm64
needs: [metadata, verify]
runs-on: macos-14
outputs:
smoke_result: ${{ steps.mac_smoke.outcome }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
@@ -505,6 +513,8 @@ jobs:
${{ runner.temp }}/mac-framework-diagnostics.txt
if-no-files-found: warn
- name: Smoke prerelease mac packaged runtime
id: mac_smoke
continue-on-error: true
working-directory: e2e
env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json
@@ -887,6 +897,8 @@ jobs:
name: Build prerelease win x64
needs: [metadata, verify]
runs-on: windows-latest
outputs:
smoke_result: ${{ steps.win_smoke.outcome }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }}
@@ -1018,6 +1030,7 @@ jobs:
"deletedFailedCacheKey=$matchedKey count=$($caches.Count)"
- name: Build prerelease win_x64 update fixture
if: ${{ inputs.win_x64_smoke_mode == 'full' && inputs.win_x64_update_metadata_url == '' && inputs.win_x64_update_target_version == '' }}
continue-on-error: true
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
@@ -1031,7 +1044,9 @@ jobs:
$updateBuild = $updateOutput | ConvertFrom-Json
pnpm.cmd exec tools-pack win validate-payload --namespace release-prerelease-win --payload-path $updateBuild.payloadPath --expected-version $updateVersion --json
- name: Smoke prerelease windows packaged runtime
id: win_smoke
if: ${{ inputs.win_x64_smoke_mode != 'skip' }}
continue-on-error: true
working-directory: e2e
env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}\release-build\win_x64\build.json
+144 -11
View File
@@ -75,8 +75,10 @@ const packagedPackageJsonPath = join(workspaceRoot, "apps", "packaged", "package
const scopesScriptPath = join(workspaceRoot, "scripts", "scopes.ts");
const runnersScriptPath = join(workspaceRoot, ".github", "scripts", "runners.py");
const notifyDailyFeishuWorkflowPath = join(workspaceRoot, ".github", "workflows", "notify-daily-feishu.yml");
const notifyReleaseFeishuWorkflowPath = join(workspaceRoot, ".github", "workflows", "notify-release-feishu.yml");
const cutReleaseWorkflowPath = join(workspaceRoot, ".github", "workflows", "cut-release.yml");
const cutPatchReleaseWorkflowPath = join(workspaceRoot, ".github", "workflows", "cut-patch-release.yml");
const feishuCardScriptPath = join(workspaceRoot, "tools", "release", "src", "notifications", "feishu.ts");
const feishuNoticeScriptPath = join(workspaceRoot, "tools", "release", "src", "notifications", "feishu-notice.ts");
const landingPageDailyFeishuWorkflowPath = join(workspaceRoot, ".github", "workflows", "landing-page-daily-feishu.yml");
const landingPageCiWorkflowPath = join(workspaceRoot, ".github", "workflows", "landing-page-ci.yml");
@@ -323,6 +325,52 @@ process.exit(1);
await writeFile(ghCmdPath, `@echo off\r\n"${process.execPath}" "%~dp0gh" %*\r\n`);
}
async function renderFeishuBuildCard(env: Record<string, string>): Promise<Record<string, unknown>> {
let payload: Record<string, unknown> | undefined;
const server = createServer((request, response) => {
void (async () => {
let raw = "";
for await (const chunk of request) raw += chunk.toString();
payload = JSON.parse(raw) as Record<string, unknown>;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ code: 0 }));
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (address == null || typeof address === "string") {
throw new Error("Feishu card fixture did not bind to a TCP port");
}
try {
await execFileAsync(process.execPath, ["--experimental-strip-types", feishuCardScriptPath], {
cwd: workspaceRoot,
env: {
...process.env,
BUILD_STATE: "success",
CHANNEL_LABEL: "Prerelease",
FEISHU_WEBHOOK: `http://127.0.0.1:${address.port}`,
VERSION: "0.19.0-prerelease.1",
...env,
},
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error == null ? resolve() : reject(error)));
});
}
if (payload == null) throw new Error("Feishu card fixture received no payload");
return payload;
}
describe("packaged smoke workflow", () => {
it("[P2] keeps packaged smoke outside the main CI gate", async () => {
const workflow = await readFile(ciWorkflowPath, "utf8");
@@ -1888,7 +1936,7 @@ process.stdin.on("end", () => {
expect(workflow).toContain("tools/release/src/notifications/feishu-notice.ts");
});
it("[P1] runs a metadata-independent prerelease Windows smoke before release cuts", async () => {
it("[P1] keeps the metadata-independent prerelease Windows smoke advisory to release cuts", async () => {
const [canary, minorCut, patchCut] = await Promise.all([
readFile(mainPrereleaseWinSmokeWorkflowPath, "utf8"),
readFile(cutReleaseWorkflowPath, "utf8"),
@@ -1924,14 +1972,99 @@ process.stdin.on("end", () => {
["cut-release", minorCut],
["cut-patch-release", patchCut],
] as const) {
const smokeJob = sectionBetween(workflow, " prerelease_win_smoke:", "\n cut:");
expect(smokeJob, label).toContain("uses: ./.github/workflows/main-prerelease-win-smoke.yml");
expect(smokeJob, label).not.toContain("with:");
expect(smokeJob, label).toContain("secrets: inherit");
expect(workflow, label).toContain("permissions:\n actions: write\n contents: read");
expect(workflow, label).toContain("needs: prerelease_win_smoke");
expect(workflow, label).toContain("ref: ${{ needs.prerelease_win_smoke.outputs.commit }}");
const cutJob = workflow.slice(workflow.indexOf(" cut:"));
expect(workflow, label).not.toContain("prerelease_win_smoke");
expect(workflow, label).not.toContain("uses: ./.github/workflows/main-prerelease-win-smoke.yml");
expect(workflow, label).toContain("permissions:\n contents: read");
expect(cutJob, label).not.toContain("needs:");
expect(cutJob, label).toContain("ref: main");
}
expect(canary).toContain("该 smoke 是独立质量信号,不阻塞 release cut 或 prerelease 打包 / 发布。");
expect(canary).not.toContain("release cut 会被阻止");
});
it("[P1] keeps prerelease smoke failures advisory and annotates the download card", async () => {
const [prerelease, notify, feishuCard] = await Promise.all([
readFile(releasePrereleaseWorkflowPath, "utf8"),
readFile(notifyReleaseFeishuWorkflowPath, "utf8"),
readFile(feishuCardScriptPath, "utf8"),
]);
const workflowCall = sectionBetween(prerelease, " workflow_call:", "permissions:");
expect(workflowCall).toContain("mac_arm64_smoke_result:");
expect(workflowCall).toContain("value: ${{ jobs.build_mac.outputs.smoke_result }}");
expect(workflowCall).toContain("win_x64_smoke_result:");
expect(workflowCall).toContain("value: ${{ jobs.build_win.outputs.smoke_result }}");
const macJob = sectionBetween(prerelease, " build_mac:", " build_mac_intel:");
const macSmoke = sectionBetween(
macJob,
" - name: Smoke prerelease mac packaged runtime",
" - name: Write mac_arm64 release report",
);
expect(macJob).toContain("outputs:\n smoke_result: ${{ steps.mac_smoke.outcome }}");
expect(macSmoke).toContain("id: mac_smoke");
expect(macSmoke).toContain("continue-on-error: true");
const winJob = sectionBetween(prerelease, " build_win:", " build_linux:");
const winSmokeFixture = sectionBetween(
winJob,
" - name: Build prerelease win_x64 update fixture",
" - name: Smoke prerelease windows packaged runtime",
);
const winSmoke = sectionBetween(
winJob,
" - name: Smoke prerelease windows packaged runtime",
" - name: Write win_x64 release report",
);
expect(winJob).toContain("outputs:\n smoke_result: ${{ steps.win_smoke.outcome }}");
expect(winSmokeFixture).toContain("continue-on-error: true");
expect(winSmoke).toContain("id: win_smoke");
expect(winSmoke).toContain("continue-on-error: true");
expect(winJob.indexOf("Smoke prerelease windows packaged runtime")).toBeLessThan(
winJob.indexOf("Publish windows prerelease platform"),
);
const notifyJob = notify.slice(notify.indexOf(" notify:"));
expect(notifyJob).toContain("MAC_ARM64_SMOKE_RESULT: ${{ needs.build.outputs.mac_arm64_smoke_result }}");
expect(notifyJob).toContain("WIN_X64_SMOKE_RESULT: ${{ needs.build.outputs.win_x64_smoke_result }}");
expect(notifyJob).toContain("MAC_ARM64_URL: ${{ needs.build.outputs.mac_arm64_url }}");
expect(notifyJob).toContain("WIN_URL: ${{ needs.build.outputs.win_url }}");
expect(notifyJob).toContain("tools/release/src/notifications/feishu.ts");
expect(notifyJob).not.toContain("tools/release/src/notifications/feishu-notice.ts");
expect(feishuCard).toContain('optional("MAC_ARM64_SMOKE_RESULT")');
expect(feishuCard).toContain('optional("WIN_X64_SMOKE_RESULT")');
expect(feishuCard).toContain("Windows x64 smoke 失败");
expect(feishuCard).toContain("macOS arm64 smoke 失败");
expect(feishuCard).toContain("产物已继续发布,可通过下方链接下载");
expect(feishuCard).toContain('template: smokeFailures.length > 0 ? "orange"');
});
it("[P1] keeps download actions on a prerelease card with a failed Windows smoke", async () => {
const payload = await renderFeishuBuildCard({
MAC_ARM64_SMOKE_RESULT: "success",
MAC_ARM64_URL: "https://releases.example/mac.dmg",
WIN_X64_SMOKE_RESULT: "failure",
WIN_URL: "https://releases.example/windows.exe",
});
const card = payload.card as {
elements: Array<{ actions?: Array<{ url?: string }>; text?: { content?: string } }>;
header: { template?: string; title?: { content?: string } };
};
expect(card.header).toMatchObject({
template: "orange",
title: { content: expect.stringContaining("Windows x64 smoke 失败") },
});
expect(card.elements.map((element) => element.text?.content).filter(Boolean)).toContain(
"**Smoke 告警**\n- Windows x64 smoke 失败\n\n产物已继续发布,可通过下方链接下载。",
);
expect(card.elements.flatMap((element) => element.actions ?? []).map((action) => action.url)).toEqual([
"https://releases.example/mac.dmg",
"https://releases.example/windows.exe",
]);
});
it("[P2] gates the Thursday patch cut on the Tuesday minor being published", async () => {
@@ -1980,9 +2113,9 @@ process.stdin.on("end", () => {
expect(workflow).toContain(`- name: ${step}\n if: steps.guard.outputs.published == 'true'`);
}
// Happy path keeps cut-release's mechanics: cut the exact main commit that
// passed the prerelease Windows canary, then push with the App token.
expect(workflow).toContain("ref: ${{ needs.prerelease_win_smoke.outputs.commit }}");
// Happy path keeps cut-release's mechanics: cut current main independently
// from the advisory Windows canary, then push with the App token.
expect(workflow).toContain("ref: main");
expect(workflow).toContain("token: ${{ steps.app.outputs.token }}");
expect(workflow).toContain('git push origin "$BRANCH"');
// The version bump is a no-op whenever main already leads stable by this exact
+27 -2
View File
@@ -12,6 +12,8 @@
// PREVIOUS_COMMIT changelog baseline (last published build); empty on cold start
// CHANGELOG_FILE path to a file holding `git log` output (one commit per line)
// BUILD_STATE build result (success | ...) — drives header color
// MAC_ARM64_SMOKE_RESULT macOS arm64 packaged smoke outcome (success | failure | skipped)
// WIN_X64_SMOKE_RESULT Windows x64 packaged smoke outcome (success | failure | skipped)
// STREAM_LABEL human label for the trigger (e.g. "release 分支推送" / "每日定时")
// REPO owner/name
// RUN_URL link back to the GitHub Actions run
@@ -52,6 +54,15 @@ const streamLabel = optional("STREAM_LABEL", "构建");
const repo = optional("REPO");
const runUrl = optional("RUN_URL");
const smokeFailures = buildState === "success"
? [
{ failureText: "macOS arm64 smoke 失败", result: optional("MAC_ARM64_SMOKE_RESULT") },
{ failureText: "Windows x64 smoke 失败", result: optional("WIN_X64_SMOKE_RESULT") },
]
.filter((entry) => entry.result === "failure")
.map((entry) => entry.failureText)
: [];
const MAX_CHANGELOG_LINES = 30;
// Download buttons, in display order. Empty URLs are skipped, so the set of
@@ -122,6 +133,15 @@ function buildCard(): FeishuCard {
fields.push({ is_short: true, text: { tag: "lark_md", content: `**触发**\n${streamLabel}` } });
const elements: FeishuElement[] = [{ tag: "div", fields }];
if (smokeFailures.length > 0) {
elements.push({
tag: "div",
text: {
tag: "lark_md",
content: `**Smoke 告警**\n${smokeFailures.map((failure) => `- ${failure}`).join("\n")}\n\n产物已继续发布,可通过下方链接下载。`,
},
});
}
elements.push({
tag: "div",
text: { tag: "lark_md", content: `**自上个 ${channelLabel} 新增提交**\n${changelogMarkdown()}` },
@@ -143,8 +163,13 @@ function buildCard(): FeishuCard {
return {
config: { wide_screen_mode: true },
header: {
template: headerTemplate(),
title: { tag: "plain_text", content: `🚀 Open Design ${channelLabel} ${version}` },
template: smokeFailures.length > 0 ? "orange" : headerTemplate(),
title: {
tag: "plain_text",
content: smokeFailures.length > 0
? `⚠️ Open Design ${channelLabel} ${version} · ${smokeFailures.join("、")}`
: `🚀 Open Design ${channelLabel} ${version}`,
},
},
elements,
};