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
This commit is contained in:
Ray Xi
2026-08-11 06:06:53 +00:00
committed by GitHub
parent 6a6bc6c45b
commit 7ecd35a746
6 changed files with 472 additions and 13 deletions
@@ -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<string, unknown>).version;
if (typeof version !== "string" || version.length === 0) {
fail(`${packageJsonPath} must contain a string version`);
}
return version;
}
function readMetadataRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value == null || Array.isArray(value)) {
fail("beta metadata.json must be a JSON object");
}
return value as Record<string, unknown>;
}
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<string, unknown>
: {};
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}`);
+37 -2
View File
@@ -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 }}
+34
View File
@@ -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: "每日定时"
+76 -8
View File
@@ -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') &&