diff --git a/.github/scripts/release/dispatch-validation.sh b/.github/scripts/release/dispatch-validation.sh new file mode 100755 index 0000000000..6ab16d3e5d --- /dev/null +++ b/.github/scripts/release/dispatch-validation.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Dispatch one prerelease validation lane (code tests, packaged smoke, or the +# progressive Feishu card) as a workflow run of its own. +# +# Why a dispatch and not a job: release-prerelease.yml holds a single +# repository-wide concurrency group (`open-design-release-prerelease`, +# cancel-in-progress: false). Anything that outlives `publish` inside that +# workflow keeps the group held, which stops the NEXT prerelease from starting. +# Validation is exactly that kind of work, so it has to leave the workflow. +# +# This helper is the one place that knows how to aim `gh workflow run` at a ref +# that actually carries the target workflow file: the built branch first, so a +# lane edited on a release branch is the lane that runs for that branch, then +# the repository default branch, so a release branch cut before these lanes +# existed still gets validated. The lane checks out the built commit either way +# — the ref chosen here decides whose workflow DEFINITION runs, not which code +# is tested. +# +# usage: PRIMARY_REF=... FALLBACK_REF=... dispatch-validation.sh [gh args...] +set -euo pipefail + +workflow="${1:?usage: dispatch-validation.sh [gh workflow run args...]}" +shift + +refs=() +for candidate in "${PRIMARY_REF:-}" "${FALLBACK_REF:-}"; do + [ -n "$candidate" ] || continue + duplicate=0 + for seen in ${refs[@]+"${refs[@]}"}; do + [ "$seen" = "$candidate" ] && duplicate=1 + done + [ "$duplicate" -eq 0 ] && refs+=("$candidate") +done + +if [ "${#refs[@]}" -eq 0 ]; then + echo "::error::dispatch-validation needs PRIMARY_REF or FALLBACK_REF to dispatch $workflow" >&2 + exit 1 +fi + +for ref in "${refs[@]}"; do + if gh workflow run "$workflow" --ref "$ref" "$@"; then + echo "dispatched $workflow on ref $ref" + if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GH_REPO:-}" ]; then + # The dispatched run carries the origin run id in its run-name, so this + # listing is how a human walks from this pipeline to its validation lane. + echo "runs: ${GITHUB_SERVER_URL}/${GH_REPO}/actions/workflows/${workflow}" + fi + exit 0 + fi + echo "::warning::could not dispatch $workflow on ref $ref" +done + +echo "::error::$workflow was not dispatched on any of: ${refs[*]}" >&2 +exit 1 diff --git a/.github/scripts/release/smoke-artifacts.ts b/.github/scripts/release/smoke-artifacts.ts new file mode 100644 index 0000000000..ba5f5e550e --- /dev/null +++ b/.github/scripts/release/smoke-artifacts.ts @@ -0,0 +1,220 @@ +// Stage a PUBLISHED release artifact for the packaged smoke, instead of a +// locally built one. +// +// The packaged smoke used to run on the same runner that produced the package, +// against `/out/...` as electron-builder left it. That coupled the +// smoke to the build job — which is what kept the release workflow's +// repository-wide concurrency group held long after the package had shipped — +// and it tested a directory tree no user ever sees. +// +// `tools-pack install` reads exactly one file, at a path it derives +// from `--dir` and `--namespace` (tools/pack/src/{mac,win}/paths.ts). So the +// smoke can run anywhere: download that one artifact out of R2, write it to +// that path, and every later `tools-pack` verb behaves as if the build had +// happened here. +// +// Two modes: +// plan — read the published version metadata and report which targets exist +// stage — download one target's artifact into the tools-pack layout +// +// Both read the metadata.json that `tools-release publish-metadata` wrote, so +// the artifact URL is the published one rather than a name reconstructed here. + +import { createHash } from "node:crypto"; +import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +type ReleaseTarget = "mac_arm64" | "mac_x64" | "win_x64" | "linux_x64"; + +type AssetEntry = { name?: unknown; sha256Url?: unknown; size?: unknown; url?: unknown }; +type TargetEntry = { artifacts?: Record; status?: unknown }; +type VersionMetadata = { + channel?: unknown; + releaseVersion?: unknown; + releaseTargets?: Record; +}; + +/** + * The artifact each platform's smoke installs. Mac installs the DMG it mounts; + * Windows installs the NSIS setup. The payload/zip artifacts are updater inputs + * and belong to the `full` Windows profile, which builds its own fixture. + */ +const PRIMARY_ARTIFACT: Record = { + mac_arm64: "dmg", + mac_x64: "dmg", + win_x64: "installer", + linux_x64: "appImage", +}; + +function required(name: string): string { + const value = process.env[name]; + if (value == null || value.length === 0) throw new Error(`${name} is required`); + return value; +} + +function optional(name: string, fallback = ""): string { + const value = process.env[name]; + return value == null || value.length === 0 ? fallback : value; +} + +function setOutput(key: string, value: string): void { + const file = process.env.GITHUB_OUTPUT; + console.log(`${key}=${value}`); + if (file == null || file.length === 0) return; + appendFileSync(file, `${key}=${value}\n`, "utf8"); +} + +function sanitizeNamespace(value: string): string { + // Mirrors sanitizeNamespace in tools/pack/src/{mac,win}/paths.ts. The token is + // what ends up in the artifact filename tools-pack looks for. + return value.replace(/[^A-Za-z0-9._-]+/g, "-"); +} + +async function fetchJson(url: string): Promise { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) throw new Error(`GET ${url} failed: HTTP ${response.status}`); + return (await response.json()) as VersionMetadata; +} + +function publishedTargets(metadata: VersionMetadata): ReleaseTarget[] { + const targets = metadata.releaseTargets ?? {}; + const published: ReleaseTarget[] = []; + for (const target of ["mac_arm64", "mac_x64", "win_x64", "linux_x64"] as const) { + const entry = targets[target]; + // A target that did not build carries `{status: "missing"|"failed"}` with no + // `artifacts` key at all, so `status` is the only safe thing to branch on. + if (entry?.status === "published" && entry.artifacts != null) published.push(target); + } + return published; +} + +function assetOf(metadata: VersionMetadata, target: ReleaseTarget): { name: string; sha256Url: string; url: string } { + const entry = metadata.releaseTargets?.[target]; + if (entry?.status !== "published" || entry.artifacts == null) { + throw new Error(`release target ${target} is not published in this metadata (status=${String(entry?.status)})`); + } + const key = PRIMARY_ARTIFACT[target]; + const asset = entry.artifacts[key]; + if (asset == null || typeof asset.url !== "string" || asset.url.length === 0) { + throw new Error(`release target ${target} has no ${key} artifact url`); + } + return { + name: typeof asset.name === "string" ? asset.name : key, + sha256Url: typeof asset.sha256Url === "string" ? asset.sha256Url : "", + url: asset.url, + }; +} + +/** + * Where `tools-pack install` looks. Keep in lockstep with + * resolveMacPaths().dmgPath and resolveWinPaths().setupPath. + */ +function artifactDestination(target: ReleaseTarget, toolsPackDir: string, namespace: string): string { + const token = sanitizeNamespace(namespace); + if (target === "win_x64") { + return join(toolsPackDir, "out", "win", "namespaces", namespace, "builder", `Open Design-${token}-setup.exe`); + } + if (target === "linux_x64") { + return join(toolsPackDir, "out", "linux", "namespaces", namespace, "builder", `Open Design-${token}.AppImage`); + } + return join(toolsPackDir, "out", "mac", "namespaces", namespace, "dmg", `Open Design-${token}.dmg`); +} + +async function download(url: string, destination: string): Promise<{ bytes: number; sha256: string }> { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) throw new Error(`GET ${url} failed: HTTP ${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, buffer); + return { bytes: buffer.byteLength, sha256: createHash("sha256").update(buffer).digest("hex") }; +} + +async function verifyChecksum(sha256Url: string, actual: string): Promise { + if (sha256Url.length === 0) { + console.warn(`::warning::published artifact carries no sha256 sidecar; downloaded bytes were not verified`); + return; + } + const response = await fetch(sha256Url, { redirect: "follow" }); + if (!response.ok) throw new Error(`GET ${sha256Url} failed: HTTP ${response.status}`); + // `shasum -a 256 ` / PowerShell both write " ". + const expected = (await response.text()).trim().split(/\s+/)[0]?.toLowerCase() ?? ""; + if (expected.length === 0) throw new Error(`sha256 sidecar at ${sha256Url} was empty`); + if (expected !== actual) { + throw new Error(`downloaded artifact sha256 ${actual} does not match published ${expected}`); + } + console.log(`sha256 verified: ${actual}`); +} + +/** + * `e2e/scripts/release-smoke.ts` refuses to start without a readable build json, + * and `tools-release write-report` throws on a zero-byte one. Nothing on the + * core smoke path parses its contents, so a stand-in only has to be honest + * about where the artifact came from and be valid JSON. + */ +function writeBuildJson(path: string, body: Record): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`, "utf8"); +} + +async function plan(): Promise { + const metadataUrl = required("VERSION_METADATA_URL"); + const metadata = await fetchJson(metadataUrl); + const expectedVersion = optional("EXPECTED_VERSION"); + if (expectedVersion.length > 0 && metadata.releaseVersion !== expectedVersion) { + throw new Error( + `version metadata is for ${String(metadata.releaseVersion)}, not the dispatched ${expectedVersion}`, + ); + } + const published = publishedTargets(metadata); + console.log(`published targets: ${published.join(", ") || ""}`); + for (const target of ["mac_arm64", "mac_x64", "win_x64", "linux_x64"] as const) { + setOutput(target, published.includes(target) ? "true" : "false"); + } + setOutput("channel", typeof metadata.channel === "string" ? metadata.channel : ""); + setOutput("release_version", typeof metadata.releaseVersion === "string" ? metadata.releaseVersion : ""); +} + +async function stage(): Promise { + const metadataUrl = required("VERSION_METADATA_URL"); + const target = required("RELEASE_TARGET") as ReleaseTarget; + if (!(target in PRIMARY_ARTIFACT)) throw new Error(`unsupported RELEASE_TARGET: ${target}`); + const toolsPackDir = required("TOOLS_PACK_DIR"); + const namespace = required("RELEASE_NAMESPACE"); + const buildJsonPath = required("BUILD_JSON_PATH"); + + const metadata = await fetchJson(metadataUrl); + const asset = assetOf(metadata, target); + const destination = artifactDestination(target, toolsPackDir, namespace); + console.log(`staging ${asset.url}\n -> ${destination}`); + const { bytes, sha256 } = await download(asset.url, destination); + await verifyChecksum(asset.sha256Url, sha256); + + writeBuildJson(buildJsonPath, { + source: "published-artifact", + generatedAt: new Date().toISOString(), + outputRoot: join(toolsPackDir, "out"), + releaseTarget: target, + releaseVersion: metadata.releaseVersion ?? null, + channel: metadata.channel ?? null, + namespace, + publishedArtifact: { bytes, name: asset.name, sha256, url: asset.url }, + ...(target === "win_x64" ? { installerPath: destination } : {}), + ...(target === "linux_x64" ? { appImagePath: destination } : {}), + ...(target === "mac_arm64" || target === "mac_x64" ? { dmgPath: destination } : {}), + cacheReport: { entries: [] }, + timings: [], + }); + + setOutput("artifact_path", destination); + setOutput("artifact_url", asset.url); + setOutput("artifact_bytes", String(bytes)); +} + +const mode = process.argv[2]; +if (mode === "plan") { + await plan(); +} else if (mode === "stage") { + await stage(); +} else { + throw new Error("usage: smoke-artifacts.ts "); +} diff --git a/.github/workflows/notify-release-feishu.yml b/.github/workflows/notify-release-feishu.yml index 06261c9a56..d5317debfe 100644 --- a/.github/workflows/notify-release-feishu.yml +++ b/.github/workflows/notify-release-feishu.yml @@ -2,7 +2,7 @@ name: notify-release-feishu # On every push to a release/** branch, build a full prerelease package by reusing # release-prerelease, then post a Feishu card with the download -# links (macOS arm64 + Intel, Windows, and Linux when enabled) and the changelog +# links (macOS arm64 and Windows always; macOS Intel and Linux when enabled) and the changelog # since the previously published prerelease. Consecutive pushes to the same branch # are throttled, not debounced: an in-flight build is allowed to finish (so a # busy release branch always produces a prerelease), while pushes that arrive @@ -46,6 +46,26 @@ on: required: false type: string default: "" + enable_mac_x64: + description: "Also build the macOS Intel (x64) package. ON by default — stable ships Intel and is gated on validated prerelease artifacts, so an Intel-less prerelease cannot be promoted. Uncheck only when this run is explicitly not about Intel and you want the ~12 min back." + required: false + type: boolean + default: true + enable_smoke: + description: "Also run the packaged smoke suites (mac arm64, mac Intel, Windows). ON by default. They no longer run inside the build jobs: release-prerelease-smoke.yml re-downloads the published packages from R2 after they ship, so smoke costs this pipeline nothing." + required: false + type: boolean + default: true + enable_tests: + description: "Also dispatch the code test suites (P0 Functional E2E, E2E Vitest, daemon shards, verify) into release-prerelease-tests.yml. ON by default; they gate nothing and run in a workflow of their own." + required: false + type: boolean + default: true + mac_sign_mode: + description: "macOS signing depth: no / sign-only / notarize. Empty uses sign-only — codesigned (which the stable promotion gate requires) but not sent to Apple's notary service. Use notarize for the last prerelease before a stable promotion." + required: false + type: string + default: "" permissions: contents: write @@ -96,9 +116,40 @@ jobs: # 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 || 'core' }} + # macOS Intel builds on every prerelease, because stable ships Intel and + # stable's gate is a validated prerelease artifact. + # + # `|| true` would NOT express that. `inputs` is unset on push, so the + # obvious `${{ inputs.enable_mac_x64 || true }}` reads correctly there — + # but on a dispatch where the operator UNCHECKED the box, `false || true` + # is also true, and the switch would be unturnoffable. Test the event + # instead: push short-circuits to a real `true`, and a dispatch forwards + # the operator's own boolean. + enable_mac_x64: ${{ github.event_name != 'workflow_dispatch' || inputs.enable_mac_x64 }} + # Same shape, same reason. Packaged smoke now runs in + # release-prerelease-smoke.yml against the artifacts re-downloaded from + # R2, so it costs the build nothing and defaults on. + enable_smoke: ${{ github.event_name != 'workflow_dispatch' || inputs.enable_smoke }} + # Same shape. The code suites run in release-prerelease-tests.yml and gate + # nothing; the switch exists only to skip them outright. + enable_tests: ${{ github.event_name != 'workflow_dispatch' || inputs.enable_tests }} + # Same failure mode as win_x64_smoke_mode above: a `workflow_call` + # `default:` only applies to an OMITTED input, so the final arm must name + # a real mode, never ''. sign-only keeps platforms.mac.signed == true, + # which is what the stable promotion gate reads; it drops only the Apple + # notary round-trip, which no prerelease consumer needs. + mac_sign_mode: ${{ inputs.mac_sign_mode || 'sign-only' }} notify: name: Notify Feishu (release) + # TRANSITIONAL. The progressive card (release-prerelease-card.yml, posted by + # the Feishu application bot) supersedes this one-shot custom-bot webhook + # card: it appears as soon as the first platform publishes and is edited in + # place as the rest of the platforms, the code tests, and the packaged smoke + # report in. This job stays until the new card has carried a couple of real + # releases, because a gap in release notification is worse than a duplicate. + # Retiring it means deleting this job and the FEISHU_RELEASE_WEBHOOK / + # FEISHU_RELEASE_SIGN_SECRET secrets it reads. needs: build if: ${{ !cancelled() && needs.build.outputs.release_version != '' }} runs-on: ubuntu-latest @@ -111,22 +162,74 @@ jobs: # can differ — anchoring on the built commit keeps the changelog range # (previous_commit..commit) correct for manual prerelease runs. ref: ${{ needs.build.outputs.commit }} - fetch-depth: 0 + # Shallow on purpose. The only thing this job runs from the repository + # is tools/release/src/notifications/feishu.ts, which imports nothing + # but node builtins; the changelog range now comes from the compare + # API below instead of local history. A full clone of this repository + # is a 2.2 GiB pack and cost 4m09s of a 56m pipeline (run + # 34102146769) to serve exactly one `git log`. - name: Compute changelog since last prerelease id: changelog env: + GH_TOKEN: ${{ github.token }} 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 + # Set the output before any early return, so every path below still + # hands feishu.ts a real (possibly empty) file — the same contract the + # local-git version had: no baseline, or an unreachable commit, means + # an empty changelog, not a failed notification. echo "file=$file" >> "$GITHUB_OUTPUT" + if [ -z "$PREV" ] || [ -z "$CUR" ]; then + echo "no comparable range (PREV='${PREV}' CUR='${CUR}'); changelog stays empty" + exit 0 + fi + + raw="$RUNNER_TEMP/changelog-compare.txt" + meta="$RUNNER_TEMP/changelog-compare-meta.txt" + : > "$raw" + : > "$meta" + + # Reproduces `git log --no-merges --pretty=format:'%s (%h)' PREV..CUR`: + # * `select((.parents | length) < 2)` is --no-merges. + # * %s is the subject — the first paragraph, folded onto one line — + # so split on the blank line, then collapse any interior newline. + # * %h abbreviated to 10 characters, which is what git's automatic + # abbreviation resolves to on this repository's object count. + # `--paginate` walks the compare endpoint's Link headers; both the page + # order and the order within a page are oldest-first, so a single `tac` + # at the end restores git log's newest-first order (which matters: the + # card renders only the first 30 lines). + subject='(.commit.message | split("\n\n")[0] | gsub("[\r\n]+"; " ") | sub("^\\s+"; "") | sub("\\s+$"; ""))' + if ! gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/compare/${PREV}...${CUR}?per_page=100" \ + --jq ".commits[] | select((.parents | length) < 2) | \"\\(${subject}) (\\(.sha[0:10]))\"" \ + > "$raw"; then + echo "::warning::compare ${PREV}...${CUR} failed (commit unreachable, history rewritten, or API error); posting the card without a changelog" + exit 0 + fi + + tac "$raw" > "$file" + + # The compare endpoint caps at 250 commits. Pagination still returns + # the newest page last, so the newest 30 lines the card renders are + # exact either way; only the "共 N 条" total would under-report. Say so + # in the log rather than silently disagreeing with the real range. + if gh api "repos/${GITHUB_REPOSITORY}/compare/${PREV}...${CUR}?per_page=1" \ + --jq '.total_commits' > "$meta" 2>/dev/null; then + total="$(tr -d '[:space:]' < "$meta")" + collected="$(wc -l < "$raw" | tr -d ' ')" + echo "compare ${PREV}...${CUR}: total_commits=${total} non-merge lines collected=${collected}" + if printf '%s' "$total" | grep -Eq '^[0-9]+$' && [ "$total" -gt 250 ]; then + echo "::warning::compare returned ${total} commits, above the endpoint's 250 cap; the card lists the newest commits correctly but its total under-reports" + fi + fi + - name: Setup pnpm uses: pnpm/action-setup@v5 with: @@ -148,8 +251,17 @@ 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 }} + # The authoritative "did a package actually ship?" bit. `BUILD_STATE` + # is the reusable workflow's overall conclusion, which goes red for + # anything in the pipeline — a failed validation dispatch, an opt-in + # platform, a post-publish cleanup — even when the packages are + # already sitting in R2 and downloadable from the buttons on this very + # card. A non-empty version_metadata_url means publish wrote metadata. + VERSION_METADATA_URL: ${{ needs.build.outputs.version_metadata_url }} + # Packaged mac/Windows smoke moved to release-prerelease-smoke.yml and + # is no longer an output of the build, so this card carries no smoke + # annotation. The progressive card (release-prerelease-card.yml) is + # where smoke results land now. 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 }} diff --git a/.github/workflows/release-prerelease-card.yml b/.github/workflows/release-prerelease-card.yml new file mode 100644 index 0000000000..94ca7d59a6 --- /dev/null +++ b/.github/workflows/release-prerelease-card.yml @@ -0,0 +1,170 @@ +name: release-prerelease-card + +# The progressive Feishu card for one prerelease. +# +# The old card was a custom-bot webhook post at the very end of the pipeline: +# one immutable message, delivered only once everything had finished, and blind +# to whether the packages had actually shipped. This replaces it with a Feishu +# *application* bot, which can PATCH a message it sent — so one card is created +# when the first platform's package lands in R2 and is edited in place as the +# rest of the platforms, the code tests, and the packaged smoke report in. +# +# Exactly one job writes that card, on purpose. A PATCH replaces the whole card, +# so three build jobs updating "their own" row would each erase the other two. +# This job therefore owns the message and learns the state by polling the jobs +# API of the three runs involved (build/publish, tests, smoke). +# +# It is dispatched by release-prerelease.yml's `dispatch_validation` job at the +# moment the build commit is resolved — before any package exists — and it holds +# a concurrency group of its own, so its long watch cannot delay a build. + +on: + workflow_dispatch: + inputs: + version: + description: "Prerelease version this card reports, e.g. 0.21.1-prerelease.3." + required: true + type: string + commit: + description: "Commit being built. Also the changelog's upper bound." + required: true + type: string + branch: + description: "Branch that was built." + required: false + type: string + default: "" + previous_commit: + description: "Commit of the previously published prerelease; the changelog's lower bound. Empty on a cold start, which the card reports as 首个 Prerelease 包." + required: false + type: string + default: "" + origin_run_id: + description: "Run id of the release-prerelease pipeline this card is watching." + required: true + type: string + expect_tests: + description: "Whether release-prerelease-tests.yml was dispatched for this build. When false the card renders no test rows instead of waiting for results that will never arrive." + required: false + type: boolean + default: true + expect_smoke: + description: "Whether release-prerelease-smoke.yml will be dispatched once the packages publish." + required: false + type: boolean + default: true + +run-name: prerelease card · ${{ inputs.version }} · origin-run ${{ inputs.origin_run_id }} + +permissions: + # Reads the job state of the build, test, and smoke runs. It writes nothing to + # the repository — the only side effect is a Feishu message. + actions: read + contents: read + +concurrency: + group: release-prerelease-card-${{ inputs.origin_run_id }} + cancel-in-progress: false + +jobs: + card: + name: Track prerelease on one Feishu card + if: github.repository == 'nexu-io/open-design' + runs-on: ubuntu-latest + # Long enough to outlive the slowest lane it watches (mac Intel build ~33 + # min, P0 Functional E2E, then the packaged smoke after publish), with the + # watcher's own budget set below it so the card gets a final "timed out" + # update instead of being killed mid-poll. + timeout-minutes: 150 + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + # Deliberately the ref this workflow was dispatched on, NOT the built + # commit: the watcher is infrastructure, and pinning it to the build's + # commit would run whatever notifier that branch happened to carry. + fetch-depth: 1 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Compute changelog since last prerelease + id: changelog + env: + GH_TOKEN: ${{ github.token }} + PREV: ${{ inputs.previous_commit }} + CUR: ${{ inputs.commit }} + run: | + set -euo pipefail + file="$RUNNER_TEMP/changelog.txt" + : > "$file" + # Set the output before any early return, so every path below still + # hands the card a real (possibly empty) file: no baseline, or an + # unreachable commit, means an empty changelog, never a failed card. + echo "file=$file" >> "$GITHUB_OUTPUT" + + if [ -z "$PREV" ] || [ -z "$CUR" ]; then + echo "no comparable range (PREV='${PREV}' CUR='${CUR}'); changelog stays empty" + exit 0 + fi + + raw="$RUNNER_TEMP/changelog-compare.txt" + meta="$RUNNER_TEMP/changelog-compare-meta.txt" + : > "$raw" + : > "$meta" + + # Reproduces `git log --no-merges --pretty=format:'%s (%h)' PREV..CUR` + # against the compare API, so the card needs no git history at all: + # * `select((.parents | length) < 2)` is --no-merges. + # * %s is the subject — the first paragraph folded onto one line — + # so split on the blank line, then collapse any interior newline. + # * %h abbreviated to 10 characters, which is what git's automatic + # abbreviation resolves to on this repository's object count. + # `--paginate` walks the compare endpoint's Link headers; both the page + # order and the order within a page are oldest-first, so a single `tac` + # at the end restores git log's newest-first order (which matters: the + # card renders only the first 30 lines). + subject='(.commit.message | split("\n\n")[0] | gsub("[\r\n]+"; " ") | sub("^\\s+"; "") | sub("\\s+$"; ""))' + if ! gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/compare/${PREV}...${CUR}?per_page=100" \ + --jq ".commits[] | select((.parents | length) < 2) | \"\\(${subject}) (\\(.sha[0:10]))\"" \ + > "$raw"; then + echo "::warning::compare ${PREV}...${CUR} failed (commit unreachable, history rewritten, or API error); the card will carry no changelog" + exit 0 + fi + + tac "$raw" > "$file" + + # The compare endpoint caps at 250 commits. Pagination still returns + # the newest page last, so the newest 30 lines the card renders are + # exact either way; only the "共 N 条" total would under-report. + if gh api "repos/${GITHUB_REPOSITORY}/compare/${PREV}...${CUR}?per_page=1" \ + --jq '.total_commits' > "$meta" 2>/dev/null; then + total="$(tr -d '[:space:]' < "$meta")" + collected="$(wc -l < "$raw" | tr -d ' ')" + echo "compare ${PREV}...${CUR}: total_commits=${total} non-merge lines collected=${collected}" + if printf '%s' "$total" | grep -Eq '^[0-9]+$' && [ "$total" -gt 250 ]; then + echo "::warning::compare returned ${total} commits, above the endpoint's 250 cap; the card lists the newest commits correctly but its total under-reports" + fi + fi + + - name: Track the prerelease on one card + env: + BRANCH: ${{ inputs.branch }} + CARD_TIMEOUT_MS: "8400000" + CHANGELOG_FILE: ${{ steps.changelog.outputs.file }} + CHANNEL_LABEL: Prerelease + COMMIT: ${{ inputs.commit }} + EXPECT_SMOKE: ${{ inputs.expect_smoke }} + EXPECT_TESTS: ${{ inputs.expect_tests }} + FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }} + FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }} + FEISHU_RELEASE_CHAT_ID: ${{ secrets.FEISHU_RELEASE_CHAT_ID }} + GH_TOKEN: ${{ github.token }} + ORIGIN_RUN_ID: ${{ inputs.origin_run_id }} + PREVIOUS_COMMIT: ${{ inputs.previous_commit }} + RELEASE_CHANNEL: prerelease + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + VERSION: ${{ inputs.version }} + run: node --experimental-strip-types tools/release/src/notifications/prerelease-progress-card.ts diff --git a/.github/workflows/release-prerelease-smoke.yml b/.github/workflows/release-prerelease-smoke.yml new file mode 100644 index 0000000000..142731d8e6 --- /dev/null +++ b/.github/workflows/release-prerelease-smoke.yml @@ -0,0 +1,398 @@ +name: release-prerelease-smoke + +# Packaged smoke for one prerelease, run against the artifacts as PUBLISHED. +# +# This used to be a step at the end of each build job. Two problems with that. +# It kept release-prerelease.yml — which holds a single repository-wide +# concurrency group — open past `publish`, so the next prerelease could not +# start. And it installed electron-builder's output directory, which is not what +# anybody downloads. +# +# So the smoke moved here and now starts from the R2 object: it reads the +# published version metadata, downloads the DMG / setup.exe a user would get, +# drops it at the one path `tools-pack install` reads, and runs the +# same spec. `.github/scripts/release/smoke-artifacts.ts` owns both halves and +# verifies the sha256 sidecar on the way in. +# +# Dispatched by release-prerelease.yml's `dispatch_smoke` job once `publish` +# succeeds. Advisory by construction: nothing consumes the result as a gate, and +# by the time this runs the package is already downloadable. Results reach a +# human through the progressive Feishu card (release-prerelease-card.yml). + +on: + workflow_dispatch: + inputs: + commit: + description: "Commit the packages were built from. The specs and tools-pack come from this ref." + required: true + type: string + version: + description: "Prerelease version being smoked, e.g. 0.21.1-prerelease.3. Must match the packages, because tools-pack derives install identity from it." + required: true + type: string + version_metadata_url: + description: "Public R2 URL of this build's version metadata.json. The authoritative list of what actually published." + required: true + type: string + origin_run_id: + description: "Run id of the release-prerelease pipeline that produced these packages. Carried in the run name so the Feishu card and a human can walk between the two." + required: false + type: string + default: "" + win_x64_smoke_mode: + description: "Windows x64 smoke coverage. `full` also builds the updater payload fixture here, which costs a complete Windows package — the published artifact cannot supply it, because it is the version being updated FROM." + required: true + type: choice + options: + - skip + - core + - full + default: core + win_x64_update_metadata_url: + description: "Optional external updater metadata URL for full Windows x64 payload smoke." + required: false + type: string + default: "" + win_x64_update_target_version: + description: "Optional external updater target version expected by full Windows x64 payload smoke." + required: false + type: string + default: "" + +run-name: prerelease smoke · ${{ inputs.version }} · origin-run ${{ inputs.origin_run_id }} + +permissions: + contents: read + +concurrency: + # Scoped to the origin run. This workflow must never be able to queue behind + # anything that could delay a build. + group: release-prerelease-smoke-${{ inputs.origin_run_id || inputs.version }} + cancel-in-progress: false + +env: + # Only the Windows `full` lane packages anything here (the updater fixture), + # and a fixture that points at a different backend than the installed build + # would make the update look like a configuration change. Prerelease packages + # ship the prod profile, so the fixture does too. + OPEN_DESIGN_AMR_PROFILE: prod + OD_VELA_WEB_URL: ${{ secrets.VELA_WEB_URL_PROD }} + OD_VELA_WEB_URL_FEATURE_TEST: ${{ secrets.VELA_WEB_URL_FEATURE_TEST }} + OD_VELA_WEB_URL_TEST: ${{ secrets.VELA_WEB_URL_TEST }} + OD_VELA_WEB_URL_PROD: ${{ secrets.VELA_WEB_URL_PROD }} + OPEN_DESIGN_TELEMETRY_RELAY_URL: ${{ vars.OPEN_DESIGN_TELEMETRY_RELAY_URL }} + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} + +jobs: + plan: + name: Resolve published prerelease targets + runs-on: ubuntu-latest + outputs: + mac_arm64: ${{ steps.plan.outputs.mac_arm64 }} + mac_x64: ${{ steps.plan.outputs.mac_x64 }} + win_x64: ${{ steps.plan.outputs.win_x64 }} + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + # This job reads published metadata over HTTP and touches no product + # code, so it runs the staging script that ships with this workflow + # rather than the one at the built commit. + fetch-depth: 1 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Read published version metadata + id: plan + env: + EXPECTED_VERSION: ${{ inputs.version }} + VERSION_METADATA_URL: ${{ inputs.version_metadata_url }} + # A platform that did not build is absent from the metadata, so the + # per-platform jobs below are gated on what actually shipped rather than + # on the flags the build was dispatched with. + run: node --experimental-strip-types .github/scripts/release/smoke-artifacts.ts plan + + mac_arm64: + name: Smoke prerelease mac arm64 + needs: plan + if: ${{ needs.plan.outputs.mac_arm64 == 'true' }} + runs-on: macos-14 + timeout-minutes: 45 + env: + RELEASE_NAMESPACE: release-prerelease + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.commit }} + fetch-depth: 1 + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + # tools-pack resolves electron and electron-builder on every subcommand, + # not just `build`, so the workspace install is required even though this + # job packages nothing. + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Stage published mac arm64 dmg + env: + BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json + RELEASE_TARGET: mac_arm64 + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + VERSION_METADATA_URL: ${{ inputs.version_metadata_url }} + run: node --experimental-strip-types .github/scripts/release/smoke-artifacts.ts stage + - name: Smoke prerelease mac packaged runtime + working-directory: e2e + env: + OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json + OD_PACKAGED_E2E_MAC: "1" + # A literal, never an expression that can resolve to ''. mac.spec.ts + # reads this with `??`, so an empty string survives and selects the + # `full` updater path, which then dies for want of a fixture. + OD_PACKAGED_E2E_MAC_SMOKE_PROFILE: core + OD_PACKAGED_E2E_NAMESPACE: release-prerelease + OD_PACKAGED_E2E_RELEASE_CHANNEL: prerelease + OD_PACKAGED_E2E_RELEASE_VERSION: ${{ inputs.version }} + OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64 + OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: | + set -euo pipefail + pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts + - name: Write mac_arm64 smoke report + if: ${{ always() }} + env: + BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json + RELEASE_BUILD_TARGET: all + RELEASE_CHANNEL: prerelease + RELEASE_NAMESPACE: release-prerelease + RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64 + RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}/release-report/mac_arm64/report.json + RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}/release-report/mac_arm64/summary.md + RELEASE_SMOKE_MODE: core + RELEASE_TARGET: mac_arm64 + RELEASE_VERSION: ${{ inputs.version }} + REPORT_TITLE: mac_arm64 prerelease packaged smoke + run: pnpm exec tools-release write-report + - name: Publish mac_arm64 smoke summary + if: ${{ always() }} + run: cat "${{ runner.temp }}/release-report/mac_arm64/summary.md" >> "$GITHUB_STEP_SUMMARY" + - name: Upload mac arm64 smoke report + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: open-design-prerelease-mac-arm64-smoke-report + path: ${{ runner.temp }}/release-report/mac_arm64 + if-no-files-found: warn + retention-days: 14 + + mac_x64: + name: Smoke prerelease mac intel x64 + needs: plan + if: ${{ needs.plan.outputs.mac_x64 == 'true' }} + runs-on: macos-15-intel + timeout-minutes: 45 + env: + RELEASE_NAMESPACE: release-prerelease-intel + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.commit }} + fetch-depth: 1 + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Stage published mac intel dmg + env: + BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_x64/build.json + RELEASE_TARGET: mac_x64 + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + VERSION_METADATA_URL: ${{ inputs.version_metadata_url }} + run: node --experimental-strip-types .github/scripts/release/smoke-artifacts.ts stage + - name: Smoke prerelease mac_x64 packaged runtime + working-directory: e2e + env: + OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_x64/build.json + OD_PACKAGED_E2E_MAC: "1" + OD_PACKAGED_E2E_MAC_SMOKE_PROFILE: core + OD_PACKAGED_E2E_NAMESPACE: release-prerelease-intel + OD_PACKAGED_E2E_RELEASE_CHANNEL: prerelease + OD_PACKAGED_E2E_RELEASE_VERSION: ${{ inputs.version }} + OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac_x64 + OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: | + set -euo pipefail + pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts + - name: Write mac_x64 smoke report + if: ${{ always() }} + env: + BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_x64/build.json + RELEASE_BUILD_TARGET: all + RELEASE_CHANNEL: prerelease + RELEASE_NAMESPACE: release-prerelease-intel + RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_x64 + RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}/release-report/mac_x64/report.json + RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}/release-report/mac_x64/summary.md + RELEASE_SMOKE_MODE: core + RELEASE_TARGET: mac_x64 + RELEASE_VERSION: ${{ inputs.version }} + REPORT_TITLE: mac_x64 prerelease packaged smoke + run: pnpm exec tools-release write-report + - name: Publish mac_x64 smoke summary + if: ${{ always() }} + run: cat "${{ runner.temp }}/release-report/mac_x64/summary.md" >> "$GITHUB_STEP_SUMMARY" + - name: Upload mac intel smoke report + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: open-design-prerelease-mac-x64-smoke-report + path: ${{ runner.temp }}/release-report/mac_x64 + if-no-files-found: warn + retention-days: 14 + + win_x64: + name: Smoke prerelease win x64 + needs: plan + # Windows smoke has to stay on a Windows runner: it drives the real NSIS + # installer, the uninstall registry keys, and the shell shortcuts. + if: ${{ needs.plan.outputs.win_x64 == 'true' && inputs.win_x64_smoke_mode != 'skip' }} + runs-on: windows-latest + timeout-minutes: 60 + env: + RELEASE_NAMESPACE: release-prerelease-win + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.commit }} + fetch-depth: 1 + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Stage published windows installer + env: + BUILD_JSON_PATH: ${{ runner.temp }}\release-build\win_x64\build.json + RELEASE_TARGET: win_x64 + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + VERSION_METADATA_URL: ${{ inputs.version_metadata_url }} + run: node --experimental-strip-types .github/scripts/release/smoke-artifacts.ts stage + - name: Clear mark-of-the-web from the downloaded installer + shell: pwsh + # tools-pack launches the installer through CreateProcess, which does not + # consult the zone stream, but ASR and AppLocker policies on a hosted + # runner can. Stripping it keeps a policy change from looking like a + # product regression. + run: | + $ErrorActionPreference = "Stop" + $installer = "${{ runner.temp }}\tools-pack\out\win\namespaces\release-prerelease-win\builder\Open Design-release-prerelease-win-setup.exe" + if (-not (Test-Path -LiteralPath $installer)) { throw "staged installer not found at $installer" } + Unblock-File -LiteralPath $installer + - name: Setup NSIS + # Only the `full` profile packages anything here — the updater payload + # fixture below. `core` installs the downloaded setup.exe and needs no + # NSIS toolchain at all. + if: ${{ inputs.win_x64_smoke_mode == 'full' }} + shell: pwsh + run: | + function Test-Makensis { + [bool]((Get-Command makensis.exe -ErrorAction SilentlyContinue) -or (Test-Path "C:\Program Files (x86)\NSIS\makensis.exe")) + } + if (Test-Makensis) { Write-Host "makensis already present"; exit 0 } + foreach ($attempt in 1..4) { + Write-Host "choco install nsis (attempt $attempt)" + choco install nsis -y --no-progress + if (Test-Makensis) { break } + Start-Sleep -Seconds (15 * $attempt) + } + if (-not (Test-Makensis)) { + Write-Error "NSIS install failed after 4 attempts: makensis absent from PATH and C:\Program Files (x86)\NSIS" + exit 1 + } + Write-Host "NSIS ready" + - name: Build prerelease win_x64 update fixture + # Only the `full` Windows smoke consumes this fixture. It is a second, + # complete Windows package built at version+1 so the updater has + # something real to apply — the published artifact cannot supply it, + # because it is the version being updated FROM. + 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" + $match = [regex]::Match("${{ inputs.version }}", "^(?\d+\.\d+\.\d+)-prerelease\.(?\d+)$") + if (-not $match.Success) { throw "full Windows smoke requires a counted prerelease version; got ${{ inputs.version }}" } + $updateVersion = "{0}-prerelease.{1}" -f $match.Groups["base"].Value, ([int]$match.Groups["number"].Value + 1) + $buildArgs = @("exec", "tools-pack", "win", "build", "--dir", "${{ runner.temp }}\release-work\win_x64\tools-pack-update-fixture", "--namespace", "release-prerelease-win", "--app-version", $updateVersion, "--to", "nsis", "--json") + $updateOutput = pnpm.cmd @buildArgs + if ($LASTEXITCODE -ne 0) { throw "tools-pack win update fixture failed with exit code $LASTEXITCODE" } + $updateOutput | Set-Content -Path "${{ runner.temp }}\release-build\win_x64\windows-tools-pack-update-build.json" -Encoding utf8 + $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 + working-directory: e2e + env: + OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}\release-build\win_x64\build.json + OD_PACKAGED_E2E_NAMESPACE: release-prerelease-win + OD_PACKAGED_E2E_RELEASE_CHANNEL: prerelease + OD_PACKAGED_E2E_RELEASE_VERSION: ${{ inputs.version }} + OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}\release-report\win_x64 + OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + OD_PACKAGED_E2E_WIN: "1" + OD_PACKAGED_E2E_WIN_SMOKE_PROFILE: ${{ inputs.win_x64_smoke_mode }} + OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE: ${{ inputs.win_x64_smoke_mode == 'full' && inputs.win_x64_update_metadata_url == '' && inputs.win_x64_update_target_version == '' && 'tools-serve' || '' }} + OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }} + OD_PACKAGED_E2E_WIN_UPDATE_VERSION: ${{ inputs.win_x64_update_target_version }} + run: | + $ErrorActionPreference = "Stop" + pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + - name: Write win_x64 smoke report + if: ${{ always() }} + env: + BUILD_JSON_PATH: ${{ runner.temp }}\release-build\win_x64\build.json + RELEASE_BUILD_TARGET: all + RELEASE_CHANNEL: prerelease + RELEASE_NAMESPACE: release-prerelease-win + RELEASE_REPORT_DIR: ${{ runner.temp }}\release-report\win_x64 + RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}\release-report\win_x64\report.json + RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}\release-report\win_x64\summary.md + RELEASE_SMOKE_MODE: ${{ inputs.win_x64_smoke_mode }} + RELEASE_TARGET: win_x64 + RELEASE_VERSION: ${{ inputs.version }} + REPORT_TITLE: win_x64 prerelease packaged smoke + run: pnpm exec tools-release write-report + - name: Publish win_x64 smoke summary + if: ${{ always() }} + shell: pwsh + run: Get-Content "${{ runner.temp }}\release-report\win_x64\summary.md" | Add-Content $env:GITHUB_STEP_SUMMARY + - name: Upload windows smoke report + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: open-design-prerelease-win-x64-smoke-report + path: ${{ runner.temp }}\release-report\win_x64 + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release-prerelease-tests.yml b/.github/workflows/release-prerelease-tests.yml new file mode 100644 index 0000000000..546c409b12 --- /dev/null +++ b/.github/workflows/release-prerelease-tests.yml @@ -0,0 +1,154 @@ +name: release-prerelease-tests + +# The code test suites for one prerelease build, run OUTSIDE the pipeline that +# produced it. +# +# These four jobs used to live in release-prerelease.yml. They never gated +# delivery there — build and publish deliberately ignore them — but they still +# held the workflow open, and release-prerelease.yml's concurrency group is one +# repository-wide string with cancel-in-progress: false. So a 45-minute test +# suite did not delay one prerelease, it delayed the NEXT one from starting. +# Moving them here removes that coupling entirely: this workflow has a +# concurrency group scoped to the origin run, so two prereleases can be +# validated at once and neither can queue a build. +# +# Nothing dispatches this on a schedule or a push. release-prerelease.yml's +# `dispatch_validation` job POSTs it the moment the build commit is resolved, +# via .github/scripts/release/dispatch-validation.sh. Results reach a human +# through the progressive Feishu card (release-prerelease-card.yml), which +# watches this run's jobs; they are not workflow outputs, because nothing in the +# release path is allowed to consume them as a gate. + +on: + workflow_dispatch: + inputs: + commit: + description: "Exact commit SHA to test. This is the commit release-prerelease actually built, not a branch head." + required: true + type: string + version: + description: "Prerelease version these suites are validating, e.g. 0.21.1-prerelease.3. Display only." + required: false + type: string + default: "" + origin_run_id: + description: "Run id of the release-prerelease pipeline that dispatched this. Carried in the run name so the Feishu card and a human can walk between the two." + required: false + type: string + default: "" + +run-name: prerelease tests · ${{ inputs.version || inputs.commit }} · origin-run ${{ inputs.origin_run_id }} + +permissions: + contents: read + +concurrency: + # Scoped to the origin run, never repository-wide: this workflow must not be + # able to queue behind — or ahead of — anything. Two prereleases in flight get + # two independent test runs. + group: release-prerelease-tests-${{ inputs.origin_run_id || inputs.commit }} + cancel-in-progress: false + +jobs: + functional_e2e: + name: P0 Functional E2E + uses: ./.github/workflows/ui-extended-main.yml + with: + ref: ${{ inputs.commit }} + suite: p0 + e2e_vitest: + name: E2E Vitest + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.commit }} + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Setup Playwright + run: pnpm -C e2e exec playwright install --with-deps chromium + # Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor. + # The daemon launches the installed Chromium directly instead of through + # Playwright. Restore user namespaces on this ephemeral runner so the real + # Chromium sandbox remains enabled rather than disabling that protection. + - name: Allow Chromium user namespaces on GitHub Ubuntu + shell: bash + run: | + set -euo pipefail + if sysctl kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + - name: Prebuild workspace type declarations + run: | + pnpm --filter @open-design/daemon build + pnpm --filter @open-design/desktop build + pnpm --filter @open-design/web build:sidecar + - name: Run E2E Vitest + run: pnpm --filter @open-design/e2e test + daemon_unit_tests: + name: Daemon tests (${{ matrix.shard }}/4) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.commit }} + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Run daemon test shard + env: + OD_WATCHER_TEST_DEBUG: "1" + run: pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4 + verify: + name: Verify build (typecheck + tests) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + ref: ${{ inputs.commit }} + # Shallow: nothing here reads git history. `pnpm guard` uses + # `git ls-files` (the index, not history), and a full clone of this + # repository is a 2.2 GiB pack. + fetch-depth: 1 + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build daemon and desktop (typecheck dependencies) + run: | + pnpm --filter @open-design/daemon build + pnpm --filter @open-design/desktop build + - name: Typecheck workspaces + run: pnpm -r --workspace-concurrency=4 --if-present run typecheck + - name: Check repository layout policies + run: pnpm guard diff --git a/.github/workflows/release-prerelease.yml b/.github/workflows/release-prerelease.yml index 64f303b766..d8bf492256 100644 --- a/.github/workflows/release-prerelease.yml +++ b/.github/workflows/release-prerelease.yml @@ -17,8 +17,32 @@ on: required: false type: string default: "" + enable_mac_x64: + description: "Build and publish macOS x64 (Intel) artifacts. ON by default: stable ships Intel and stable is gated on validated prerelease artifacts, so an Intel-less prerelease cannot be promoted. The switch stays so a run that is explicitly not about Intel can buy back the ~12 min it adds to the critical path (~33 min on the macos-15-intel fleet against ~21 min everywhere else); turning it off costs that build its promotion eligibility." + required: false + type: boolean + default: true + enable_smoke: + description: "Run the packaged smoke suites (mac arm64, mac Intel, Windows). ON by default. Smoke no longer runs inside the build jobs and now costs this pipeline nothing but a dispatch: release-prerelease-smoke.yml picks it up after the packages are in R2 and re-downloads them from R2, so it exercises the artifact a user actually receives. Turn it off only to skip those runners entirely." + required: false + type: boolean + default: true + enable_tests: + description: "Dispatch the code test suites (P0 Functional E2E, E2E Vitest, daemon shards, verify) into release-prerelease-tests.yml. ON by default. They run against this build's commit in a workflow of their own, gate nothing here, and cost this pipeline only the seconds the dispatch takes." + required: false + type: boolean + default: true + mac_sign_mode: + description: "macOS signing depth for both mac lanes. 'sign-only' (default) codesigns with the Apple certificate but skips the Apple notary round-trip, which is minutes of waiting on Apple that no prerelease consumer needs. 'notarize' also notarizes and staples — use it for the last prerelease before a stable promotion, since stable ships notarized. 'no' skips signing entirely and will FAIL the stable promotion gate, which requires platforms.mac.signed == true." + required: true + type: choice + options: + - "no" + - "sign-only" + - "notarize" + default: "sign-only" win_x64_smoke_mode: - description: "Windows x64 smoke coverage." + description: "Windows x64 smoke coverage, applied only when enable_smoke is on. enable_smoke decides WHETHER packaged smoke runs; this input decides HOW DEEP the Windows one goes." required: true type: choice options: @@ -53,8 +77,28 @@ on: required: false type: string default: "" + enable_mac_x64: + description: "Build and publish macOS x64 (Intel) artifacts. ON by default: stable ships Intel and stable is gated on validated prerelease artifacts, so an Intel-less prerelease cannot be promoted. The switch stays so a run that is explicitly not about Intel can buy back the ~12 min it adds to the critical path; turning it off costs that build its promotion eligibility." + required: false + type: boolean + default: true + enable_smoke: + description: "Run the packaged smoke suites (mac arm64, mac Intel, Windows). ON by default. Smoke no longer runs inside the build jobs: release-prerelease-smoke.yml picks it up after the packages are in R2 and re-downloads them from R2, so it exercises the artifact a user actually receives." + required: false + type: boolean + default: true + enable_tests: + description: "Dispatch the code test suites (P0 Functional E2E, E2E Vitest, daemon shards, verify) into release-prerelease-tests.yml. ON by default. They run against this build's commit in a workflow of their own and gate nothing here." + required: false + type: boolean + default: true + mac_sign_mode: + description: "macOS signing depth for both mac lanes: no / sign-only / notarize. Defaults to sign-only — codesigned but not sent to Apple's notary service. Use notarize for the last prerelease before a stable promotion. 'no' fails the stable promotion gate, which requires platforms.mac.signed == true." + required: false + type: string + default: "sign-only" win_x64_smoke_mode: - description: "Windows x64 smoke coverage: skip, core, or full." + description: "Windows x64 smoke coverage, applied only when enable_smoke is on: skip, core, or full." required: false type: string default: "core" @@ -84,14 +128,26 @@ 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 }} + # --- Advisory signals ------------------------------------------------ + # Nothing below gates publication. + # + # The code tests and the packaged mac/Windows smoke no longer run in this + # workflow at all: they are dispatched into release-prerelease-tests.yml + # and release-prerelease-smoke.yml, which have concurrency groups of their + # own. That is the whole point — a validation job that outlives `publish` + # used to hold this workflow open, and this workflow's concurrency group + # is a single repository-wide string, so the next prerelease could not + # even START until the last one's tests finished. Their results reach a + # human through the progressive Feishu card (release-prerelease-card.yml), + # which watches all three runs, not through outputs here. + # + # Linux keeps its in-job smoke because the whole Linux lane is opt-in + # behind vars.ENABLE_STABLE_LINUX and is not on anyone's critical path. + linux_x64_smoke_result: + description: "Linux AppImage smoke outcome. Advisory; `skipped` unless vars.ENABLE_STABLE_LINUX is on." + value: ${{ jobs.build_linux.outputs.smoke_result }} version_metadata_url: - description: "Public R2 URL of this build's version metadata.json." + description: "Public R2 URL of this build's version metadata.json. This is the authoritative \"did a package actually ship?\" signal — it is non-empty only when the publish job wrote metadata. Do NOT read the overall workflow conclusion for that: it now goes red whenever an advisory test job fails, even though the package shipped." value: ${{ jobs.publish.outputs.version_metadata_url }} mac_arm64_url: description: "macOS arm64 dmg download URL." @@ -175,7 +231,12 @@ jobs: uses: actions/checkout@v6.0.2 with: ref: ${{ inputs.ref != '' && inputs.ref || github.ref }} - fetch-depth: 0 + # Shallow. Nothing in this workflow reads git history: the built + # commit arrives as an explicit input/env (RELEASE_COMMIT), `pnpm + # guard` uses `git ls-files` (index, not history), and tools-pack / + # tools-release never shell out to git. A full clone of this + # repository is a 2.2 GiB pack and cost 44-102s per job. + fetch-depth: 1 - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -218,115 +279,81 @@ jobs: RELEASE_STORAGE_REGION: auto RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} run: pnpm exec tools-release check-storage - functional_e2e: - name: P0 Functional E2E - needs: metadata - uses: ./.github/workflows/ui-extended-main.yml - with: - ref: ${{ needs.metadata.outputs.commit }} - suite: p0 - e2e_vitest: - name: E2E Vitest + dispatch_validation: + name: Dispatch prerelease validation + # Fire-and-forget, and deliberately absent from every other job's `needs`. + # + # This workflow holds ONE repository-wide concurrency group + # (`open-design-release-prerelease`, cancel-in-progress: false), so any job + # that outlives `publish` keeps that group held and stops the NEXT + # prerelease from STARTING — not from publishing, from starting. The test + # suites were doing exactly that. Handing them to workflows of their own + # costs this pipeline the seconds it takes to POST a dispatch. + # + # A dispatch failure is loud (this job goes red) and harmless: nothing + # depends on this job, so `publish` cannot see it. needs: metadata + if: ${{ needs.metadata.result == 'success' }} runs-on: ubuntu-latest - timeout-minutes: 20 + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PRIMARY_REF: ${{ needs.metadata.outputs.branch }} + FALLBACK_REF: ${{ github.event.repository.default_branch || 'main' }} + # `secrets` is not a context an `if:` may read, so the card dispatch + # checks for its own credentials through env instead. A fork, or a + # repository without the Feishu app configured, simply skips the card. + FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }} + FEISHU_RELEASE_CHAT_ID: ${{ secrets.FEISHU_RELEASE_CHAT_ID }} steps: - - name: Checkout + - name: Checkout dispatch helper uses: actions/checkout@v6.0.2 with: - ref: ${{ needs.metadata.outputs.commit }} - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10.33.2 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Setup Playwright - run: pnpm -C e2e exec playwright install --with-deps chromium - # Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor. - # The daemon launches the installed Chromium directly instead of through - # Playwright. Restore user namespaces on this ephemeral runner so the real - # Chromium sandbox remains enabled rather than disabling that protection. - - name: Allow Chromium user namespaces on GitHub Ubuntu - shell: bash - run: | - set -euo pipefail - if sysctl kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - - name: Prebuild workspace type declarations - run: | - pnpm --filter @open-design/daemon build - pnpm --filter @open-design/desktop build - pnpm --filter @open-design/web build:sidecar - - name: Run E2E Vitest - run: pnpm --filter @open-design/e2e test - daemon_unit_tests: - name: Daemon tests (${{ matrix.shard }}/4) - needs: metadata - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - steps: - - name: Checkout - uses: actions/checkout@v6.0.2 - with: - ref: ${{ needs.metadata.outputs.commit }} - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10.33.2 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Run daemon test shard + # Deliberately the ref this workflow was loaded from, NOT the built + # commit. The helper is infrastructure that ships with this file, so + # the two are always in step; pinning it to the built commit would + # break the moment a release branch carried an older .github tree. + fetch-depth: 1 + - name: Dispatch prerelease code tests + if: ${{ inputs.enable_tests }} env: - OD_WATCHER_TEST_DEBUG: "1" - run: pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4 - verify: - name: Verify build (typecheck + tests) - needs: metadata - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6.0.2 - with: - ref: ${{ needs.metadata.outputs.commit }} - fetch-depth: 0 - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - version: 10.33.2 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Build daemon and desktop (typecheck dependencies) + COMMIT: ${{ needs.metadata.outputs.commit }} + VERSION: ${{ needs.metadata.outputs.release_version }} run: | - pnpm --filter @open-design/daemon build - pnpm --filter @open-design/desktop build - - name: Typecheck workspaces - run: pnpm -r --workspace-concurrency=4 --if-present run typecheck - - name: Check repository layout policies - run: pnpm guard + bash .github/scripts/release/dispatch-validation.sh release-prerelease-tests.yml \ + -f "commit=$COMMIT" \ + -f "version=$VERSION" \ + -f "origin_run_id=$GITHUB_RUN_ID" + - name: Dispatch progressive Feishu card + if: ${{ env.FEISHU_APP_ID != '' && env.FEISHU_RELEASE_CHAT_ID != '' }} + env: + BRANCH: ${{ needs.metadata.outputs.branch }} + COMMIT: ${{ needs.metadata.outputs.commit }} + EXPECT_SMOKE: ${{ inputs.enable_smoke }} + EXPECT_TESTS: ${{ inputs.enable_tests }} + PREVIOUS_COMMIT: ${{ needs.metadata.outputs.previous_commit }} + VERSION: ${{ needs.metadata.outputs.release_version }} + run: | + bash .github/scripts/release/dispatch-validation.sh release-prerelease-card.yml \ + -f "version=$VERSION" \ + -f "commit=$COMMIT" \ + -f "branch=$BRANCH" \ + -f "previous_commit=$PREVIOUS_COMMIT" \ + -f "origin_run_id=$GITHUB_RUN_ID" \ + -f "expect_tests=$EXPECT_TESTS" \ + -f "expect_smoke=$EXPECT_SMOKE" build_mac: name: Build prerelease mac arm64 - needs: [metadata, functional_e2e, e2e_vitest, daemon_unit_tests, verify] + # Deliberately NOT gated on any test suite, and neither is `publish`. A + # prerelease exists so a person can install it and test it by hand; + # automated CI is a parallel opinion about the same commit, not a valve on + # delivery. Chaining the two meant a single flaky suite cost the team a + # whole day's package — the expensive failure mode, and the one this + # pipeline kept hitting. The suites still run on every prerelease, in + # release-prerelease-tests.yml, where they can take as long as they like + # without holding this workflow's concurrency group. + needs: metadata runs-on: macos-14 - outputs: - smoke_result: ${{ steps.mac_smoke.outcome }} env: GH_TOKEN: ${{ github.token }} RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }} @@ -335,7 +362,8 @@ jobs: uses: actions/checkout@v6.0.2 with: ref: ${{ needs.metadata.outputs.commit }} - fetch-depth: 0 + # Shallow — see the metadata job's checkout for why. + fetch-depth: 1 - name: Setup pnpm uses: pnpm/action-setup@v5 with: @@ -396,6 +424,7 @@ jobs: echo "Continuing into tools-pack because electron-builder is the source of truth for whether packaging actually works." fi - name: Prepare Apple signing certificate + if: ${{ inputs.mac_sign_mode != 'no' }} env: APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }} APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} @@ -431,9 +460,22 @@ jobs: --mac-compression normal --to all --json - --signed - --notarize ) + # Signing depth is a dispatch input (default sign-only), mirroring + # release-beta.yml. Prerelease keeps --signed on every mode except an + # explicit 'no', because tools/release/src/metadata/prepare-stable.ts + # requires platforms.mac.signed == true (and platforms.macIntel.signed + # == true) in the prerelease metadata it promotes; an unsigned + # prerelease cannot become a stable release. Notarization is what the + # default drops: it is a round-trip to Apple's notary service that + # buys nothing for an internal channel, and metadata carries no + # notarized field for the stable gate to check. + case "${{ inputs.mac_sign_mode }}" in + no) ;; + sign-only) build_args+=(--signed) ;; + notarize) build_args+=(--signed --notarize) ;; + *) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;; + esac if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_arm64/build.log" >&2))"; then printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_arm64/build.json" else @@ -463,9 +505,14 @@ jobs: --mac-compression normal --to all --json - --signed - --notarize ) + # Signing depth follows mac_sign_mode — see the primary mac arm64 build. + case "${{ inputs.mac_sign_mode }}" in + no) ;; + sign-only) build_args+=(--signed) ;; + notarize) build_args+=(--signed --notarize) ;; + *) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;; + esac if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_arm64/build.log" >&2))"; then printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_arm64/build.json" else @@ -492,7 +539,8 @@ jobs: done <<< "$cache_ids" echo "deletedFailedCacheKey=$matched_key count=$count" - - name: Verify prerelease mac_arm64 notarization + - name: Verify prerelease mac_arm64 signature and notarization + if: ${{ inputs.mac_sign_mode != 'no' }} run: | set -euo pipefail build_json="$RUNNER_TEMP/release-build/mac_arm64/build.json" @@ -515,9 +563,18 @@ jobs: echo "prerelease app not found in DMG: $candidate_app" >&2 exit 1 fi + # The signature is checked on every signed build, because + # platforms.mac[Intel].signed == true is what the stable promotion + # gate reads. The stapled-ticket and Gatekeeper assessments only pass + # for a notarized build, so they follow mac_sign_mode rather than + # failing the job for doing exactly what it was asked to do. codesign --verify --deep --strict "$candidate_app" - xcrun stapler validate "$candidate_app" - spctl --assess --type execute --verbose=4 "$candidate_app" + if [ "${{ inputs.mac_sign_mode }}" = "notarize" ]; then + xcrun stapler validate "$candidate_app" + spctl --assess --type execute --verbose=4 "$candidate_app" + else + echo "mac_sign_mode=${{ inputs.mac_sign_mode }}: signature verified; skipping stapler/spctl, which require a notarization ticket this build did not request." + fi - name: Capture mac framework diagnostics if: ${{ failure() }} @@ -590,22 +647,6 @@ jobs: ${{ runner.temp }}/release-build/mac_arm64/build.json ${{ 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 - OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.log - OD_PACKAGED_E2E_MAC: "1" - OD_PACKAGED_E2E_NAMESPACE: release-prerelease - OD_PACKAGED_E2E_RELEASE_CHANNEL: prerelease - OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64 - OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack - run: | - set -euo pipefail - pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts - name: Write mac_arm64 release report if: ${{ always() }} env: @@ -617,7 +658,9 @@ jobs: RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64 RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}/release-report/mac_arm64/report.json RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}/release-report/mac_arm64/summary.md - RELEASE_SMOKE_MODE: core + # The build jobs no longer smoke; the packaged smoke lives in + # release-prerelease-smoke.yml and writes its own report there. + RELEASE_SMOKE_MODE: skip RELEASE_TARGET: mac_arm64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} REPORT_TITLE: mac_arm64 prerelease build @@ -626,14 +669,6 @@ jobs: - name: Publish mac_arm64 report summary if: ${{ always() }} run: cat "${{ runner.temp }}/release-report/mac_arm64/summary.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Upload mac e2e spec report - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - name: open-design-prerelease-mac-e2e-report - path: ${{ runner.temp }}/release-report/mac_arm64 - if-no-files-found: warn - name: Prune mac_arm64 tools-pack cache continue-on-error: true run: bash .github/scripts/release/cache/mac.sh @@ -689,7 +724,10 @@ jobs: RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64 RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac_arm64-report.zip - RELEASE_SIGNED: "true" + # platforms.mac[Intel].signed in the published metadata comes from + # this value via the platform manifest, and the stable promotion gate + # reads it. It must describe what was actually done, not a constant. + RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }} RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }} RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }} RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }} @@ -705,12 +743,22 @@ jobs: path: ${{ runner.temp }}/release-platform-manifests/mac_arm64.json if-no-files-found: error retention-days: 1 + # The packaged smoke used to run here, after the publish manifest. It now + # runs in release-prerelease-smoke.yml against the DMG re-downloaded from + # R2 — closer to what a user installs, and off this workflow's + # concurrency group entirely. build_mac_intel: name: Build prerelease mac intel x64 - needs: [metadata, functional_e2e, e2e_vitest, daemon_unit_tests, verify] + # Tests never gate delivery on this channel — see build_mac. + needs: metadata + # On by default, and switchable. Intel is this pipeline's critical path: it + # runs on the slower macos-15-intel fleet and takes ~33 min against ~21 min + # for every other platform. It still builds on every run, because the stable + # promotion gate reads platforms.macIntel out of the prerelease metadata and + # an Intel-less prerelease simply cannot be promoted — see the + # enable_mac_x64 input description and AGENTS.md "Release channel model". + if: ${{ inputs.enable_mac_x64 }} runs-on: macos-15-intel - outputs: - smoke_result: ${{ steps.mac_x64_smoke.outcome }} env: GH_TOKEN: ${{ github.token }} RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }} @@ -719,7 +767,8 @@ jobs: uses: actions/checkout@v6.0.2 with: ref: ${{ needs.metadata.outputs.commit }} - fetch-depth: 0 + # Shallow — see the metadata job's checkout for why. + fetch-depth: 1 - name: Setup pnpm uses: pnpm/action-setup@v5 with: @@ -758,6 +807,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - name: Prepare Apple signing certificate + if: ${{ inputs.mac_sign_mode != 'no' }} env: APPLE_SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLE_SIGNING_CERTIFICATE_BASE64 }} APPLE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_SIGNING_CERTIFICATE_PASSWORD }} @@ -793,9 +843,14 @@ jobs: --mac-compression normal --to all --json - --signed - --notarize ) + # Signing depth follows mac_sign_mode — see the primary mac arm64 build. + case "${{ inputs.mac_sign_mode }}" in + no) ;; + sign-only) build_args+=(--signed) ;; + notarize) build_args+=(--signed --notarize) ;; + *) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;; + esac if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_x64/build.log" >&2))"; then printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_x64/build.json" else @@ -826,9 +881,14 @@ jobs: --mac-compression normal --to all --json - --signed - --notarize ) + # Signing depth follows mac_sign_mode — see the primary mac arm64 build. + case "${{ inputs.mac_sign_mode }}" in + no) ;; + sign-only) build_args+=(--signed) ;; + notarize) build_args+=(--signed --notarize) ;; + *) echo "unsupported mac_sign_mode: ${{ inputs.mac_sign_mode }}" >&2; exit 1 ;; + esac if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$RUNNER_TEMP/release-build/mac_x64/build.log" >&2))"; then printf '%s\n' "$build_output" | tee "$RUNNER_TEMP/release-build/mac_x64/build.json" else @@ -855,7 +915,8 @@ jobs: done <<< "$cache_ids" echo "deletedFailedCacheKey=$matched_key count=$count" - - name: Verify prerelease mac_x64 notarization + - name: Verify prerelease mac_x64 signature and notarization + if: ${{ inputs.mac_sign_mode != 'no' }} run: | set -euo pipefail build_json="$RUNNER_TEMP/release-build/mac_x64/build.json" @@ -878,26 +939,18 @@ jobs: echo "prerelease app not found in DMG: $candidate_app" >&2 exit 1 fi + # The signature is checked on every signed build, because + # platforms.mac[Intel].signed == true is what the stable promotion + # gate reads. The stapled-ticket and Gatekeeper assessments only pass + # for a notarized build, so they follow mac_sign_mode rather than + # failing the job for doing exactly what it was asked to do. codesign --verify --deep --strict "$candidate_app" - xcrun stapler validate "$candidate_app" - spctl --assess --type execute --verbose=4 "$candidate_app" - - - name: Smoke prerelease mac_x64 packaged runtime - id: mac_x64_smoke - continue-on-error: true - working-directory: e2e - env: - OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_x64/build.json - OD_PACKAGED_E2E_BUILD_LOG_PATH: ${{ runner.temp }}/release-build/mac_x64/build.log - OD_PACKAGED_E2E_MAC: "1" - OD_PACKAGED_E2E_NAMESPACE: release-prerelease-intel - OD_PACKAGED_E2E_RELEASE_CHANNEL: prerelease - OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/mac_x64 - OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack - run: | - set -euo pipefail - pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts + if [ "${{ inputs.mac_sign_mode }}" = "notarize" ]; then + xcrun stapler validate "$candidate_app" + spctl --assess --type execute --verbose=4 "$candidate_app" + else + echo "mac_sign_mode=${{ inputs.mac_sign_mode }}: signature verified; skipping stapler/spctl, which require a notarization ticket this build did not request." + fi - name: Write mac_x64 release report if: ${{ always() }} @@ -910,7 +963,9 @@ jobs: RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_x64 RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}/release-report/mac_x64/report.json RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}/release-report/mac_x64/summary.md - RELEASE_SMOKE_MODE: core + # The build jobs no longer smoke; the packaged smoke lives in + # release-prerelease-smoke.yml and writes its own report there. + RELEASE_SMOKE_MODE: skip RELEASE_TARGET: mac_x64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} REPORT_TITLE: mac_x64 prerelease build @@ -974,7 +1029,10 @@ jobs: RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_x64 RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac_x64-report.zip - RELEASE_SIGNED: "true" + # platforms.mac[Intel].signed in the published metadata comes from + # this value via the platform manifest, and the stable promotion gate + # reads it. It must describe what was actually done, not a constant. + RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }} RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }} RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }} RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }} @@ -990,12 +1048,12 @@ jobs: path: ${{ runner.temp }}/release-platform-manifests/mac_x64.json if-no-files-found: error retention-days: 1 + # Packaged smoke moved to release-prerelease-smoke.yml — see build_mac. build_win: name: Build prerelease win x64 - needs: [metadata, functional_e2e, e2e_vitest, daemon_unit_tests, verify] + # Tests never gate delivery on this channel — see build_mac. + needs: metadata runs-on: windows-latest - outputs: - smoke_result: ${{ steps.win_smoke.outcome }} env: GH_TOKEN: ${{ github.token }} RELEASE_COMMIT: ${{ needs.metadata.outputs.commit }} @@ -1004,17 +1062,25 @@ jobs: uses: actions/checkout@v6.0.2 with: ref: ${{ needs.metadata.outputs.commit }} - fetch-depth: 0 + # Shallow — see the metadata job's checkout for why. + fetch-depth: 1 - name: Setup pnpm uses: pnpm/action-setup@v5 with: version: 10.33.2 + # No `cache: pnpm`. It caches the pnpm STORE, and on this repository that + # store never paid for itself on Windows. Measured across four consecutive + # prerelease runs: on a miss (3 of 4) it cost 100s / 66s / 43s in the + # "Post Setup Node.js" save with no install speedup, and on the one hit it + # traded a 32s restore for a 36s faster install — a wash. Worse, it + # competes for the repository's 10 GB Actions cache budget against the + # per-lane tools-pack caches, which are worth ~11 min of build time each. + # This is now the only release build job without it, which is exactly the + # shape every other build job (mac arm64, mac Intel, linux) already had. - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Compute Windows tools-pack cache key id: win_tools_pack_cache_key shell: pwsh @@ -1125,44 +1191,6 @@ jobs: gh cache delete $cache.id } "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" - $match = [regex]::Match("${{ needs.metadata.outputs.release_version }}", "^(?\d+\.\d+\.\d+)-prerelease\.(?\d+)$") - if (-not $match.Success) { throw "full Windows smoke requires a counted prerelease version; got ${{ needs.metadata.outputs.release_version }}" } - $updateVersion = "{0}-prerelease.{1}" -f $match.Groups["base"].Value, ([int]$match.Groups["number"].Value + 1) - $buildArgs = @("exec", "tools-pack", "win", "build", "--dir", "${{ runner.temp }}\release-work\win_x64\tools-pack-update-fixture", "--cache-dir", "${{ runner.temp }}\tools-pack-cache", "--namespace", "release-prerelease-win", "--app-version", $updateVersion, "--to", "nsis", "--json") - $updateOutput = pnpm.cmd @buildArgs - if ($LASTEXITCODE -ne 0) { throw "tools-pack win update fixture failed with exit code $LASTEXITCODE" } - $updateOutput | Set-Content -Path "${{ runner.temp }}\release-build\win_x64\windows-tools-pack-update-build.json" -Encoding utf8 - $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 - OD_PACKAGED_E2E_WIN: "1" - OD_PACKAGED_E2E_NAMESPACE: release-prerelease-win - OD_PACKAGED_E2E_RELEASE_CHANNEL: prerelease - OD_PACKAGED_E2E_RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}\release-report\win_x64 - OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack - OD_PACKAGED_E2E_WIN_SMOKE_PROFILE: ${{ inputs.win_x64_smoke_mode }} - OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE: ${{ inputs.win_x64_smoke_mode == 'full' && inputs.win_x64_update_metadata_url == '' && inputs.win_x64_update_target_version == '' && 'tools-serve' || '' }} - OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }} - OD_PACKAGED_E2E_WIN_UPDATE_VERSION: ${{ inputs.win_x64_update_target_version }} - run: | - $ErrorActionPreference = "Stop" - pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - name: Write win_x64 release report if: ${{ always() }} env: @@ -1173,7 +1201,8 @@ jobs: RELEASE_REPORT_DIR: ${{ runner.temp }}\release-report\win_x64 RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}\release-report\win_x64\report.json RELEASE_REPORT_SUMMARY_PATH: ${{ runner.temp }}\release-report\win_x64\summary.md - RELEASE_SMOKE_MODE: ${{ inputs.win_x64_smoke_mode }} + # See the mac lanes: packaged smoke moved out of this job. + RELEASE_SMOKE_MODE: skip RELEASE_TARGET: win_x64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} REPORT_TITLE: win_x64 prerelease build @@ -1183,14 +1212,6 @@ jobs: if: ${{ always() }} shell: pwsh run: Get-Content "${{ runner.temp }}\release-report\win_x64\summary.md" | Add-Content $env:GITHUB_STEP_SUMMARY - - - name: Upload windows e2e spec report - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - name: open-design-prerelease-win-e2e-report - path: ${{ runner.temp }}\release-report\win_x64 - if-no-files-found: warn - name: Prune Windows tools-pack cache shell: pwsh continue-on-error: true @@ -1257,9 +1278,14 @@ jobs: path: ${{ runner.temp }}\release-platform-manifests\win_x64.json if-no-files-found: error retention-days: 1 + # The Windows update fixture and the packaged Windows smoke both moved + # to release-prerelease-smoke.yml, which rebuilds the fixture there when + # `full` coverage is requested and installs the setup.exe re-downloaded + # from R2 — see build_mac. build_linux: name: Build prerelease linux x64 - needs: [metadata, functional_e2e, e2e_vitest, daemon_unit_tests, verify] + # Tests never gate delivery on this channel — see build_mac. + needs: metadata if: ${{ vars.ENABLE_STABLE_LINUX == 'true' }} runs-on: ubuntu-latest outputs: @@ -1272,7 +1298,8 @@ jobs: uses: actions/checkout@v6.0.2 with: ref: ${{ needs.metadata.outputs.commit }} - fetch-depth: 0 + # Shallow — see the metadata job's checkout for why. + fetch-depth: 1 - name: Setup pnpm uses: pnpm/action-setup@v5 with: @@ -1380,12 +1407,19 @@ jobs: retention-days: 1 publish: name: Publish prerelease release + # No test suite appears in `needs` or `if`, and none may be added. + # prerelease is the internal channel: its job is to put a package in a + # colleague's hands so a human can test it, and machine CI is a second, + # parallel signal about the same commit — not a valve on the first one. A + # flaky suite used to mean nobody got a build that day, which is a worse + # outcome than shipping an internal package alongside a red test. The + # suites now live in release-prerelease-tests.yml and the packaged smoke in + # release-prerelease-smoke.yml; the progressive Feishu card is what carries + # their verdict to a human. Stable is unaffected: its promotion gate reads + # the prerelease metadata through tools/release/src/metadata/prepare-stable.ts + # and runs its own checks. needs: - metadata - - functional_e2e - - e2e_vitest - - daemon_unit_tests - - verify - build_mac - build_mac_intel - build_win @@ -1395,12 +1429,8 @@ jobs: always() && !cancelled() && needs.metadata.result == 'success' && - needs.functional_e2e.result == 'success' && - needs.e2e_vitest.result == 'success' && - needs.daemon_unit_tests.result == 'success' && - needs.verify.result == 'success' && needs.build_mac.result == 'success' && - needs.build_mac_intel.result == 'success' && + (needs.build_mac_intel.result == 'success' || needs.build_mac_intel.result == 'skipped') && needs.build_win.result == 'success' && (needs.build_linux.result == 'success' || needs.build_linux.result == 'skipped') }} @@ -1419,13 +1449,11 @@ jobs: BRANCH_NAME: ${{ needs.metadata.outputs.branch }} ENABLE_LINUX: ${{ needs.build_linux.result == 'success' }} ENABLE_MAC: "true" - ENABLE_MAC_INTEL: "true" ENABLE_WIN: "true" GITHUB_RELEASE_ENABLED: "false" - MAC_INTEL_SIGNED: "true" RELEASE_CHANNEL: prerelease RELEASE_NAME: ${{ needs.metadata.outputs.release_name }} - RELEASE_SIGNED: "true" + RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }} RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} REPORT_MODE: zip STATE_SOURCE: ${{ needs.metadata.outputs.state_source }} @@ -1434,17 +1462,20 @@ jobs: uses: actions/checkout@v6.0.2 with: ref: ${{ needs.metadata.outputs.commit }} - fetch-depth: 0 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 + # Shallow — see the metadata job's checkout for why. + fetch-depth: 1 + # The artifact downloads below run on the runner's own Node, and the pnpm + # toolchain this job actually uses is set up further down, right before + # `pnpm install`. The Setup Node.js that used to sit here was a second, + # redundant copy of that one — it paid for its own post-job step and + # bought nothing. - name: Download mac prerelease publish manifest uses: actions/download-artifact@v8 with: name: open-design-prerelease-mac-arm64-publish-manifest path: ${{ runner.temp }}/release-platform-manifests - name: Download mac intel prerelease publish manifest + if: ${{ needs.build_mac_intel.result == 'success' }} uses: actions/download-artifact@v8 with: name: open-design-prerelease-mac-x64-publish-manifest @@ -1496,7 +1527,7 @@ jobs: BASE_VERSION: ${{ needs.metadata.outputs.base_version }} ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }} ENABLE_MAC_ARM64: "true" - ENABLE_MAC_X64: "true" + ENABLE_MAC_X64: ${{ needs.build_mac_intel.result == 'success' }} ENABLE_WIN_X64: "true" LINUX_X64_RESULT: ${{ needs.build_linux.result }} MAC_ARM64_RESULT: ${{ needs.build_mac.result }} @@ -1512,7 +1543,7 @@ jobs: RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} - RELEASE_SIGNED: "true" + RELEASE_SIGNED: ${{ inputs.mac_sign_mode != 'no' && 'true' || 'false' }} RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }} RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }} RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }} @@ -1534,7 +1565,7 @@ jobs: env: ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }} ENABLE_MAC_ARM64: "true" - ENABLE_MAC_X64: "true" + ENABLE_MAC_X64: ${{ needs.build_mac_intel.result == 'success' }} ENABLE_WIN_X64: "true" LINUX_X64_RESULT: ${{ needs.build_linux.result }} MAC_ARM64_RESULT: ${{ needs.build_mac.result }} @@ -1560,6 +1591,49 @@ jobs: - name: Cleanup workflow artifacts if: ${{ success() }} run: bash .github/scripts/release/github/cleanup-artifacts.sh + dispatch_smoke: + name: Dispatch packaged smoke + # Packaged smoke needs a package, so unlike dispatch_validation this one + # waits for `publish` — but it only waits long enough to POST the dispatch. + # The smoke itself downloads the artifacts back out of R2 in + # release-prerelease-smoke.yml, which is both closer to what a user installs + # and completely off this workflow's concurrency group. + needs: + - metadata + - publish + if: ${{ inputs.enable_smoke && needs.publish.result == 'success' && needs.publish.outputs.version_metadata_url != '' }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PRIMARY_REF: ${{ needs.metadata.outputs.branch }} + FALLBACK_REF: ${{ github.event.repository.default_branch || 'main' }} + steps: + - name: Checkout dispatch helper + uses: actions/checkout@v6.0.2 + with: + # Deliberately the ref this workflow was loaded from, NOT the built + # commit. The helper is infrastructure that ships with this file, so + # the two are always in step; pinning it to the built commit would + # break the moment a release branch carried an older .github tree. + fetch-depth: 1 + - name: Dispatch packaged smoke + env: + COMMIT: ${{ needs.metadata.outputs.commit }} + VERSION: ${{ needs.metadata.outputs.release_version }} + VERSION_METADATA_URL: ${{ needs.publish.outputs.version_metadata_url }} + WIN_X64_SMOKE_MODE: ${{ inputs.win_x64_smoke_mode }} + WIN_X64_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }} + WIN_X64_UPDATE_TARGET_VERSION: ${{ inputs.win_x64_update_target_version }} + run: | + bash .github/scripts/release/dispatch-validation.sh release-prerelease-smoke.yml \ + -f "commit=$COMMIT" \ + -f "version=$VERSION" \ + -f "origin_run_id=$GITHUB_RUN_ID" \ + -f "version_metadata_url=$VERSION_METADATA_URL" \ + -f "win_x64_smoke_mode=$WIN_X64_SMOKE_MODE" \ + -f "win_x64_update_metadata_url=$WIN_X64_UPDATE_METADATA_URL" \ + -f "win_x64_update_target_version=$WIN_X64_UPDATE_TARGET_VERSION" cleanup_partial_release_assets: name: Cleanup unpublished prerelease asset artifacts needs: diff --git a/AGENTS.md b/AGENTS.md index d51fc1a037..247c9f9d39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,6 +221,20 @@ confidence methodology in `specs/current/ci.md`. - `beta` is the daily R&D/development validation channel. It is optimized for fast development feedback and is not part of the stable promotion gate. - `prerelease` is the internal validation channel for stable delivery. Stable releases remain gated by validated prerelease artifacts. +- Prerelease builds macOS x64 (Intel) on every run. `release-prerelease.yml` takes an `enable_mac_x64` input that defaults to `true`, and `notify-release-feishu.yml` (the push-to-`release/**` entry point) forwards `true` on push. The switch stays because Intel is the pipeline's critical path — it runs on the slower `macos-15-intel` fleet, ~33 min against ~21 min for every other platform — so a run that is explicitly not about Intel can buy that time back. Turning it off costs that build its promotion eligibility, so it is not a routine choice. +- **A prerelease built without `enable_mac_x64` cannot be promoted to stable.** Stable ships Intel and stable's gate is validated prerelease artifacts, so an Intel-less prerelease has not validated the Intel artifact stable will ship. This is enforced, not merely documented: `validateStablePrereleaseMetadata` in `tools/release/src/metadata/prepare-stable.ts` requires `platforms.macIntel` (with `enabled: true`, `arch: "x64"`, `signed: true`, and versioned dmg/zip URLs) in the promoted prerelease's metadata, so promoting an Intel-less prerelease fails the stable `metadata` job instead of silently shipping an unvalidated Intel build. `platforms.linux` is deliberately not required there. +- The `notify-release-feishu.yml` forwarding for a default-on boolean must be `${{ github.event_name != 'workflow_dispatch' || inputs. }}`, never `${{ inputs. || true }}`. `inputs` is unset on push, so the second form reads correctly there — but on a dispatch where the operator UNCHECKED the box, `false || true` is also true and the switch becomes unturnoffable. (A default-*off* boolean uses `${{ inputs. || false }}` for the mirror-image reason: a bare `${{ inputs. }}` forwards `''` to a `type: boolean` callee input.) +- `enable_smoke` and `enable_tests` also default to `true`, because neither costs the build anything any more — see "Prerelease validation runs outside the pipeline" below. `mac_sign_mode` (default `sign-only`) codesigns without the Apple notary round-trip; `notarize` is the pre-stable setting, since stable ships notarized. `win_x64_smoke_mode` still chooses how deep the Windows smoke goes, and applies only when `enable_smoke` is on. +- **Signing is not optional for a prerelease that will be promoted.** `validateStablePrereleaseMetadata` requires `platforms.mac.signed == true` and `platforms.macIntel.signed == true`, so `mac_sign_mode: no` produces a prerelease that cannot become a stable release. Notarization has no metadata field and is therefore not checked — which is exactly why it, and not signing, is what the fast default drops. +- **Automated tests do not gate prerelease delivery.** A prerelease exists so a person can install it and test it by hand; machine CI is a parallel opinion about the same commit rather than a valve on delivery, and a flaky suite must never cost the team a day's package. Do not reintroduce a test job into `build_*` or `publish`'s `needs`/`if`; if a check must genuinely block delivery, that is a channel-policy decision, not a workflow edit. +- **Prerelease validation runs outside the pipeline.** `release-prerelease.yml` holds one repository-wide concurrency group (`open-design-release-prerelease`, `cancel-in-progress: false`), so any job that outlives `publish` keeps that group held and stops the NEXT prerelease from *starting*. Making the test jobs advisory was therefore not enough — they still blocked the next build. The workflow now contains only `metadata → build_* → publish` plus two fire-and-forget dispatcher jobs, and the validation lives in three dispatched workflows with concurrency groups scoped to the origin run: + - `release-prerelease-tests.yml` — `functional_e2e`, `e2e_vitest`, `daemon_unit_tests`, `verify`, dispatched as soon as the build commit resolves. + - `release-prerelease-smoke.yml` — the packaged mac/Windows smoke, dispatched after `publish`. It does **not** test a local build directory: `.github/scripts/release/smoke-artifacts.ts` reads the published version metadata, downloads the DMG / setup.exe a user would get, verifies its sha256 sidecar, and writes it to the one path `tools-pack install` reads. Linux keeps its in-job smoke, because the whole Linux lane is opt-in behind `vars.ENABLE_STABLE_LINUX` and is on nobody's critical path. + - `release-prerelease-card.yml` — the progressive Feishu card. + All three are dispatched through `.github/scripts/release/dispatch-validation.sh`, which aims `gh workflow run` at the built branch first and the default branch second, so a release branch cut before these lanes existed still gets validated. A dispatch failure is loud and non-blocking: nothing depends on the dispatcher jobs. +- **One writer owns the release card.** A Feishu `PATCH` replaces the entire card, so build jobs updating "their own" row would each erase the other two's. `release-prerelease-card.yml` therefore owns the message alone and learns everything by polling the jobs API of the three runs; `tools/release/src/notifications/prerelease-card.ts` renders the card as a total function of that state (unit-tested in `tools/release/tests/prerelease-card.test.ts`) and `feishu-app.ts` is the application-bot transport. The card is deliberately withheld until the first platform publishes — a "构建中…" card posted at dispatch time becomes a permanent lie when a build dies — with total build failure as the one exception, where silence would hide an incident. +- **`version_metadata_url` is the authoritative "did a package ship?" signal, not the workflow conclusion.** The conclusion goes red for anything anywhere in the release run, including work that happens after publication, so keying a notification off it paints the card red while its own download buttons work. `tools/release/src/notifications/feishu.ts` reads `VERSION_METADATA_URL` for both header colour and smoke annotations, falling back to `BUILD_STATE` only for callers that do not pass it. +- The one-shot webhook card in `notify-release-feishu.yml`'s `notify` job is transitional and runs alongside the progressive card until the new one has carried a couple of real releases; a gap in release notification is worse than a duplicate. Retiring it means deleting that job and the `FEISHU_RELEASE_WEBHOOK` / `FEISHU_RELEASE_SIGN_SECRET` secrets it reads. - `preview` is an independent early-access channel with stable-like release rigor. It should use preview versions such as `X.Y.Z-preview.N`, publish to the `preview` R2 channel, publish updater feeds under `preview/latest`, and follow stable's platform policy including the existing optional Linux enablement. - `stable` is the formal delivery channel. Do not make stable promotion depend on preview; stable continues to depend on prerelease only. - Public packaged app identity must stay channel-distinct: stable uses `Open Design`, beta uses `Open Design Beta`, prerelease uses `Open Design Prerelease`, and preview uses `Open Design Preview`. Do not ship beta, prerelease, or preview mac DMGs whose drag-install app bundle is `Open Design.app`. diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index ef1e991815..c83c248439 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -79,6 +79,10 @@ const dailyBetaRecoveryScriptPath = join( "resolve-daily-beta-recovery.ts", ); const releasePrereleaseWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-prerelease.yml"); +const releasePrereleaseTestsWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-prerelease-tests.yml"); +const releasePrereleaseSmokeWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-prerelease-smoke.yml"); +const releasePrereleaseCardWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-prerelease-card.yml"); +const prereleaseCardScriptPath = join(workspaceRoot, "tools", "release", "src", "notifications", "prerelease-progress-card.ts"); const mainPrereleaseWinSmokeWorkflowPath = join( workspaceRoot, ".github", @@ -1877,11 +1881,13 @@ process.stdin.on("end", () => { marker: "fromJSON(needs.runners.outputs.runs_on).workspace_unit", }, { + // The suite moved to release-prerelease-tests.yml, where the commit + // arrives as a dispatch input instead of a `needs` output. name: "Functional E2E commit pin", - workflowPath: releasePrereleaseWorkflowPath, + workflowPath: releasePrereleaseTestsWorkflowPath, jobStart: " functional_e2e:", jobEnd: " e2e_vitest:", - marker: "ref: ${{ needs.metadata.outputs.commit }}", + marker: "ref: ${{ inputs.commit }}", }, ])("[P1] keeps $name bounded to its owning job", async ({ workflowPath, @@ -1988,21 +1994,52 @@ process.stdin.on("end", () => { expect(uiFull).toContain("needs: [validate_inputs, p0_runners]"); }); - it("[P1] gates prerelease packaging on P0 Functional E2E at the resolved build commit", async () => { - const [prerelease, functionalE2e] = await Promise.all([ + it("[P1] runs the prerelease code suites outside the pipeline they validate", async () => { + // The inverse of what this test used to assert, and deliberately so. + // + // These four suites once gated `build_*` and `publish`, which meant one + // flaky run cost the team a day's package. Making them advisory fixed that + // but not the second-order problem: release-prerelease.yml holds ONE + // repository-wide concurrency group with cancel-in-progress: false, so a + // 45-minute suite still kept the group held and stopped the NEXT prerelease + // from starting. They now live in a dispatched workflow of their own. + const [prerelease, tests, functionalE2e] = await Promise.all([ readFile(releasePrereleaseWorkflowPath, "utf8"), + readFile(releasePrereleaseTestsWorkflowPath, "utf8"), readFile(uiExtendedMainWorkflowPath, "utf8"), ]); - const gate = sectionBetween(prerelease, " functional_e2e:", " e2e_vitest:"); - expect(gate).toContain("needs: metadata"); + // Gone from the pipeline, in both `needs` and `if`. + for (const jobId of [" functional_e2e:", " e2e_vitest:", " daemon_unit_tests:", " verify:", " test_signals:"]) { + expect(prerelease, jobId).not.toContain(jobId); + } + for (const [start, end] of [ + [" build_mac:", " build_mac_intel:"], + [" build_mac_intel:", " build_win:"], + [" build_win:", " build_linux:"], + [" build_linux:", " publish:"], + [" publish:", " dispatch_smoke:"], + ] as const) { + const job = sectionBetween(prerelease, start, end); + expect(job).toContain("ref: ${{ needs.metadata.outputs.commit }}"); + // Match the dependency forms, not the bare word: build_mac legitimately + // contains "verify" inside `codesign --verify`. + for (const suite of ["functional_e2e", "e2e_vitest", "daemon_unit_tests", "verify"]) { + expect(job, `${start} must not depend on ${suite}`).not.toContain(`needs.${suite}`); + expect(job, `${start} must not depend on ${suite}`).not.toContain(`- ${suite}\n`); + expect(job, `${start} must not depend on ${suite}`).not.toContain(`, ${suite}`); + } + } + + // Present, intact, and aimed at the exact commit that was built — a branch + // head would validate a different tree than the one that shipped. + const gate = sectionBetween(tests, " functional_e2e:", " e2e_vitest:"); expect(gate).toContain("uses: ./.github/workflows/ui-extended-main.yml"); - expect(gate).toContain("ref: ${{ needs.metadata.outputs.commit }}"); + expect(gate).toContain("ref: ${{ inputs.commit }}"); expect(gate).toContain("suite: p0"); - const e2eVitestGate = sectionBetween(prerelease, " e2e_vitest:", " daemon_unit_tests:"); - expect(e2eVitestGate).toContain("needs: metadata"); - expect(e2eVitestGate).toContain("ref: ${{ needs.metadata.outputs.commit }}"); + const e2eVitestGate = sectionBetween(tests, " e2e_vitest:", " daemon_unit_tests:"); + expect(e2eVitestGate).toContain("ref: ${{ inputs.commit }}"); expect(e2eVitestGate).toContain("playwright install --with-deps chromium"); expect(e2eVitestGate).toContain("Allow Chromium user namespaces on GitHub Ubuntu"); expect(e2eVitestGate).toContain("kernel.apparmor_restrict_unprivileged_userns=0"); @@ -2011,34 +2048,23 @@ process.stdin.on("end", () => { .toBeLessThan(e2eVitestGate.indexOf("pnpm --filter @open-design/e2e test")); expect(e2eVitestGate).toContain("pnpm --filter @open-design/e2e test"); - const daemonGate = sectionBetween(prerelease, " daemon_unit_tests:", " verify:"); + const daemonGate = sectionBetween(tests, " daemon_unit_tests:", " verify:"); expect(daemonGate).toContain("shard: [1, 2, 3, 4]"); - expect(daemonGate).toContain("ref: ${{ needs.metadata.outputs.commit }}"); + expect(daemonGate).toContain("ref: ${{ inputs.commit }}"); expect(daemonGate).toContain("pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4"); + const verifyGate = tests.slice(tests.indexOf(" verify:")); + expect(verifyGate).toContain("ref: ${{ inputs.commit }}"); + expect(verifyGate).toContain("pnpm -r --workspace-concurrency=4 --if-present run typecheck"); + expect(verifyGate).toContain("run: pnpm guard"); + + // A group scoped to the origin run, never the pipeline's. + expect(tests).toContain("group: release-prerelease-tests-${{ inputs.origin_run_id || inputs.commit }}"); + expect(tests).not.toContain("open-design-release-prerelease"); + expect(functionalE2e).toContain("workflow_call:"); expect(functionalE2e).not.toContain("schedule:"); expect(functionalE2e).toContain("ref: ${{ inputs.ref || github.sha }}"); - - for (const [start, end] of [ - [" build_mac:", " build_mac_intel:"], - [" build_mac_intel:", " build_win:"], - [" build_win:", " build_linux:"], - [" build_linux:", " publish:"], - ] as const) { - const buildJob = sectionBetween(prerelease, start, end); - expect(buildJob).toContain("needs: [metadata, functional_e2e, e2e_vitest, daemon_unit_tests, verify]"); - expect(buildJob).toContain("ref: ${{ needs.metadata.outputs.commit }}"); - } - - const publish = sectionBetween(prerelease, " publish:", " cleanup_partial_release_assets:"); - expect(publish).toContain("- functional_e2e"); - expect(publish).toContain("- e2e_vitest"); - expect(publish).toContain("- daemon_unit_tests"); - expect(publish).toContain("needs.functional_e2e.result == 'success'"); - expect(publish).toContain("needs.e2e_vitest.result == 'success'"); - expect(publish).toContain("needs.daemon_unit_tests.result == 'success'"); - expect(publish).toContain("ref: ${{ needs.metadata.outputs.commit }}"); }); it("[P2] rejects duplicate file assignments across UI P0 shards", () => { @@ -2309,7 +2335,10 @@ process.stdin.on("end", () => { expect(releaseStableWorkflow).not.toMatch(/OD_PACKAGED_E2E_NAMESPACE: release-stable(?:-win|-linux)?\b/); expect(releaseStableWorkflow).not.toMatch(/namespaces\/release-stable(?:-intel|-win|-linux)?\b/); - expectChannelWorkflowNamespaces(releasePrereleaseWorkflow, "prerelease", { hasLinuxSmoke: true }); + expectChannelWorkflowNamespaces(releasePrereleaseWorkflow, "prerelease", { + hasLinuxSmoke: true, + packagedSmokeWorkflow: await readFile(releasePrereleaseSmokeWorkflowPath, "utf8"), + }); expect(releaseBetaWorkflow).toContain("RELEASE_NAMESPACE: release-beta"); expect(releaseBetaWorkflow).toContain("RELEASE_NAMESPACE: release-beta-win"); expect(releaseBetaWorkflow).toContain("RELEASE_NAMESPACE: release-beta-x64"); @@ -2343,7 +2372,11 @@ process.stdin.on("end", () => { expect(betaWindowsBuildScript).toContain('$env:OD_PACKAGED_E2E_WIN_UPDATE_FIXTURE = "tools-serve"'); expectWindowsUpdaterSmokeContract(releaseBetaWorkflow, "beta"); - expectWindowsUpdaterSmokeContract(releasePrereleaseWorkflow, "prerelease"); + // prerelease runs its packaged smoke in release-prerelease-smoke.yml, so + // the Windows updater contract is asserted there rather than on the build + // pipeline. The knobs still originate in release-prerelease.yml, which + // forwards them on dispatch. + expectWindowsUpdaterSmokeContract(await readFile(releasePrereleaseSmokeWorkflowPath, "utf8"), "prerelease"); expectWindowsUpdaterSmokeContract(releaseStableWorkflow, "stable"); }); @@ -2790,60 +2823,28 @@ process.stdin.on("end", () => { 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([ + it("[P1] smokes the published prerelease artifact, not the build directory", async () => { + const [prerelease, smoke, stage] = await Promise.all([ readFile(releasePrereleaseWorkflowPath, "utf8"), - readFile(notifyReleaseFeishuWorkflowPath, "utf8"), - readFile(feishuCardScriptPath, "utf8"), + readFile(releasePrereleaseSmokeWorkflowPath, "utf8"), + readFile(join(workspaceRoot, ".github", "scripts", "release", "smoke-artifacts.ts"), "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 }}"); - + // The packaged mac/Windows smoke is gone from the build jobs. It used to + // sit after `Publish platform`, which kept it from withholding a + // package but still held release-prerelease.yml's repository-wide + // concurrency group open long after the package had shipped. 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 macX64Job = sectionBetween(prerelease, " build_mac_intel:", " build_win:"); - const macX64Smoke = sectionBetween( - macX64Job, - " - name: Smoke prerelease mac_x64 packaged runtime", - " - name: Write mac_x64 release report", - ); - expect(macX64Job).toContain("outputs:\n smoke_result: ${{ steps.mac_x64_smoke.outcome }}"); - expect(macX64Smoke).toContain("id: mac_x64_smoke"); - expect(macX64Smoke).toContain("continue-on-error: true"); - expect(macX64Smoke).toContain("pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts"); - expect(macX64Job).toContain("RELEASE_SMOKE_MODE: core"); - 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"), - ); + for (const [label, job] of [["mac", macJob], ["mac_x64", macX64Job], ["win", winJob]] as const) { + expect(job, label).not.toContain("release-smoke.ts"); + expect(job, label).not.toContain("smoke_result"); + expect(job, label).toContain("RELEASE_SMOKE_MODE: skip"); + } + // Linux is the exception on purpose: that whole lane is opt-in behind + // vars.ENABLE_STABLE_LINUX and is on nobody's critical path. const linuxJob = sectionBetween(prerelease, " build_linux:", " publish:"); const linuxSmoke = sectionBetween( linuxJob, @@ -2855,26 +2856,85 @@ process.stdin.on("end", () => { expect(linuxSmoke).toContain("continue-on-error: true"); expect(linuxSmoke).toContain('OD_PACKAGED_E2E_LINUX_APPIMAGE: "1"'); expect(linuxSmoke).toContain("xvfb-run -a pnpm test specs/linux.spec.ts"); - expect(linuxJob.indexOf("Smoke prerelease linux AppImage runtime")).toBeLessThan( - linuxJob.indexOf("Publish linux prerelease platform"), - ); + // The smoke lane starts from the R2 object a user downloads, not from + // electron-builder's output directory. + expect(smoke).toContain("smoke-artifacts.ts plan"); + expect(smoke).toContain("smoke-artifacts.ts stage"); + expect(smoke).toContain("pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts"); + expect(smoke).toContain("pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts"); + expect(smoke).toContain("VERSION_METADATA_URL: ${{ inputs.version_metadata_url }}"); + // The install identity (bundle name, uninstall registry key, launcher + // channel) is derived from the version, so it has to be the version the + // artifact was actually built with. + expect(smoke).toContain("OD_PACKAGED_E2E_RELEASE_VERSION: ${{ inputs.version }}"); + expect(smoke).toContain("OD_PACKAGED_E2E_RELEASE_CHANNEL: prerelease"); + + // The staged path must stay in lockstep with resolveMacPaths().dmgPath and + // resolveWinPaths().setupPath, which is the only file `tools-pack install` + // reads. A drift here fails as "no mac dmg found at ...". + expect(stage).toContain('join(toolsPackDir, "out", "mac", "namespaces", namespace, "dmg", `Open Design-${token}.dmg`)'); + expect(stage).toContain('join(toolsPackDir, "out", "win", "namespaces", namespace, "builder", `Open Design-${token}-setup.exe`)'); + // A target that did not build carries no `artifacts` key at all, so status + // is the only safe thing to branch on. + expect(stage).toContain('entry?.status === "published"'); + expect(stage).toContain("verifyChecksum"); + // release-smoke.ts refuses to start without a readable build json, and + // write-report throws on a zero-byte one. + expect(stage).toContain("writeBuildJson"); + }); + + it("[P1] keeps one writer on the progressive prerelease card", async () => { + const [prerelease, card, watcher, notify, feishuCard] = await Promise.all([ + readFile(releasePrereleaseWorkflowPath, "utf8"), + readFile(releasePrereleaseCardWorkflowPath, "utf8"), + readFile(prereleaseCardScriptPath, "utf8"), + readFile(notifyReleaseFeishuWorkflowPath, "utf8"), + readFile(feishuCardScriptPath, "utf8"), + ]); + + // A Feishu PATCH replaces the whole card, so three build jobs updating + // "their own" row would each erase the other two's. Exactly one job may + // talk to Feishu about a prerelease, and it must not be one of the build + // jobs. + expect(prerelease).not.toContain("FEISHU_APP_SECRET"); + expect(prerelease).not.toContain("prerelease-progress-card.ts"); + expect(prerelease).toContain("dispatch-validation.sh release-prerelease-card.yml"); + + expect(card).toContain("tools/release/src/notifications/prerelease-progress-card.ts"); + expect(card).toContain("actions: read"); + expect(card).not.toContain("actions: write"); + expect((card.match(/prerelease-progress-card\.ts/g) ?? []).length).toBe(1); + // Dispatched before any package exists, and told which lanes to wait for. + expect(card).toContain("origin_run_id:"); + expect(card).toContain("expect_tests:"); + expect(card).toContain("expect_smoke:"); + // Same compare-API changelog the webhook card uses; no local git history. + expect(card).toContain("gh api --paginate"); + expect(card).toContain("compare/${PREV}...${CUR}?per_page=100"); + + // The watcher polls; it is never pushed to. + expect(watcher).toContain("/repos/${repo}/actions/runs/${runId}/jobs"); + expect(watcher).toContain("origin-run ${originRunId}"); + expect(watcher).toContain("shouldPostFirstCard"); + // A wrong URL would ship a 404 button to the whole channel. + expect(watcher).toContain("verifyDownloadUrl"); + + // The old one-shot webhook card stays until the new one has carried a + // couple of real releases: a gap in release notification is worse than a + // duplicate. 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("tools/release/src/notifications/feishu.ts"); 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 || releaseState === "partial" ? "orange"', - ); + // ...but it no longer claims a smoke verdict it cannot see, and it colours + // itself from whether a package shipped rather than from a workflow + // conclusion that goes red for anything anywhere in the run. + expect(notifyJob).not.toContain("MAC_ARM64_SMOKE_RESULT: ${{ needs.build.outputs.mac_arm64_smoke_result }}"); + expect(notifyJob).toContain("VERSION_METADATA_URL: ${{ needs.build.outputs.version_metadata_url }}"); + expect(feishuCard).toContain("const packagePublished = versionMetadataUrl.length > 0 || buildState === \"success\";"); + expect(feishuCard).toContain("const smokeFailures = packagePublished"); + expect(feishuCard).toContain("return packagePublished ? \"blue\" : \"red\";"); }); it("[P1] keeps download actions on a prerelease card with a failed Windows smoke", async () => { @@ -3671,14 +3731,20 @@ process.stdin.on("end", () => { function expectChannelWorkflowNamespaces( workflow: string, channel: "beta" | "prerelease", - options: { hasLinuxSmoke: boolean }, + options: { hasLinuxSmoke: boolean; packagedSmokeWorkflow?: string }, ): void { const namespace = `release-${channel}`; + // The packaged mac/Windows smoke can live in a workflow of its own (that is + // what prerelease does, so the smoke cannot hold the build's concurrency + // group), but the namespace it installs under still has to be the namespace + // the artifact was built under — the artifact filename carries the token. + const packagedSmoke = options.packagedSmokeWorkflow ?? workflow; expect(workflow).toContain(`--namespace ${namespace}`); - expect(workflow).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}`); + expect(packagedSmoke).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}`); expect(workflow).toContain(`--namespace ${namespace}-intel`); + expect(packagedSmoke).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}-intel`); expect(workflow).toContain(`"--namespace", "${namespace}-win",`); - expect(workflow).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}-win`); + expect(packagedSmoke).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}-win`); expect(workflow).toContain(`--namespace ${namespace}-linux`); if (options.hasLinuxSmoke) { diff --git a/e2e/tests/scripts/smoke-artifacts.test.ts b/e2e/tests/scripts/smoke-artifacts.test.ts new file mode 100644 index 0000000000..edb8035a8b --- /dev/null +++ b/e2e/tests/scripts/smoke-artifacts.test.ts @@ -0,0 +1,238 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const workspaceRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); +const scriptPath = join(workspaceRoot, ".github", "scripts", "release", "smoke-artifacts.ts"); + +const VERSION = "0.21.1-prerelease.3"; +const DMG_BYTES = Buffer.from("a fake but byte-stable dmg\n"); +const EXE_BYTES = Buffer.from("a fake but byte-stable setup.exe\n"); + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** + * The shape `tools-release publish-metadata` writes. The important part for + * this script is that a target which did not build carries no `artifacts` key + * at all — only a status — so anything that reaches for `artifacts` without + * checking status first crashes on a perfectly normal Intel-less build. + */ +function metadataBody(origin: string): Record { + const versionUrl = `${origin}/prerelease/versions/${VERSION}`; + const asset = (name: string, bytes: Buffer) => ({ + contentType: "application/octet-stream", + name, + sha256Url: `${versionUrl}/${name}.sha256`, + size: bytes.byteLength, + url: `${versionUrl}/${name}`, + }); + return { + channel: "prerelease", + releaseVersion: VERSION, + releaseTargets: { + mac_arm64: { + status: "published", + artifacts: { dmg: asset(`open-design-${VERSION}-mac-arm64.dmg`, DMG_BYTES) }, + }, + mac_x64: { status: "missing", enabled: false, reason: "not requested", result: "skipped" }, + win_x64: { + status: "published", + artifacts: { installer: asset(`open-design-${VERSION}-win-x64-setup.exe`, EXE_BYTES) }, + }, + }, + }; +} + +let server: Server; +let origin = ""; +let workDir = ""; +/** Flipped by a test to prove the checksum guard actually fires. */ +let corruptDmg = false; + +beforeAll(async () => { + server = createServer((request, response) => { + const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + const send = (body: Buffer | string, contentType: string) => { + response.setHeader("content-type", contentType); + response.statusCode = 200; + if (request.method === "HEAD") { + response.end(); + return; + } + response.end(body); + }; + if (path.endsWith("/metadata.json")) { + send(JSON.stringify(metadataBody(origin)), "application/json"); + return; + } + if (path.endsWith("-mac-arm64.dmg.sha256")) { + send(`${sha256(DMG_BYTES)} open-design-${VERSION}-mac-arm64.dmg\n`, "text/plain"); + return; + } + if (path.endsWith("-win-x64-setup.exe.sha256")) { + send(`${sha256(EXE_BYTES)} open-design-${VERSION}-win-x64-setup.exe\n`, "text/plain"); + return; + } + if (path.endsWith("-mac-arm64.dmg")) { + send(corruptDmg ? Buffer.from("tampered\n") : DMG_BYTES, "application/octet-stream"); + return; + } + if (path.endsWith("-win-x64-setup.exe")) { + send(EXE_BYTES, "application/octet-stream"); + return; + } + response.statusCode = 404; + response.end("not found"); + }); + await new Promise((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("fixture server did not bind"); + origin = `http://127.0.0.1:${address.port}`; + workDir = await mkdtemp(join(tmpdir(), "od-smoke-artifacts-")); +}); + +afterAll(async () => { + await new Promise((resolve, reject) => server.close((error) => (error == null ? resolve() : reject(error)))); + if (workDir.length > 0) await rm(workDir, { force: true, recursive: true }); +}); + +async function run(mode: "plan" | "stage", env: Record): Promise<{ stdout: string }> { + return execFileAsync( + process.execPath, + ["--experimental-strip-types", scriptPath, mode], + { cwd: workspaceRoot, env: { ...process.env, ...env } }, + ); +} + +describe("smoke-artifacts", () => { + it("plans only the targets that actually published", async () => { + const { stdout } = await run("plan", { + EXPECTED_VERSION: VERSION, + VERSION_METADATA_URL: `${origin}/prerelease/versions/${VERSION}/metadata.json`, + }); + // mac_x64 is present in the metadata but was never built, and it carries no + // `artifacts` key — the gate has to read `status`, not "is the key there". + expect(stdout).toContain("mac_arm64=true"); + expect(stdout).toContain("mac_x64=false"); + expect(stdout).toContain("win_x64=true"); + expect(stdout).toContain("release_version=0.21.1-prerelease.3"); + }); + + it("refuses metadata for a different version than the one dispatched", async () => { + // A stale metadata URL would silently smoke last week's package and report + // it as this build's result. + await expect( + run("plan", { + EXPECTED_VERSION: "0.21.1-prerelease.4", + VERSION_METADATA_URL: `${origin}/prerelease/versions/${VERSION}/metadata.json`, + }), + ).rejects.toThrow(/is for 0\.21\.1-prerelease\.3, not the dispatched 0\.21\.1-prerelease\.4/); + }); + + it("stages the mac dmg exactly where tools-pack mac install looks for it", async () => { + const toolsPackDir = join(workDir, "mac"); + const buildJsonPath = join(workDir, "mac-build.json"); + await run("stage", { + BUILD_JSON_PATH: buildJsonPath, + RELEASE_NAMESPACE: "release-prerelease", + RELEASE_TARGET: "mac_arm64", + TOOLS_PACK_DIR: toolsPackDir, + VERSION_METADATA_URL: `${origin}/prerelease/versions/${VERSION}/metadata.json`, + }); + + // resolveMacPaths().dmgPath. A drift here surfaces only as + // "no mac dmg found at ..." halfway through a release smoke. + const staged = join( + toolsPackDir, + "out", + "mac", + "namespaces", + "release-prerelease", + "dmg", + "Open Design-release-prerelease.dmg", + ); + expect(existsSync(staged)).toBe(true); + expect(await readFile(staged)).toEqual(DMG_BYTES); + + // release-smoke.ts refuses to start without a readable build json, and + // tools-release write-report throws on a zero-byte one. + const build = JSON.parse(await readFile(buildJsonPath, "utf8")) as Record; + expect(build.source).toBe("published-artifact"); + expect(build.dmgPath).toBe(staged); + expect(build.releaseVersion).toBe(VERSION); + expect((build.publishedArtifact as { sha256: string }).sha256).toBe(sha256(DMG_BYTES)); + }); + + it("stages the windows installer exactly where tools-pack win install looks for it", async () => { + const toolsPackDir = join(workDir, "win"); + const buildJsonPath = join(workDir, "win-build.json"); + await run("stage", { + BUILD_JSON_PATH: buildJsonPath, + RELEASE_NAMESPACE: "release-prerelease-win", + RELEASE_TARGET: "win_x64", + TOOLS_PACK_DIR: toolsPackDir, + VERSION_METADATA_URL: `${origin}/prerelease/versions/${VERSION}/metadata.json`, + }); + + // resolveWinPaths().setupPath. + const staged = join( + toolsPackDir, + "out", + "win", + "namespaces", + "release-prerelease-win", + "builder", + "Open Design-release-prerelease-win-setup.exe", + ); + expect(existsSync(staged)).toBe(true); + expect(await readFile(staged)).toEqual(EXE_BYTES); + const build = JSON.parse(await readFile(buildJsonPath, "utf8")) as Record; + expect(build.installerPath).toBe(staged); + }); + + it("fails loudly when the download does not match its published checksum", async () => { + corruptDmg = true; + try { + await expect( + run("stage", { + BUILD_JSON_PATH: join(workDir, "corrupt-build.json"), + RELEASE_NAMESPACE: "release-prerelease", + RELEASE_TARGET: "mac_arm64", + TOOLS_PACK_DIR: join(workDir, "corrupt"), + VERSION_METADATA_URL: `${origin}/prerelease/versions/${VERSION}/metadata.json`, + }), + ).rejects.toThrow(/does not match published/); + } finally { + corruptDmg = false; + } + }); + + it("refuses to stage a target that never published", async () => { + await expect( + run("stage", { + BUILD_JSON_PATH: join(workDir, "intel-build.json"), + RELEASE_NAMESPACE: "release-prerelease-intel", + RELEASE_TARGET: "mac_x64", + TOOLS_PACK_DIR: join(workDir, "intel"), + VERSION_METADATA_URL: `${origin}/prerelease/versions/${VERSION}/metadata.json`, + }), + ).rejects.toThrow(/mac_x64 is not published/); + }); +}); diff --git a/tools/pack/tests/release-workflows.test.ts b/tools/pack/tests/release-workflows.test.ts index 14ffeb962f..71809c05bb 100644 --- a/tools/pack/tests/release-workflows.test.ts +++ b/tools/pack/tests/release-workflows.test.ts @@ -65,7 +65,7 @@ describe("release workflows", () => { const linux = sectionBetween(beta, " build_linux_x64:", " publish:"); const betaMetadata = sectionBetween(beta, " metadata:", " build_mac_arm64:"); const betaPublish = sectionAfter(beta, " publish:"); - const prereleaseMetadata = sectionBetween(prerelease, " metadata:", " verify:"); + const prereleaseMetadata = sectionBetween(prerelease, " metadata:", " dispatch_validation:"); const prereleasePublish = sectionBetween(prerelease, " publish:", " cleanup_partial_release_assets:"); const prereleaseMac = sectionBetween(prerelease, " build_mac:", " build_mac_intel:"); const prereleaseMacX64 = sectionBetween(prerelease, " build_mac_intel:", " build_win:"); @@ -254,7 +254,7 @@ describe("release workflows", () => { ).toBe(2); expect(prereleaseMacX64).toContain("tools-release write-report"); for (const [prereleaseMacJob, nextStep] of [ - [prereleaseMac, "Smoke prerelease mac"], + [prereleaseMac, "Write mac_arm64 release report"], [prereleaseMacX64, "Write mac_x64 release report"], ] as const) { expect(prereleaseMacJob).toContain("Verify prerelease mac"); @@ -343,6 +343,134 @@ describe("release workflows", () => { expect(modeLine).toMatch(/\|\|\s*'core'\s*\}\}/); }); + it("keeps every prerelease validation lane out of the release concurrency group", async () => { + // release-prerelease.yml holds ONE repository-wide concurrency group with + // cancel-in-progress: false. Anything that outlives `publish` inside it + // keeps that group held, which stops the next prerelease from STARTING — + // so making the test jobs advisory was not enough, they had to leave the + // workflow. Lock that: the pipeline is metadata → build → publish plus two + // fire-and-forget dispatchers, and the lanes live in workflows whose + // concurrency is scoped to the origin run. + const [prerelease, tests, smoke, card, dispatcher] = await Promise.all([ + readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-prerelease-tests.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-prerelease-smoke.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-prerelease-card.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/scripts/release/dispatch-validation.sh", import.meta.url), "utf8"), + ]); + + expect(prerelease).toContain("group: open-design-release-prerelease"); + for (const jobId of [" functional_e2e:", " e2e_vitest:", " daemon_unit_tests:", " verify:", " test_signals:"]) { + expect(prerelease, `${jobId} must not be a release-prerelease job any more`).not.toContain(jobId); + } + // The mac/Windows packaged smoke left with them; Linux keeps its in-job + // smoke because that whole lane is opt-in and on nobody's critical path. + expect(prerelease).not.toContain("Smoke prerelease mac packaged runtime"); + expect(prerelease).not.toContain("Smoke prerelease mac_x64 packaged runtime"); + expect(prerelease).not.toContain("Smoke prerelease windows packaged runtime"); + expect(prerelease).toContain("Smoke prerelease linux AppImage runtime"); + + // Dispatchers, and nothing depending on them. + expect(prerelease).toContain(" dispatch_validation:"); + expect(prerelease).toContain(" dispatch_smoke:"); + expect(prerelease).toContain("dispatch-validation.sh release-prerelease-tests.yml"); + expect(prerelease).toContain("dispatch-validation.sh release-prerelease-card.yml"); + expect(prerelease).toContain("dispatch-validation.sh release-prerelease-smoke.yml"); + expect(prerelease).not.toContain("- dispatch_validation"); + expect(prerelease).not.toContain("- dispatch_smoke"); + // Smoke needs a package, so it waits for publish — but only long enough to + // POST the dispatch. + expect(prerelease).toContain( + "if: ${{ inputs.enable_smoke && needs.publish.result == 'success' && needs.publish.outputs.version_metadata_url != '' }}", + ); + + // Two refs, tried in order, so a release branch cut before these lanes + // existed still gets validated from the default branch. + expect(dispatcher).toContain('for candidate in "${PRIMARY_REF:-}" "${FALLBACK_REF:-}"'); + expect(dispatcher).toContain('gh workflow run "$workflow" --ref "$ref"'); + + for (const [label, workflow] of [ + ["tests", tests], + ["smoke", smoke], + ["card", card], + ] as const) { + expect(workflow, label).toContain("workflow_dispatch:"); + expect(workflow, label).not.toContain("open-design-release-prerelease"); + expect(workflow, label).toContain("cancel-in-progress: false"); + // Correlation is the run name: `gh workflow run` returns no run id, so + // the Feishu card finds these runs by matching `origin-run `. + expect(workflow, label).toContain("origin-run ${{ inputs.origin_run_id }}"); + expect(workflow, label).toContain("group: release-prerelease-"); + } + + // The suites moved verbatim and still run against the resolved build commit. + for (const suite of [ + "pnpm --filter @open-design/e2e test", + "pnpm --filter @open-design/daemon test --shard=${{ matrix.shard }}/4", + "pnpm -r --workspace-concurrency=4 --if-present run typecheck", + "run: pnpm guard", + "uses: ./.github/workflows/ui-extended-main.yml", + ]) { + expect(tests).toContain(suite); + } + expect(tests).toContain("ref: ${{ inputs.commit }}"); + expect(tests).not.toContain("needs.metadata.outputs.commit"); + + // Smoke installs the PUBLISHED artifact, not a local build directory. + expect(smoke).toContain("smoke-artifacts.ts plan"); + expect(smoke).toContain("smoke-artifacts.ts stage"); + expect(smoke).toContain("pnpm exec tsx scripts/release-smoke.ts mac specs/mac.spec.ts"); + expect(smoke).toContain("pnpm exec tsx scripts/release-smoke.ts win specs/win.spec.ts"); + expect(smoke).not.toContain("tools-pack mac build"); + // mac.spec.ts reads the profile with a bare `??`, so an empty string reads + // as "not core" and selects the updater path — which then dies for want of + // a fixture. It must be a literal, never an expression. + expect(smoke).toContain("OD_PACKAGED_E2E_MAC_SMOKE_PROFILE: core"); + expect(smoke).not.toMatch(/OD_PACKAGED_E2E_MAC_SMOKE_PROFILE: \$\{\{/); + // Every platform lane is gated on what actually published, not on the flags + // the build was dispatched with. + expect(smoke).toContain("if: ${{ needs.plan.outputs.mac_arm64 == 'true' }}"); + expect(smoke).toContain("if: ${{ needs.plan.outputs.mac_x64 == 'true' }}"); + + // The card watcher reads job state and writes only to Feishu. + expect(card).toContain("actions: read"); + expect(card).not.toContain("actions: write"); + expect(card).toContain("tools/release/src/notifications/prerelease-progress-card.ts"); + expect(card).toContain("FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}"); + expect(card).toContain("FEISHU_RELEASE_CHAT_ID: ${{ secrets.FEISHU_RELEASE_CHAT_ID }}"); + }); + + it("keeps macOS Intel and the validation lanes on by default without breaking the off switch", async () => { + const [prerelease, notify] = await Promise.all([ + readFile(new URL("../../../.github/workflows/release-prerelease.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/notify-release-feishu.yml", import.meta.url), "utf8"), + ]); + + // Intel builds on every prerelease: stable ships Intel, and stable's gate + // is a validated prerelease artifact, so an Intel-less prerelease cannot be + // promoted at all. + for (const flag of ["enable_mac_x64", "enable_smoke", "enable_tests"]) { + const declarations = prerelease + .split("\n") + .map((line, index) => ({ index, line })) + .filter((entry) => entry.line.trim() === `${flag}:`); + // Declared once for workflow_dispatch and once for workflow_call. + expect(declarations, flag).toHaveLength(2); + for (const declaration of declarations) { + const block = prerelease.split("\n").slice(declaration.index, declaration.index + 6).join("\n"); + expect(block, flag).toContain("default: true"); + } + } + + // `${{ inputs. || true }}` would be unturnoffable: `inputs` is unset + // on push (so the fallback is right there) but on a dispatch with the box + // UNCHECKED, `false || true` is also true. Test the event instead. + for (const flag of ["enable_mac_x64", "enable_smoke", "enable_tests"]) { + expect(notify).toContain(`${flag}: \${{ github.event_name != 'workflow_dispatch' || inputs.${flag} }}`); + expect(notify).not.toContain(`${flag}: \${{ inputs.${flag} || true }}`); + } + }); + it("bakes both halves of the workspace-team gate into every shipping lane", async () => { const [beta, prerelease, stable, canary] = await Promise.all([ readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"), diff --git a/tools/release/src/notifications/feishu-app.ts b/tools/release/src/notifications/feishu-app.ts new file mode 100644 index 0000000000..6bfa5e2ff3 --- /dev/null +++ b/tools/release/src/notifications/feishu-app.ts @@ -0,0 +1,148 @@ +// Feishu (Lark) *application* bot transport. +// +// The custom-bot webhook in `feishu.ts` can only append: once a card is in the +// channel it is frozen, so a build that reports progress has to post one +// message per state change and the channel fills with stale cards. An +// application bot can PATCH a message it sent, which is what lets one card +// track a release from "first platform ready" to "everything green". +// +// Three endpoints, in the order a caller uses them: +// POST /open-apis/auth/v3/tenant_access_token/internal {app_id, app_secret} +// POST /open-apis/im/v1/messages?receive_id_type=chat_id +// PATCH /open-apis/im/v1/messages/{message_id} +// +// `content` is a JSON *string* on both message endpoints, not an object — the +// card is serialized into a field, and forgetting that is the usual first +// failure (code 230001, "param invalid"). + +const FEISHU_BASE_URL = process.env.FEISHU_BASE_URL ?? "https://open.feishu.cn"; + +// Tenant tokens are valid for two hours. A progressive card can outlive that, +// so refresh a minute early rather than discovering expiry as a 99991663. +const TOKEN_LEEWAY_MS = 60_000; +const MAX_ATTEMPTS = 5; + +export type FeishuCard = Record; + +export type FeishuAppClientOptions = { + appId: string; + appSecret: string; + /** Injected in tests. Defaults to global fetch. */ + fetchImpl?: typeof fetch; + /** Injected in tests so retry backoff does not really sleep. */ + sleepImpl?: (ms: number) => Promise; + now?: () => number; +}; + +type FeishuResponse = { + code?: number; + msg?: string; + data?: Record; +}; + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * True for failures worth another attempt: transport errors, 5xx, 429, and the + * Feishu-side rate limit / internal codes. A 4xx with a Feishu error code is a + * configuration mistake (bad app secret, bot not in the chat) and retrying it + * only delays the diagnosis. + */ +function retryable(status: number, code: number | null): boolean { + if (status === 429 || status >= 500) return true; + return code === 99991400 || code === 230020 || code === 11232; +} + +export class FeishuAppClient { + readonly #appId: string; + readonly #appSecret: string; + readonly #fetch: typeof fetch; + readonly #sleep: (ms: number) => Promise; + readonly #now: () => number; + #token: string | null = null; + #tokenExpiresAt = 0; + + constructor(options: FeishuAppClientOptions) { + this.#appId = options.appId; + this.#appSecret = options.appSecret; + this.#fetch = options.fetchImpl ?? fetch; + this.#sleep = options.sleepImpl ?? defaultSleep; + this.#now = options.now ?? Date.now; + } + + async #request(path: string, init: RequestInit): Promise { + let lastError: Error | null = null; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + let response: Response; + try { + response = await this.#fetch(`${FEISHU_BASE_URL}${path}`, init); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.warn(`[feishu-app] ${path} attempt ${attempt}/${MAX_ATTEMPTS} threw: ${lastError.message}`); + if (attempt === MAX_ATTEMPTS) break; + await this.#sleep(Math.min(1000 * 2 ** (attempt - 1), 15_000)); + continue; + } + const text = await response.text(); + let parsed: FeishuResponse = {}; + try { + parsed = JSON.parse(text) as FeishuResponse; + } catch { + // Non-JSON body: fall through to the failure path with code null. + } + const code = typeof parsed.code === "number" ? parsed.code : null; + if (response.ok && code === 0) return parsed; + const detail = `HTTP ${response.status} code ${code ?? "n/a"}: ${text.slice(0, 400)}`; + lastError = new Error(`Feishu ${path} failed: ${detail}`); + console.warn(`[feishu-app] ${path} attempt ${attempt}/${MAX_ATTEMPTS} ${detail}`); + if (!retryable(response.status, code) || attempt === MAX_ATTEMPTS) break; + await this.#sleep(Math.min(1000 * 2 ** (attempt - 1), 15_000)); + } + throw lastError ?? new Error(`Feishu ${path} failed`); + } + + async token(): Promise { + if (this.#token != null && this.#now() < this.#tokenExpiresAt - TOKEN_LEEWAY_MS) return this.#token; + const parsed = await this.#request("/open-apis/auth/v3/tenant_access_token/internal", { + method: "POST", + headers: { "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ app_id: this.#appId, app_secret: this.#appSecret }), + }); + const token = (parsed as { tenant_access_token?: unknown }).tenant_access_token; + if (typeof token !== "string" || token.length === 0) { + throw new Error("Feishu tenant_access_token response carried no token"); + } + const expire = (parsed as { expire?: unknown }).expire; + const ttlMs = typeof expire === "number" && expire > 0 ? expire * 1000 : 7200_000; + this.#token = token; + this.#tokenExpiresAt = this.#now() + ttlMs; + return token; + } + + /** Post a new interactive card. Returns the message id needed to update it. */ + async sendCard(chatId: string, card: FeishuCard): Promise { + const token = await this.token(); + const parsed = await this.#request("/open-apis/im/v1/messages?receive_id_type=chat_id", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) }), + }); + const messageId = parsed.data?.message_id; + if (typeof messageId !== "string" || messageId.length === 0) { + throw new Error("Feishu message send returned no message_id"); + } + return messageId; + } + + /** Replace the card of a message this app sent. Whole-card replace, not a merge. */ + async patchCard(messageId: string, card: FeishuCard): Promise { + const token = await this.token(); + await this.#request(`/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`, { + method: "PATCH", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" }, + body: JSON.stringify({ content: JSON.stringify(card) }), + }); + } +} diff --git a/tools/release/src/notifications/feishu.ts b/tools/release/src/notifications/feishu.ts index 52c4a38f2a..252a0ed9e4 100644 --- a/tools/release/src/notifications/feishu.ts +++ b/tools/release/src/notifications/feishu.ts @@ -11,7 +11,16 @@ // COMMIT commit SHA that was built // 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 +// BUILD_STATE build workflow result (success | ...). Only consulted +// when VERSION_METADATA_URL is absent — see below. +// VERSION_METADATA_URL public URL of the published version metadata.json. +// Non-empty means the packages really shipped. This is +// the authoritative signal, and it outranks BUILD_STATE: +// a release workflow's conclusion goes red for anything +// anywhere in the pipeline, including work that runs +// after publication, so a red BUILD_STATE routinely +// accompanies packages that are already downloadable +// from the buttons on this very card. // 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) @@ -52,13 +61,28 @@ const commit = optional("COMMIT"); const previousCommit = optional("PREVIOUS_COMMIT"); const changelogFile = optional("CHANGELOG_FILE"); const buildState = optional("BUILD_STATE", "success"); +const versionMetadataUrl = optional("VERSION_METADATA_URL"); 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"); -const smokeFailures = buildState === "success" +/** + * Did a package actually ship? + * + * `version_metadata_url` is only non-empty once the publish job wrote the + * version metadata to R2, which makes it the one honest answer. The workflow + * conclusion is not: it goes red whenever ANY job in the release run fails, + * including advisory jobs and jobs that run after publication, so keying off it + * paints the card red — and hides the smoke annotations — while the downloads + * beside them work perfectly. A caller that does not pass the URL at all keeps + * the old BUILD_STATE behaviour, so this stays safe for the lanes that have not + * been rewired. + */ +const packagePublished = versionMetadataUrl.length > 0 || buildState === "success"; + +const smokeFailures = packagePublished ? [ { failureText: "macOS arm64 smoke 失败", result: optional("MAC_ARM64_SMOKE_RESULT") }, { failureText: "Windows x64 smoke 失败", result: optional("WIN_X64_SMOKE_RESULT") }, @@ -120,7 +144,9 @@ function changelogMarkdown(): string { } function headerTemplate(): "blue" | "red" { - return buildState === "success" ? "blue" : "red"; + // Red means "no package". It does not mean "something in the pipeline was + // red" — see packagePublished. + return packagePublished ? "blue" : "red"; } function buildCard(): FeishuCard { diff --git a/tools/release/src/notifications/prerelease-card.ts b/tools/release/src/notifications/prerelease-card.ts new file mode 100644 index 0000000000..98af173db0 --- /dev/null +++ b/tools/release/src/notifications/prerelease-card.ts @@ -0,0 +1,295 @@ +// Pure rendering for the progressive prerelease Feishu card. +// +// One card tracks one prerelease from "the first platform finished uploading" +// to "every lane has reported". Because a Feishu PATCH replaces the whole card, +// rendering has to be a total function of the state — no incremental edits, no +// memory of what the previous card said. Everything in this module is pure so +// the state machine can be tested without a network. +// +// The single-writer rule lives at the workflow layer: exactly one job +// (release-prerelease-card.yml) owns the message. Three build jobs racing to +// PATCH the same card would each overwrite the other two's rows, because each +// only knows its own. + +export type LaneStatus = + | "pending" + | "running" + | "success" + | "failure" + | "cancelled" + | "skipped" + | "unknown"; + +export type PlatformKey = "mac_arm64" | "mac_x64" | "win_x64" | "linux_x64"; + +export type PlatformLane = { + key: PlatformKey; + label: string; + /** Outcome of the build job that produces and publishes this platform. */ + build: LaneStatus; + /** Public R2 download URL; empty until the platform is published. */ + downloadUrl: string; + /** Packaged smoke outcome for this platform. `skipped` when not requested. */ + smoke: LaneStatus; +}; + +export type TestLane = { + key: string; + label: string; + status: LaneStatus; +}; + +export type Changelog = { + lines: string[]; + total: number; + truncated: boolean; +}; + +export type PrereleaseCardState = { + channelLabel: string; + version: string; + branch: string; + commit: string; + previousCommit: string; + repo: string; + originRunUrl: string; + testsRunUrl: string; + smokeRunUrl: string; + changelog: Changelog; + platforms: PlatformLane[]; + tests: TestLane[]; + expectTests: boolean; + expectSmoke: boolean; + /** Everything the card waits on has reported. */ + finished: boolean; + /** The watcher hit its wall clock before everything reported. */ + timedOut: boolean; +}; + +export type FeishuCard = Record; +type FeishuElement = Record; + +export const PLATFORM_LABELS: Record = { + mac_arm64: "macOS (Apple Silicon)", + mac_x64: "macOS (Intel)", + win_x64: "Windows", + linux_x64: "Linux", +}; + +/** Display order for platform rows and download buttons. */ +export const PLATFORM_ORDER: PlatformKey[] = ["mac_arm64", "mac_x64", "win_x64", "linux_x64"]; + +const MAX_CHANGELOG_LINES = 30; + +const STATUS_GLYPH: Record = { + pending: "⏳", + running: "⏳", + success: "✅", + failure: "❌", + cancelled: "⚠️", + skipped: "⚪️", + unknown: "⏳", +}; + +const BUILD_STATUS_TEXT: Record = { + pending: "排队中", + running: "构建中", + success: "已发布", + failure: "构建失败", + cancelled: "已取消", + skipped: "本次未构建", + unknown: "状态未知", +}; + +const CHECK_STATUS_TEXT: Record = { + pending: "排队中", + running: "运行中", + success: "通过", + failure: "未通过", + cancelled: "已取消", + skipped: "未运行", + unknown: "状态未知", +}; + +export function isTerminal(status: LaneStatus): boolean { + return status === "success" || status === "failure" || status === "cancelled" || status === "skipped"; +} + +/** A lane that a human has to look at. `skipped` is a decision, not a problem. */ +export function isBadNews(status: LaneStatus): boolean { + return status === "failure" || status === "cancelled"; +} + +/** + * Whether the very first card may be posted yet. + * + * Deliberately NOT "the run started". A card posted at dispatch time leaves a + * permanent "构建中…" in the channel whenever a build dies, which is worse than + * silence because it looks like something is still coming. So the card appears + * on the first real artifact — or, if every expected platform has failed, on + * the failure, which is the one case where silence would hide an incident. + */ +export function shouldPostFirstCard(state: PrereleaseCardState): boolean { + if (state.platforms.some((platform) => platform.build === "success")) return true; + const expected = state.platforms.filter((platform) => platform.build !== "skipped"); + if (expected.length === 0) return false; + return expected.every((platform) => isTerminal(platform.build) && platform.build !== "success"); +} + +export function anyPackagePublished(state: PrereleaseCardState): boolean { + return state.platforms.some((platform) => platform.build === "success"); +} + +export function failureCount(state: PrereleaseCardState): number { + let failures = 0; + for (const platform of state.platforms) { + if (isBadNews(platform.build)) failures += 1; + if (isBadNews(platform.smoke)) failures += 1; + } + for (const test of state.tests) { + if (isBadNews(test.status)) failures += 1; + } + return failures; +} + +export function headerTemplate(state: PrereleaseCardState): string { + if (!anyPackagePublished(state)) { + // Nothing shipped. Red whether the builds failed or the watcher gave up + // waiting — either way there is no package and someone has to look. + return state.platforms.every((platform) => !isTerminal(platform.build)) && !state.timedOut ? "blue" : "red"; + } + if (failureCount(state) > 0) return "orange"; + return state.finished && !state.timedOut ? "green" : "blue"; +} + +export function headerTitle(state: PrereleaseCardState): string { + const name = `Open Design ${state.channelLabel} ${state.version}`; + if (!anyPackagePublished(state)) { + if (state.timedOut) return `🚨 ${name} · 等待产物超时`; + return state.platforms.some((platform) => isTerminal(platform.build)) + ? `🚨 ${name} · 全部平台构建失败` + : `🚀 ${name} · 构建中`; + } + const failures = failureCount(state); + if (failures > 0) return `⚠️ ${name} · ${failures} 项未通过`; + if (!state.finished || state.timedOut) return `🚀 ${name} · 进行中`; + return `🚀 ${name}`; +} + +function platformLine(platform: PlatformLane): string { + return `${STATUS_GLYPH[platform.build]} ${platform.label} · ${BUILD_STATUS_TEXT[platform.build]}`; +} + +function checkLine(label: string, status: LaneStatus): string { + return `${STATUS_GLYPH[status]} ${label} · ${CHECK_STATUS_TEXT[status]}`; +} + +export function changelogMarkdown(state: PrereleaseCardState): string { + if (state.previousCommit.length === 0) { + return `首个 ${state.channelLabel} 包,无上个版本可对比。`; + } + if (state.changelog.lines.length === 0) { + return `与上个 ${state.channelLabel} 包之间没有新增提交。`; + } + const body = state.changelog.lines.map((line) => `- ${line}`).join("\n"); + if (!state.changelog.truncated) return body; + const rest = state.changelog.total - state.changelog.lines.length; + return `${body}\n- …还有 ${rest} 条提交(共 ${state.changelog.total} 条)`; +} + +export function readChangelogLines(raw: string): Changelog { + 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, total: all.length, truncated }; +} + +function downloadButtons(state: PrereleaseCardState): FeishuElement[] { + const ready = state.platforms.filter((platform) => platform.downloadUrl.length > 0); + return ready.map((platform, index) => ({ + tag: "button", + text: { tag: "plain_text", content: `下载 ${platform.label}` }, + type: index === 0 ? "primary" : "default", + url: platform.downloadUrl, + })); +} + +export function renderPrereleaseCard(state: PrereleaseCardState): FeishuCard { + const shortCommit = state.commit.length >= 7 ? state.commit.slice(0, 7) : state.commit; + const fields: FeishuElement[] = []; + if (state.branch.length > 0) { + fields.push({ is_short: true, text: { tag: "lark_md", content: `**分支**\n${state.branch}` } }); + } + if (shortCommit.length > 0) { + const link = state.repo.length > 0 + ? `[\`${shortCommit}\`](https://github.com/${state.repo}/commit/${state.commit})` + : `\`${shortCommit}\``; + fields.push({ is_short: true, text: { tag: "lark_md", content: `**提交**\n${link}` } }); + } + + const elements: FeishuElement[] = []; + if (fields.length > 0) elements.push({ tag: "div", fields }); + + const platformLines = state.platforms.map(platformLine).join("\n"); + elements.push({ tag: "div", text: { tag: "lark_md", content: `**平台产物**\n${platformLines}` } }); + + const checks: string[] = []; + if (state.expectTests) { + for (const test of state.tests) checks.push(checkLine(test.label, test.status)); + } + if (state.expectSmoke) { + for (const platform of state.platforms) { + if (platform.smoke === "skipped") continue; + checks.push(checkLine(`${platform.label} packaged smoke`, platform.smoke)); + } + } + if (checks.length > 0) { + elements.push({ + tag: "div", + text: { + tag: "lark_md", + content: `**验证**(不阻塞发布)\n${checks.join("\n")}`, + }, + }); + } + + elements.push({ + tag: "div", + text: { tag: "lark_md", content: `**自上个 ${state.channelLabel} 新增提交**\n${changelogMarkdown(state)}` }, + }); + + if (state.timedOut) { + elements.push({ + tag: "div", + text: { + tag: "lark_md", + content: "**注意**\n看板任务已达到时间上限,上面的状态可能不是最终结果;点下方链接看运行本身。", + }, + }); + } + + const buttons = downloadButtons(state); + if (buttons.length > 0) { + elements.push({ tag: "hr" }); + elements.push({ tag: "action", actions: buttons }); + } + + const noteLinks: string[] = []; + if (state.originRunUrl.length > 0) noteLinks.push(`[打包运行](${state.originRunUrl})`); + if (state.testsRunUrl.length > 0) noteLinks.push(`[代码测试](${state.testsRunUrl})`); + if (state.smokeRunUrl.length > 0) noteLinks.push(`[包 smoke](${state.smokeRunUrl})`); + if (noteLinks.length > 0) { + elements.push({ tag: "note", elements: [{ tag: "lark_md", content: noteLinks.join(" · ") }] }); + } + + return { + config: { wide_screen_mode: true }, + header: { + template: headerTemplate(state), + title: { tag: "plain_text", content: headerTitle(state) }, + }, + elements, + }; +} diff --git a/tools/release/src/notifications/prerelease-progress-card.ts b/tools/release/src/notifications/prerelease-progress-card.ts new file mode 100644 index 0000000000..5cd6b4617f --- /dev/null +++ b/tools/release/src/notifications/prerelease-progress-card.ts @@ -0,0 +1,440 @@ +// Watches one prerelease across three workflow runs and keeps a single Feishu +// card up to date. +// +// The single-writer rule is the whole design. A PATCH replaces the entire card, +// so if the three build jobs each tried to update "their" row they would each +// overwrite the other two — every one of them only knows its own state. Nothing +// in the build path talks to Feishu at all: this job owns the message, and it +// learns everything by polling the GitHub jobs API of +// * the release-prerelease run that is building and publishing, +// * the release-prerelease-tests run dispatched alongside it, +// * the release-prerelease-smoke run dispatched after publish. +// +// It starts immediately and depends on nothing, so it costs the release +// pipeline nothing; it lives in a workflow of its own so it cannot hold the +// pipeline's repository-wide concurrency group. +// +// The first card is deliberately withheld until a platform actually publishes. +// A "构建中…" card posted at dispatch time becomes a permanent lie whenever a +// build dies. The one exception is total failure, where silence would hide an +// incident — see shouldPostFirstCard. + +import { existsSync, readFileSync } from "node:fs"; + +import { FeishuAppClient } from "./feishu-app.ts"; +import { + PLATFORM_LABELS, + PLATFORM_ORDER, + isTerminal, + readChangelogLines, + renderPrereleaseCard, + shouldPostFirstCard, +} from "./prerelease-card.ts"; +import type { + Changelog, + LaneStatus, + PlatformKey, + PlatformLane, + PrereleaseCardState, + TestLane, +} from "./prerelease-card.ts"; + +type GithubJob = { name?: unknown; status?: unknown; conclusion?: unknown }; +type GithubRun = { id?: unknown; name?: unknown; html_url?: unknown; status?: unknown }; +type DispatchedRun = { completed: boolean; id: string; url: string }; + +function required(name: string): string { + const value = process.env[name]; + if (value == null || value.length === 0) throw new Error(`${name} is required`); + return value; +} + +function optional(name: string, fallback = ""): string { + const value = process.env[name]; + return value == null || value.length === 0 ? fallback : value; +} + +function numberEnv(name: string, fallback: number): number { + const raw = optional(name); + if (raw.length === 0) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function boolEnv(name: string, fallback: boolean): boolean { + const raw = optional(name).toLowerCase(); + if (raw.length === 0) return fallback; + return raw === "true" || raw === "1" || raw === "yes"; +} + +const apiBase = optional("GITHUB_API_URL", "https://api.github.com").replace(/\/+$/, ""); +const serverUrl = optional("GITHUB_SERVER_URL", "https://github.com").replace(/\/+$/, ""); +const repo = required("GITHUB_REPOSITORY"); +const githubToken = optional("GH_TOKEN") || required("GITHUB_TOKEN"); + +const version = required("VERSION"); +const commit = optional("COMMIT"); +const branch = optional("BRANCH"); +const previousCommit = optional("PREVIOUS_COMMIT"); +const channelLabel = optional("CHANNEL_LABEL", "Prerelease"); +const channelSlug = optional("RELEASE_CHANNEL", "prerelease"); +const originRunId = required("ORIGIN_RUN_ID"); +const publicOrigin = optional("RELEASE_PUBLIC_ORIGIN").replace(/\/+$/, ""); +const expectTests = boolEnv("EXPECT_TESTS", true); +const expectSmoke = boolEnv("EXPECT_SMOKE", true); + +const pollIntervalMs = numberEnv("CARD_POLL_INTERVAL_MS", 30_000); +const timeoutMs = numberEnv("CARD_TIMEOUT_MS", 140 * 60 * 1000); +// How long a dispatched lane may take to become a visible run before the card +// reports it as never having started. Long enough to absorb a GitHub queue, +// short enough that a failed dispatch does not pin the card open for hours. +const discoveryGraceMs = numberEnv("CARD_DISCOVERY_GRACE_MS", 20 * 60 * 1000); + +const testsWorkflowFile = optional("TESTS_WORKFLOW_FILE", "release-prerelease-tests.yml"); +const smokeWorkflowFile = optional("SMOKE_WORKFLOW_FILE", "release-prerelease-smoke.yml"); +// Both dispatched workflows put `origin-run ` in their run-name, which is +// the only reliable correlation the Actions API offers: `gh workflow run` +// returns no run id at all. +const runMarker = `origin-run ${originRunId}`; + +const ORIGIN_BUILD_JOB: Record = { + mac_arm64: "Build prerelease mac arm64", + mac_x64: "Build prerelease mac intel x64", + win_x64: "Build prerelease win x64", + linux_x64: "Build prerelease linux x64", +}; +const ORIGIN_PUBLISH_JOB = "Publish prerelease release"; +const SMOKE_JOB: Record = { + mac_arm64: "Smoke prerelease mac arm64", + mac_x64: "Smoke prerelease mac intel x64", + win_x64: "Smoke prerelease win x64", + linux_x64: "Smoke prerelease linux x64", +}; +const TEST_JOBS: Array<{ key: string; label: string; prefix: string }> = [ + { key: "functional_e2e", label: "P0 Functional E2E", prefix: "P0 Functional E2E" }, + { key: "e2e_vitest", label: "E2E Vitest", prefix: "E2E Vitest" }, + { key: "daemon_unit_tests", label: "Daemon 单测(4 分片)", prefix: "Daemon tests" }, + { key: "verify", label: "Verify build(typecheck + guard)", prefix: "Verify build" }, +]; + +/** + * A reusable workflow prefixes its jobs with the calling job's name + * ("Build prerelease from release branch / Build prerelease mac arm64"), and a + * caller-side job name can equally end up as the prefix. Match either side of a + * " / " boundary so the same catalogue works whether release-prerelease was + * called or dispatched directly. + */ +function jobMatches(jobName: string, needle: string): boolean { + return ( + jobName === needle || + jobName.endsWith(` / ${needle}`) || + jobName.startsWith(`${needle} / `) || + jobName.includes(` / ${needle} / `) + ); +} + +function jobFamilyMatches(jobName: string, prefix: string): boolean { + const tail = jobName.includes(" / ") ? (jobName.split(" / ").pop() ?? jobName) : jobName; + const head = jobName.split(" / ")[0] ?? jobName; + return tail.startsWith(prefix) || head.startsWith(prefix) || jobName.startsWith(prefix); +} + +function statusOf(job: GithubJob): LaneStatus { + const status = typeof job.status === "string" ? job.status : ""; + if (status === "queued" || status === "waiting" || status === "pending" || status === "requested") return "pending"; + if (status === "in_progress") return "running"; + const conclusion = typeof job.conclusion === "string" ? job.conclusion : ""; + if (conclusion === "success" || conclusion === "neutral") return "success"; + if (conclusion === "skipped") return "skipped"; + if (conclusion === "cancelled") return "cancelled"; + if (conclusion.length === 0) return "running"; + return "failure"; +} + +/** Worst-wins, with "still moving" beating "all done" so a family never reads terminal early. */ +function rollup(statuses: LaneStatus[]): LaneStatus { + if (statuses.length === 0) return "unknown"; + if (statuses.some((status) => status === "failure")) return "failure"; + if (statuses.some((status) => status === "cancelled")) return "cancelled"; + if (statuses.some((status) => status === "running")) return "running"; + if (statuses.some((status) => status === "pending")) return "pending"; + if (statuses.every((status) => status === "skipped")) return "skipped"; + if (statuses.some((status) => status === "unknown")) return "unknown"; + return "success"; +} + +async function githubJson(path: string): Promise { + const response = await fetch(`${apiBase}${path}`, { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${githubToken}`, + "x-github-api-version": "2022-11-28", + }, + }); + if (!response.ok) throw new Error(`GitHub ${path} failed: HTTP ${response.status}`); + return (await response.json()) as T; +} + +async function listJobs(runId: string): Promise { + const jobs: GithubJob[] = []; + for (let page = 1; page <= 5; page += 1) { + const body = await githubJson<{ jobs?: GithubJob[]; total_count?: number }>( + `/repos/${repo}/actions/runs/${runId}/jobs?per_page=100&page=${page}&filter=latest`, + ); + const batch = body.jobs ?? []; + jobs.push(...batch); + if (batch.length < 100) break; + } + return jobs; +} + +async function findDispatchedRun(workflowFile: string): Promise { + const body = await githubJson<{ workflow_runs?: GithubRun[] }>( + `/repos/${repo}/actions/workflows/${encodeURIComponent(workflowFile)}/runs?event=workflow_dispatch&per_page=40`, + ); + for (const run of body.workflow_runs ?? []) { + const name = typeof run.name === "string" ? run.name : ""; + if (!name.includes(runMarker)) continue; + return { + completed: run.status === "completed", + id: String(run.id), + url: typeof run.html_url === "string" ? run.html_url : "", + }; + } + return null; +} + +const ARTIFACT_BASENAME: Record = { + mac_arm64: "mac-arm64.dmg", + mac_x64: "mac-x64.dmg", + win_x64: "win-x64-setup.exe", + linux_x64: "linux-x64.AppImage", +}; + +function downloadUrlFor(platform: PlatformKey): string { + if (publicOrigin.length === 0) return ""; + return `${publicOrigin}/${channelSlug}/versions/${version}/open-design-${version}-${ARTIFACT_BASENAME[platform]}`; +} + +const verifiedUrls = new Set(); + +/** + * The card links straight at R2, so a wrong URL would ship a 404 button to the + * whole channel. HEAD it once per platform; a miss just means no button on this + * cycle and another attempt on the next, which also absorbs R2 propagation. + */ +async function verifyDownloadUrl(url: string): Promise { + if (url.length === 0) return false; + if (verifiedUrls.has(url)) return true; + try { + const response = await fetch(url, { method: "HEAD", redirect: "follow" }); + if (response.ok) { + verifiedUrls.add(url); + return true; + } + console.warn(`[card] download url not reachable yet (HTTP ${response.status}): ${url}`); + } catch (error) { + console.warn(`[card] download url HEAD threw: ${error instanceof Error ? error.message : String(error)}`); + } + return false; +} + +function readChangelog(): Changelog { + const file = optional("CHANGELOG_FILE"); + if (file.length === 0 || !existsSync(file)) return { lines: [], total: 0, truncated: false }; + try { + return readChangelogLines(readFileSync(file, "utf8")); + } catch { + return { lines: [], total: 0, truncated: false }; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type Watch = { + originJobs: GithubJob[]; + publish: LaneStatus; + testsRun: DispatchedRun | null; + testsJobs: GithubJob[]; + smokeRun: DispatchedRun | null; + smokeJobs: GithubJob[]; + publishSucceededAt: number | null; +}; + +async function collect(previous: Watch): Promise { + const originJobs = await listJobs(originRunId); + const publishJob = originJobs.find((job) => jobMatches(String(job.name ?? ""), ORIGIN_PUBLISH_JOB)); + const publish = publishJob == null ? "pending" : statusOf(publishJob); + + // Re-listed every cycle rather than cached, because the run's own + // completion is what tells a MISSING job apart from a job the API has not + // created yet. A run that has only just started reports a partial job list, + // and reading that as "skipped" would fabricate green. + let testsRun = previous.testsRun; + let testsJobs = previous.testsJobs; + if (expectTests && !(previous.testsRun?.completed ?? false)) { + testsRun = (await findDispatchedRun(testsWorkflowFile)) ?? previous.testsRun; + if (testsRun != null) testsJobs = await listJobs(testsRun.id); + } + + let smokeRun = previous.smokeRun; + let smokeJobs = previous.smokeJobs; + // The smoke run is only dispatched once publish succeeds, so do not even look + // for it before then — an early lookup would only burn API budget. + if (expectSmoke && publish === "success" && !(previous.smokeRun?.completed ?? false)) { + smokeRun = (await findDispatchedRun(smokeWorkflowFile)) ?? previous.smokeRun; + if (smokeRun != null) smokeJobs = await listJobs(smokeRun.id); + } + + return { + originJobs, + publish, + testsRun, + testsJobs, + smokeRun, + smokeJobs, + publishSucceededAt: previous.publishSucceededAt ?? (publish === "success" ? Date.now() : null), + }; +} + +async function buildState(watch: Watch, startedAt: number, timedOut: boolean): Promise { + const platforms: PlatformLane[] = []; + for (const key of PLATFORM_ORDER) { + const job = watch.originJobs.find((candidate) => jobMatches(String(candidate.name ?? ""), ORIGIN_BUILD_JOB[key])); + const build: LaneStatus = job == null ? "skipped" : statusOf(job); + let downloadUrl = ""; + if (build === "success" && (await verifyDownloadUrl(downloadUrlFor(key)))) { + downloadUrl = downloadUrlFor(key); + } + let smoke: LaneStatus = "skipped"; + if (expectSmoke && build === "success") { + const smokeJob = watch.smokeJobs.find((candidate) => jobMatches(String(candidate.name ?? ""), SMOKE_JOB[key])); + // Linux never had a lane in the packaged smoke workflow; a platform with + // no job there stays "skipped" rather than pretending to be pending. + if (key === "linux_x64") smoke = "skipped"; + else if (smokeJob != null) smoke = statusOf(smokeJob); + // No job for this platform, and the run has FINISHED: it genuinely did + // not run. While the run is still going, a missing job just means the + // API has not created it yet. + else if (watch.smokeRun?.completed === true) smoke = "skipped"; + else if (watch.smokeRun != null || watch.publish === "success") smoke = "pending"; + else smoke = "skipped"; + } + platforms.push({ key, label: PLATFORM_LABELS[key], build, downloadUrl, smoke }); + } + + const tests: TestLane[] = TEST_JOBS.map((entry) => { + if (!expectTests) return { key: entry.key, label: entry.label, status: "skipped" as LaneStatus }; + const family = watch.testsJobs.filter((job) => jobFamilyMatches(String(job.name ?? ""), entry.prefix)); + if (family.length === 0) { + // Same rule as the smoke lanes: only a COMPLETED run proves a job is + // absent rather than not yet created. + return { + key: entry.key, + label: entry.label, + status: (watch.testsRun?.completed === true ? "skipped" : "pending") as LaneStatus, + }; + } + return { key: entry.key, label: entry.label, status: rollup(family.map(statusOf)) }; + }); + + const originDone = platforms.every((platform) => isTerminal(platform.build)) && isTerminal(watch.publish); + const testsDone = !expectTests + ? true + : watch.testsRun == null + ? Date.now() - startedAt > discoveryGraceMs + : tests.every((test) => isTerminal(test.status)); + const smokeDone = !expectSmoke + ? true + : watch.publish !== "success" + ? isTerminal(watch.publish) + : watch.smokeRun == null + ? watch.publishSucceededAt != null && Date.now() - watch.publishSucceededAt > discoveryGraceMs + : platforms.every((platform) => isTerminal(platform.smoke)); + + return { + channelLabel, + version, + branch, + commit, + previousCommit, + repo, + originRunUrl: `${serverUrl}/${repo}/actions/runs/${originRunId}`, + testsRunUrl: watch.testsRun?.url ?? "", + smokeRunUrl: watch.smokeRun?.url ?? "", + changelog: readChangelog(), + platforms, + tests, + expectTests, + expectSmoke, + finished: originDone && testsDone && smokeDone, + timedOut, + }; +} + +async function main(): Promise { + const client = new FeishuAppClient({ + appId: required("FEISHU_APP_ID"), + appSecret: required("FEISHU_APP_SECRET"), + }); + const chatId = required("FEISHU_RELEASE_CHAT_ID"); + + const startedAt = Date.now(); + let watch: Watch = { + originJobs: [], + publish: "pending", + testsRun: null, + testsJobs: [], + smokeRun: null, + smokeJobs: [], + publishSucceededAt: null, + }; + let messageId: string | null = null; + let lastRendered = ""; + + for (;;) { + const timedOut = Date.now() - startedAt > timeoutMs; + try { + watch = await collect(watch); + } catch (error) { + // A transient API failure must not end the watch: the card would freeze + // mid-release with no way back. + console.warn(`[card] poll failed: ${error instanceof Error ? error.message : String(error)}`); + } + const state = await buildState(watch, startedAt, timedOut); + const done = state.finished || timedOut; + + if (messageId != null || shouldPostFirstCard(state) || done) { + const card = renderPrereleaseCard(state); + const rendered = JSON.stringify(card); + if (rendered !== lastRendered) { + try { + if (messageId == null) { + messageId = await client.sendCard(chatId, card); + console.log(`[card] posted ${messageId}`); + } else { + await client.patchCard(messageId, card); + console.log("[card] updated"); + } + lastRendered = rendered; + } catch (error) { + // Keep watching. The next cycle re-renders from the same state and + // tries again, so one Feishu hiccup does not lose the card. + console.warn(`[card] delivery failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + if (done) { + if (timedOut && !state.finished) { + console.warn(`::warning::prerelease card watcher timed out after ${Math.round(timeoutMs / 60000)} minutes`); + } + return; + } + await sleep(pollIntervalMs); + } +} + +await main(); diff --git a/tools/release/tests/prerelease-card.test.ts b/tools/release/tests/prerelease-card.test.ts new file mode 100644 index 0000000000..eebd83ff85 --- /dev/null +++ b/tools/release/tests/prerelease-card.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; + +import { + PLATFORM_LABELS, + headerTemplate, + headerTitle, + renderPrereleaseCard, + shouldPostFirstCard, +} from "../src/notifications/prerelease-card.ts"; +import type { LaneStatus, PlatformKey, PlatformLane, PrereleaseCardState } from "../src/notifications/prerelease-card.ts"; + +const R2 = "https://releases.example/prerelease/versions/0.21.1-prerelease.3"; + +function platform( + key: PlatformKey, + build: LaneStatus, + overrides: Partial = {}, +): PlatformLane { + return { + key, + label: PLATFORM_LABELS[key], + build, + downloadUrl: build === "success" ? `${R2}/open-design-0.21.1-prerelease.3-${key}` : "", + smoke: "skipped", + ...overrides, + }; +} + +function state(overrides: Partial = {}): PrereleaseCardState { + return { + channelLabel: "Prerelease", + version: "0.21.1-prerelease.3", + branch: "release/v0.21.1", + commit: "0123456789abcdef0123456789abcdef01234567", + previousCommit: "fedcba9876543210fedcba9876543210fedcba98", + repo: "nexu-io/open-design", + originRunUrl: "https://github.com/nexu-io/open-design/actions/runs/1", + testsRunUrl: "", + smokeRunUrl: "", + changelog: { lines: ["fix(web): keep the composer mounted (abc1234567)"], total: 1, truncated: false }, + platforms: [ + platform("mac_arm64", "pending"), + platform("mac_x64", "pending"), + platform("win_x64", "pending"), + ], + tests: [], + expectTests: false, + expectSmoke: false, + finished: false, + timedOut: false, + ...overrides, + }; +} + +type RenderedCard = { + elements: Array<{ actions?: Array<{ url?: string }>; text?: { content?: string }; elements?: Array<{ content?: string }> }>; + header: { template?: string; title?: { content?: string } }; +}; + +function render(input: PrereleaseCardState): RenderedCard { + return renderPrereleaseCard(input) as unknown as RenderedCard; +} + +function texts(card: RenderedCard): string[] { + return card.elements.map((element) => element.text?.content).filter((value): value is string => value != null); +} + +function buttonUrls(card: RenderedCard): string[] { + return card.elements.flatMap((element) => element.actions ?? []).map((action) => action.url ?? ""); +} + +describe("prerelease progress card", () => { + it("withholds the first card until a platform actually publishes", () => { + // A card posted at dispatch time becomes a permanent "构建中…" whenever the + // build dies, which reads as "still coming" forever. + expect(shouldPostFirstCard(state())).toBe(false); + expect( + shouldPostFirstCard( + state({ + platforms: [ + platform("mac_arm64", "running"), + platform("mac_x64", "pending"), + platform("win_x64", "pending"), + ], + }), + ), + ).toBe(false); + + const firstReady = state({ + platforms: [ + platform("mac_arm64", "success"), + platform("mac_x64", "running"), + platform("win_x64", "pending"), + ], + }); + expect(shouldPostFirstCard(firstReady)).toBe(true); + + const card = render(firstReady); + expect(card.header.template).toBe("blue"); + expect(card.header.title?.content).toContain("进行中"); + expect(texts(card).join("\n")).toContain("✅ macOS (Apple Silicon) · 已发布"); + expect(texts(card).join("\n")).toContain("⏳ Windows · 排队中"); + // Only the published platform gets a button; the others would 404. + expect(buttonUrls(card)).toEqual([`${R2}/open-design-0.21.1-prerelease.3-mac_arm64`]); + }); + + it("adds a row and a button as each further platform publishes", () => { + const later = render( + state({ + platforms: [ + platform("mac_arm64", "success"), + platform("mac_x64", "success"), + platform("win_x64", "success"), + ], + finished: true, + }), + ); + expect(later.header.template).toBe("green"); + expect(later.header.title?.content).toBe("🚀 Open Design Prerelease 0.21.1-prerelease.3"); + expect(buttonUrls(later)).toEqual([ + `${R2}/open-design-0.21.1-prerelease.3-mac_arm64`, + `${R2}/open-design-0.21.1-prerelease.3-mac_x64`, + `${R2}/open-design-0.21.1-prerelease.3-win_x64`, + ]); + const platformBlock = texts(later).find((text) => text.startsWith("**平台产物**")) ?? ""; + expect(platformBlock.split("\n")).toHaveLength(4); + }); + + it("keeps a shipped release out of the red when a check fails", () => { + // The whole point of the version_metadata_url rule, expressed on the + // progressive card: a failed check colours the card orange, never red, + // because the packages are downloadable from the buttons right below it. + const partial = state({ + platforms: [ + platform("mac_arm64", "success", { smoke: "failure" }), + platform("mac_x64", "success", { smoke: "success" }), + platform("win_x64", "success", { smoke: "success" }), + ], + expectSmoke: true, + expectTests: true, + tests: [ + { key: "functional_e2e", label: "P0 Functional E2E", status: "success" }, + { key: "e2e_vitest", label: "E2E Vitest", status: "failure" }, + ], + finished: true, + }); + const card = render(partial); + expect(card.header.template).toBe("orange"); + expect(card.header.title?.content).toContain("2 项未通过"); + expect(buttonUrls(card)).toHaveLength(3); + const checks = texts(card).find((text) => text.startsWith("**验证**")) ?? ""; + expect(checks).toContain("❌ E2E Vitest · 未通过"); + expect(checks).toContain("✅ P0 Functional E2E · 通过"); + expect(checks).toContain("❌ macOS (Apple Silicon) packaged smoke · 未通过"); + }); + + it("posts a failure card when every platform failed, so silence never hides an incident", () => { + const dead = state({ + platforms: [ + platform("mac_arm64", "failure"), + platform("mac_x64", "failure"), + platform("win_x64", "failure"), + ], + finished: true, + }); + expect(shouldPostFirstCard(dead)).toBe(true); + const card = render(dead); + expect(card.header.template).toBe("red"); + expect(card.header.title?.content).toContain("全部平台构建失败"); + expect(buttonUrls(card)).toEqual([]); + }); + + it("does not wait on a platform that was never requested", () => { + // A skipped platform is a decision, not a pending result: it must not keep + // the failure card from being posted when everything else died. + const dead = state({ + platforms: [ + platform("mac_arm64", "failure"), + platform("mac_x64", "skipped"), + platform("win_x64", "failure"), + ], + }); + expect(shouldPostFirstCard(dead)).toBe(true); + expect(headerTemplate(dead)).toBe("red"); + expect(texts(render(dead)).join("\n")).toContain("⚪️ macOS (Intel) · 本次未构建"); + }); + + it("says so when the changelog is empty, and distinguishes empty from cold start", () => { + const noBaseline = state({ + previousCommit: "", + changelog: { lines: [], total: 0, truncated: false }, + platforms: [platform("mac_arm64", "success"), platform("win_x64", "success")], + }); + expect(texts(render(noBaseline)).join("\n")).toContain("首个 Prerelease 包,无上个版本可对比。"); + + const noCommits = state({ + changelog: { lines: [], total: 0, truncated: false }, + platforms: [platform("mac_arm64", "success"), platform("win_x64", "success")], + }); + expect(texts(render(noCommits)).join("\n")).toContain("与上个 Prerelease 包之间没有新增提交。"); + }); + + it("truncates a long changelog and says how much it dropped", () => { + const lines = Array.from({ length: 30 }, (_, index) => `fix: change ${index}`); + const card = render( + state({ + changelog: { lines, total: 47, truncated: true }, + platforms: [platform("mac_arm64", "success")], + }), + ); + expect(texts(card).join("\n")).toContain("…还有 17 条提交(共 47 条)"); + }); + + it("renders no check rows for lanes that were never dispatched", () => { + const card = render( + state({ + platforms: [platform("mac_arm64", "success")], + expectTests: false, + expectSmoke: false, + tests: [{ key: "verify", label: "Verify build", status: "pending" }], + }), + ); + expect(texts(card).some((text) => text.startsWith("**验证**"))).toBe(false); + }); + + it("marks a timed-out watch instead of presenting a partial state as final", () => { + const timedOut = state({ + platforms: [platform("mac_arm64", "success"), platform("win_x64", "running")], + expectTests: true, + tests: [{ key: "verify", label: "Verify build", status: "running" }], + timedOut: true, + }); + const card = render(timedOut); + expect(card.header.title?.content).toContain("进行中"); + expect(texts(card).join("\n")).toContain("看板任务已达到时间上限"); + }); + + it("reports a timeout with no package at all as red", () => { + const nothing = state({ platforms: [platform("mac_arm64", "running")], timedOut: true }); + expect(headerTemplate(nothing)).toBe("red"); + expect(headerTitle(nothing)).toContain("等待产物超时"); + }); + + it("links every run it is watching", () => { + const card = render( + state({ + platforms: [platform("mac_arm64", "success")], + testsRunUrl: "https://github.com/nexu-io/open-design/actions/runs/2", + smokeRunUrl: "https://github.com/nexu-io/open-design/actions/runs/3", + }), + ); + const note = card.elements.at(-1)?.elements?.[0]?.content ?? ""; + expect(note).toContain("[打包运行](https://github.com/nexu-io/open-design/actions/runs/1)"); + expect(note).toContain("[代码测试](https://github.com/nexu-io/open-design/actions/runs/2)"); + expect(note).toContain("[包 smoke](https://github.com/nexu-io/open-design/actions/runs/3)"); + }); +});