From 7ecd35a746317983d2d0b4ab79293ab5d0d58d91 Mon Sep 17 00:00:00 2001 From: Ray Xi <2667192167@qq.com> Date: Tue, 11 Aug 2026 14:06:53 +0800 Subject: [PATCH] fix(release): guard automated release trains (#6729) * fix(release): guard automated release trains * fix(release): resolve beta recovery before checkout * style(release): group workflow outputs * fix(release): publish daily beta snapshots without promotion --- .../release/resolve-daily-beta-recovery.ts | 116 +++++++++++ .github/workflows/cut-release.yml | 39 +++- .github/workflows/notify-daily-feishu.yml | 34 ++++ .github/workflows/release-beta.yml | 84 +++++++- e2e/tests/packaged-smoke-workflow.test.ts | 192 +++++++++++++++++- tools/release/src/notifications/feishu.ts | 20 +- 6 files changed, 472 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/release/resolve-daily-beta-recovery.ts diff --git a/.github/scripts/release/resolve-daily-beta-recovery.ts b/.github/scripts/release/resolve-daily-beta-recovery.ts new file mode 100644 index 0000000000..863c2bb3e4 --- /dev/null +++ b/.github/scripts/release/resolve-daily-beta-recovery.ts @@ -0,0 +1,116 @@ +import { appendFileSync, readFileSync } from "node:fs"; + +type ReleaseBaseVersion = { + major: number; + minor: number; + patch: number; +}; + +function fail(message: string): never { + throw new Error(`[daily-beta] ${message}`); +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (value == null || value.length === 0) fail(`${name} is required`); + return value; +} + +function setOutput(name: string, value: string): void { + appendFileSync(requiredEnv("GITHUB_OUTPUT"), `${name}=${value}\n`, "utf8"); +} + +function parseReleaseBaseVersion(value: string, source: string): ReleaseBaseVersion { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value); + if (match == null) fail(`${source} must be x.y.z; got ${value}`); + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +function compareReleaseBaseVersions(left: ReleaseBaseVersion, right: ReleaseBaseVersion): number { + return left.major - right.major || left.minor - right.minor || left.patch - right.patch; +} + +function readPackagedVersion(): string { + const direct = process.env.PACKAGED_VERSION?.trim(); + if (direct != null && direct.length > 0) return direct; + + const packageJsonPath = requiredEnv("PACKAGED_PACKAGE_JSON_PATH"); + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as unknown; + if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) { + fail(`${packageJsonPath} must contain a JSON object`); + } + const version = (parsed as Record).version; + if (typeof version !== "string" || version.length === 0) { + fail(`${packageJsonPath} must contain a string version`); + } + return version; +} + +function readMetadataRecord(value: unknown): Record { + if (typeof value !== "object" || value == null || Array.isArray(value)) { + fail("beta metadata.json must be a JSON object"); + } + return value as Record; +} + +function finish(force: boolean, promote: boolean, reason: string, releaseVersion = ""): void { + console.log( + `[daily-beta] force: ${force ? "true" : "false"}; promote: ${promote ? "true" : "false"} (${reason})`, + ); + setOutput("force", force ? "true" : "false"); + setOutput("promote", promote ? "true" : "false"); + setOutput("reason", reason); + setOutput("release_version", releaseVersion); +} + +const buildRef = requiredEnv("BUILD_REF"); +if (buildRef !== "main") { + console.log(`[daily-beta] recovery disabled for non-main ref ${buildRef}`); + finish(false, true, "non-main-ref"); + process.exit(0); +} + +const packagedVersion = readPackagedVersion(); +const packagedBase = parseReleaseBaseVersion(packagedVersion, "packaged version"); +const metadataUrl = new URL(requiredEnv("OPEN_DESIGN_BETA_METADATA_URL")); +if (metadataUrl.protocol !== "https:") fail("OPEN_DESIGN_BETA_METADATA_URL must use https"); + +const response = await fetch(metadataUrl, { + headers: { accept: "application/json" }, + redirect: "error", +}); +if (response.status === 404) { + finish(false, true, "beta-metadata-missing"); + process.exit(0); +} +if (!response.ok) fail(`beta metadata request failed with HTTP ${response.status}`); + +const metadata = readMetadataRecord(await response.json()); +const baseVersion = metadata.baseVersion; +if (typeof baseVersion !== "string") fail("beta metadata.json baseVersion must be a string"); +const betaBase = parseReleaseBaseVersion(baseVersion, "beta metadata.json baseVersion"); + +const github = metadata.github; +const githubRecord = typeof github === "object" && github != null && !Array.isArray(github) + ? github as Record + : {}; +const branch = typeof githubRecord.branch === "string" ? githubRecord.branch.trim() : ""; + +if (compareReleaseBaseVersions(betaBase, packagedBase) <= 0) { + finish(false, true, "beta-not-ahead"); + process.exit(0); +} +if (branch.length === 0 || branch === "main") { + finish(false, true, "ahead-beta-owned-by-main-or-unknown"); + process.exit(0); +} + +console.log(`[daily-beta] recovering shared beta from foreign branch ${branch}`); +console.log(`[daily-beta] packaged ${packagedVersion} is behind shared beta base ${baseVersion}`); +const runNumber = requiredEnv("GITHUB_RUN_NUMBER"); +if (!/^[1-9]\d*$/.test(runNumber)) fail(`GITHUB_RUN_NUMBER must be a positive integer; got ${runNumber}`); +finish(true, false, "foreign-ahead-beta", `${packagedVersion}-beta.${runNumber}`); diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml index 4033580f1e..ceb9a32aed 100644 --- a/.github/workflows/cut-release.yml +++ b/.github/workflows/cut-release.yml @@ -50,6 +50,7 @@ jobs: INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail + latest="" if [ -n "$INPUT_VERSION" ]; then # Strict x.y.z, digits only — rejects shell metacharacters, newlines, etc. if ! printf '%s' "$INPUT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then @@ -66,11 +67,42 @@ jobs: V="${major}.$((minor+1)).0" fi # V is now guaranteed to match ^[0-9]+\.[0-9]+\.[0-9]+$ - echo "version=$V" >> "$GITHUB_OUTPUT" - echo "branch=release/v$V" >> "$GITHUB_OUTPUT" + { + echo "version=$V" + echo "branch=release/v$V" + echo "latest_release=$latest" + } >> "$GITHUB_OUTPUT" echo "Cutting v$V (branch release/v$V)" + - name: Check the current release line is published + id: gate + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + LATEST_RELEASE: ${{ steps.ver.outputs.latest_release }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + echo "Manual cut requested; published-line guard does not apply." + exit 0 + fi + + published="$(gh release view "open-design-v$LATEST_RELEASE" \ + --repo nexu-io/open-design \ + --json isDraft,isPrerelease \ + --jq '(.isDraft or .isPrerelease) | not' 2>/dev/null || true)" + if [ "$published" != "true" ]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "Scheduled cut skipped: release/v$LATEST_RELEASE has not been published stable." | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "ready=true" >> "$GITHUB_OUTPUT" + echo "release/v$LATEST_RELEASE is published stable; scheduled cut may proceed." + - name: Bail out if the branch already exists + if: steps.gate.outputs.ready == 'true' env: BRANCH: ${{ steps.ver.outputs.branch }} run: | @@ -81,6 +113,7 @@ jobs: fi - name: Create branch + bump version + push + if: steps.gate.outputs.ready == 'true' env: VERSION: ${{ steps.ver.outputs.version }} BRANCH: ${{ steps.ver.outputs.branch }} @@ -94,6 +127,7 @@ jobs: git push origin "$BRANCH" # App token push -> triggers notify-release-feishu (nightly + Feishu) - name: Create backport label + if: steps.gate.outputs.ready == 'true' env: GH_TOKEN: ${{ steps.app.outputs.token }} VERSION: ${{ steps.ver.outputs.version }} @@ -111,6 +145,7 @@ jobs: # build. feishu-notice.ts only imports node:crypto, so the setup-node above # is all it needs. - name: Notify Feishu that the branch was cut + if: steps.gate.outputs.ready == 'true' env: FEISHU_WEBHOOK: ${{ secrets.FEISHU_RELEASE_WEBHOOK }} FEISHU_SIGN_SECRET: ${{ secrets.FEISHU_RELEASE_SIGN_SECRET }} diff --git a/.github/workflows/notify-daily-feishu.yml b/.github/workflows/notify-daily-feishu.yml index 4104ec24e2..2f7ecc2527 100644 --- a/.github/workflows/notify-daily-feishu.yml +++ b/.github/workflows/notify-daily-feishu.yml @@ -44,6 +44,9 @@ jobs: runs-on: ubuntu-latest outputs: ref: ${{ steps.pick.outputs.ref }} + force: ${{ steps.recovery.outputs.force }} + promote: ${{ steps.recovery.outputs.promote }} + release_version: ${{ steps.recovery.outputs.release_version }} steps: - name: Pick build ref (main, or override) id: pick @@ -59,6 +62,30 @@ jobs: echo "using default ref: main" echo "ref=main" >> "$GITHUB_OUTPUT" + - name: Checkout workflow helper + uses: actions/checkout@v6.0.2 + with: + path: workflow-source + + - name: Checkout build ref for recovery check + uses: actions/checkout@v6.0.2 + with: + ref: ${{ steps.pick.outputs.ref }} + path: build-source + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Resolve foreign beta recovery + id: recovery + env: + BUILD_REF: ${{ steps.pick.outputs.ref }} + OPEN_DESIGN_BETA_METADATA_URL: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/beta/latest/metadata.json + PACKAGED_PACKAGE_JSON_PATH: build-source/apps/packaged/package.json + run: node --experimental-strip-types workflow-source/.github/scripts/release/resolve-daily-beta-recovery.ts + build: name: Build beta from main needs: resolve @@ -67,7 +94,10 @@ jobs: with: enable_mac_arm64: true enable_win_x64: true + force: ${{ needs.resolve.outputs.force == 'true' }} + promote: ${{ needs.resolve.outputs.promote == 'true' }} ref: ${{ needs.resolve.outputs.ref }} + release_version: ${{ needs.resolve.outputs.release_version }} notify: name: Notify Feishu (daily) @@ -116,6 +146,10 @@ jobs: PREVIOUS_COMMIT: ${{ needs.build.outputs.previous_commit }} CHANGELOG_FILE: ${{ steps.changelog.outputs.file }} BUILD_STATE: ${{ needs.build.result }} + RELEASE_STATE: ${{ needs.build.outputs.release_state }} + RELEASE_NOTE: ${{ needs.build.outputs.release_state == 'partial' && '共享 beta/latest 当前版本高于 main,已保留原指针;本次仅发布可下载的版本化快照。' || '' }} + 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 }} WIN_URL: ${{ needs.build.outputs.win_x64_url }} STREAM_LABEL: "每日定时" diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 339e567954..4ee21fdd96 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -194,6 +194,11 @@ on: required: false type: boolean default: false + promote: + description: "Promote this build to beta/latest after publishing versioned platform artifacts." + required: false + type: boolean + default: true release_version: required: false type: string @@ -285,16 +290,22 @@ on: value: ${{ jobs.metadata.outputs.previous_commit }} release_state: description: "complete | partial | failed." - value: ${{ jobs.publish.outputs.release_state }} + value: ${{ jobs.publish.outputs.release_state || (jobs.metadata.outputs.promote == 'false' && 'partial') }} version_metadata_url: description: "Public URL of this build's version metadata.json." value: ${{ jobs.publish.outputs.version_metadata_url }} mac_arm64_url: description: "macOS arm64 dmg download URL." - value: ${{ jobs.publish.outputs.mac_arm64_url }} + value: ${{ jobs.publish.outputs.mac_arm64_url || jobs.build_mac_arm64.outputs.mac_arm64_url }} win_x64_url: description: "Windows x64 installer download URL." - value: ${{ jobs.publish.outputs.win_x64_url }} + value: ${{ jobs.publish.outputs.win_x64_url || jobs.build_win_x64.outputs.win_x64_url }} + mac_arm64_smoke_result: + description: "macOS arm64 packaged smoke outcome; advisory to publication." + value: ${{ jobs.build_mac_arm64.outputs.smoke_result }} + win_x64_smoke_result: + description: "Windows x64 packaged smoke outcome; advisory to publication." + value: ${{ jobs.build_win_x64.outputs.smoke_result }} mac_x64_url: description: "macOS x64 dmg download URL." value: ${{ jobs.publish.outputs.mac_x64_url }} @@ -330,7 +341,7 @@ env: # # Secrets read here: VELA_WEB_URL_FEATURE_TEST, VELA_WEB_URL_TEST. OD_VELA_WEB_URL: ${{ inputs.amr_profile == 'feature-test' && secrets.VELA_WEB_URL_FEATURE_TEST || inputs.amr_profile == 'test' && secrets.VELA_WEB_URL_TEST || '' }} - RELEASE_BRANCH: ${{ github.ref_name }} + RELEASE_BRANCH: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} RELEASE_RUN_ID: ${{ github.run_id }} @@ -348,9 +359,10 @@ jobs: asset_version_suffix: ${{ steps.beta.outputs.asset_version_suffix }} base_version: ${{ steps.beta.outputs.base_version }} beta_version: ${{ inputs.release_version != '' && inputs.release_version || steps.beta.outputs.beta_version }} - branch: ${{ steps.beta.outputs.branch }} - commit: ${{ steps.beta.outputs.commit }} + branch: ${{ steps.identity.outputs.branch }} + commit: ${{ steps.identity.outputs.commit }} previous_commit: ${{ steps.prev.outputs.previous_commit }} + promote: ${{ inputs.promote }} release_name: ${{ steps.beta.outputs.release_name }} state_source: ${{ steps.beta.outputs.state_source }} steps: @@ -373,8 +385,33 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Resolve built commit - run: echo "BUILT_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + - name: Resolve built identity + id: identity + env: + BUILD_REF: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} + run: | + set -euo pipefail + built_sha="$(git rev-parse HEAD)" + echo "BUILT_SHA=$built_sha" >> "$GITHUB_ENV" + { + echo "branch=$BUILD_REF" + echo "commit=$built_sha" + } >> "$GITHUB_OUTPUT" + + - name: Validate shared beta publisher + if: ${{ inputs.publish }} + run: | + set -euo pipefail + built_sha="$(git rev-parse HEAD)" + main_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ -z "$main_sha" ]; then + echo "Could not resolve the current main commit; refusing shared beta publication." >&2 + exit 1 + fi + if [ "$built_sha" != "$main_sha" ]; then + echo "Shared beta publication is restricted to the current main commit ($main_sha); got $built_sha. Re-run this ref with publish=false for dogfood artifacts." >&2 + exit 1 + fi - name: Capture previous beta commit id: prev @@ -427,6 +464,9 @@ jobs: needs: metadata if: ${{ inputs.enable_mac_arm64 }} runs-on: macos-14 + outputs: + mac_arm64_url: ${{ steps.mac_arm64_platform_outputs.outputs.dmg_url }} + smoke_result: ${{ steps.mac_arm64_smoke.outcome }} env: RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }} steps: @@ -660,7 +700,9 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Smoke beta mac_arm64 packaged runtime + id: mac_arm64_smoke if: ${{ inputs.mac_arm64_smoke_mode != 'skip' }} + continue-on-error: true working-directory: e2e env: OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json @@ -768,6 +810,16 @@ jobs: RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} run: pnpm exec tools-release publish-platform + - name: Read mac_arm64 platform outputs + id: mac_arm64_platform_outputs + if: ${{ inputs.publish }} + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs"); + const outputs = JSON.parse(fs.readFileSync(`${process.env.RUNNER_TEMP}/release-platform-outputs/mac_arm64.json`, "utf8")); + for (const [key, value] of Object.entries(outputs)) core.setOutput(key, String(value)); + - name: Upload mac_arm64 publish manifest if: ${{ inputs.publish }} uses: actions/upload-artifact@v7 @@ -1049,6 +1101,9 @@ jobs: needs: metadata if: ${{ inputs.enable_win_x64 }} runs-on: windows-latest + outputs: + smoke_result: ${{ steps.win_x64_smoke.outcome }} + win_x64_url: ${{ steps.win_x64_platform_outputs.outputs.installer_url }} env: RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }} steps: @@ -1232,7 +1287,9 @@ jobs: pnpm.cmd exec tools-pack win validate-payload --namespace release-beta-win --payload-path $updateBuild.payloadPath --expected-version $updateVersion --json - name: Smoke beta win_x64 packaged runtime + id: win_x64_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 @@ -1332,6 +1389,16 @@ jobs: RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} run: pnpm exec tools-release publish-platform + - name: Read win_x64 platform outputs + id: win_x64_platform_outputs + if: ${{ inputs.publish }} + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs"); + const outputs = JSON.parse(fs.readFileSync(`${process.env.RUNNER_TEMP}/release-platform-outputs/win_x64.json`, "utf8")); + for (const [key, value] of Object.entries(outputs)) core.setOutput(key, String(value)); + - name: Upload win_x64 publish manifest if: ${{ inputs.publish }} uses: actions/upload-artifact@v7 @@ -1466,6 +1533,7 @@ jobs: always() && !cancelled() && inputs.publish && + inputs.promote && needs.metadata.result == 'success' && (inputs.enable_mac_arm64 || inputs.enable_win_x64 || inputs.enable_mac_x64 || inputs.enable_linux_x64) && (!inputs.enable_mac_arm64 || needs.build_mac_arm64.result == 'success') && diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index 2128cdebf2..5a641f3165 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -57,6 +57,13 @@ const bakePreviewsReleaseWorkflowPath = join( const finalizeReleaseWorkflowPath = join(workspaceRoot, ".github", "workflows", "finalize-release.yml"); const handoffScriptPath = join(workspaceRoot, ".github", "scripts", "handoff.py"); const releaseBetaWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-beta.yml"); +const dailyBetaRecoveryScriptPath = join( + workspaceRoot, + ".github", + "scripts", + "release", + "resolve-daily-beta-recovery.ts", +); const releaseBetaSelfHostedWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-beta-s.yml"); const releasePreviewWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-preview.yml"); const releasePrereleaseWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-prerelease.yml"); @@ -1907,6 +1914,131 @@ process.stdin.on("end", () => { } }); + it("[P1] lets the daily main build recover a shared beta advanced by a feature branch", async () => { + const packagedVersion = await readPackagedVersion(); + const objects: Record = { + "beta/latest/metadata.json": { + baseVersion: "0.19.0", + channel: "beta", + github: { branch: "feat/standalone-closure" }, + releaseNumber: 9, + releaseVersion: "0.19.0-beta.9", + }, + }; + const fixture = await startStablePrereleaseMetadataServer(objects); + const runnerTemp = await mkdtemp(join(tmpdir(), "od-release-beta-foreign-recovery-")); + const outputPath = join(runnerTemp, "outputs.txt"); + const baseEnv = { + ...process.env, + BUILD_REF: "main", + GITHUB_OUTPUT: outputPath, + GITHUB_RUN_NUMBER: "4242", + NODE_TLS_REJECT_UNAUTHORIZED: "0", + OPEN_DESIGN_BETA_METADATA_URL: `${fixture.origin}/beta/latest/metadata.json`, + PACKAGED_VERSION: packagedVersion, + }; + + try { + const recovered = await execFileAsync( + process.execPath, + ["--experimental-strip-types", dailyBetaRecoveryScriptPath], + { + cwd: workspaceRoot, + env: baseEnv, + maxBuffer: 1024 * 1024, + }, + ); + expect(recovered.stdout).toContain( + "[daily-beta] recovering shared beta from foreign branch feat/standalone-closure", + ); + const recoveryOutputs = await readFile(outputPath, "utf8"); + expect(recoveryOutputs).toContain("force=true"); + expect(recoveryOutputs).toContain("promote=false"); + expect(recoveryOutputs).toContain(`release_version=${packagedVersion}-beta.4242`); + + await writeFile(outputPath, "", "utf8"); + const featureBuild = await execFileAsync( + process.execPath, + ["--experimental-strip-types", dailyBetaRecoveryScriptPath], + { + cwd: workspaceRoot, + env: { ...baseEnv, BUILD_REF: "feat/standalone-closure" }, + maxBuffer: 1024 * 1024, + }, + ); + expect(featureBuild.stdout).toContain("[daily-beta] recovery disabled for non-main ref"); + const featureOutputs = await readFile(outputPath, "utf8"); + expect(featureOutputs).toContain("force=false"); + expect(featureOutputs).toContain("promote=true"); + } finally { + await fixture.close(); + await rm(runnerTemp, { force: true, recursive: true }); + } + }); + + it("[P1] keeps shared beta publication main-owned while feature refs stay dogfood-only", async () => { + const [betaWorkflow, dailyWorkflow] = await Promise.all([ + readFile(releaseBetaWorkflowPath, "utf8"), + readFile(notifyDailyFeishuWorkflowPath, "utf8"), + ]); + const metadataJob = sectionBetween(betaWorkflow, " metadata:", " build_mac_arm64:"); + const publisherGuard = sectionBetween( + metadataJob, + "- name: Validate shared beta publisher", + "- name: Capture previous beta commit", + ); + + expect(betaWorkflow).toContain( + "RELEASE_BRANCH: ${{ inputs.ref != '' && inputs.ref || github.ref_name }}", + ); + expect(publisherGuard).toContain("if: ${{ inputs.publish }}"); + expect(publisherGuard).toContain('built_sha="$(git rev-parse HEAD)"'); + expect(publisherGuard).toContain( + 'main_sha="$(git ls-remote origin refs/heads/main | awk \'{print $1}\')"', + ); + expect(publisherGuard).toContain('[ "$built_sha" != "$main_sha" ]'); + expect(publisherGuard).toContain("publish=false"); + expect(betaWorkflow).not.toContain("recover_foreign_beta"); + expect(betaWorkflow).not.toContain("OPEN_DESIGN_RECOVER_FOREIGN_BETA"); + expect(metadataJob).toContain("branch: ${{ steps.identity.outputs.branch }}"); + expect(metadataJob).toContain("commit: ${{ steps.identity.outputs.commit }}"); + expect(metadataJob).toContain("promote: ${{ inputs.promote }}"); + expect(betaWorkflow).toContain("value: ${{ jobs.build_mac_arm64.outputs.smoke_result }}"); + expect(betaWorkflow).toContain("value: ${{ jobs.build_win_x64.outputs.smoke_result }}"); + expect(betaWorkflow).toContain( + "value: ${{ jobs.publish.outputs.mac_arm64_url || jobs.build_mac_arm64.outputs.mac_arm64_url }}", + ); + expect(betaWorkflow).toContain( + "value: ${{ jobs.publish.outputs.win_x64_url || jobs.build_win_x64.outputs.win_x64_url }}", + ); + + const macJob = sectionBetween(betaWorkflow, " build_mac_arm64:", " build_mac_x64:"); + expect(macJob).toContain("smoke_result: ${{ steps.mac_arm64_smoke.outcome }}"); + expect(macJob).toContain("mac_arm64_url: ${{ steps.mac_arm64_platform_outputs.outputs.dmg_url }}"); + expect(macJob).toContain("id: mac_arm64_smoke"); + expect(macJob).toContain("continue-on-error: true"); + expect(macJob).toContain("id: mac_arm64_platform_outputs"); + + const winJob = sectionBetween(betaWorkflow, " build_win_x64:", " build_linux_x64:"); + expect(winJob).toContain("smoke_result: ${{ steps.win_x64_smoke.outcome }}"); + expect(winJob).toContain("win_x64_url: ${{ steps.win_x64_platform_outputs.outputs.installer_url }}"); + expect(winJob).toContain("id: win_x64_smoke"); + expect(winJob).toContain("continue-on-error: true"); + expect(winJob).toContain("id: win_x64_platform_outputs"); + + const publishJob = betaWorkflow.slice(betaWorkflow.indexOf(" publish:")); + expect(publishJob).toContain("inputs.promote"); + expect(dailyWorkflow).toContain("resolve-daily-beta-recovery.ts"); + expect(dailyWorkflow).toContain("force: ${{ needs.resolve.outputs.force == 'true' }}"); + expect(dailyWorkflow).toContain("promote: ${{ needs.resolve.outputs.promote == 'true' }}"); + expect(dailyWorkflow).toContain("release_version: ${{ needs.resolve.outputs.release_version }}"); + expect(dailyWorkflow).toContain( + "MAC_ARM64_SMOKE_RESULT: ${{ needs.build.outputs.mac_arm64_smoke_result }}", + ); + expect(dailyWorkflow).toContain("WIN_X64_SMOKE_RESULT: ${{ needs.build.outputs.win_x64_smoke_result }}"); + expect(dailyWorkflow).toContain("RELEASE_STATE: ${{ needs.build.outputs.release_state }}"); + }); + it("[P2] daily beta resolve defaults to main and preserves the ref override", async () => { // Beta is the daily R&D channel and must track the development tip (main). // Selecting the highest-semver release/vX.Y.Z branch stalls the build: once @@ -1921,6 +2053,9 @@ process.stdin.on("end", () => { // builds main, and the workflow_dispatch override is still propagated. const workflow = await readFile(notifyDailyFeishuWorkflowPath, "utf8"); const resolveJob = sectionBetween(workflow, " resolve:", "\n build:"); + expect(resolveJob).toContain("force: ${{ steps.recovery.outputs.force }}"); + expect(resolveJob).toContain("promote: ${{ steps.recovery.outputs.promote }}"); + expect(resolveJob).toContain("release_version: ${{ steps.recovery.outputs.release_version }}"); // Override path: workflow_dispatch ref is wired in and forwarded verbatim. expect(resolveJob).toContain("OVERRIDE_REF: ${{ inputs.ref }}"); expect(resolveJob).toContain('echo "ref=$OVERRIDE_REF" >> "$GITHUB_OUTPUT"'); @@ -1936,6 +2071,32 @@ process.stdin.on("end", () => { expect(workflow).toContain("tools/release/src/notifications/feishu-notice.ts"); }); + it("[P1] skips the scheduled minor cut until the highest release branch is published stable", async () => { + const workflow = await readFile(cutReleaseWorkflowPath, "utf8"); + const gate = sectionBetween( + workflow, + "- name: Check the current release line is published", + "- name: Bail out if the branch already exists", + ); + + expect(gate).toContain("GITHUB_EVENT_NAME: ${{ github.event_name }}"); + expect(gate).toContain("LATEST_RELEASE: ${{ steps.ver.outputs.latest_release }}"); + expect(gate).toContain('[ "$GITHUB_EVENT_NAME" != "schedule" ]'); + expect(gate).toContain('gh release view "open-design-v$LATEST_RELEASE"'); + expect(gate).toContain("--jq '(.isDraft or .isPrerelease) | not'"); + expect(gate).toContain('echo "ready=false" >> "$GITHUB_OUTPUT"'); + expect(gate).toContain('echo "ready=true" >> "$GITHUB_OUTPUT"'); + + for (const step of [ + "Bail out if the branch already exists", + "Create branch + bump version + push", + "Create backport label", + "Notify Feishu that the branch was cut", + ]) { + expect(workflow).toContain(`- name: ${step}\n if: steps.gate.outputs.ready == 'true'`); + } + }); + it("[P1] keeps the metadata-independent prerelease Windows smoke advisory to release cuts", async () => { const [canary, minorCut, patchCut] = await Promise.all([ readFile(mainPrereleaseWinSmokeWorkflowPath, "utf8"), @@ -2039,7 +2200,9 @@ process.stdin.on("end", () => { expect(feishuCard).toContain("Windows x64 smoke 失败"); expect(feishuCard).toContain("macOS arm64 smoke 失败"); expect(feishuCard).toContain("产物已继续发布,可通过下方链接下载"); - expect(feishuCard).toContain('template: smokeFailures.length > 0 ? "orange"'); + expect(feishuCard).toContain( + 'template: smokeFailures.length > 0 || releaseState === "partial" ? "orange"', + ); }); it("[P1] keeps download actions on a prerelease card with a failed Windows smoke", async () => { @@ -2067,6 +2230,33 @@ process.stdin.on("end", () => { ]); }); + it("[P1] keeps download actions on a partial beta card without claiming latest promotion", async () => { + const payload = await renderFeishuBuildCard({ + CHANNEL_LABEL: "Beta", + MAC_ARM64_URL: "https://releases.example/beta-mac.dmg", + RELEASE_NOTE: "共享 beta/latest 版本高于 main,本次仅发布版本化快照。", + RELEASE_STATE: "partial", + VERSION: "0.18.2-beta.4242", + WIN_URL: "https://releases.example/beta-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("未更新 Beta latest") }, + }); + expect(card.elements.map((element) => element.text?.content).filter(Boolean)).toContain( + "**渠道状态**\n产物已发布并可下载,但未更新 Beta latest。\n\n共享 beta/latest 版本高于 main,本次仅发布版本化快照。", + ); + expect(card.elements.flatMap((element) => element.actions ?? []).map((action) => action.url)).toEqual([ + "https://releases.example/beta-mac.dmg", + "https://releases.example/beta-windows.exe", + ]); + }); + it("[P2] gates the Thursday patch cut on the Tuesday minor being published", async () => { // cut-patch-release is the Tuesday cut-release flow, one weekday later, with a // PATCH bump and a publish guard. Lock the three properties that make it safe: diff --git a/tools/release/src/notifications/feishu.ts b/tools/release/src/notifications/feishu.ts index 2b573fde91..52c4a38f2a 100644 --- a/tools/release/src/notifications/feishu.ts +++ b/tools/release/src/notifications/feishu.ts @@ -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 +// RELEASE_STATE complete | partial; partial means artifacts exist but channel latest was not promoted +// RELEASE_NOTE optional operator-facing explanation for a partial release // 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 分支推送" / "每日定时") @@ -50,6 +52,8 @@ const commit = optional("COMMIT"); const previousCommit = optional("PREVIOUS_COMMIT"); const changelogFile = optional("CHANGELOG_FILE"); const buildState = optional("BUILD_STATE", "success"); +const releaseState = optional("RELEASE_STATE", "complete"); +const releaseNote = optional("RELEASE_NOTE"); const streamLabel = optional("STREAM_LABEL", "构建"); const repo = optional("REPO"); const runUrl = optional("RUN_URL"); @@ -142,6 +146,16 @@ function buildCard(): FeishuCard { }, }); } + if (releaseState === "partial") { + const detail = releaseNote.length > 0 ? `\n\n${releaseNote}` : ""; + elements.push({ + tag: "div", + text: { + tag: "lark_md", + content: `**渠道状态**\n产物已发布并可下载,但未更新 ${channelLabel} latest。${detail}`, + }, + }); + } elements.push({ tag: "div", text: { tag: "lark_md", content: `**自上个 ${channelLabel} 新增提交**\n${changelogMarkdown()}` }, @@ -163,10 +177,12 @@ function buildCard(): FeishuCard { return { config: { wide_screen_mode: true }, header: { - template: smokeFailures.length > 0 ? "orange" : headerTemplate(), + template: smokeFailures.length > 0 || releaseState === "partial" ? "orange" : headerTemplate(), title: { tag: "plain_text", - content: smokeFailures.length > 0 + content: releaseState === "partial" + ? `⚠️ Open Design ${channelLabel} ${version} · 未更新 ${channelLabel} latest` + : smokeFailures.length > 0 ? `⚠️ Open Design ${channelLabel} ${version} · ${smokeFailures.join("、")}` : `🚀 Open Design ${channelLabel} ${version}`, },