mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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}`);
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: "每日定时"
|
||||
|
||||
@@ -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') &&
|
||||
|
||||
Reference in New Issue
Block a user