From f9433a7a078caa62fb057a4a3afa2322a7279489 Mon Sep 17 00:00:00 2001 From: PerishFire <39043006+PerishCode@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:50:01 +0800 Subject: [PATCH] release: harden packaged launcher validation (#3995) * Unify release metadata publishing * Allow empty release asset suffix * Harden beta-s release publishing * Clean beta Windows release namespace * Clean Windows launcher namespace * Retry transient mac notarization uploads * Control mac notarization upload mode * Notarize release mac DMGs after build * Harden release mac DMG notarization retries * Make beta-s mac updates payload-first * Bake mac updater metadata URL * Add unsafe DMG install helper * Fix beta-s feed and About version * Improve release validation observability * Build launcher proto before packaging * Notarize release-stable mac artifacts * Enable mac notarization in tools-pack * test: update packaged release workflow assertions --- .../scripts/release/build-platform.ps1 | 9 + .../scripts/release/build-platform.sh | 87 +++- .../release/prepare-platform-assets.ps1 | 1 + .../release/prepare-platform-assets.sh | 3 +- .../storage/beta-version-reservation.ts | 1 - .../release/storage/publish-beta-metadata.ts | 326 +-------------- .../release/storage/publish-metadata.ts | 388 ++++++++++++++++++ .../release/storage/publish-platform.ts | 4 +- .../scripts/release/storage/summary-beta.ts | 31 +- .../release/storage/summary-metadata.ts | 40 ++ .../release/storage/verify-beta-metadata.ts | 33 +- .../release/storage/verify-metadata.ts | 53 +++ .github/workflows/release-beta-s.yml | 53 ++- .github/workflows/release-beta.yml | 8 +- .github/workflows/release-preview.yml | 281 +++++++++---- .github/workflows/release-stable.yml | 282 ++++++++++--- apps/desktop/src/main/index.ts | 12 + apps/desktop/src/main/updater.ts | 14 + .../main/amr-environment-profile-menu.test.ts | 8 + apps/desktop/tests/main/updater.test.ts | 43 ++ e2e/tests/packaged-smoke-workflow.test.ts | 65 ++- scripts/install-unsafe-dmg.sh | 156 +++++++ tools/pack/resources/mac/notarize.cjs | 146 ++++++- tools/pack/src/cache.ts | 3 + tools/pack/src/linux.ts | 1 + tools/pack/src/mac/app.ts | 1 + tools/pack/src/mac/artifacts.ts | 4 +- tools/pack/src/mac/build.ts | 6 +- tools/pack/src/mac/builder.ts | 2 +- tools/pack/src/mac/fs.ts | 14 +- tools/pack/src/mac/lifecycle.ts | 8 +- tools/pack/src/mac/workspace.ts | 1 + tools/pack/src/win/app.ts | 1 + tools/pack/src/win/lifecycle.ts | 5 + tools/pack/src/win/types.ts | 1 + tools/pack/tests/cache.test.ts | 42 ++ tools/pack/tests/mac.test.ts | 71 ++++ tools/pack/tests/release-workflows.test.ts | 96 ++++- tools/serve/src/index.ts | 29 +- tools/serve/src/release-storage-fixture.ts | 158 +++++++ tools/serve/src/updater-fixture.ts | 99 ++++- .../tests/beta-version-reservation.test.ts | 69 ++++ .../tests/release-metadata-publish.test.ts | 147 +++++++ .../tests/release-storage-fixture.test.ts | 62 +++ tools/serve/tests/updater-fixture.test.ts | 41 ++ 45 files changed, 2293 insertions(+), 612 deletions(-) create mode 100644 .github/workflow/scripts/release/storage/publish-metadata.ts create mode 100644 .github/workflow/scripts/release/storage/summary-metadata.ts create mode 100644 .github/workflow/scripts/release/storage/verify-metadata.ts create mode 100755 scripts/install-unsafe-dmg.sh create mode 100644 tools/serve/src/release-storage-fixture.ts create mode 100644 tools/serve/tests/beta-version-reservation.test.ts create mode 100644 tools/serve/tests/release-metadata-publish.test.ts create mode 100644 tools/serve/tests/release-storage-fixture.test.ts diff --git a/.github/workflow/scripts/release/build-platform.ps1 b/.github/workflow/scripts/release/build-platform.ps1 index 1cd172b500..f40c7a5d2a 100644 --- a/.github/workflow/scripts/release/build-platform.ps1 +++ b/.github/workflow/scripts/release/build-platform.ps1 @@ -207,6 +207,15 @@ New-Item -ItemType Directory -Force -Path $WorkRoot, $ToolsPackDir, $CacheDir, $ Remove-Item -LiteralPath $BuildJsonPath -Force -ErrorAction SilentlyContinue try { + Measure-Step "clean tools-pack win namespace" { + Invoke-CommandChecked -Arguments @( + "pnpm.cmd", "exec", "tools-pack", "win", "cleanup", + "--dir", $ToolsPackDir, + "--namespace", $ReleaseNamespace, + "--json" + ) + } + $buildArgs = @( "pnpm.cmd", "exec", "tools-pack", "win", "build", "--dir", $ToolsPackDir, diff --git a/.github/workflow/scripts/release/build-platform.sh b/.github/workflow/scripts/release/build-platform.sh index 735c614b38..33ed246fb8 100644 --- a/.github/workflow/scripts/release/build-platform.sh +++ b/.github/workflow/scripts/release/build-platform.sh @@ -39,6 +39,81 @@ measure_step() { echo "##[endgroup]" } +is_transient_notary_output() { + local output="$1" + case "$output" in + *abortedUpload* | *deadlineExceeded* | *ECONNRESET* | *ETIMEDOUT* | *ENOTFOUND* | *EAI_AGAIN* | *"socket hang up"* | *"network connection was lost"*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +notary_auth_args() { + if [ -n "${APPLE_NOTARY_KEYCHAIN_PROFILE:-}" ]; then + if [ -n "${APPLE_NOTARY_KEYCHAIN:-}" ]; then + printf '%s\0' --keychain "$APPLE_NOTARY_KEYCHAIN" + fi + printf '%s\0' --keychain-profile "$APPLE_NOTARY_KEYCHAIN_PROFILE" + else + printf '%s\0' --apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID" + fi +} + +notarize_mac_dmg_once() { + local dmg_path="$1" + local auth_args=() + local s3_arg="--no-s3-acceleration" + local status + if [ "${OPEN_DESIGN_NOTARIZE_S3_ACCELERATION:-false}" = "true" ]; then + s3_arg="--s3-acceleration" + fi + while IFS= read -r -d '' arg; do + auth_args+=("$arg") + done < <(notary_auth_args) + + echo "[release notarize] submitting $(basename "$dmg_path") ($(wc -c < "$dmg_path" | tr -d ' ') bytes) with $s3_arg" + xcrun notarytool submit "$dmg_path" \ + "${auth_args[@]}" \ + --wait \ + --output-format json \ + "$s3_arg" + status=$? + if [ "$status" -ne 0 ]; then + return "$status" + fi + xcrun stapler staple "$dmg_path" +} + +notarize_mac_dmg() { + local dmg_path="$1" + local attempts="${OPEN_DESIGN_NOTARIZE_ATTEMPTS:-8}" + local retry_delay_ms="${OPEN_DESIGN_NOTARIZE_RETRY_DELAY_MS:-15000}" + local attempt output status + if [ ! -f "$dmg_path" ]; then + echo "expected dmg not found for notarization: $dmg_path" >&2 + return 1 + fi + for ((attempt = 1; attempt <= attempts; attempt += 1)); do + set +e + output="$(notarize_mac_dmg_once "$dmg_path" 2>&1)" + status=$? + set -e + printf '%s\n' "$output" + if [ "$status" -eq 0 ]; then + return 0 + fi + if [ "$attempt" -lt "$attempts" ] && is_transient_notary_output "$output"; then + echo "[release notarize] transient notarytool failure on attempt $attempt/$attempts; retrying in ${retry_delay_ms}ms" >&2 + sleep "$(node -e 'process.stdout.write(String(Number(process.argv[1]) / 1000))' "$retry_delay_ms")" + else + return "$status" + fi + done +} + prepare_mac_signing() { if [ -n "${CSC_KEYCHAIN:-}" ]; then unset CSC_LINK @@ -166,9 +241,6 @@ case "$RELEASE_TARGET" in if [ "$sign_mode" != "no" ]; then build_args+=(--signed) fi - if [ "$sign_mode" = "notarize" ]; then - build_args+=(--notarize) - fi ;; linux_x64) if [ "$RELEASE_BUILD_TARGET" != "appimage" ]; then @@ -196,6 +268,15 @@ esac if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$BUILD_LOG_PATH" >&2))"; then printf '%s\n' "$build_output" | tee "$BUILD_JSON_PATH" + if [ "${sign_mode:-no}" = "notarize" ]; then + dmg_path="$(BUILD_JSON_PATH="$BUILD_JSON_PATH" node --input-type=module <<'NODE' +import { readFileSync } from "node:fs"; +const build = JSON.parse(readFileSync(process.env.BUILD_JSON_PATH, "utf8")); +process.stdout.write(build.dmgPath ?? ""); +NODE +)" + measure_step "notarize mac dmg" notarize_mac_dmg "$dmg_path" + fi BUILD_JSON_PATH="$BUILD_JSON_PATH" RELEASE_TIMINGS_JSON="[$release_timings_json]" node --input-type=module <<'NODE' import { readFileSync, writeFileSync } from "node:fs"; diff --git a/.github/workflow/scripts/release/prepare-platform-assets.ps1 b/.github/workflow/scripts/release/prepare-platform-assets.ps1 index 82760770b3..acc86db57d 100644 --- a/.github/workflow/scripts/release/prepare-platform-assets.ps1 +++ b/.github/workflow/scripts/release/prepare-platform-assets.ps1 @@ -11,6 +11,7 @@ param( [Parameter(Mandatory = $true)] [string]$ReleaseVersion, [Parameter(Mandatory = $true)] + [AllowEmptyString()] [string]$ReleaseAssetSuffix, [Parameter(Mandatory = $true)] [string]$ReleaseChannel, diff --git a/.github/workflow/scripts/release/prepare-platform-assets.sh b/.github/workflow/scripts/release/prepare-platform-assets.sh index 4714b8c38e..63565f9bc8 100644 --- a/.github/workflow/scripts/release/prepare-platform-assets.sh +++ b/.github/workflow/scripts/release/prepare-platform-assets.sh @@ -20,7 +20,6 @@ sha256_file() { } required RELEASE_ASSETS_DIR -required RELEASE_ASSET_SUFFIX required RELEASE_CHANNEL required RELEASE_NAMESPACE required RELEASE_PUBLIC_ORIGIN @@ -28,6 +27,8 @@ required RELEASE_TARGET required RELEASE_VERSION required TOOLS_PACK_DIR +RELEASE_ASSET_SUFFIX="${RELEASE_ASSET_SUFFIX:-}" + mkdir -p "$RELEASE_ASSETS_DIR" case "$RELEASE_TARGET" in diff --git a/.github/workflow/scripts/release/storage/beta-version-reservation.ts b/.github/workflow/scripts/release/storage/beta-version-reservation.ts index 22a8505f3a..b586751f16 100644 --- a/.github/workflow/scripts/release/storage/beta-version-reservation.ts +++ b/.github/workflow/scripts/release/storage/beta-version-reservation.ts @@ -42,7 +42,6 @@ function sameOwner(left: Record, right: Record return left.repository === right.repository && left.workflow === right.workflow && left.runId === right.runId && - left.runAttempt === right.runAttempt && left.commit === right.commit; } diff --git a/.github/workflow/scripts/release/storage/publish-beta-metadata.ts b/.github/workflow/scripts/release/storage/publish-beta-metadata.ts index 5253b0abbe..6adde5b516 100644 --- a/.github/workflow/scripts/release/storage/publish-beta-metadata.ts +++ b/.github/workflow/scripts/release/storage/publish-beta-metadata.ts @@ -1,325 +1 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { - contentType, - githubInfo, - optional, - publicUrl, - required, - storageConfigFromEnv, - writeJson, -} from "./common.ts"; -import { assertCurrentVersionReservation, versionLockObjectKey } from "./beta-version-reservation.ts"; -import { getStorageObject, putStorageObject, putStorageObjectWithStatus } from "./s3-upload.ts"; - -type PlatformManifest = { - artifacts?: Record; - channel?: string; - enabled?: boolean; - feed?: { - name?: string; - url?: string; - } | null; - github?: { - commit?: string; - runAttempt?: number; - runId?: number; - }; - legacyPlatformKey?: string; - platformKey?: string; - r2?: { versionPrefix?: string }; - reason?: string | null; - releaseTarget?: string; - releaseVersion?: string; - signed?: boolean; - status?: string; -}; - -type TargetDef = { - enableEnv: string; - label: string; - legacyKey: "mac" | "macIntel" | "win" | "linux"; - resultEnv: string; - target: "mac_arm64" | "mac_x64" | "win_x64" | "linux_x64"; -}; - -const storage = storageConfigFromEnv(); -const releaseChannel = required("RELEASE_CHANNEL"); -if (releaseChannel !== "beta") { - throw new Error(`publish-beta-metadata only supports beta, got ${releaseChannel}`); -} -const releaseVersion = required("RELEASE_VERSION"); -const publicOrigin = required("RELEASE_PUBLIC_ORIGIN").replace(/\/+$/, ""); -const metadataDir = required("RELEASE_METADATA_DIR"); -const manifestDir = required("RELEASE_MANIFEST_DIR"); -const outputsPath = required("RELEASE_OUTPUTS_PATH"); -const requestedAssetVersionSuffix = optional("RELEASE_ASSET_SUFFIX"); -const latestPrefix = `${releaseChannel}/latest`; -const currentCommit = optional("RELEASE_COMMIT"); -const currentRunId = Number(optional("RELEASE_RUN_ID", "0")); -const versionLockRequired = process.env.RELEASE_VERSION_LOCK_REQUIRED === "true"; -const versionLockKey = optional("RELEASE_VERSION_LOCK_KEY", versionLockObjectKey(releaseVersion)); -const latestCasRequired = process.env.RELEASE_LATEST_CAS_REQUIRED === "true"; - -if (versionLockRequired) { - await assertCurrentVersionReservation(storage, releaseVersion, versionLockKey); - console.log(`verified beta version reservation ${versionLockKey}`); -} - -const targetDefs: TargetDef[] = [ - { enableEnv: "ENABLE_MAC_ARM64", label: "macOS arm64", legacyKey: "mac", resultEnv: "MAC_ARM64_RESULT", target: "mac_arm64" }, - { enableEnv: "ENABLE_WIN_X64", label: "Windows x64", legacyKey: "win", resultEnv: "WIN_X64_RESULT", target: "win_x64" }, - { enableEnv: "ENABLE_MAC_X64", label: "macOS x64", legacyKey: "macIntel", resultEnv: "MAC_X64_RESULT", target: "mac_x64" }, - { enableEnv: "ENABLE_LINUX_X64", label: "Linux x64", legacyKey: "linux", resultEnv: "LINUX_X64_RESULT", target: "linux_x64" }, -]; - -function parseReleaseVersion(value: string): { base: [number, number, number]; betaNumber: number } | null { - const match = /^(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$/.exec(value); - if (match?.[1] == null || match[2] == null || match[3] == null || match[4] == null) return null; - const base: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])]; - const betaNumber = Number(match[4]); - if (base.some((part) => !Number.isSafeInteger(part)) || !Number.isSafeInteger(betaNumber) || betaNumber < 1) return null; - return { base, betaNumber }; -} - -function compareReleaseVersions(left: string, right: string): number { - const parsedLeft = parseReleaseVersion(left); - const parsedRight = parseReleaseVersion(right); - if (parsedLeft == null || parsedRight == null) { - throw new Error(`invalid beta version comparison: ${left} vs ${right}`); - } - for (let index = 0; index < parsedLeft.base.length; index += 1) { - if (parsedLeft.base[index] > parsedRight.base[index]) return 1; - if (parsedLeft.base[index] < parsedRight.base[index]) return -1; - } - if (parsedLeft.betaNumber > parsedRight.betaNumber) return 1; - if (parsedLeft.betaNumber < parsedRight.betaNumber) return -1; - return 0; -} - -async function upload(path: string, objectKey: string, cacheControl: string, type = "application/json; charset=utf-8"): Promise { - await putStorageObject({ - ...storage, - bodyPath: path, - cacheControl, - contentType: type, - objectKey, - }); -} - -async function uploadLatestMetadataWithCas(path: string, objectKey: string): Promise { - if (!latestCasRequired) { - await upload(path, objectKey, "public, max-age=60, must-revalidate"); - return; - } - - if (parseReleaseVersion(releaseVersion) == null) { - throw new Error(`invalid beta version for latest CAS: ${releaseVersion}`); - } - - for (let attempt = 1; attempt <= 5; attempt += 1) { - const latest = await getStorageObject({ ...storage, objectKey }); - const headers: Record = {}; - if (latest == null) { - headers["if-none-match"] = "*"; - } else { - let latestBetaVersion = ""; - try { - const parsed = JSON.parse(latest.text.replace(/^\uFEFF/u, "")) as { betaVersion?: unknown }; - latestBetaVersion = typeof parsed.betaVersion === "string" ? parsed.betaVersion : ""; - } catch (error) { - throw new Error(`latest metadata is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); - } - if (latestBetaVersion.length > 0 && compareReleaseVersions(latestBetaVersion, releaseVersion) > 0) { - throw new Error(`refusing to move beta latest backward from ${latestBetaVersion} to ${releaseVersion}`); - } - if (latest.etag.length === 0) { - throw new Error("latest metadata GET did not return an ETag for CAS update"); - } - headers["if-match"] = latest.etag; - } - - const result = await putStorageObjectWithStatus({ - ...storage, - bodyPath: path, - cacheControl: "public, max-age=60, must-revalidate", - contentType: "application/json; charset=utf-8", - headers, - objectKey, - }); - if (result.ok) return; - if (result.status !== 412) { - throw new Error(`latest metadata CAS PUT ${objectKey} failed with HTTP ${result.status}${result.body.length > 0 ? `: ${result.body}` : ""}`); - } - console.log(`latest metadata CAS conflict on attempt ${attempt}; retrying`); - } - - throw new Error(`failed to update latest metadata with CAS after 5 attempts: ${objectKey}`); -} - -async function publishLatestPlatformObjects(manifests: Record): Promise { - for (const [target, manifest] of Object.entries(manifests)) { - const manifestPath = join(manifestDir, `${target}.json`); - await upload(manifestPath, `${latestPrefix}/platforms/${target}.json`, "public, max-age=60, must-revalidate"); - - const feedName = manifest.feed?.name; - if (feedName == null || feedName.length === 0) continue; - - const feedVersionPrefix = manifest.r2?.versionPrefix; - if (feedVersionPrefix == null || feedVersionPrefix.length === 0) { - throw new Error(`published ${target} platform manifest is missing r2.versionPrefix for ${feedName}`); - } - const versionFeed = await getStorageObject({ ...storage, objectKey: `${feedVersionPrefix}/${feedName}` }); - if (versionFeed == null) { - throw new Error(`expected versioned feed object not found: ${feedVersionPrefix}/${feedName}`); - } - const feedPath = join(metadataDir, "latest-feeds", feedName); - mkdirSync(join(metadataDir, "latest-feeds"), { recursive: true }); - writeFileSync(feedPath, versionFeed.text, "utf8"); - await upload(feedPath, `${latestPrefix}/${feedName}`, "public, max-age=60, must-revalidate", contentType(feedName)); - } -} - -function enabled(name: string): boolean { - return process.env[name] === "true"; -} - -function readManifest(target: string): PlatformManifest | null { - const path = join(manifestDir, `${target}.json`); - if (!existsSync(path)) return null; - return JSON.parse(readFileSync(path, "utf8")) as PlatformManifest; -} - -function validateManifest(target: string, manifest: PlatformManifest): string | null { - if (manifest.channel !== releaseChannel) return `channel=${String(manifest.channel)}`; - if (manifest.releaseVersion !== releaseVersion) return `releaseVersion=${String(manifest.releaseVersion)}`; - if (manifest.platformKey !== target) return `platformKey=${String(manifest.platformKey)}`; - if (manifest.releaseTarget != null && manifest.releaseTarget !== target) return `releaseTarget=${String(manifest.releaseTarget)}`; - if (manifest.status !== "published") return `status=${String(manifest.status)}`; - if (currentRunId > 0 && manifest.github?.runId !== currentRunId) return `github.runId=${String(manifest.github?.runId)}`; - if (currentCommit.length > 0 && manifest.github?.commit !== currentCommit) return `github.commit=${String(manifest.github?.commit)}`; - if (manifest.r2?.versionPrefix == null || !manifest.r2.versionPrefix.includes(`/versions/${releaseVersion}`)) { - return `versionPrefix=${String(manifest.r2?.versionPrefix)}`; - } - return null; -} - -const expectedTargets: string[] = []; -const readyTargets: string[] = []; -const failedTargets: string[] = []; -const releaseTargets: Record = {}; -const platforms: Record = {}; - -for (const def of targetDefs) { - if (!enabled(def.enableEnv)) continue; - expectedTargets.push(def.target); - const result = optional(def.resultEnv, "skipped"); - const manifest = readManifest(def.target); - const invalidReason = manifest == null ? null : validateManifest(def.target, manifest); - if (manifest != null && invalidReason != null && result === "success") { - throw new Error(`refusing stale ${def.target} platform manifest for ${releaseVersion}: ${invalidReason}`); - } - if (manifest != null && invalidReason == null && result === "success") { - const readyManifest = { - ...manifest, - enabled: true, - status: "published", - }; - releaseTargets[def.target] = readyManifest; - platforms[def.legacyKey] = readyManifest; - readyTargets.push(def.target); - } else { - const status = result === "success" ? "missing" : "failed"; - const failedManifest = { - enabled: true, - label: def.label, - reason: manifest == null ? "missing manifest" : invalidReason, - result, - status, - }; - releaseTargets[def.target] = failedManifest; - platforms[def.legacyKey] = failedManifest; - failedTargets.push(def.target); - } -} - -let releaseState = "failed"; -if (expectedTargets.length > 0 && readyTargets.length === expectedTargets.length) releaseState = "complete"; -else if (readyTargets.length > 0) releaseState = "partial"; - -let assetVersionSuffix = requestedAssetVersionSuffix; -if (assetVersionSuffix === "auto") { - const readyManifests = readyTargets.map((target) => releaseTargets[target]).filter((manifest) => manifest != null); - const allReadyTargetsSigned = readyManifests.length > 0 && readyManifests.every((manifest) => manifest.signed === true); - assetVersionSuffix = allReadyTargetsSigned ? ".signed" : ".unsigned"; -} -const versionPrefix = optional("RELEASE_VERSION_PREFIX", `${releaseChannel}/versions/${releaseVersion}${assetVersionSuffix}`); - -const latestMetadataUpdated = releaseState === "complete"; -const metadata = { - assetVersionSuffix, - baseVersion: required("BASE_VERSION"), - betaNumber: Number(releaseVersion.split("-beta.")[1]), - betaVersion: releaseVersion, - channel: releaseChannel, - expectedPlatforms: expectedTargets, - expectedTargets, - failedPlatforms: failedTargets, - failedTargets, - generatedAt: new Date().toISOString(), - github: githubInfo(), - platforms, - r2: { - latestMetadataUrl: publicUrl(publicOrigin, latestPrefix, "metadata.json"), - latestMetadataUpdated, - latestPrefix, - publicOrigin, - report: { - type: "directory", - url: publicUrl(publicOrigin, versionPrefix, "report/"), - }, - reportUrl: publicUrl(publicOrigin, versionPrefix, "report/"), - reportZipUrl: null, - versionMetadataUrl: publicUrl(publicOrigin, versionPrefix, "metadata.json"), - versionPrefix, - }, - readyPlatforms: readyTargets, - readyTargets, - releaseState, - releaseTargets, - signed: process.env.RELEASE_SIGNED === "true", - stateSource: required("STATE_SOURCE"), - version: 1, -}; - -mkdirSync(metadataDir, { recursive: true }); -const metadataPath = join(metadataDir, "metadata.json"); -writeJson(metadataPath, metadata); -await upload(metadataPath, `${versionPrefix}/metadata.json`, "public, max-age=31536000, immutable"); -if (latestMetadataUpdated) { - await uploadLatestMetadataWithCas(metadataPath, `${latestPrefix}/metadata.json`); - await publishLatestPlatformObjects(releaseTargets); -} else { - console.log(`left ${metadata.r2.latestMetadataUrl} unchanged because releaseState=${releaseState}`); -} - -const outputs: Record = { - latest_metadata_updated: String(latestMetadataUpdated), - metadata_url: metadata.r2.latestMetadataUrl, - release_state: releaseState, - report_url: metadata.r2.reportUrl, - version_metadata_url: metadata.r2.versionMetadataUrl, - version_prefix: versionPrefix, -}; -for (const [target, manifest] of Object.entries(releaseTargets)) { - if (manifest.status !== "published") continue; - for (const [artifactName, artifact] of Object.entries(manifest.artifacts ?? {})) { - if (artifact.url != null) outputs[`${target}_${artifactName}_url`] = artifact.url; - } - if ((manifest as { feed?: { latestUrl?: string } }).feed?.latestUrl != null) { - outputs[`${target}_feed_url`] = (manifest as { feed: { latestUrl: string } }).feed.latestUrl; - } -} -writeJson(outputsPath, outputs); - -console.log(`published beta version metadata (${releaseState}) to ${metadata.r2.versionMetadataUrl}`); +import "./publish-metadata.ts"; diff --git a/.github/workflow/scripts/release/storage/publish-metadata.ts b/.github/workflow/scripts/release/storage/publish-metadata.ts new file mode 100644 index 0000000000..63e5e25e92 --- /dev/null +++ b/.github/workflow/scripts/release/storage/publish-metadata.ts @@ -0,0 +1,388 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + contentType, + githubInfo, + optional, + publicUrl, + required, + storageConfigFromEnv, + writeJson, +} from "./common.ts"; +import { assertCurrentVersionReservation, versionLockObjectKey } from "./beta-version-reservation.ts"; +import { getStorageObject, putStorageObject, putStorageObjectWithStatus } from "./s3-upload.ts"; + +type PlatformManifest = { + artifacts?: Record; + channel?: string; + enabled?: boolean; + feed?: { + name?: string; + url?: string; + } | null; + github?: { + commit?: string; + runAttempt?: number; + runId?: number; + }; + legacyPlatformKey?: string; + platformKey?: string; + r2?: { versionPrefix?: string }; + reason?: string | null; + releaseTarget?: string; + releaseVersion?: string; + signed?: boolean; + status?: string; +}; + +type TargetDef = { + enableEnv: string; + label: string; + legacyKey: "mac" | "macIntel" | "win" | "linux"; + resultEnv: string; + target: "mac_arm64" | "mac_x64" | "win_x64" | "linux_x64"; +}; + +const storage = storageConfigFromEnv(); +const releaseChannel = required("RELEASE_CHANNEL"); +if (releaseChannel !== "beta" && releaseChannel !== "preview" && releaseChannel !== "nightly" && releaseChannel !== "stable") { + throw new Error(`RELEASE_CHANNEL must be beta, preview, nightly, or stable; got ${releaseChannel}`); +} +const releaseVersion = required("RELEASE_VERSION"); +const publicOrigin = required("RELEASE_PUBLIC_ORIGIN").replace(/\/+$/, ""); +const metadataDir = required("RELEASE_METADATA_DIR"); +const manifestDir = required("RELEASE_MANIFEST_DIR"); +const outputsPath = required("RELEASE_OUTPUTS_PATH"); +const requestedAssetVersionSuffix = optional("RELEASE_ASSET_SUFFIX"); +const latestPrefix = `${releaseChannel}/latest`; +const currentCommit = optional("RELEASE_COMMIT"); +const currentRunId = Number(optional("RELEASE_RUN_ID", "0")); +const versionLockRequired = process.env.RELEASE_VERSION_LOCK_REQUIRED === "true"; +const versionLockKey = optional("RELEASE_VERSION_LOCK_KEY", versionLockObjectKey(releaseVersion)); +const latestCasRequired = process.env.RELEASE_LATEST_CAS_REQUIRED === "true"; + +if (versionLockRequired) { + await assertCurrentVersionReservation(storage, releaseVersion, versionLockKey); + console.log(`verified beta version reservation ${versionLockKey}`); +} + +const targetDefs: TargetDef[] = [ + { enableEnv: "ENABLE_MAC_ARM64", label: "macOS arm64", legacyKey: "mac", resultEnv: "MAC_ARM64_RESULT", target: "mac_arm64" }, + { enableEnv: "ENABLE_WIN_X64", label: "Windows x64", legacyKey: "win", resultEnv: "WIN_X64_RESULT", target: "win_x64" }, + { enableEnv: "ENABLE_MAC_X64", label: "macOS x64", legacyKey: "macIntel", resultEnv: "MAC_X64_RESULT", target: "mac_x64" }, + { enableEnv: "ENABLE_LINUX_X64", label: "Linux x64", legacyKey: "linux", resultEnv: "LINUX_X64_RESULT", target: "linux_x64" }, +]; + +function parseCountedReleaseVersion(value: string, label: "beta" | "preview"): { baseVersion: string; number: number } | null { + const match = new RegExp(`^(\\d+\\.\\d+\\.\\d+)-${label}\\.(\\d+)$`).exec(value); + if (match?.[1] == null || match[2] == null) return null; + const number = Number(match[2]); + if (!Number.isSafeInteger(number) || number < 1) return null; + return { baseVersion: match[1], number }; +} + +function parseNightlyReleaseVersion(value: string): { baseVersion: string; number: number } | null { + const match = /^(\d+\.\d+\.\d+)\.nightly\.(\d+)$/.exec(value); + if (match?.[1] == null || match[2] == null) return null; + const number = Number(match[2]); + if (!Number.isSafeInteger(number) || number < 1) return null; + return { baseVersion: match[1], number }; +} + +function parseStableReleaseVersion(value: string): { baseVersion: string } | null { + return /^\d+\.\d+\.\d+$/.test(value) ? { baseVersion: value } : null; +} + +function releaseMetadataFields(): Record { + if (releaseChannel === "beta") { + const parsed = parseCountedReleaseVersion(releaseVersion, "beta"); + if (parsed == null) throw new Error(`beta release version must be x.y.z-beta.N; got ${releaseVersion}`); + return { + assetVersionSuffix, + baseVersion: optional("BASE_VERSION", parsed.baseVersion), + betaNumber: parsed.number, + betaVersion: releaseVersion, + }; + } + if (releaseChannel === "preview") { + const parsed = parseCountedReleaseVersion(releaseVersion, "preview"); + if (parsed == null) throw new Error(`preview release version must be x.y.z-preview.N; got ${releaseVersion}`); + return { + assetVersionSuffix, + baseVersion: optional("BASE_VERSION", parsed.baseVersion), + previewNumber: parsed.number, + previewVersion: releaseVersion, + releaseVersion, + }; + } + if (releaseChannel === "nightly") { + const parsed = parseNightlyReleaseVersion(releaseVersion); + if (parsed == null) throw new Error(`nightly release version must be x.y.z.nightly.N; got ${releaseVersion}`); + return { + baseVersion: optional("BASE_VERSION", parsed.baseVersion), + nightlyNumber: parsed.number, + nightlyVersion: releaseVersion, + releaseVersion, + }; + } + const parsed = parseStableReleaseVersion(releaseVersion); + if (parsed == null) throw new Error(`stable release version must be x.y.z; got ${releaseVersion}`); + return { + baseVersion: optional("BASE_VERSION", parsed.baseVersion), + releaseVersion, + stableVersion: optional("STABLE_VERSION", parsed.baseVersion), + versionTag: optional("VERSION_TAG"), + }; +} + +function parseBetaReleaseVersion(value: string): { base: [number, number, number]; betaNumber: number } | null { + const match = /^(\d+)\.(\d+)\.(\d+)-beta\.(\d+)$/.exec(value); + if (match?.[1] == null || match[2] == null || match[3] == null || match[4] == null) return null; + const base: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const betaNumber = Number(match[4]); + if (base.some((part) => !Number.isSafeInteger(part)) || !Number.isSafeInteger(betaNumber) || betaNumber < 1) return null; + return { base, betaNumber }; +} + +function compareReleaseVersions(left: string, right: string): number { + const parsedLeft = parseBetaReleaseVersion(left); + const parsedRight = parseBetaReleaseVersion(right); + if (parsedLeft == null || parsedRight == null) { + throw new Error(`invalid beta version comparison: ${left} vs ${right}`); + } + for (let index = 0; index < parsedLeft.base.length; index += 1) { + if (parsedLeft.base[index] > parsedRight.base[index]) return 1; + if (parsedLeft.base[index] < parsedRight.base[index]) return -1; + } + if (parsedLeft.betaNumber > parsedRight.betaNumber) return 1; + if (parsedLeft.betaNumber < parsedRight.betaNumber) return -1; + return 0; +} + +async function upload(path: string, objectKey: string, cacheControl: string, type = "application/json; charset=utf-8"): Promise { + await putStorageObject({ + ...storage, + bodyPath: path, + cacheControl, + contentType: type, + objectKey, + }); +} + +async function uploadLatestMetadataWithCas(path: string, objectKey: string): Promise { + if (!latestCasRequired) { + await upload(path, objectKey, "public, max-age=60, must-revalidate"); + return; + } + + if (releaseChannel !== "beta") { + throw new Error("latest metadata CAS is only supported for beta releases"); + } + if (parseBetaReleaseVersion(releaseVersion) == null) { + throw new Error(`invalid beta version for latest CAS: ${releaseVersion}`); + } + + for (let attempt = 1; attempt <= 5; attempt += 1) { + const latest = await getStorageObject({ ...storage, objectKey }); + const headers: Record = {}; + if (latest == null) { + headers["if-none-match"] = "*"; + } else { + let latestBetaVersion = ""; + try { + const parsed = JSON.parse(latest.text.replace(/^\uFEFF/u, "")) as { betaVersion?: unknown }; + latestBetaVersion = typeof parsed.betaVersion === "string" ? parsed.betaVersion : ""; + } catch (error) { + throw new Error(`latest metadata is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + if (latestBetaVersion.length > 0 && compareReleaseVersions(latestBetaVersion, releaseVersion) > 0) { + throw new Error(`refusing to move beta latest backward from ${latestBetaVersion} to ${releaseVersion}`); + } + if (latest.etag.length === 0) { + throw new Error("latest metadata GET did not return an ETag for CAS update"); + } + headers["if-match"] = latest.etag; + } + + const result = await putStorageObjectWithStatus({ + ...storage, + bodyPath: path, + cacheControl: "public, max-age=60, must-revalidate", + contentType: "application/json; charset=utf-8", + headers, + objectKey, + }); + if (result.ok) return; + if (result.status !== 412) { + throw new Error(`latest metadata CAS PUT ${objectKey} failed with HTTP ${result.status}${result.body.length > 0 ? `: ${result.body}` : ""}`); + } + console.log(`latest metadata CAS conflict on attempt ${attempt}; retrying`); + } + + throw new Error(`failed to update latest metadata with CAS after 5 attempts: ${objectKey}`); +} + +async function publishLatestPlatformObjects(manifests: Record): Promise { + for (const [target, manifest] of Object.entries(manifests)) { + const manifestPath = join(manifestDir, `${target}.json`); + await upload(manifestPath, `${latestPrefix}/platforms/${target}.json`, "public, max-age=60, must-revalidate"); + + const feedName = manifest.feed?.name; + if (feedName == null || feedName.length === 0) continue; + + const feedVersionPrefix = manifest.r2?.versionPrefix; + if (feedVersionPrefix == null || feedVersionPrefix.length === 0) { + throw new Error(`published ${target} platform manifest is missing r2.versionPrefix for ${feedName}`); + } + const versionFeed = await getStorageObject({ ...storage, objectKey: `${feedVersionPrefix}/${feedName}` }); + if (versionFeed == null) { + throw new Error(`expected versioned feed object not found: ${feedVersionPrefix}/${feedName}`); + } + const feedPath = join(metadataDir, "latest-feeds", feedName); + mkdirSync(join(metadataDir, "latest-feeds"), { recursive: true }); + writeFileSync(feedPath, versionFeed.text, "utf8"); + await upload(feedPath, `${latestPrefix}/${feedName}`, "public, max-age=60, must-revalidate", contentType(feedName)); + } +} + +function enabled(name: string): boolean { + return process.env[name] === "true"; +} + +function readManifest(target: string): PlatformManifest | null { + const path = join(manifestDir, `${target}.json`); + if (!existsSync(path)) return null; + return JSON.parse(readFileSync(path, "utf8")) as PlatformManifest; +} + +function validateManifest(target: string, manifest: PlatformManifest): string | null { + if (manifest.channel !== releaseChannel) return `channel=${String(manifest.channel)}`; + if (manifest.releaseVersion !== releaseVersion) return `releaseVersion=${String(manifest.releaseVersion)}`; + if (manifest.platformKey !== target) return `platformKey=${String(manifest.platformKey)}`; + if (manifest.releaseTarget != null && manifest.releaseTarget !== target) return `releaseTarget=${String(manifest.releaseTarget)}`; + if (manifest.status !== "published") return `status=${String(manifest.status)}`; + if (currentRunId > 0 && manifest.github?.runId !== currentRunId) return `github.runId=${String(manifest.github?.runId)}`; + if (currentCommit.length > 0 && manifest.github?.commit !== currentCommit) return `github.commit=${String(manifest.github?.commit)}`; + if (manifest.r2?.versionPrefix == null || !manifest.r2.versionPrefix.includes(`/versions/${releaseVersion}`)) { + return `versionPrefix=${String(manifest.r2?.versionPrefix)}`; + } + return null; +} + +const expectedTargets: string[] = []; +const readyTargets: string[] = []; +const failedTargets: string[] = []; +const releaseTargets: Record = {}; +const platforms: Record = {}; + +for (const def of targetDefs) { + if (!enabled(def.enableEnv)) continue; + expectedTargets.push(def.target); + const result = optional(def.resultEnv, "skipped"); + const manifest = readManifest(def.target); + const invalidReason = manifest == null ? null : validateManifest(def.target, manifest); + if (manifest != null && invalidReason != null && result === "success") { + throw new Error(`refusing stale ${def.target} platform manifest for ${releaseVersion}: ${invalidReason}`); + } + if (manifest != null && invalidReason == null && result === "success") { + const readyManifest = { + ...manifest, + enabled: true, + status: "published", + }; + releaseTargets[def.target] = readyManifest; + platforms[def.legacyKey] = readyManifest; + readyTargets.push(def.target); + } else { + const status = result === "success" ? "missing" : "failed"; + const failedManifest = { + enabled: true, + label: def.label, + reason: manifest == null ? "missing manifest" : invalidReason, + result, + status, + }; + releaseTargets[def.target] = failedManifest; + platforms[def.legacyKey] = failedManifest; + failedTargets.push(def.target); + } +} + +let releaseState = "failed"; +if (expectedTargets.length > 0 && readyTargets.length === expectedTargets.length) releaseState = "complete"; +else if (readyTargets.length > 0) releaseState = "partial"; + +let assetVersionSuffix = requestedAssetVersionSuffix; +const readyManifests = readyTargets.map((target) => releaseTargets[target]).filter((manifest) => manifest != null); +const allReadyTargetsSigned = readyManifests.length > 0 && readyManifests.every((manifest) => manifest.signed === true); +if (assetVersionSuffix === "auto") { + assetVersionSuffix = allReadyTargetsSigned ? ".signed" : ".unsigned"; +} +const versionPrefix = optional("RELEASE_VERSION_PREFIX", `${releaseChannel}/versions/${releaseVersion}${assetVersionSuffix}`); + +const latestMetadataUpdated = releaseState === "complete"; +const metadata = { + ...releaseMetadataFields(), + channel: releaseChannel, + expectedPlatforms: expectedTargets, + expectedTargets, + failedPlatforms: failedTargets, + failedTargets, + generatedAt: new Date().toISOString(), + github: githubInfo(), + platforms, + r2: { + latestMetadataUrl: publicUrl(publicOrigin, latestPrefix, "metadata.json"), + latestMetadataUpdated, + latestPrefix, + publicOrigin, + report: { + type: "directory", + url: publicUrl(publicOrigin, versionPrefix, "report/"), + }, + reportUrl: publicUrl(publicOrigin, versionPrefix, "report/"), + reportZipUrl: null, + versionMetadataUrl: publicUrl(publicOrigin, versionPrefix, "metadata.json"), + versionPrefix, + }, + readyPlatforms: readyTargets, + readyTargets, + releaseState, + releaseTargets, + signed: process.env.RELEASE_SIGNED === "true", + allReadyTargetsSigned, + stateSource: required("STATE_SOURCE"), + version: 1, +}; + +mkdirSync(metadataDir, { recursive: true }); +const metadataPath = join(metadataDir, "metadata.json"); +writeJson(metadataPath, metadata); +await upload(metadataPath, `${versionPrefix}/metadata.json`, "public, max-age=31536000, immutable"); +if (latestMetadataUpdated) { + await uploadLatestMetadataWithCas(metadataPath, `${latestPrefix}/metadata.json`); + await publishLatestPlatformObjects(releaseTargets); +} else { + console.log(`left ${metadata.r2.latestMetadataUrl} unchanged because releaseState=${releaseState}`); +} + +const outputs: Record = { + latest_metadata_updated: String(latestMetadataUpdated), + metadata_url: metadata.r2.latestMetadataUrl, + release_state: releaseState, + report_url: metadata.r2.reportUrl, + version_metadata_url: metadata.r2.versionMetadataUrl, + version_prefix: versionPrefix, +}; +for (const [target, manifest] of Object.entries(releaseTargets)) { + if (manifest.status !== "published") continue; + for (const [artifactName, artifact] of Object.entries(manifest.artifacts ?? {})) { + if (artifact.url != null) outputs[`${target}_${artifactName}_url`] = artifact.url; + } + if ((manifest as { feed?: { latestUrl?: string } }).feed?.latestUrl != null) { + outputs[`${target}_feed_url`] = (manifest as { feed: { latestUrl: string } }).feed.latestUrl; + } +} +writeJson(outputsPath, outputs); + +console.log(`published ${releaseChannel} version metadata (${releaseState}) to ${metadata.r2.versionMetadataUrl}`); diff --git a/.github/workflow/scripts/release/storage/publish-platform.ts b/.github/workflow/scripts/release/storage/publish-platform.ts index 607a4b26f8..b801458756 100644 --- a/.github/workflow/scripts/release/storage/publish-platform.ts +++ b/.github/workflow/scripts/release/storage/publish-platform.ts @@ -288,7 +288,7 @@ writeJson(outputsPath, outputs); writeFileSync( optional("RELEASE_SUMMARY_PATH", join(dirname(outputsPath), `${target}-publish-summary.md`)), [ - `## ${config.label} beta publish`, + `## ${config.label} ${releaseChannel} publish`, "", `- target: \`${target}\``, `- version: \`${releaseVersion}\``, @@ -298,4 +298,4 @@ writeFileSync( "utf8", ); -console.log(`published ${config.label} beta assets to ${versionPrefix}`); +console.log(`published ${config.label} ${releaseChannel} assets to ${versionPrefix}`); diff --git a/.github/workflow/scripts/release/storage/summary-beta.ts b/.github/workflow/scripts/release/storage/summary-beta.ts index 5ac90c0085..0cbe8e142f 100644 --- a/.github/workflow/scripts/release/storage/summary-beta.ts +++ b/.github/workflow/scripts/release/storage/summary-beta.ts @@ -1,30 +1 @@ -import { optional, required, writeText } from "./common.ts"; - -const metadataUrl = required("RELEASE_METADATA_URL"); -const summaryPath = required("RELEASE_SUMMARY_PATH"); -const cacheBuster = optional("RELEASE_CACHE_BUSTER", "local"); - -const response = await fetch(`${metadataUrl}${metadataUrl.includes("?") ? "&" : "?"}run=${cacheBuster}`, { - headers: { "Cache-Control": "no-cache" }, -}); -if (!response.ok) { - throw new Error(`metadata fetch failed with HTTP ${response.status}`); -} - -const metadata = await response.json() as { - betaVersion?: string; - readyTargets?: string[]; - releaseState?: string; - r2?: { versionMetadataUrl?: string }; -}; - -writeText(summaryPath, [ - "## Beta release metadata", - "", - `- version: \`${metadata.betaVersion ?? ""}\``, - `- state: \`${metadata.releaseState ?? ""}\``, - `- ready targets: \`${(metadata.readyTargets ?? []).join(", ")}\``, - `- metadata: ${metadata.r2?.versionMetadataUrl ?? metadataUrl}`, -].join("\n")); - -console.log(`wrote beta release summary to ${summaryPath}`); +import "./summary-metadata.ts"; diff --git a/.github/workflow/scripts/release/storage/summary-metadata.ts b/.github/workflow/scripts/release/storage/summary-metadata.ts new file mode 100644 index 0000000000..6e45654c4f --- /dev/null +++ b/.github/workflow/scripts/release/storage/summary-metadata.ts @@ -0,0 +1,40 @@ +import { optional, required, writeText } from "./common.ts"; + +const releaseChannel = required("RELEASE_CHANNEL"); +const metadataUrl = required("RELEASE_METADATA_URL"); +const summaryPath = required("RELEASE_SUMMARY_PATH"); +const cacheBuster = optional("RELEASE_CACHE_BUSTER", "local"); + +function versionFromMetadata(metadata: Record): string { + const field = + releaseChannel === "beta" ? "betaVersion" : + releaseChannel === "preview" ? "previewVersion" : + releaseChannel === "nightly" ? "nightlyVersion" : + "releaseVersion"; + const value = metadata[field]; + return typeof value === "string" ? value : ""; +} + +const response = await fetch(`${metadataUrl}${metadataUrl.includes("?") ? "&" : "?"}run=${cacheBuster}`, { + headers: { "Cache-Control": "no-cache" }, +}); +if (!response.ok) { + throw new Error(`metadata fetch failed with HTTP ${response.status}`); +} + +const metadata = await response.json() as { + readyTargets?: string[]; + releaseState?: string; + r2?: { versionMetadataUrl?: string }; +}; + +writeText(summaryPath, [ + `## ${releaseChannel[0]?.toUpperCase() ?? ""}${releaseChannel.slice(1)} release metadata`, + "", + `- version: \`${versionFromMetadata(metadata as Record)}\``, + `- state: \`${metadata.releaseState ?? ""}\``, + `- ready targets: \`${(metadata.readyTargets ?? []).join(", ")}\``, + `- metadata: ${metadata.r2?.versionMetadataUrl ?? metadataUrl}`, +].join("\n")); + +console.log(`wrote ${releaseChannel} release summary to ${summaryPath}`); diff --git a/.github/workflow/scripts/release/storage/verify-beta-metadata.ts b/.github/workflow/scripts/release/storage/verify-beta-metadata.ts index f3cc6d7a14..5677949926 100644 --- a/.github/workflow/scripts/release/storage/verify-beta-metadata.ts +++ b/.github/workflow/scripts/release/storage/verify-beta-metadata.ts @@ -1,32 +1 @@ -import { optional, required } from "./common.ts"; - -const metadataUrl = required("RELEASE_METADATA_URL"); -const releaseVersion = required("RELEASE_VERSION"); -const cacheBuster = optional("RELEASE_CACHE_BUSTER", "local"); - -const response = await fetch(`${metadataUrl}${metadataUrl.includes("?") ? "&" : "?"}run=${cacheBuster}`, { - headers: { "Cache-Control": "no-cache" }, -}); -if (!response.ok) { - throw new Error(`metadata fetch failed with HTTP ${response.status}`); -} - -const metadata = await response.json() as { - betaVersion?: string; - releaseState?: string; - releaseTargets?: Record; -}; - -if (metadata.betaVersion !== releaseVersion) { - throw new Error(`metadata betaVersion mismatch: expected ${releaseVersion}, got ${String(metadata.betaVersion)}`); -} -for (const target of ["mac_arm64", "win_x64", "mac_x64", "linux_x64"]) { - if (process.env[`ENABLE_${target.toUpperCase()}`] !== "true") continue; - const status = metadata.releaseTargets?.[target]?.status; - const result = optional(`${target.toUpperCase()}_RESULT`, "skipped"); - if (result === "success" && status !== "published") { - throw new Error(`metadata target ${target} is not published: ${String(status)}`); - } -} - -console.log(`verified beta metadata ${metadataUrl} (${metadata.releaseState ?? "unknown"})`); +import "./verify-metadata.ts"; diff --git a/.github/workflow/scripts/release/storage/verify-metadata.ts b/.github/workflow/scripts/release/storage/verify-metadata.ts new file mode 100644 index 0000000000..79efa57491 --- /dev/null +++ b/.github/workflow/scripts/release/storage/verify-metadata.ts @@ -0,0 +1,53 @@ +import { optional, required } from "./common.ts"; + +const releaseChannel = required("RELEASE_CHANNEL"); +const metadataUrl = required("RELEASE_METADATA_URL"); +const releaseVersion = required("RELEASE_VERSION"); +const cacheBuster = optional("RELEASE_CACHE_BUSTER", "local"); + +function expectedVersionField(channel: string): string { + if (channel === "beta") return "betaVersion"; + if (channel === "preview") return "previewVersion"; + if (channel === "nightly") return "nightlyVersion"; + if (channel === "stable") return "releaseVersion"; + throw new Error(`unsupported RELEASE_CHANNEL: ${channel}`); +} + +const response = await fetch(`${metadataUrl}${metadataUrl.includes("?") ? "&" : "?"}run=${cacheBuster}`, { + headers: { "Cache-Control": "no-cache" }, +}); +if (!response.ok) { + throw new Error(`metadata fetch failed with HTTP ${response.status}`); +} + +const metadata = await response.json() as { + channel?: string; + releaseState?: string; + releaseTargets?: Record; status?: string }>; + [key: string]: unknown; +}; + +if (metadata.channel !== releaseChannel) { + throw new Error(`metadata channel mismatch: expected ${releaseChannel}, got ${String(metadata.channel)}`); +} + +const versionField = expectedVersionField(releaseChannel); +if (metadata[versionField] !== releaseVersion) { + throw new Error(`metadata ${versionField} mismatch: expected ${releaseVersion}, got ${String(metadata[versionField])}`); +} + +for (const target of ["mac_arm64", "win_x64", "mac_x64", "linux_x64"]) { + if (process.env[`ENABLE_${target.toUpperCase()}`] !== "true") continue; + const targetMetadata = metadata.releaseTargets?.[target]; + const status = targetMetadata?.status; + const result = optional(`${target.toUpperCase()}_RESULT`, "skipped"); + if (result === "success" && status !== "published") { + throw new Error(`metadata target ${target} is not published: ${String(status)}`); + } + if (result !== "success" || targetMetadata == null) continue; + if ((target === "mac_arm64" || target === "win_x64") && targetMetadata.artifacts?.payload?.url == null) { + throw new Error(`metadata target ${target} is missing launcher payload artifact`); + } +} + +console.log(`verified ${releaseChannel} metadata ${metadataUrl} (${metadata.releaseState ?? "unknown"})`); diff --git a/.github/workflows/release-beta-s.yml b/.github/workflows/release-beta-s.yml index 192df95045..18344d07d3 100644 --- a/.github/workflows/release-beta-s.yml +++ b/.github/workflows/release-beta-s.yml @@ -50,8 +50,16 @@ on: required: false type: string default: "" + mac_arm64_delivery_mode: + description: "macOS arm64 delivery policy. Internal updater signs payloads without notarization; public notarized validates first-install DMGs." + required: true + type: choice + options: + - "internal-updater" + - "public-notarized" + default: "internal-updater" mac_arm64_sign_mode: - description: "macOS arm64 signing mode." + description: "macOS arm64 signing mode used when delivery mode is public-notarized." required: true type: choice options: @@ -196,6 +204,10 @@ on: required: false type: string default: "" + mac_arm64_delivery_mode: + required: false + type: string + default: internal-updater mac_arm64_sign_mode: required: false type: string @@ -418,6 +430,17 @@ jobs: echo "release-beta-s publish requires win_x64_target=nsis or all so the installer and updater feed are produced" >&2 exit 1 fi + case "${{ inputs.mac_arm64_delivery_mode }}" in + internal-updater | public-notarized) ;; + *) + echo "unsupported mac_arm64_delivery_mode: ${{ inputs.mac_arm64_delivery_mode }}" >&2 + exit 1 + ;; + esac + if [ "${{ inputs.mac_arm64_delivery_mode }}" = "public-notarized" ] && [ "${{ inputs.mac_arm64_sign_mode }}" != "notarize" ]; then + echo "public-notarized mac_arm64_delivery_mode requires mac_arm64_sign_mode=notarize" >&2 + exit 1 + fi - name: Capture previous beta commit id: prev @@ -454,6 +477,7 @@ jobs: env: BASE_VERSION: ${{ steps.beta.outputs.base_version }} RELEASE_CHANNEL: beta + RELEASE_COMMIT: ${{ steps.checkout.outputs.commit }} RELEASE_LANE: release-beta-s RELEASE_METADATA_DIR: ${{ runner.temp }}/release-version-reservation RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin }} @@ -534,13 +558,15 @@ jobs: BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json BUILD_LOG_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.log CSC_KEYCHAIN: /Library/Keychains/open-design-release-signing.keychain + OD_UPDATE_METADATA_URL: ${{ inputs.release_public_origin }}/beta/latest/metadata.json OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL: ${{ inputs.mac_arm64_update_metadata_url }} OD_PACKAGED_E2E_MAC_UPDATE_VERSION: ${{ inputs.mac_arm64_update_target_version }} RELEASE_BUILD_TARGET: ${{ inputs.mac_arm64_target }} + RELEASE_DELIVERY_MODE: ${{ inputs.mac_arm64_delivery_mode }} RELEASE_NAMESPACE: release-beta RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-build/mac_arm64/outputs.json RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64 - RELEASE_SIGN_MODE: ${{ inputs.mac_arm64_sign_mode }} + RELEASE_SIGN_MODE: ${{ inputs.mac_arm64_delivery_mode == 'internal-updater' && 'sign-only' || inputs.mac_arm64_sign_mode }} RELEASE_SMOKE_MODE: ${{ inputs.mac_arm64_smoke_mode }} RELEASE_TARGET: mac_arm64 RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} @@ -556,7 +582,7 @@ jobs: # shellcheck disable=SC1090 source "$OPEN_DESIGN_RELEASE_PROFILE" export PATH="/usr/local/libexec/open-design/wrappers:$PATH" - if [ "${{ inputs.mac_arm64_sign_mode }}" != "no" ] && [ -n "${OPEN_DESIGN_MAC_SIGNING_HELPER:-}" ]; then + if [ "${{ inputs.mac_arm64_delivery_mode == 'internal-updater' && 'sign-only' || inputs.mac_arm64_sign_mode }}" != "no" ] && [ -n "${OPEN_DESIGN_MAC_SIGNING_HELPER:-}" ]; then cert_path="${{ runner.temp }}/open-design-signing.p12" password_path="${{ runner.temp }}/open-design-signing.p12.password" if ! printf '%s' "$APPLE_SIGNING_CERTIFICATE_BASE64" | base64 --decode > "$cert_path" 2>/dev/null; then @@ -579,6 +605,7 @@ jobs: BUILD_LOG_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.log RELEASE_BUILD_TARGET: ${{ inputs.mac_arm64_target }} RELEASE_CHANNEL: beta + RELEASE_DELIVERY_MODE: ${{ inputs.mac_arm64_delivery_mode }} RELEASE_NAMESPACE: release-beta RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac_arm64 RELEASE_REPORT_JSON_PATH: ${{ runner.temp }}/release-report/mac_arm64/report.json @@ -605,11 +632,11 @@ jobs: working-directory: _release-build env: RELEASE_ARTIFACT_MODE: dmg-and-payload - RELEASE_ASSET_SUFFIX: ${{ inputs.mac_arm64_sign_mode != 'no' && '.signed' || '.unsigned' }} + RELEASE_ASSET_SUFFIX: ${{ (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && '.signed' || '.unsigned' }} RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_arm64 RELEASE_CHANNEL: beta RELEASE_NAMESPACE: release-beta - RELEASE_NOTES: Open Design beta ${{ needs.metadata.outputs.beta_version }}${{ inputs.mac_arm64_sign_mode != 'no' && '.signed' || '.unsigned' }} + RELEASE_NOTES: Open Design beta ${{ needs.metadata.outputs.beta_version }}${{ (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && '.signed' || '.unsigned' }} ${{ inputs.mac_arm64_delivery_mode }} RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin }} RELEASE_TARGET: mac_arm64 RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} @@ -620,7 +647,7 @@ jobs: if: ${{ inputs.publish }} working-directory: _release-build env: - RELEASE_ASSET_SUFFIX: ${{ inputs.mac_arm64_sign_mode != 'no' && '.signed' || '.unsigned' }} + RELEASE_ASSET_SUFFIX: ${{ (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && '.signed' || '.unsigned' }} RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_arm64 RELEASE_ARTIFACT_MODE: dmg-and-payload RELEASE_CHANNEL: beta @@ -629,7 +656,7 @@ jobs: RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_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: ${{ inputs.mac_arm64_sign_mode != 'no' && 'true' || 'false' }} + RELEASE_SIGNED: ${{ (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && 'true' || 'false' }} RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.NEXU_S3_ACCESS_KEY_ID }} RELEASE_STORAGE_BUCKET: ${{ secrets.NEXU_S3_BUCKET }} RELEASE_STORAGE_ENDPOINT: ${{ secrets.NEXU_S3_ENDPOINT }} @@ -814,7 +841,7 @@ jobs: - name: Download mac_arm64 platform manifest if: ${{ inputs.enable_mac_arm64 && needs.build_mac_arm64.result == 'success' }} env: - RELEASE_ASSET_SUFFIX: ${{ inputs.mac_arm64_sign_mode != 'no' && '.signed' || '.unsigned' }} + RELEASE_ASSET_SUFFIX: ${{ (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && '.signed' || '.unsigned' }} RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin }} RELEASE_TARGET: mac_arm64 @@ -884,7 +911,7 @@ jobs: RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin }} - RELEASE_SIGNED: "false" + RELEASE_SIGNED: ${{ inputs.enable_mac_arm64 && (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && 'true' || 'false' }} RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.NEXU_S3_ACCESS_KEY_ID }} RELEASE_STORAGE_BUCKET: ${{ secrets.NEXU_S3_BUCKET }} RELEASE_STORAGE_ENDPOINT: ${{ secrets.NEXU_S3_ENDPOINT }} @@ -903,7 +930,7 @@ jobs: fi # shellcheck disable=SC1090 source "$OPEN_DESIGN_RELEASE_PROFILE" - fnm exec --using=24 -- node --experimental-strip-types .github/workflow/scripts/release/storage/publish-beta-metadata.ts + fnm exec --using=24 -- node --experimental-strip-types .github/workflow/scripts/release/storage/publish-metadata.ts - name: Read metadata outputs id: outputs @@ -921,6 +948,7 @@ jobs: ENABLE_MAC_X64: "false" ENABLE_WIN_X64: ${{ inputs.enable_win_x64 }} MAC_ARM64_RESULT: ${{ needs.build_mac_arm64.result }} + RELEASE_CHANNEL: beta RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} WIN_X64_RESULT: ${{ needs.build_win_x64.result }} @@ -932,10 +960,11 @@ jobs: fi # shellcheck disable=SC1090 source "$OPEN_DESIGN_RELEASE_PROFILE" - fnm exec --using=24 -- node --experimental-strip-types .github/workflow/scripts/release/storage/verify-beta-metadata.ts + fnm exec --using=24 -- node --experimental-strip-types .github/workflow/scripts/release/storage/verify-metadata.ts - name: Write beta metadata summary env: + RELEASE_CHANNEL: beta RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_SUMMARY_PATH: ${{ runner.temp }}/release-metadata/summary.md working-directory: _release-scripts @@ -946,7 +975,7 @@ jobs: fi # shellcheck disable=SC1090 source "$OPEN_DESIGN_RELEASE_PROFILE" - fnm exec --using=24 -- node --experimental-strip-types .github/workflow/scripts/release/storage/summary-beta.ts + fnm exec --using=24 -- node --experimental-strip-types .github/workflow/scripts/release/storage/summary-metadata.ts - name: Publish summary run: cat "${{ runner.temp }}/release-metadata/summary.md" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 1689e43db1..8d0b12edf2 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -956,7 +956,7 @@ jobs: RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} STATE_SOURCE: ${{ needs.metadata.outputs.state_source }} WIN_X64_RESULT: ${{ needs.build_win_x64.result }} - run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-beta-metadata.ts + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-metadata.ts - name: Read metadata outputs id: outputs @@ -976,16 +976,18 @@ jobs: LINUX_X64_RESULT: ${{ needs.build_linux_x64.result }} MAC_ARM64_RESULT: ${{ needs.build_mac_arm64.result }} MAC_X64_RESULT: ${{ needs.build_mac_x64.result }} + RELEASE_CHANNEL: beta RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} WIN_X64_RESULT: ${{ needs.build_win_x64.result }} - run: node --experimental-strip-types .github/workflow/scripts/release/storage/verify-beta-metadata.ts + run: node --experimental-strip-types .github/workflow/scripts/release/storage/verify-metadata.ts - name: Write beta metadata summary env: + RELEASE_CHANNEL: beta RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_SUMMARY_PATH: ${{ runner.temp }}/release-metadata/summary.md - run: node --experimental-strip-types .github/workflow/scripts/release/storage/summary-beta.ts + run: node --experimental-strip-types .github/workflow/scripts/release/storage/summary-metadata.ts - name: Publish summary run: cat "${{ runner.temp }}/release-metadata/summary.md" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index a931f22d2c..37d1ef1960 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -301,18 +301,45 @@ jobs: - name: Prepare mac preview assets env: - CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_arm64 RELEASE_CHANNEL: preview + RELEASE_NAMESPACE: release-preview RELEASE_NOTES: Open Design Preview ${{ needs.metadata.outputs.release_version }} + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_TARGET: mac_arm64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: release-preview - run: bash .github/scripts/release/assets/mac.sh + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: bash .github/workflow/scripts/release/prepare-platform-assets.sh - - name: Upload mac preview bundle + - name: Publish mac preview platform + env: + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_arm64 + RELEASE_CHANNEL: preview + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/mac_arm64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac + RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac-report.zip + RELEASE_SIGNED: "true" + 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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: mac_arm64 + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload mac preview publish manifest uses: actions/upload-artifact@v7 with: - name: open-design-preview-mac-release-assets - path: ${{ runner.temp }}/release-assets + name: open-design-preview-mac-arm64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests/mac_arm64.json + if-no-files-found: error retention-days: 1 build_mac_intel: @@ -374,16 +401,42 @@ jobs: - name: Prepare mac intel preview assets env: + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_x64 RELEASE_CHANNEL: preview + RELEASE_NAMESPACE: release-preview-intel + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_TARGET: mac_x64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: release-preview-intel - run: bash .github/scripts/release/assets/mac-intel.sh + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: bash .github/workflow/scripts/release/prepare-platform-assets.sh - - name: Upload mac intel preview bundle + - name: Publish mac intel preview platform + env: + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/mac_x64 + RELEASE_CHANNEL: preview + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/mac_x64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_SIGNED: "true" + 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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: mac_x64 + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload mac intel preview publish manifest uses: actions/upload-artifact@v7 with: - name: open-design-preview-mac-intel-release-assets - path: ${{ runner.temp }}/release-assets + name: open-design-preview-mac-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests/mac_x64.json + if-no-files-found: error retention-days: 1 build_win: @@ -557,19 +610,45 @@ jobs: - name: Prepare windows preview assets shell: pwsh - env: - CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} - RELEASE_CHANNEL: preview - RELEASE_NOTES: Open Design Preview ${{ needs.metadata.outputs.release_version }} - RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: release-preview-win - run: ./.github/scripts/release/assets/win.ps1 + run: | + .\.github\workflow\scripts\release\prepare-platform-assets.ps1 ` + -ReleaseTarget win_x64 ` + -ReleaseAssetsDir "${{ runner.temp }}\release-assets\win_x64" ` + -BuildJsonPath "${{ runner.temp }}\windows-tools-pack-build.json" ` + -ReleaseNamespace release-preview-win ` + -ReleaseVersion "${{ needs.metadata.outputs.release_version }}" ` + -ReleaseAssetSuffix "" ` + -ReleaseChannel preview ` + -ReleasePublicOrigin "${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}" ` + -ReleaseNotes "Open Design Preview ${{ needs.metadata.outputs.release_version }}" ` + -IncludeZip $true - - name: Upload windows preview bundle + - name: Publish windows preview platform + env: + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}\release-assets\win_x64 + RELEASE_CHANNEL: preview + RELEASE_MANIFEST_DIR: ${{ runner.temp }}\release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}\release-platform-outputs\win_x64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_REPORT_DIR: ${{ runner.temp }}\release-report\win + RELEASE_SIGNED: "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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: win_x64 + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + WIN_INCLUDE_ZIP: "true" + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload windows preview publish manifest uses: actions/upload-artifact@v7 with: - name: open-design-preview-win-release-assets - path: ${{ runner.temp }}/release-assets + name: open-design-preview-win-x64-publish-manifest + path: ${{ runner.temp }}\release-platform-manifests\win_x64.json + if-no-files-found: error retention-days: 1 build_linux: @@ -612,15 +691,40 @@ jobs: - name: Prepare linux preview assets env: + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/linux_x64 + RELEASE_CHANNEL: preview + RELEASE_NAMESPACE: release-preview-linux + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_TARGET: linux_x64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: release-preview-linux - run: bash .github/scripts/release/assets/linux.sh + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: bash .github/workflow/scripts/release/prepare-platform-assets.sh - - name: Upload linux preview bundle + - name: Publish linux preview platform + env: + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets/linux_x64 + RELEASE_CHANNEL: preview + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/linux_x64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_SIGNED: "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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: linux_x64 + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload linux preview publish manifest uses: actions/upload-artifact@v7 with: - name: open-design-preview-linux-release-assets - path: ${{ runner.temp }}/release-assets + name: open-design-preview-linux-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests/linux_x64.json + if-no-files-found: error retention-days: 1 publish: @@ -678,78 +782,91 @@ jobs: with: node-version: 24 - - name: Download mac preview bundle + - name: Download mac preview publish manifest uses: actions/download-artifact@v8 with: - name: open-design-preview-mac-release-assets - path: ${{ runner.temp }}/release-assets/mac + name: open-design-preview-mac-arm64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests - - name: Download mac intel preview bundle + - name: Download mac intel preview publish manifest uses: actions/download-artifact@v8 with: - name: open-design-preview-mac-intel-release-assets - path: ${{ runner.temp }}/release-assets/mac-intel + name: open-design-preview-mac-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests - - name: Download windows preview bundle + - name: Download windows preview publish manifest uses: actions/download-artifact@v8 with: - name: open-design-preview-win-release-assets - path: ${{ runner.temp }}/release-assets/win + name: open-design-preview-win-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests - - name: Download linux preview bundle + - name: Download linux preview publish manifest if: ${{ needs.build_linux.result == 'success' }} uses: actions/download-artifact@v8 with: - name: open-design-preview-linux-release-assets - path: ${{ runner.temp }}/release-assets/linux + name: open-design-preview-linux-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests - - name: Download mac e2e spec report - uses: actions/download-artifact@v8 - with: - name: open-design-preview-mac-e2e-report - path: ${{ runner.temp }}/release-report/mac - - - name: Download windows e2e spec report - uses: actions/download-artifact@v8 - with: - name: open-design-preview-win-e2e-report - path: ${{ runner.temp }}/release-report/win - - - name: Publish preview assets and metadata to R2 - id: r2 - run: bash .github/scripts/release/r2/publish.sh - - - name: Verify R2 preview publish + - name: Publish preview metadata env: - R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }} - R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }} - R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }} - R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }} - R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }} - R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }} - R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }} - R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} - R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} - R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} - R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} - run: bash .github/scripts/release/r2/verify.sh + BASE_VERSION: ${{ needs.metadata.outputs.base_version }} + ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }} + ENABLE_MAC_ARM64: "true" + ENABLE_MAC_X64: "true" + ENABLE_WIN_X64: "true" + LINUX_X64_RESULT: ${{ needs.build_linux.result }} + MAC_ARM64_RESULT: ${{ needs.build_mac.result }} + MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} + RELEASE_ASSET_SUFFIX: "" + RELEASE_CHANNEL: preview + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_SIGNED: "true" + 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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + STATE_SOURCE: ${{ needs.metadata.outputs.state_source }} + WIN_X64_RESULT: ${{ needs.build_win.result }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-metadata.ts + + - name: Read metadata outputs + id: outputs + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs"); + const outputs = JSON.parse(fs.readFileSync(`${process.env.RUNNER_TEMP}/release-metadata/outputs.json`, "utf8")); + for (const [key, value] of Object.entries(outputs)) core.setOutput(key, String(value)); + + - name: Verify preview metadata + env: + ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }} + ENABLE_MAC_ARM64: "true" + ENABLE_MAC_X64: "true" + ENABLE_WIN_X64: "true" + LINUX_X64_RESULT: ${{ needs.build_linux.result }} + MAC_ARM64_RESULT: ${{ needs.build_mac.result }} + MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} + RELEASE_CHANNEL: preview + RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + WIN_X64_RESULT: ${{ needs.build_win.result }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/verify-metadata.ts + + - name: Write preview metadata summary + env: + RELEASE_CHANNEL: preview + RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} + RELEASE_SUMMARY_PATH: ${{ runner.temp }}/release-metadata/summary.md + run: node --experimental-strip-types .github/workflow/scripts/release/storage/summary-metadata.ts - name: Publish summary - env: - R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }} - R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }} - R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }} - R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }} - R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }} - R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }} - R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }} - R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} - R2_VERSION_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }} - R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }} - R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} - R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} - R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} - run: bash .github/scripts/release/r2/summary.sh + run: cat "${{ runner.temp }}/release-metadata/summary.md" >> "$GITHUB_STEP_SUMMARY" - name: Cleanup workflow artifacts if: ${{ success() }} diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index b39b990aea..7c427ed61c 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -339,6 +339,7 @@ jobs: --require-vela-cli --json --signed + --notarize ) if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then printf '%s\n' "$build_output" | tee "$build_json_path" @@ -453,12 +454,46 @@ jobs: - name: Prepare mac release assets id: assets env: - CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_NAMESPACE: ${{ needs.metadata.outputs.namespace }} RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }} + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_TARGET: mac_arm64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.namespace }} - run: bash .github/scripts/release/assets/mac.sh + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: bash .github/workflow/scripts/release/prepare-platform-assets.sh + + - name: Publish mac platform + env: + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/mac_arm64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/mac + RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac-report.zip + RELEASE_SIGNED: "true" + 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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: mac_arm64 + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload mac publish manifest + uses: actions/upload-artifact@v7 + with: + name: open-design-release-mac-arm64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests/mac_arm64.json + if-no-files-found: error + retention-days: 1 - name: Upload mac release bundle uses: actions/upload-artifact@v7 @@ -524,15 +559,49 @@ jobs: --to all \ --require-vela-cli \ --json \ - --signed + --signed \ + --notarize - name: Prepare mac intel release assets id: assets env: + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_NAMESPACE: ${{ needs.metadata.outputs.mac_intel_namespace }} + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_TARGET: mac_x64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.mac_intel_namespace }} - run: bash .github/scripts/release/assets/mac-intel.sh + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: bash .github/workflow/scripts/release/prepare-platform-assets.sh + + - name: Publish mac intel platform + env: + RELEASE_ARTIFACT_MODE: all + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/mac_x64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_SIGNED: "true" + 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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: mac_x64 + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload mac intel publish manifest + uses: actions/upload-artifact@v7 + with: + name: open-design-release-mac-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests/mac_x64.json + if-no-files-found: error + retention-days: 1 - name: Upload mac intel release bundle uses: actions/upload-artifact@v7 @@ -715,13 +784,46 @@ jobs: - name: Prepare windows release assets shell: pwsh + run: | + .\.github\workflow\scripts\release\prepare-platform-assets.ps1 ` + -ReleaseTarget win_x64 ` + -ReleaseAssetsDir "${{ runner.temp }}\release-assets" ` + -BuildJsonPath "${{ runner.temp }}\windows-tools-pack-build.json" ` + -ReleaseNamespace "${{ needs.metadata.outputs.win_namespace }}" ` + -ReleaseVersion "${{ needs.metadata.outputs.release_version }}" ` + -ReleaseAssetSuffix "" ` + -ReleaseChannel "${{ needs.metadata.outputs.channel }}" ` + -ReleasePublicOrigin "${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}" ` + -ReleaseNotes "Open Design ${{ needs.metadata.outputs.release_version }}" ` + -IncludeZip $true + + - name: Publish windows platform env: - CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}\release-assets RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} - RELEASE_NOTES: Open Design ${{ needs.metadata.outputs.release_version }} + RELEASE_MANIFEST_DIR: ${{ runner.temp }}\release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}\release-platform-outputs\win_x64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_REPORT_DIR: ${{ runner.temp }}\release-report\win + RELEASE_SIGNED: "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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: win_x64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }} - run: ./.github/scripts/release/assets/win.ps1 + WIN_INCLUDE_ZIP: "true" + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload windows publish manifest + uses: actions/upload-artifact@v7 + with: + name: open-design-release-win-x64-publish-manifest + path: ${{ runner.temp }}\release-platform-manifests\win_x64.json + if-no-files-found: error + retention-days: 1 - name: Upload windows release bundle uses: actions/upload-artifact@v7 @@ -831,9 +933,42 @@ jobs: - name: Prepare linux release assets env: + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }} + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_TARGET: linux_x64 RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} - TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.linux_namespace }} - run: bash .github/scripts/release/assets/linux.sh + TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: bash .github/workflow/scripts/release/prepare-platform-assets.sh + + - name: Publish linux platform + env: + RELEASE_ASSET_SUFFIX: "" + RELEASE_ASSETS_DIR: ${{ runner.temp }}/release-assets + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-platform-outputs/linux_x64.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_REPORT_DIR: ${{ runner.temp }}/release-report/linux + RELEASE_SIGNED: "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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_TARGET: linux_x64 + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-platform.ts + + - name: Upload linux publish manifest + uses: actions/upload-artifact@v7 + with: + name: open-design-release-linux-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests/linux_x64.json + if-no-files-found: error + retention-days: 1 - name: Upload linux release bundle uses: actions/upload-artifact@v7 @@ -865,11 +1000,11 @@ jobs: }} runs-on: ubuntu-latest outputs: - version_metadata_url: ${{ steps.r2.outputs.version_metadata_url }} - mac_arm64_url: ${{ steps.r2.outputs.mac_dmg_url }} - mac_intel_url: ${{ steps.r2.outputs.mac_intel_dmg_url }} - win_url: ${{ steps.r2.outputs.win_installer_url }} - linux_url: ${{ steps.r2.outputs.linux_appimage_url }} + version_metadata_url: ${{ steps.outputs.outputs.version_metadata_url }} + mac_arm64_url: ${{ steps.outputs.outputs.mac_arm64_dmg_url }} + mac_intel_url: ${{ steps.outputs.outputs.mac_x64_dmg_url }} + win_url: ${{ steps.outputs.outputs.win_x64_installer_url }} + linux_url: ${{ steps.outputs.outputs.linux_x64_appImage_url }} env: GH_TOKEN: ${{ github.token }} # Record the real built commit in metadata.json. Under workflow_call the @@ -911,6 +1046,31 @@ jobs: with: node-version: 24 + - name: Download mac publish manifest + uses: actions/download-artifact@v8 + with: + name: open-design-release-mac-arm64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests + + - name: Download mac intel publish manifest + uses: actions/download-artifact@v8 + with: + name: open-design-release-mac-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests + + - name: Download windows publish manifest + uses: actions/download-artifact@v8 + with: + name: open-design-release-win-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests + + - name: Download linux publish manifest + if: ${{ needs.build_linux.result == 'success' }} + uses: actions/download-artifact@v8 + with: + name: open-design-release-linux-x64-publish-manifest + path: ${{ runner.temp }}/release-platform-manifests + - name: Pre-flight tag/release check if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }} run: | @@ -1000,24 +1160,58 @@ jobs: fi gh release upload "$VERSION_TAG" "$all_release_dir"/* - - name: Publish release assets and metadata to R2 - id: r2 - run: bash .github/scripts/release/r2/publish.sh - - - name: Verify R2 release publish + - name: Publish release metadata to R2 env: - R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }} - R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }} - R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }} - R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }} - R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }} - R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }} - R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }} - R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} - R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} - R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} - R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} - run: bash .github/scripts/release/r2/verify.sh + BASE_VERSION: ${{ needs.metadata.outputs.base_version }} + ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }} + ENABLE_MAC_ARM64: "true" + ENABLE_MAC_X64: "true" + ENABLE_WIN_X64: "true" + LINUX_X64_RESULT: ${{ needs.build_linux.result }} + MAC_ARM64_RESULT: ${{ needs.build_mac.result }} + MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} + RELEASE_ASSET_SUFFIX: "" + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests + RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata + RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_SIGNED: "true" + 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 }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + STABLE_VERSION: ${{ needs.metadata.outputs.stable_version }} + STATE_SOURCE: ${{ needs.metadata.outputs.state_source }} + VERSION_TAG: ${{ needs.metadata.outputs.version_tag }} + WIN_X64_RESULT: ${{ needs.build_win.result }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/publish-metadata.ts + + - name: Read metadata outputs + id: outputs + uses: actions/github-script@v8 + with: + script: | + const fs = require("fs"); + const outputs = JSON.parse(fs.readFileSync(`${process.env.RUNNER_TEMP}/release-metadata/outputs.json`, "utf8")); + for (const [key, value] of Object.entries(outputs)) core.setOutput(key, String(value)); + + - name: Verify release metadata + env: + ENABLE_LINUX_X64: ${{ needs.build_linux.result == 'success' }} + ENABLE_MAC_ARM64: "true" + ENABLE_MAC_X64: "true" + ENABLE_WIN_X64: "true" + LINUX_X64_RESULT: ${{ needs.build_linux.result }} + MAC_ARM64_RESULT: ${{ needs.build_mac.result }} + MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + WIN_X64_RESULT: ${{ needs.build_win.result }} + run: node --experimental-strip-types .github/workflow/scripts/release/storage/verify-metadata.ts - name: Promote draft to published latest if: ${{ needs.metadata.outputs.github_release_enabled == 'true' }} @@ -1038,20 +1232,12 @@ jobs: - name: Publish summary env: - R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }} - R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }} - R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }} - R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }} - R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }} - R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }} - R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }} - R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }} - R2_VERSION_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }} - R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }} - R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }} - R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }} - R2_WIN_PORTABLE_ZIP_URL: ${{ steps.r2.outputs.win_portable_zip_url }} - run: bash .github/scripts/release/r2/summary.sh + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} + RELEASE_SUMMARY_PATH: ${{ runner.temp }}/release-metadata/summary.md + run: | + node --experimental-strip-types .github/workflow/scripts/release/storage/summary-metadata.ts + cat "${{ runner.temp }}/release-metadata/summary.md" >> "$GITHUB_STEP_SUMMARY" - name: Cleanup workflow artifacts if: ${{ success() }} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 306231917d..f872113e6d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -251,6 +251,17 @@ export function createAmrEnvironmentProfileMenuItems( ]; } +export function resolveAboutPanelVersion(options: DesktopMainOptions): string | null { + const version = options.update?.currentVersion?.trim(); + return version == null || version.length === 0 ? null : version; +} + +function configureAboutPanel(options: DesktopMainOptions): void { + const version = resolveAboutPanelVersion(options); + if (version == null) return; + app.setAboutPanelOptions({ version }); +} + function appConfigUrl(baseUrl: string): string { return new URL("/api/app-config", baseUrl).toString(); } @@ -550,6 +561,7 @@ export async function runDesktopMain( const osLocale = applyOsLocaleSwitch(app); await app.whenReady(); + configureAboutPanel(options); // PR #974: mint a per-process auth secret and hand it to the daemon // BEFORE the BrowserWindow loads. The daemon uses it to verify the diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 611965f125..0d9499d08b 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -103,6 +103,7 @@ const WINDOWS_DEFERRED_INSTALLER_TIMEOUT_MS = 10 * 60 * 1000; const ARTIFACT_DOWNLOAD_MAX_ATTEMPTS = 3; const DESKTOP_UPDATE_CHANNEL_VALUES = new Set(Object.values(DESKTOP_UPDATE_CHANNELS)); const execFileAsync = promisify(execFile); +const MAC_PAYLOAD_XATTRS_TO_SCRUB = ["com.apple.quarantine", "com.apple.provenance", "com.apple.macl"] as const; export type DesktopUpdaterConfigInput = { appVersion?: string | null; @@ -1225,6 +1226,9 @@ async function defaultExtractLauncherPayloadArchive(input: LauncherPayloadExtrac await mkdir(input.destinationRoot, { recursive: true }); if (input.platform === "darwin") { await execFileAsync("ditto", ["-x", "-k", input.archivePath, input.destinationRoot]); + for (const attribute of MAC_PAYLOAD_XATTRS_TO_SCRUB) { + await execFileAsync("xattr", ["-dr", attribute, input.destinationRoot]).catch(() => undefined); + } return; } if (input.platform === "win32") { @@ -2282,17 +2286,27 @@ export function createDesktopUpdater( let state: DesktopUpdateState = DESKTOP_UPDATE_STATES.IDLE; let error: DesktopUpdateErrorSnapshot | undefined; let operation: Promise = Promise.resolve(); + const sessionId = `${now().toISOString()}-${processPid}`; function logUpdateEvent(event: string, fields: Record = {}): void { logger.info?.("[open-design updater] lifecycle", { currentVersion: config.currentVersion, event, mode: config.mode, + namespace: config.namespace, platform: config.platform, + sessionId, + source: config.source, ...fields, }); } + logUpdateEvent("session-start", { + autoCheck: config.autoCheck, + enabled: config.enabled, + metadataUrl: config.metadataUrl, + }); + function supported(): boolean { return config.enabled && config.mode === DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER && isSupportedPackageLauncherPlatform(config.platform); } diff --git a/apps/desktop/tests/main/amr-environment-profile-menu.test.ts b/apps/desktop/tests/main/amr-environment-profile-menu.test.ts index 6fed0efa11..ac10e0b766 100644 --- a/apps/desktop/tests/main/amr-environment-profile-menu.test.ts +++ b/apps/desktop/tests/main/amr-environment-profile-menu.test.ts @@ -4,6 +4,7 @@ import { createAmrEnvironmentProfileMenuItems, mergeAmrEnvironmentProfileConfig, normalizeAmrEnvironmentProfile, + resolveAboutPanelVersion, } from "../../src/main/index.js"; describe("AMR Environment Profile desktop menu helpers", () => { @@ -162,4 +163,11 @@ describe("AMR Environment Profile desktop menu helpers", () => { localItem?.click?.(undefined as never, undefined as never, undefined as never); expect(selected).toEqual(["local"]); }); + + it("uses the active packaged runtime version for the native About panel", () => { + expect(resolveAboutPanelVersion({ update: { currentVersion: "0.10.0-beta.24" } })).toBe("0.10.0-beta.24"); + expect(resolveAboutPanelVersion({ update: { currentVersion: " 0.10.0-beta.24 " } })).toBe("0.10.0-beta.24"); + expect(resolveAboutPanelVersion({ update: { currentVersion: "" } })).toBeNull(); + expect(resolveAboutPanelVersion({})).toBeNull(); + }); }); diff --git a/apps/desktop/tests/main/updater.test.ts b/apps/desktop/tests/main/updater.test.ts index 8162690dcd..247ba8032c 100644 --- a/apps/desktop/tests/main/updater.test.ts +++ b/apps/desktop/tests/main/updater.test.ts @@ -299,6 +299,49 @@ describe("desktop updater", () => { } }); + it("adds session and source context to lifecycle logs", async () => { + const root = makeRoot(); + const fixture = await createUpdaterFixture(); + const logger = { error: vi.fn(), info: vi.fn(), warn: vi.fn() }; + try { + const updater = createDesktopUpdater( + { + arch: "arm64", + downloadRoot: root, + env: updaterEnv(fixture.metadataUrl), + namespace: "release-beta", + source: SIDECAR_SOURCES.PACKAGED, + }, + { + logger, + now: () => new Date("2026-06-09T07:50:51.000Z"), + processPid: 12345, + }, + ); + + await updater.checkForUpdates({ autoDownload: false }); + + expect(logger.info).toHaveBeenCalledWith("[open-design updater] lifecycle", expect.objectContaining({ + enabled: true, + event: "session-start", + metadataUrl: fixture.metadataUrl, + namespace: "release-beta", + sessionId: "2026-06-09T07:50:51.000Z-12345", + source: SIDECAR_SOURCES.PACKAGED, + })); + expect(logger.info).toHaveBeenCalledWith("[open-design updater] lifecycle", expect.objectContaining({ + event: "check-start", + metadataUrl: fixture.metadataUrl, + namespace: "release-beta", + sessionId: "2026-06-09T07:50:51.000Z-12345", + source: SIDECAR_SOURCES.PACKAGED, + })); + } finally { + await fixture.close(); + rmSync(root, { force: true, recursive: true }); + } + }); + it("downloads, verifies, persists, and dry-runs opening a mac package", async () => { const root = makeRoot(); const fixture = await createUpdaterFixture(); diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index 5033944281..34e1239a21 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -18,14 +18,14 @@ const releaseBetaSelfHostedWorkflowPath = join(workspaceRoot, ".github", "workfl const releasePreviewWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-preview.yml"); const releaseStableWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-stable.yml"); const releaseStableScriptPath = join(workspaceRoot, "scripts", "release-stable.ts"); -const releasePublishBetaMetadataScriptPath = join( +const releasePublishMetadataScriptPath = join( workspaceRoot, ".github", "workflow", "scripts", "release", "storage", - "publish-beta-metadata.ts", + "publish-metadata.ts", ); const releaseBetaPosixBuildScriptPath = join(workspaceRoot, ".github", "workflow", "scripts", "release", "build-platform.sh"); const releaseBetaWindowsBuildScriptPath = join(workspaceRoot, ".github", "workflow", "scripts", "release", "build-platform.ps1"); @@ -176,15 +176,12 @@ describe("packaged smoke workflow", () => { expect(releaseStableWorkflow).toContain("linux_namespace: ${{ steps.stable.outputs.linux_namespace }}"); expect(releaseStableWorkflow).toContain('--namespace "${{ needs.metadata.outputs.namespace }}"'); expect(releaseStableWorkflow).toContain("OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.namespace }}"); - expect(releaseStableWorkflow).toContain("TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.namespace }}"); expect(releaseStableWorkflow).toContain('"--namespace", "${{ needs.metadata.outputs.win_namespace }}",'); expect(releaseStableWorkflow).toContain('OD_PACKAGED_E2E_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }}'); - expect(releaseStableWorkflow).toContain('TOOLS_PACK_NAMESPACE: ${{ needs.metadata.outputs.win_namespace }}'); expect(releaseStableWorkflow).toContain('--namespace "${{ needs.metadata.outputs.linux_namespace }}"'); expect(releaseStableWorkflow).toContain('"namespace": "${{ needs.metadata.outputs.linux_namespace }}",'); expect(releaseStableWorkflow).not.toMatch(/--namespace release-stable(?:-intel|-win|-linux)?\b/); expect(releaseStableWorkflow).not.toMatch(/OD_PACKAGED_E2E_NAMESPACE: release-stable(?:-win|-linux)?\b/); - expect(releaseStableWorkflow).not.toMatch(/TOOLS_PACK_NAMESPACE: release-stable(?:-intel|-win|-linux)?\b/); expect(releaseStableWorkflow).not.toMatch(/namespaces\/release-stable(?:-intel|-win|-linux)?\b/); expectChannelWorkflowNamespaces(releasePreviewWorkflow, "preview", { hasLinuxSmoke: false }); @@ -313,17 +310,17 @@ describe("packaged smoke workflow", () => { }); it("keeps both beta release lanes on the shared payload-aware metadata surface", async () => { - const [releaseBetaWorkflow, releaseBetaSelfHostedWorkflow, platformPublishScript, publishBetaMetadataScript] = await Promise.all([ + const [releaseBetaWorkflow, releaseBetaSelfHostedWorkflow, platformPublishScript, publishMetadataScript] = await Promise.all([ readFile(releaseBetaWorkflowPath, "utf8"), readFile(releaseBetaSelfHostedWorkflowPath, "utf8"), readFile(releaseBetaPlatformPublishScriptPath, "utf8"), - readFile(releasePublishBetaMetadataScriptPath, "utf8"), + readFile(releasePublishMetadataScriptPath, "utf8"), ]); for (const workflow of [releaseBetaWorkflow, releaseBetaSelfHostedWorkflow]) { expect(workflow).toContain("RELEASE_ARTIFACT_MODE: dmg-and-payload"); expect(workflow).toContain(".github/workflow/scripts/release/storage/publish-platform.ts"); - expect(workflow).toContain(".github/workflow/scripts/release/storage/publish-beta-metadata.ts"); + expect(workflow).toContain(".github/workflow/scripts/release/storage/publish-metadata.ts"); expect(workflow).toContain("RELEASE_MANIFEST_DIR:"); } expect(releaseBetaWorkflow).toContain("RELEASE_ASSET_SUFFIX: ${{ needs.metadata.outputs.asset_version_suffix }}"); @@ -331,8 +328,8 @@ describe("packaged smoke workflow", () => { expect(platformPublishScript).toContain("artifacts.payload"); expect(platformPublishScript).toContain("open-design-${releaseVersion}${assetSuffix}-mac-${arch}-payload.zip"); expect(platformPublishScript).toContain("open-design-${releaseVersion}${assetSuffix}-win-x64-payload.7z"); - expect(publishBetaMetadataScript).toContain("for (const [artifactName, artifact] of Object.entries(manifest.artifacts ?? {}))"); - expect(publishBetaMetadataScript).toContain("outputs[`${target}_${artifactName}_url`] = artifact.url"); + expect(publishMetadataScript).toContain("for (const [artifactName, artifact] of Object.entries(manifest.artifacts ?? {}))"); + expect(publishMetadataScript).toContain("outputs[`${target}_${artifactName}_url`] = artifact.url"); }); it("publishes release-beta mac_x64 payloads while preserving the zip feed", async () => { @@ -347,12 +344,12 @@ describe("packaged smoke workflow", () => { }); it("keeps the self-hosted beta lane metadata-driven with reusable platform publish scripts", async () => { - const [workflow, posixBuildScript, windowsBuildScript, platformPublishScript, publishBetaMetadataScript] = await Promise.all([ + const [workflow, posixBuildScript, windowsBuildScript, platformPublishScript, publishMetadataScript] = await Promise.all([ readFile(releaseBetaSelfHostedWorkflowPath, "utf8"), readFile(releaseBetaPosixBuildScriptPath, "utf8"), readFile(releaseBetaWindowsBuildScriptPath, "utf8"), readFile(releaseBetaPlatformPublishScriptPath, "utf8"), - readFile(releasePublishBetaMetadataScriptPath, "utf8"), + readFile(releasePublishMetadataScriptPath, "utf8"), ]); expect(workflow).toContain("enable_win_x64:"); @@ -435,14 +432,14 @@ describe("packaged smoke workflow", () => { expect(workflow).toContain("fnm exec --using=24 -- node --experimental-strip-types .github/workflow/scripts/release/report/write-report.ts"); expect(workflow).toContain("Prepare mac_arm64 assets"); expect(workflow).toContain("RELEASE_TARGET: mac_arm64"); - expect(workflow).toContain("RELEASE_SIGNED: ${{ inputs.mac_arm64_sign_mode != 'no' && 'true' || 'false' }}"); + expect(workflow).toContain("RELEASE_SIGNED: ${{ (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && 'true' || 'false' }}"); expect(workflow).toContain("RELEASE_REPORT_ZIP_PATH: ${{ runner.temp }}/release-report/mac_arm64-report.zip"); expect(workflow).toContain("name: Publish beta metadata to Nexu S3"); expect(workflow).toContain("Download mac_arm64 platform manifest"); expect(workflow).toContain("Download win_x64 platform manifest"); expect(workflow).toContain('manifest_url="${RELEASE_PUBLIC_ORIGIN%/}/beta/versions/${RELEASE_VERSION}${RELEASE_ASSET_SUFFIX}/platforms/${RELEASE_TARGET}.json"'); expect(workflow).toContain('curl -fsSL "$manifest_url" -o "$RELEASE_MANIFEST_DIR/$RELEASE_TARGET.json"'); - expect(workflow).toContain(".github/workflow/scripts/release/storage/publish-beta-metadata.ts"); + expect(workflow).toContain(".github/workflow/scripts/release/storage/publish-metadata.ts"); expect(workflow).toContain("RELEASE_ASSET_SUFFIX: auto"); expect(workflow).toContain("RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests"); expect(workflow).toContain("-IncludeZip $${{ inputs.win_x64_target == 'all' || inputs.win_x64_target == 'zip' }}"); @@ -451,19 +448,19 @@ describe("packaged smoke workflow", () => { expect(workflow).not.toContain("open-design-beta-s-mac-arm64-publish-manifest"); expect(workflow).toContain('STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}'); expect(workflow).toContain("Verify beta metadata"); - expect(workflow).toContain(".github/workflow/scripts/release/storage/verify-beta-metadata.ts"); - expect(publishBetaMetadataScript).toContain("validateManifest"); - expect(publishBetaMetadataScript).toContain("manifest.releaseVersion !== releaseVersion"); - expect(publishBetaMetadataScript).toContain("manifest.github?.runId !== currentRunId"); - expect(publishBetaMetadataScript).not.toContain("manifest.github?.runAttempt !== currentRunAttempt"); - expect(publishBetaMetadataScript).toContain("manifest.github?.commit !== currentCommit"); - expect(publishBetaMetadataScript).toContain("manifest.platformKey !== target"); - expect(publishBetaMetadataScript).toContain("manifest.r2.versionPrefix.includes(`/versions/${releaseVersion}`)"); - expect(publishBetaMetadataScript).toContain('if (assetVersionSuffix === "auto")'); - expect(publishBetaMetadataScript).toContain('assetVersionSuffix = allReadyTargetsSigned ? ".signed" : ".unsigned";'); - expect(publishBetaMetadataScript).toContain("const feedVersionPrefix = manifest.r2?.versionPrefix;"); - expect(publishBetaMetadataScript).toContain("refusing stale ${def.target} platform manifest"); - expect(publishBetaMetadataScript).toContain("publishLatestPlatformObjects"); + expect(workflow).toContain(".github/workflow/scripts/release/storage/verify-metadata.ts"); + expect(publishMetadataScript).toContain("validateManifest"); + expect(publishMetadataScript).toContain("manifest.releaseVersion !== releaseVersion"); + expect(publishMetadataScript).toContain("manifest.github?.runId !== currentRunId"); + expect(publishMetadataScript).not.toContain("manifest.github?.runAttempt !== currentRunAttempt"); + expect(publishMetadataScript).toContain("manifest.github?.commit !== currentCommit"); + expect(publishMetadataScript).toContain("manifest.platformKey !== target"); + expect(publishMetadataScript).toContain("manifest.r2.versionPrefix.includes(`/versions/${releaseVersion}`)"); + expect(publishMetadataScript).toContain('if (assetVersionSuffix === "auto")'); + expect(publishMetadataScript).toContain('assetVersionSuffix = allReadyTargetsSigned ? ".signed" : ".unsigned";'); + expect(publishMetadataScript).toContain("const feedVersionPrefix = manifest.r2?.versionPrefix;"); + expect(publishMetadataScript).toContain("refusing stale ${def.target} platform manifest"); + expect(publishMetadataScript).toContain("publishLatestPlatformObjects"); expect(platformPublishScript).not.toContain("await upload(join(releaseAssetsDir, name), `${latestPrefix}/${name}`"); expect(platformPublishScript).not.toContain("await upload(manifestPath, `${latestPrefix}/platforms/${target}.json`"); expect(platformPublishScript).toContain('const target = requiredTarget();'); @@ -515,7 +512,7 @@ describe("packaged smoke workflow", () => { ); const result = await execFileAsync( process.execPath, - ["--experimental-strip-types", releasePublishBetaMetadataScriptPath], + ["--experimental-strip-types", releasePublishMetadataScriptPath], { cwd: workspaceRoot, env: { @@ -599,7 +596,7 @@ describe("packaged smoke workflow", () => { ); const result = await execFileAsync( process.execPath, - ["--experimental-strip-types", releasePublishBetaMetadataScriptPath], + ["--experimental-strip-types", releasePublishMetadataScriptPath], { cwd: workspaceRoot, env: { @@ -681,7 +678,7 @@ describe("packaged smoke workflow", () => { 2, )}\n`, ); - await execFileAsync(process.execPath, ["--experimental-strip-types", releasePublishBetaMetadataScriptPath], { + await execFileAsync(process.execPath, ["--experimental-strip-types", releasePublishMetadataScriptPath], { cwd: workspaceRoot, env: { ...process.env, @@ -766,7 +763,7 @@ describe("packaged smoke workflow", () => { )}\n`, ); - await execFileAsync(process.execPath, ["--experimental-strip-types", releasePublishBetaMetadataScriptPath], { + await execFileAsync(process.execPath, ["--experimental-strip-types", releasePublishMetadataScriptPath], { cwd: workspaceRoot, env: { ...process.env, @@ -896,7 +893,7 @@ describe("packaged smoke workflow", () => { )}\n`, ); - await execFileAsync(process.execPath, ["--experimental-strip-types", releasePublishBetaMetadataScriptPath], { + await execFileAsync(process.execPath, ["--experimental-strip-types", releasePublishMetadataScriptPath], { cwd: workspaceRoot, env: { ...process.env, @@ -991,14 +988,10 @@ function expectChannelWorkflowNamespaces( const namespace = `release-${channel}`; expect(workflow).toContain(`--namespace ${namespace}`); expect(workflow).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}`); - expect(workflow).toContain(`TOOLS_PACK_NAMESPACE: ${namespace}`); expect(workflow).toContain(`--namespace ${namespace}-intel`); - expect(workflow).toContain(`TOOLS_PACK_NAMESPACE: ${namespace}-intel`); expect(workflow).toContain(`"--namespace", "${namespace}-win",`); expect(workflow).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}-win`); - expect(workflow).toContain(`TOOLS_PACK_NAMESPACE: ${namespace}-win`); expect(workflow).toContain(`--namespace ${namespace}-linux`); - expect(workflow).toContain(`TOOLS_PACK_NAMESPACE: ${namespace}-linux`); if (options.hasLinuxSmoke) { expect(workflow).toContain(`OD_PACKAGED_E2E_NAMESPACE: ${namespace}-linux`); diff --git a/scripts/install-unsafe-dmg.sh b/scripts/install-unsafe-dmg.sh new file mode 100755 index 0000000000..0963596d76 --- /dev/null +++ b/scripts/install-unsafe-dmg.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/install-unsafe-dmg.sh [--force] [--name ] + +Internal helper for installing non-notarized macOS DMGs during beta-s validation. +It downloads with curl, mounts the DMG, copies the single app bundle to +/Applications, and removes quarantine/provenance xattrs from the installed app. + +Options: + --force Overwrite an existing ~/Downloads DMG and /Applications app. + --name DMG filename to use under ~/Downloads. Required when the URL + path does not end in a parseable .dmg filename. + -h, --help Show this help. +EOF +} + +fail() { + echo "install-unsafe-dmg: $*" >&2 + exit 1 +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + fail "this script only runs on macOS" +fi + +force=0 +dmg_name="" +url="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --force) + force=1 + shift + ;; + --name) + [[ $# -ge 2 ]] || fail "--name requires a value" + dmg_name="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --*) + fail "unknown option: $1" + ;; + *) + [[ -z "$url" ]] || fail "expected exactly one DMG URL" + url="$1" + shift + ;; + esac +done + +[[ -n "$url" ]] || { + usage >&2 + exit 1 +} + +if [[ -z "$dmg_name" ]]; then + dmg_name="$( + URL="$url" node --input-type=module <<'NODE' +const raw = process.env.URL ?? ""; +try { + const parsed = new URL(raw); + const parts = parsed.pathname.split("/").filter(Boolean); + const last = parts.at(-1); + process.stdout.write(last == null ? "" : decodeURIComponent(last)); +} catch { + process.stdout.write(""); +} +NODE + )" +fi + +[[ -n "$dmg_name" ]] || fail "could not infer DMG filename from URL; pass --name " +[[ "$dmg_name" != */* ]] || fail "--name must be a filename, not a path: $dmg_name" +[[ "$dmg_name" == *.dmg ]] || fail "DMG filename must end with .dmg: $dmg_name" + +downloads_dir="$HOME/Downloads" +download_path="$downloads_dir/$dmg_name" +partial_path="$download_path.partial.$$" +mount_dir="$(mktemp -d "${TMPDIR:-/tmp}/open-design-unsafe-dmg.XXXXXX")" +mounted=0 + +cleanup() { + local status=$? + if [[ "$mounted" -eq 1 ]]; then + hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || hdiutil detach "$mount_dir" -force -quiet >/dev/null 2>&1 || true + fi + rm -f "$partial_path" + rmdir "$mount_dir" >/dev/null 2>&1 || true + exit "$status" +} +trap cleanup EXIT + +mkdir -p "$downloads_dir" + +if [[ -e "$download_path" && "$force" -ne 1 ]]; then + fail "download target already exists: $download_path (pass --force to overwrite)" +fi + +echo "Downloading DMG:" +echo " url: $url" +echo " path: $download_path" +curl -fL --output "$partial_path" "$url" +mv -f "$partial_path" "$download_path" + +echo "Mounting DMG:" +echo " path: $download_path" +echo " mount: $mount_dir" +hdiutil attach "$download_path" -mountpoint "$mount_dir" -nobrowse -quiet +mounted=1 + +mapfile -t apps < <(find "$mount_dir" -maxdepth 1 -type d -name "*.app" -print | sort) +if [[ "${#apps[@]}" -eq 0 ]]; then + fail "no .app bundle found at DMG root" +fi +if [[ "${#apps[@]}" -gt 1 ]]; then + printf 'install-unsafe-dmg: multiple .app bundles found at DMG root:\n' >&2 + printf ' %s\n' "${apps[@]}" >&2 + fail "refusing to guess which app to install" +fi + +source_app="${apps[0]}" +app_name="$(basename "$source_app")" +target_app="/Applications/$app_name" + +if [[ -e "$target_app" && "$force" -ne 1 ]]; then + fail "target app already exists: $target_app (pass --force to overwrite)" +fi + +if [[ -e "$target_app" ]]; then + echo "Removing existing app:" + echo " path: $target_app" + rm -rf "$target_app" +fi + +echo "Installing app:" +echo " from: $source_app" +echo " to: $target_app" +ditto "$source_app" "$target_app" + +echo "Removing unsafe-install xattrs:" +echo " path: $target_app" +xattr -dr com.apple.quarantine "$target_app" 2>/dev/null || true +xattr -dr com.apple.provenance "$target_app" 2>/dev/null || true +xattr -dr com.apple.macl "$target_app" 2>/dev/null || true + +echo "Installed unsafe DMG app:" +echo " app: $target_app" diff --git a/tools/pack/resources/mac/notarize.cjs b/tools/pack/resources/mac/notarize.cjs index 22c1b5747e..ed21eb98d9 100644 --- a/tools/pack/resources/mac/notarize.cjs +++ b/tools/pack/resources/mac/notarize.cjs @@ -1,4 +1,127 @@ const path = require("node:path"); +const { mkdtemp, rm } = require("node:fs/promises"); +const { tmpdir } = require("node:os"); +const { spawn } = require("node:child_process"); + +const DEFAULT_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 15000; + +function parsePositiveInteger(value, fallback) { + if (value == null || value === "") { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + ...options, + stdio: ["ignore", "pipe", "pipe"], + }); + const chunks = []; + child.stdout.on("data", (chunk) => chunks.push(chunk)); + child.stderr.on("data", (chunk) => chunks.push(chunk)); + child.on("error", reject); + child.on("close", (code) => { + resolve({ + code, + output: Buffer.concat(chunks).toString("utf8"), + }); + }); + }); +} + +function isTransientNotaryError(error) { + const message = `${error?.message ?? ""}\n${error?.stack ?? ""}`; + return [ + "abortedUpload", + "deadlineExceeded", + "ECONNRESET", + "ETIMEDOUT", + "ENOTFOUND", + "EAI_AGAIN", + "socket hang up", + "network connection was lost", + ].some((marker) => message.includes(marker)); +} + +function authorizationArgs(credentials) { + if (credentials.keychainProfile) { + return [ + ...(credentials.keychain ? ["--keychain", credentials.keychain] : []), + "--keychain-profile", + credentials.keychainProfile, + ]; + } + + return [ + "--apple-id", + credentials.appleId, + "--password", + credentials.appleIdPassword, + "--team-id", + credentials.teamId, + ]; +} + +async function submitNotarization(filePath, credentials) { + const useS3Acceleration = process.env.OPEN_DESIGN_NOTARIZE_S3_ACCELERATION === "true"; + const result = await run("xcrun", [ + "notarytool", + "submit", + filePath, + ...authorizationArgs(credentials), + "--wait", + "--output-format", + "json", + useS3Acceleration ? "--s3-acceleration" : "--no-s3-acceleration", + ]); + const rawOutput = result.output.trim(); + let parsed; + try { + parsed = JSON.parse(rawOutput); + } catch { + throw new Error(`Failed to notarize via notarytool. Failed with unexpected result:\n\n${rawOutput}`); + } + + if (result.code === 0 && parsed.status === "Accepted") { + return parsed; + } + + throw new Error(`Failed to notarize via notarytool:\n\n${rawOutput}`); +} + +async function stapleApp(appPath) { + const result = await run("xcrun", ["stapler", "staple", appPath]); + if (result.code !== 0) { + throw new Error(`Failed to staple notarized app:\n\n${result.output.trim()}`); + } +} + +async function notarizeApp(appPath, credentials) { + const tempDir = await mkdtemp(path.join(tmpdir(), "open-design-notarize-")); + try { + const filePath = path.join(tempDir, `${path.parse(appPath).name}.zip`); + const zipResult = await run("ditto", ["-c", "-k", "--sequesterRsrc", "--keepParent", path.basename(appPath), filePath], { + cwd: path.dirname(appPath), + }); + if (zipResult.code !== 0) { + throw new Error(`Failed to zip app for notarization:\n\n${zipResult.output.trim()}`); + } + + await submitNotarization(filePath, credentials); + await stapleApp(appPath); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } +} module.exports = async function notarize(context) { if (context.electronPlatformName !== "darwin") { @@ -41,10 +164,23 @@ module.exports = async function notarize(context) { const productFilename = context.packager.appInfo.productFilename; const appPath = path.join(context.appOutDir, `${productFilename}.app`); - const { notarize } = await import("@electron/notarize"); + const attempts = parsePositiveInteger(process.env.OPEN_DESIGN_NOTARIZE_ATTEMPTS, DEFAULT_ATTEMPTS); + const retryDelayMs = parsePositiveInteger(process.env.OPEN_DESIGN_NOTARIZE_RETRY_DELAY_MS, DEFAULT_RETRY_DELAY_MS); - await notarize({ - appPath, - ...credentials, - }); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await notarizeApp(appPath, credentials); + return; + } catch (error) { + const canRetry = attempt < attempts && isTransientNotaryError(error); + if (!canRetry) { + throw error; + } + + console.warn( + `[tools-pack notarize] transient notarytool failure on attempt ${attempt}/${attempts}; retrying in ${retryDelayMs}ms`, + ); + await sleep(retryDelayMs); + } + } }; diff --git a/tools/pack/src/cache.ts b/tools/pack/src/cache.ts index 007a70e6b0..1f2eaf3ad3 100644 --- a/tools/pack/src/cache.ts +++ b/tools/pack/src/cache.ts @@ -251,6 +251,9 @@ export class ToolPackCache { if (manifest.schemaVersion !== CACHE_SCHEMA_VERSION || manifest.nodeId !== node.id || manifest.key !== alias.key) return false; const seedOutputs = manifest.outputs.map(normalizeRelativePath); if ((await assertOutputsExist(alias.entryPath, seedOutputs)) != null) return false; + for (const target of source.materialize) { + if ((await assertOutputsExist(alias.entryPath, [normalizeRelativePath(target.from)])) != null) return false; + } if ((await node.invalidate({ entryRoot: alias.entryPath, manifest })) != null) return false; for (const target of source.materialize) { diff --git a/tools/pack/src/linux.ts b/tools/pack/src/linux.ts index 8eaa27b1fd..3f61335d6c 100644 --- a/tools/pack/src/linux.ts +++ b/tools/pack/src/linux.ts @@ -394,6 +394,7 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); await runPnpm(config, ["--filter", "@open-design/registry-protocol", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); + await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]); await runPnpm(config, ["--filter", "@open-design/host", "build"]); diff --git a/tools/pack/src/mac/app.ts b/tools/pack/src/mac/app.ts index 8d8a13474c..78062f49b2 100644 --- a/tools/pack/src/mac/app.ts +++ b/tools/pack/src/mac/app.ts @@ -160,6 +160,7 @@ export function renderMacPackagedConfig(options: { : {}), namespace: options.config.namespace, ...(options.config.telemetryRelayUrl == null ? {} : { telemetryRelayUrl: options.config.telemetryRelayUrl }), + ...(options.config.updateMetadataUrl == null ? {} : { updateMetadataUrl: options.config.updateMetadataUrl }), ...(options.config.posthogKey == null ? {} : { posthogKey: options.config.posthogKey }), ...(options.config.posthogHost == null ? {} : { posthogHost: options.config.posthogHost }), ...(options.usePrebundledStandaloneWeb ? { webSidecarEntryRelative: MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH } : {}), diff --git a/tools/pack/src/mac/artifacts.ts b/tools/pack/src/mac/artifacts.ts index 5c0f55c9e6..4ab6117b68 100644 --- a/tools/pack/src/mac/artifacts.ts +++ b/tools/pack/src/mac/artifacts.ts @@ -4,7 +4,7 @@ import { basename, dirname, join } from "node:path"; import type { ToolPackConfig } from "../config.js"; import { PRODUCT_NAME } from "./constants.js"; -import { clearQuarantine, pathExists } from "./fs.js"; +import { pathExists, scrubMacExtendedAttributes } from "./fs.js"; import { readPackagedVersion } from "./manifest.js"; import { sanitizeNamespace } from "./paths.js"; import type { MacPackResult, MacPaths } from "./types.js"; @@ -20,7 +20,7 @@ async function moveBuilderArtifact(options: { await mkdir(dirname(options.destinationPath), { recursive: true }); await rm(options.destinationPath, { force: true, recursive: true }); await rename(options.sourcePath, options.destinationPath); - await clearQuarantine(options.destinationPath); + await scrubMacExtendedAttributes(options.destinationPath); return options.destinationPath; } diff --git a/tools/pack/src/mac/build.ts b/tools/pack/src/mac/build.ts index feef0508ca..57117b313d 100644 --- a/tools/pack/src/mac/build.ts +++ b/tools/pack/src/mac/build.ts @@ -4,7 +4,7 @@ import { collectWorkspaceTarballs, copyResourceTree, writeAssembledApp } from ". import { seedPackagedAppConfig } from "./app-config.js"; import { finalizeMacArtifacts } from "./artifacts.js"; import { resolveElectronBuilderTargets, runElectronBuilder } from "./builder.js"; -import { clearQuarantine } from "./fs.js"; +import { scrubMacExtendedAttributes } from "./fs.js"; import { createMacLauncherPayloadArchive } from "./payload.js"; import { resolveMacPaths } from "./paths.js"; import { collectMacSizeReport } from "./report.js"; @@ -58,8 +58,8 @@ export async function packMac(config: ToolPackConfig): Promise { await runPhase("electron-builder", async () => { await runElectronBuilder(config, paths, targets); }); - await runPhase("quarantine", async () => { - await clearQuarantine(paths.appPath); + await runPhase("xattr-scrub", async () => { + await scrubMacExtendedAttributes(paths.appPath); }); const payloadPath = await runPhase("payload-artifact", async () => createMacLauncherPayloadArchive(config, paths)); const artifacts = await runPhase("artifacts", async () => finalizeMacArtifacts(config, paths)); diff --git a/tools/pack/src/mac/builder.ts b/tools/pack/src/mac/builder.ts index e86e833ee6..83576f4b0e 100644 --- a/tools/pack/src/mac/builder.ts +++ b/tools/pack/src/mac/builder.ts @@ -126,7 +126,7 @@ export async function runElectronBuilder( hardenedRuntime: config.signed, icon: macResources.icon, identity: config.signed ? undefined : null, - notarize: false, + notarize: config.macNotarize ? undefined : false, target: targets, }, nodeGypRebuild: false, diff --git a/tools/pack/src/mac/fs.ts b/tools/pack/src/mac/fs.ts index e8332ed331..22d9455557 100644 --- a/tools/pack/src/mac/fs.ts +++ b/tools/pack/src/mac/fs.ts @@ -61,10 +61,14 @@ export async function sumChildDirectorySizes(path: string, includeChild: (name: } -export async function clearQuarantine(path: string): Promise { - try { - await execFileAsync("xattr", ["-dr", "com.apple.quarantine", path]); - } catch { - // Ignore when the attribute is absent or unsupported for local unsigned artifacts. +export const MAC_XATTRS_TO_SCRUB = ["com.apple.quarantine", "com.apple.provenance", "com.apple.macl"] as const; + +export async function scrubMacExtendedAttributes(path: string): Promise { + for (const attribute of MAC_XATTRS_TO_SCRUB) { + try { + await execFileAsync("xattr", ["-dr", attribute, path]); + } catch { + // Ignore when the attribute is absent, protected, or unsupported for local artifacts. + } } } diff --git a/tools/pack/src/mac/lifecycle.ts b/tools/pack/src/mac/lifecycle.ts index 9ac6569171..2094fb485d 100644 --- a/tools/pack/src/mac/lifecycle.ts +++ b/tools/pack/src/mac/lifecycle.ts @@ -31,7 +31,7 @@ import { readToolPackLauncherRuntimeSnapshot } from "../launcher-runtime-snapsho import { readToolPackUpdateCacheLifecycleSnapshot } from "../update-cache-lifecycle-snapshot.js"; import { PACKAGED_CONFIG_PATH_ENV, writeLaunchPackagedConfig } from "./app-config.js"; import { DESKTOP_LOG_ECHO_ENV } from "./constants.js"; -import { clearQuarantine, pathExists } from "./fs.js"; +import { pathExists, scrubMacExtendedAttributes } from "./fs.js"; import { resolveMacInstallIdentity } from "./identity.js"; import { desktopIdentityPath, desktopLogPath, macAppExecutablePath, resolveMacPaths } from "./paths.js"; import type { DesktopRootIdentityFallback, DesktopRootIdentityMarker, MacCleanupResult, MacInspectResult, MacInstallResult, MacStartResult, MacStartSource, MacStopResult, MacUninstallResult } from "./types.js"; @@ -321,10 +321,12 @@ async function collectLaunchXattrSummary(appPath: string): Promise { const lines = nonEmptyLines(result.stdout); const quarantine = lines.filter((line) => line.includes("com.apple.quarantine")); const provenance = lines.filter((line) => line.includes("com.apple.provenance")); - const matched = [...quarantine, ...provenance]; + const macl = lines.filter((line) => line.includes("com.apple.macl")); + const matched = [...quarantine, ...provenance, ...macl]; return [ `quarantine entries: ${quarantine.length}`, `provenance entries: ${provenance.length}`, + `macl entries: ${macl.length}`, ...(matched.length === 0 ? [] : tailLines(matched, 8).map((line) => truncateLine(line))), ]; } catch (error) { @@ -507,7 +509,7 @@ export async function installPackedMacDmg(config: ToolPackConfig): Promise { await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); await runPnpm(config, ["--filter", "@open-design/registry-protocol", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); + await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]); await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); diff --git a/tools/pack/src/win/app.ts b/tools/pack/src/win/app.ts index 86ea73cedc..4d55b9246e 100644 --- a/tools/pack/src/win/app.ts +++ b/tools/pack/src/win/app.ts @@ -120,6 +120,7 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); await runPnpm(config, ["--filter", "@open-design/registry-protocol", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); + await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"]); await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); await runPnpm(config, ["--filter", "@open-design/platform", "build"]); await runPnpm(config, ["--filter", "@open-design/agui-adapter", "build"]); diff --git a/tools/pack/src/win/lifecycle.ts b/tools/pack/src/win/lifecycle.ts index 7f53bc9171..be2b0d8e38 100644 --- a/tools/pack/src/win/lifecycle.ts +++ b/tools/pack/src/win/lifecycle.ts @@ -25,6 +25,7 @@ import { } from "@open-design/platform"; import type { ToolPackConfig } from "../config.js"; +import { resolveToolPackLauncherLayout } from "../launcher-layout.js"; import { readToolPackLauncherRuntimeSnapshot } from "../launcher-runtime-snapshot.js"; import { readToolPackUpdateCacheLifecycleSnapshot } from "../update-cache-lifecycle-snapshot.js"; import { DESKTOP_LOG_ECHO_ENV } from "./constants.js"; @@ -363,6 +364,7 @@ export async function uninstallPackedWinApp(config: ToolPackConfig): Promise { const paths = resolveWinPaths(config); + const launcher = resolveToolPackLauncherLayout(config); const registeredPaths = await resolveWinRegisteredPaths(config, paths); const removalPlan = await createWinRemovalPlan(config); if (await pathExists(registeredPaths.uninstallerPath)) { @@ -371,6 +373,7 @@ export async function cleanupPackedWinNamespace(config: ToolPackConfig): Promise const stop = await stopPackedWinApp(config); const removedOutputRoot = await pathExists(config.roots.output.namespaceRoot); const removedRuntimeNamespaceRoot = await pathExists(config.roots.runtime.namespaceRoot); + const removedLauncherNamespaceRoot = await pathExists(launcher.paths.namespaceRoot); const removedProductUserDataRoot = removalPlan.some((target) => target.scope === "product-user-data" && target.willRemove && target.exists); await cleanupWinRegistryResidues(registeredPaths, config); for (const target of removalPlan) { @@ -378,8 +381,10 @@ export async function cleanupPackedWinNamespace(config: ToolPackConfig): Promise } await removeTree(config.roots.output.namespaceRoot); await removeTree(config.roots.runtime.namespaceRoot); + await removeTree(launcher.paths.namespaceRoot); return { namespace: config.namespace, + removedLauncherNamespaceRoot, removedOutputRoot, removedProductUserDataRoot, removedRuntimeNamespaceRoot, diff --git a/tools/pack/src/win/types.ts b/tools/pack/src/win/types.ts index c820e2a79a..9910e21e69 100644 --- a/tools/pack/src/win/types.ts +++ b/tools/pack/src/win/types.ts @@ -249,6 +249,7 @@ export type WinUninstallResult = { export type WinCleanupResult = { namespace: string; + removedLauncherNamespaceRoot: boolean; removedOutputRoot: boolean; removedProductUserDataRoot: boolean; removedRuntimeNamespaceRoot: boolean; diff --git a/tools/pack/tests/cache.test.ts b/tools/pack/tests/cache.test.ts index eaa756fbf6..1d67293234 100644 --- a/tools/pack/tests/cache.test.ts +++ b/tools/pack/tests/cache.test.ts @@ -201,4 +201,46 @@ describe("ToolPackCache", () => { await rm(root, { force: true, recursive: true }); } }); + + it("skips alias seeds that cannot materialize the requested source", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-cache-missing-seed-source-")); + const cacheRoot = join(root, "cache"); + const out = join(root, "out", "payload"); + let builds = 0; + const cache = new ToolPackCache(cacheRoot); + const createNode = (key: string) => ({ + id: "test.missing-seed-source", + key, + outputs: ["payload"], + invalidate: async () => null, + build: async ({ entryRoot }: { entryRoot: string }) => { + builds += 1; + const seed = await readFile(join(out, "value.txt"), "utf8").catch(() => "\n"); + await mkdir(join(entryRoot, "payload"), { recursive: true }); + await writeFile(join(entryRoot, "payload", "value.txt"), `build-${builds};seed=${seed.trim()}\n`, "utf8"); + return { builds }; + }, + }); + + try { + await cache.acquire({ + aliases: ["family"], + materialize: [{ from: "payload", to: out }], + node: createNode("key-1"), + }); + await rm(out, { force: true, recursive: true }); + await cache.acquire({ + aliases: ["family"], + materialize: [{ from: "payload", to: out }], + node: createNode("key-2"), + seedFrom: [{ aliasKey: "family", materialize: [{ from: "missing", to: out }] }], + }); + + expect(builds).toBe(2); + expect(await readFile(join(out, "value.txt"), "utf8")).toBe("build-2;seed=\n"); + expect(cache.report().entries.at(-1)?.reason).not.toContain("seeded from alias"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); }); diff --git a/tools/pack/tests/mac.test.ts b/tools/pack/tests/mac.test.ts index 0926eb6afc..3545da18a1 100644 --- a/tools/pack/tests/mac.test.ts +++ b/tools/pack/tests/mac.test.ts @@ -12,6 +12,7 @@ import { renderMacPackagedConfig, validateMacNativeRebuildOutput, } from "../src/mac/app.js"; +import { runElectronBuilder } from "../src/mac/builder.js"; import { resolveSeededAppConfigPaths, seedPackagedAppConfig, writeLaunchPackagedConfig } from "../src/mac/index.js"; import { resolveMacPaths } from "../src/mac/paths.js"; @@ -200,6 +201,76 @@ describe("renderMacPackagedConfig", () => { await rm(root, { force: true, recursive: true }); } }); + + it("bakes the configured updater metadata URL for mac beta validation", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-")); + try { + const config = makeConfig(root, { + updateMetadataUrl: "http://127.0.0.1:4567/beta/latest/metadata.json", + }); + + const packagedConfig = JSON.parse( + renderMacPackagedConfig({ + appVersion: "1.2.3-beta.0", + config, + usePrebundledStandaloneWeb: true, + }), + ) as Record; + + expect(packagedConfig.updateMetadataUrl).toBe("http://127.0.0.1:4567/beta/latest/metadata.json"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + +describe("runElectronBuilder", () => { + async function prepareElectronBuilderConfig(root: string, overrides: Partial) { + const cliPath = join(root, "fake-electron-builder.mjs"); + await writeFile(cliPath, "process.exit(0);\n", "utf8"); + + const config = makeConfig(root, { + appVersion: "1.2.3.nightly.4", + electronBuilderCliPath: cliPath, + signed: true, + webOutputMode: "server", + ...overrides, + }); + const paths = resolveMacPaths(config); + + await runElectronBuilder(config, paths, ["dir"]); + + return JSON.parse(await readFile(paths.appBuilderConfigPath, "utf8")) as { + afterSign?: string; + mac?: { + notarize?: boolean; + }; + }; + } + + it("does not explicitly disable electron-builder notarization for notarized mac builds", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-")); + try { + const builderConfig = await prepareElectronBuilderConfig(root, { macNotarize: true }); + + expect(builderConfig.afterSign).toContain("notarize.cjs"); + expect(builderConfig.mac).not.toHaveProperty("notarize"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("keeps signed-only mac builds from invoking electron-builder notarization", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-")); + try { + const builderConfig = await prepareElectronBuilderConfig(root, { macNotarize: false }); + + expect(builderConfig.afterSign).toBeUndefined(); + expect(builderConfig.mac?.notarize).toBe(false); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); }); describe("createMacElectronRebuildOptions", () => { diff --git a/tools/pack/tests/release-workflows.test.ts b/tools/pack/tests/release-workflows.test.ts index ae36e2bebd..0b95236084 100644 --- a/tools/pack/tests/release-workflows.test.ts +++ b/tools/pack/tests/release-workflows.test.ts @@ -10,15 +10,30 @@ function sectionBetween(content: string, start: string, end: string): string { return content.slice(startIndex, endIndex); } +function countOccurrences(content: string, needle: string): number { + return content.split(needle).length - 1; +} + describe("release workflows", () => { it("requires Vela CLI only for beta mac arm64 packaging", async () => { - const [beta, betaSelfHosted, buildMac, buildWin, prepareWin, publishPlatform] = await Promise.all([ + const [beta, betaSelfHosted, preview, stable, buildMac, buildWin, prepareMac, prepareWin, publishPlatform, winLifecycle, desktopUpdater, macBuild, macFs, installUnsafeDmg, winApp, macWorkspace, linuxPack] = await Promise.all([ readFile(new URL("../../../.github/workflows/release-beta.yml", import.meta.url), "utf8"), readFile(new URL("../../../.github/workflows/release-beta-s.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-preview.yml", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflows/release-stable.yml", import.meta.url), "utf8"), readFile(new URL("../../../.github/workflow/scripts/release/build-platform.sh", import.meta.url), "utf8"), readFile(new URL("../../../.github/workflow/scripts/release/build-platform.ps1", import.meta.url), "utf8"), + readFile(new URL("../../../.github/workflow/scripts/release/prepare-platform-assets.sh", import.meta.url), "utf8"), readFile(new URL("../../../.github/workflow/scripts/release/prepare-platform-assets.ps1", import.meta.url), "utf8"), readFile(new URL("../../../.github/workflow/scripts/release/storage/publish-platform.ts", import.meta.url), "utf8"), + readFile(new URL("../src/win/lifecycle.ts", import.meta.url), "utf8"), + readFile(new URL("../../../apps/desktop/src/main/updater.ts", import.meta.url), "utf8"), + readFile(new URL("../src/mac/build.ts", import.meta.url), "utf8"), + readFile(new URL("../src/mac/fs.ts", import.meta.url), "utf8"), + readFile(new URL("../../../scripts/install-unsafe-dmg.sh", import.meta.url), "utf8"), + readFile(new URL("../src/win/app.ts", import.meta.url), "utf8"), + readFile(new URL("../src/mac/workspace.ts", import.meta.url), "utf8"), + readFile(new URL("../src/linux.ts", import.meta.url), "utf8"), ]); const mac = sectionBetween(beta, " build_mac_arm64:", " build_mac_x64:"); const macX64 = sectionBetween(beta, " build_mac_x64:", " build_win_x64:"); @@ -50,17 +65,96 @@ describe("release workflows", () => { expect(beta).toContain("win_x64_update_metadata_url:"); expect(beta).toContain("OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL: ${{ inputs.mac_arm64_update_metadata_url }}"); expect(beta).toContain("OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }}"); + expect(beta).not.toContain("publish-beta-metadata.ts"); + expect(beta).not.toContain("verify-beta-metadata.ts"); + expect(beta).not.toContain("summary-beta.ts"); + expect(beta).toContain("publish-metadata.ts"); + expect(beta).toContain("verify-metadata.ts"); + expect(beta).toContain("summary-metadata.ts"); expect(betaSelfHosted).toContain("mac_arm64_update_metadata_url:"); + expect(betaSelfHosted).toContain("mac_arm64_delivery_mode:"); + expect(betaSelfHosted).toContain("internal-updater"); + expect(betaSelfHosted).toContain("public-notarized"); + expect(selfHostedMac).toContain("RELEASE_DELIVERY_MODE: ${{ inputs.mac_arm64_delivery_mode }}"); + expect(selfHostedMac).toContain("RELEASE_SIGN_MODE: ${{ inputs.mac_arm64_delivery_mode == 'internal-updater' && 'sign-only' || inputs.mac_arm64_sign_mode }}"); + expect(selfHostedMac).toContain("OD_UPDATE_METADATA_URL: ${{ inputs.release_public_origin }}/beta/latest/metadata.json"); + expect(betaSelfHosted).toContain("public-notarized mac_arm64_delivery_mode requires mac_arm64_sign_mode=notarize"); + expect(betaSelfHosted).toContain("RELEASE_SIGNED: ${{ inputs.enable_mac_arm64 && (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && 'true' || 'false' }}"); expect(selfHostedMac).toContain("OD_PACKAGED_E2E_MAC_UPDATE_METADATA_URL: ${{ inputs.mac_arm64_update_metadata_url }}"); + expect(selfHostedMac).toContain("RELEASE_ARTIFACT_MODE: dmg-and-payload"); + expect(macBuild).toContain('runPhase("xattr-scrub"'); + expect(macBuild).toContain("scrubMacExtendedAttributes(paths.appPath)"); + expect(macFs).toContain("com.apple.provenance"); + expect(macFs).toContain("com.apple.macl"); + expect(desktopUpdater).toContain("MAC_PAYLOAD_XATTRS_TO_SCRUB"); + expect(desktopUpdater).toContain('execFileAsync("xattr", ["-dr", attribute, input.destinationRoot])'); + expect(desktopUpdater).toContain("com.apple.macl"); + expect(installUnsafeDmg).toContain("com.apple.macl"); + expect(betaSelfHosted).not.toContain("publish-beta-metadata.ts"); + expect(betaSelfHosted).not.toContain("verify-beta-metadata.ts"); + expect(betaSelfHosted).not.toContain("summary-beta.ts"); + expect(betaSelfHosted).toContain("publish-metadata.ts"); + expect(betaSelfHosted).toContain("verify-metadata.ts"); + expect(betaSelfHosted).toContain("summary-metadata.ts"); expect(win).toContain("-IncludeZip $${{ inputs.win_x64_target == 'all' || inputs.win_x64_target == 'zip' }}"); expect(selfHostedWin).toContain("-IncludeZip $${{ inputs.win_x64_target == 'all' || inputs.win_x64_target == 'zip' }}"); + expect(prepareMac).not.toContain("required RELEASE_ASSET_SUFFIX"); + expect(prepareMac).toContain('RELEASE_ASSET_SUFFIX="${RELEASE_ASSET_SUFFIX:-}"'); + expect(prepareWin).toContain("[AllowEmptyString()]"); expect(prepareWin).toContain("$sourcePayload = [string]$build.payloadPath"); expect(prepareWin).toContain("open-design-$ReleaseVersion$ReleaseAssetSuffix-win-x64-payload.7z"); expect(publishPlatform).toContain("open-design-${releaseVersion}${assetSuffix}-win-x64-payload.7z"); expect(publishPlatform).toContain("payload: assetEntry(payload)"); expect(buildWin).toContain("function Validate-WinLauncherPayloadArchive"); + expect(buildWin).toContain('Measure-Step "clean tools-pack win namespace"'); + expect(buildWin.indexOf('Measure-Step "clean tools-pack win namespace"')).toBeLessThan(buildWin.indexOf('Measure-Step "tools-pack win build"')); + expect(buildWin).toContain('"tools-pack", "win", "cleanup"'); + expect(winLifecycle).toContain("const launcher = resolveToolPackLauncherLayout(config)"); + expect(winLifecycle).toContain("await removeTree(launcher.paths.namespaceRoot)"); + expect(winLifecycle).toContain("removedLauncherNamespaceRoot"); expect(buildWin).toContain('Measure-Step "validate launcher payload artifact"'); expect(buildWin).toContain('Measure-Step "validate launcher payload update fixture"'); expect(buildWin).toContain('Test-JsonString $manifest.entry.executable "entry.executable" "payload/Open Design.exe"'); + for (const workspaceBuild of [winApp, macWorkspace, linuxPack]) { + const sidecarProtoBuild = 'await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"])'; + const launcherProtoBuild = 'await runPnpm(config, ["--filter", "@open-design/launcher-proto", "build"])'; + const sidecarBuild = 'await runPnpm(config, ["--filter", "@open-design/sidecar", "build"])'; + expect(workspaceBuild).toContain(launcherProtoBuild); + expect(workspaceBuild.indexOf(sidecarProtoBuild)).toBeLessThan(workspaceBuild.indexOf(launcherProtoBuild)); + expect(workspaceBuild.indexOf(launcherProtoBuild)).toBeLessThan(workspaceBuild.indexOf(sidecarBuild)); + } + expect(preview).not.toContain(".github/scripts/release/assets/mac.sh"); + expect(preview).not.toContain(".github/scripts/release/assets/mac-intel.sh"); + expect(preview).not.toContain(".github/scripts/release/assets/win.ps1"); + expect(preview).not.toContain(".github/scripts/release/assets/linux.sh"); + expect(preview).not.toContain(".github/scripts/release/r2/publish.sh"); + expect(preview).not.toContain(".github/scripts/release/r2/verify.sh"); + expect(preview).not.toContain(".github/scripts/release/r2/summary.sh"); + expect(countOccurrences(preview, ".github/workflow/scripts/release/prepare-platform-assets.sh")).toBeGreaterThanOrEqual(3); + expect(preview).toContain(".github\\workflow\\scripts\\release\\prepare-platform-assets.ps1"); + expect(countOccurrences(preview, ".github/workflow/scripts/release/storage/publish-platform.ts")).toBeGreaterThanOrEqual(4); + expect(preview).toContain(".github/workflow/scripts/release/storage/publish-metadata.ts"); + expect(preview).toContain(".github/workflow/scripts/release/storage/verify-metadata.ts"); + expect(preview).toContain(".github/workflow/scripts/release/storage/summary-metadata.ts"); + expect(preview).toContain("RELEASE_ARTIFACT_MODE: all"); + expect(preview).toContain("open-design-preview-mac-arm64-publish-manifest"); + expect(preview).toContain("open-design-preview-win-x64-publish-manifest"); + expect(stable).not.toContain(".github/scripts/release/assets/mac.sh"); + expect(stable).not.toContain(".github/scripts/release/assets/mac-intel.sh"); + expect(stable).not.toContain(".github/scripts/release/assets/win.ps1"); + expect(stable).not.toContain(".github/scripts/release/assets/linux.sh"); + expect(stable).not.toContain(".github/scripts/release/r2/publish.sh"); + expect(stable).not.toContain(".github/scripts/release/r2/verify.sh"); + expect(stable).not.toContain(".github/scripts/release/r2/summary.sh"); + expect(countOccurrences(stable, ".github/workflow/scripts/release/prepare-platform-assets.sh")).toBeGreaterThanOrEqual(3); + expect(stable).toContain(".github\\workflow\\scripts\\release\\prepare-platform-assets.ps1"); + expect(countOccurrences(stable, ".github/workflow/scripts/release/storage/publish-platform.ts")).toBeGreaterThanOrEqual(4); + expect(stable).toContain(".github/workflow/scripts/release/storage/publish-metadata.ts"); + expect(stable).toContain(".github/workflow/scripts/release/storage/verify-metadata.ts"); + expect(stable).toContain(".github/workflow/scripts/release/storage/summary-metadata.ts"); + expect(stable).toContain("open-design-release-mac-arm64-publish-manifest"); + expect(stable).toContain("open-design-release-win-x64-publish-manifest"); + expect(stable).toContain("--signed\n --notarize"); + expect(stable).toContain("--signed \\\n --notarize"); }); }); diff --git a/tools/serve/src/index.ts b/tools/serve/src/index.ts index 76bdf91302..8ca96484fa 100644 --- a/tools/serve/src/index.ts +++ b/tools/serve/src/index.ts @@ -1,5 +1,6 @@ import { cac } from "cac"; +import { startReleaseStorageFixtureServer } from "./release-storage-fixture.js"; import { startUpdaterFixtureServer } from "./updater-fixture.js"; type CliOptions = { @@ -8,8 +9,9 @@ type CliOptions = { host?: string; json?: boolean; platform?: "mac" | "win"; - payloadPath?: string; port?: string; + includePayload?: boolean; + payloadPath?: string; version?: string; }; @@ -33,13 +35,33 @@ function parsePlatform(value: string | undefined): "mac" | "win" { } async function start(service: string, options: CliOptions): Promise { + if (service === "release-storage") { + const server = await startReleaseStorageFixtureServer({ + host: options.host, + port: parsePort(options.port), + }); + if (options.json === true) { + printJson(server.info); + } else { + process.stdout.write(`tools-serve release-storage: ${server.info.endpointUrl} bucket=${server.info.bucket}\n`); + } + + const shutdown = () => { + void server.close().finally(() => process.exit(0)); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + return; + } + if (service !== "updater") throw new Error(`unsupported tools-serve service: ${service}`); const server = await startUpdaterFixtureServer({ artifactPath: options.artifactPath, channel: options.channel, host: options.host, - payloadPath: options.payloadPath, platform: parsePlatform(options.platform), + includePayload: options.includePayload, + payloadPath: options.payloadPath, port: parsePort(options.port), version: options.version, }); @@ -73,8 +95,9 @@ cli .option("--channel ", "Updater channel: stable|beta|nightly|preview", { default: "stable" }) .option("--host ", "Host to bind", { default: "127.0.0.1" }) .option("--json", "Print JSON") + .option("--include-payload", "Include launcher payload metadata") + .option("--payload-path ", "Serve launcher payload bytes from a real archive") .option("--platform ", "Updater platform: mac|win", { default: "mac" }) - .option("--payload-path ", "Serve a local launcher payload artifact file") .option("--port ", "Port to bind, 0 for dynamic", { default: "0" }) .option("--version ", "Fixture update version", { default: "99.0.0" }) .action((service: string, options: CliOptions) => { diff --git a/tools/serve/src/release-storage-fixture.ts b/tools/serve/src/release-storage-fixture.ts new file mode 100644 index 0000000000..c3d4bb3ff5 --- /dev/null +++ b/tools/serve/src/release-storage-fixture.ts @@ -0,0 +1,158 @@ +import { createHash } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +export type ReleaseStorageFixtureOptions = { + host?: string; + port?: number; +}; + +export type ReleaseStorageFixtureInfo = { + bucket: string; + endpointUrl: string; + origin: string; +}; + +export type ReleaseStorageFixtureServer = { + close(): Promise; + getObject(key: string): Buffer | null; + info: ReleaseStorageFixtureInfo; + listObjectKeys(): string[]; +}; + +type StoredObject = { + body: Buffer; + cacheControl: string | null; + contentType: string | null; + etag: string; +}; + +const BUCKET = "open-design-release-fixture"; + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(port, host, () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolveClose, rejectClose) => { + server.close((error) => (error == null ? resolveClose() : rejectClose(error))); + }); +} + +function serverOrigin(server: Server): string { + const address = server.address(); + if (address == null || typeof address === "string") throw new Error("release storage fixture did not listen on TCP"); + return `http://127.0.0.1:${address.port}`; +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolveRead, rejectRead) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("error", rejectRead); + request.on("end", () => resolveRead(Buffer.concat(chunks))); + }); +} + +function objectKeyFromRequest(request: IncomingMessage, response: ServerResponse): string | null { + const url = new URL(request.url ?? "/", "http://fixture.local"); + const segments = url.pathname.split("/").filter((segment) => segment.length > 0).map(decodeURIComponent); + if (segments[0] !== BUCKET || segments.length < 2) { + response.statusCode = 404; + response.end("bucket or object not found"); + return null; + } + return segments.slice(1).join("/"); +} + +function etag(body: Buffer): string { + return `"${createHash("sha256").update(body).digest("hex")}"`; +} + +function preconditionFailed(response: ServerResponse): void { + response.statusCode = 412; + response.end("precondition failed"); +} + +export async function startReleaseStorageFixtureServer( + options: ReleaseStorageFixtureOptions = {}, +): Promise { + const host = options.host ?? "127.0.0.1"; + const port = options.port ?? 0; + const objects = new Map(); + + const server = createServer((request, response) => { + void (async () => { + const key = objectKeyFromRequest(request, response); + if (key == null) return; + + if (request.method === "PUT") { + const current = objects.get(key); + const ifNoneMatch = request.headers["if-none-match"]; + const ifMatch = request.headers["if-match"]; + if (ifNoneMatch === "*" && current != null) { + preconditionFailed(response); + return; + } + if (typeof ifMatch === "string" && ifMatch.length > 0 && current?.etag !== ifMatch) { + preconditionFailed(response); + return; + } + + const body = await readBody(request); + const stored = { + body, + cacheControl: typeof request.headers["cache-control"] === "string" ? request.headers["cache-control"] : null, + contentType: typeof request.headers["content-type"] === "string" ? request.headers["content-type"] : null, + etag: etag(body), + }; + objects.set(key, stored); + response.statusCode = 200; + response.setHeader("etag", stored.etag); + response.end("ok"); + return; + } + + if (request.method === "GET" || request.method === "HEAD") { + const stored = objects.get(key); + if (stored == null) { + response.statusCode = 404; + response.end("not found"); + return; + } + response.statusCode = 200; + response.setHeader("etag", stored.etag); + response.setHeader("content-length", String(stored.body.byteLength)); + if (stored.cacheControl != null) response.setHeader("cache-control", stored.cacheControl); + if (stored.contentType != null) response.setHeader("content-type", stored.contentType); + response.end(request.method === "HEAD" ? undefined : stored.body); + return; + } + + response.statusCode = 405; + response.end("method not allowed"); + })().catch((error: unknown) => { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + }); + }); + + await listen(server, port, host); + const origin = serverOrigin(server); + + return { + close: () => close(server), + getObject: (key: string) => objects.get(key)?.body ?? null, + info: { + bucket: BUCKET, + endpointUrl: origin, + origin, + }, + listObjectKeys: () => [...objects.keys()].sort(), + }; +} diff --git a/tools/serve/src/updater-fixture.ts b/tools/serve/src/updater-fixture.ts index cf55c96118..dbf9a480df 100644 --- a/tools/serve/src/updater-fixture.ts +++ b/tools/serve/src/updater-fixture.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http"; import { basename } from "node:path"; @@ -50,6 +51,19 @@ function listen(server: Server, port: number, host: string): Promise { }); } +async function sha256File(path: string): Promise { + const hash = createHash("sha256"); + await new Promise((resolveHash, rejectHash) => { + const stream = createReadStream(path); + stream.on("data", (chunk) => { + hash.update(chunk); + }); + stream.on("error", rejectHash); + stream.on("end", resolveHash); + }); + return hash.digest("hex"); +} + function close(server: Server): Promise { return new Promise((resolveClose, rejectClose) => { server.close((error) => (error == null ? resolveClose() : rejectClose(error))); @@ -149,6 +163,43 @@ function sendArtifact( endWithOptionalBody(request, response, artifactBody); } +function sendFileArtifact( + request: IncomingMessage, + response: ServerResponse, + path: string, + size: number, + contentType: string, +): void { + response.setHeader("accept-ranges", "bytes"); + response.setHeader("content-type", contentType); + const range = parseByteRange(request.headers.range, size); + if (range === "invalid" || range === "unsatisfiable") { + response.statusCode = 416; + response.setHeader("content-range", `bytes */${size}`); + response.end(); + return; + } + + if (range != null) { + response.statusCode = 206; + response.setHeader("content-length", String(range.end - range.start + 1)); + response.setHeader("content-range", `bytes ${range.start}-${range.end}/${size}`); + if (request.method === "HEAD") { + response.end(); + return; + } + createReadStream(path, { end: range.end, start: range.start }).pipe(response); + return; + } + + response.setHeader("content-length", String(size)); + if (request.method === "HEAD") { + response.end(); + return; + } + createReadStream(path).pipe(response); +} + function normalizeChannel(value: string | undefined): UpdaterFixtureChannel { if (value == null || value.length === 0) return "stable"; if (value === "stable" || value === "beta" || value === "nightly" || value === "preview") return value; @@ -214,24 +265,36 @@ export async function startUpdaterFixtureServer(options: UpdaterFixtureOptions = const contentType = platform === "win" ? "application/vnd.microsoft.portable-executable" : "application/x-apple-diskimage"; - const artifactBody = options.artifactPath != null - ? await readFile(options.artifactPath) - : Buffer.isBuffer(options.artifactBody) + const artifactFileStat = options.artifactPath == null ? null : await stat(options.artifactPath); + if (artifactFileStat != null && (!artifactFileStat.isFile() || artifactFileStat.size <= 0)) { + throw new Error(`updater fixture artifact path must be a non-empty file: ${options.artifactPath}`); + } + const artifactBody = Buffer.isBuffer(options.artifactBody) ? options.artifactBody : Buffer.from(options.artifactBody ?? `Open Design updater fixture ${version}\n`, "utf8"); - const sha256 = createHash("sha256").update(artifactBody).digest("hex"); - const payloadName = platform === "win" - ? `open-design-${version}-win-x64-payload.7z` - : `open-design-${version}-mac-arm64-payload.zip`; + const artifactSize = artifactFileStat?.size ?? artifactBody.byteLength; + const sha256 = options.artifactPath == null + ? createHash("sha256").update(artifactBody).digest("hex") + : await sha256File(options.artifactPath); + const payloadName = options.payloadPath == null + ? platform === "win" + ? `open-design-${version}-win-x64-payload.7z` + : `open-design-${version}-mac-arm64-payload.zip` + : basename(options.payloadPath); const artifactPathSegment = encodeURIComponent(artifactName); const payloadPathSegment = encodeURIComponent(payloadName); const includePayload = options.includePayload === true || options.payloadPath != null; - const payloadBody = options.payloadPath != null - ? await readFile(options.payloadPath) - : Buffer.isBuffer(options.payloadBody) + const payloadBody = Buffer.isBuffer(options.payloadBody) ? options.payloadBody : Buffer.from(options.payloadBody ?? `Open Design launcher payload fixture ${version}\n`, "utf8"); - const payloadSha256 = createHash("sha256").update(payloadBody).digest("hex"); + const payloadFileStat = options.payloadPath == null ? null : await stat(options.payloadPath); + if (payloadFileStat != null && (!payloadFileStat.isFile() || payloadFileStat.size <= 0)) { + throw new Error(`updater fixture payload path must be a non-empty file: ${options.payloadPath}`); + } + const payloadSize = payloadFileStat?.size ?? payloadBody.byteLength; + const payloadSha256 = options.payloadPath == null + ? createHash("sha256").update(payloadBody).digest("hex") + : await sha256File(options.payloadPath); let info: UpdaterFixtureInfo | null = null; const server = createServer((request, response) => { @@ -255,7 +318,7 @@ export async function startUpdaterFixtureServer(options: UpdaterFixtureOptions = contentType, name: artifactName, sha256Url: info.checksumUrl, - size: artifactBody.byteLength, + size: artifactSize, url: info.artifactUrl, }, ...(includePayload && info.payloadUrl != null && info.payloadChecksumUrl != null @@ -264,7 +327,7 @@ export async function startUpdaterFixtureServer(options: UpdaterFixtureOptions = contentType: platform === "win" ? "application/x-7z-compressed" : "application/zip", name: payloadName, sha256Url: info.payloadChecksumUrl, - size: payloadBody.byteLength, + size: payloadSize, url: info.payloadUrl, }, } @@ -284,10 +347,18 @@ export async function startUpdaterFixtureServer(options: UpdaterFixtureOptions = return; } if (path === `/${channel}/versions/${version}/${artifactPathSegment}`) { + if (options.artifactPath != null && artifactFileStat != null) { + sendFileArtifact(request, response, options.artifactPath, artifactFileStat.size, contentType); + return; + } sendArtifact(request, response, artifactBody, contentType); return; } if (includePayload && path === `/${channel}/versions/${version}/${payloadPathSegment}`) { + if (options.payloadPath != null && payloadFileStat != null) { + sendFileArtifact(request, response, options.payloadPath, payloadFileStat.size, platform === "win" ? "application/x-7z-compressed" : "application/zip"); + return; + } sendArtifact(request, response, payloadBody, platform === "win" ? "application/x-7z-compressed" : "application/zip"); return; } diff --git a/tools/serve/tests/beta-version-reservation.test.ts b/tools/serve/tests/beta-version-reservation.test.ts new file mode 100644 index 0000000000..3bb8e080e4 --- /dev/null +++ b/tools/serve/tests/beta-version-reservation.test.ts @@ -0,0 +1,69 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +function runReservationCheck(options: { releaseRunId?: string }): Promise { + const repoRoot = resolve(import.meta.dirname, "../../.."); + const script = ` + const { validateVersionReservation } = await import("./.github/workflow/scripts/release/storage/beta-version-reservation.ts"); + const reservation = { + baseVersion: "1.2.3", + betaNumber: 4, + channel: "beta", + createdAt: "2026-06-09T07:00:00.000Z", + kind: "version-reservation", + lane: "release-beta-s", + owner: { + branch: "codex/release-stable-launcher", + commit: "abc123", + repository: "nexu-io/open-design", + runAttempt: 1, + runId: 42, + workflow: "release-beta-s" + }, + releaseVersion: "1.2.3-beta.4", + state: "reserved", + version: 1 + }; + const result = validateVersionReservation(reservation, "1.2.3-beta.4"); + process.stdout.write(result == null ? "ok" : result); + `; + return new Promise((resolveRun, rejectRun) => { + const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script], { + cwd: repoRoot, + env: { + ...process.env, + RELEASE_BRANCH: "codex/release-stable-launcher", + RELEASE_COMMIT: "abc123", + RELEASE_REPOSITORY: "nexu-io/open-design", + RELEASE_RUN_ATTEMPT: "2", + RELEASE_RUN_ID: options.releaseRunId ?? "42", + RELEASE_WORKFLOW: "release-beta-s", + }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", rejectRun); + child.on("close", (code) => { + if (code === 0) resolveRun(stdout); + else rejectRun(new Error(`reservation check exited ${String(code)}\n${stdout}\n${stderr}`)); + }); + }); +} + +describe("beta version reservation ownership", () => { + it("allows a later rerun attempt from the same workflow run to reuse the lock", async () => { + await expect(runReservationCheck({})).resolves.toBe("ok"); + }); + + it("rejects a reservation owned by a different workflow run", async () => { + await expect(runReservationCheck({ releaseRunId: "43" })).resolves.toContain("owner="); + }); +}); diff --git a/tools/serve/tests/release-metadata-publish.test.ts b/tools/serve/tests/release-metadata-publish.test.ts new file mode 100644 index 0000000000..00349f9910 --- /dev/null +++ b/tools/serve/tests/release-metadata-publish.test.ts @@ -0,0 +1,147 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { startReleaseStorageFixtureServer } from "../src/release-storage-fixture.js"; + +function runNode(args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): Promise { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(process.execPath, args, options); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", rejectRun); + child.on("close", (code) => { + if (code === 0) { + resolveRun(); + } else { + rejectRun(new Error(`node ${args.join(" ")} exited ${String(code)}\n${stdout}\n${stderr}`)); + } + }); + }); +} + +describe("shared release metadata publisher", () => { + it("publishes complete beta, preview, nightly, and stable metadata through the release storage fixture", async () => { + const repoRoot = resolve(import.meta.dirname, "../../.."); + const root = await mkdtemp(join(tmpdir(), "od-release-metadata-publish-")); + const server = await startReleaseStorageFixtureServer(); + try { + for (const [channel, version] of [ + ["beta", "1.2.3-beta.4"], + ["preview", "1.2.3-preview.4"], + ["nightly", "1.2.3.nightly.4"], + ["stable", "1.2.3"], + ] as const) { + const manifestDir = join(root, channel, "manifests"); + const metadataDir = join(root, channel, "metadata"); + await mkdir(manifestDir, { recursive: true }); + const base = { + channel, + enabled: true, + github: { commit: "abc123", runId: 42 }, + r2: { versionPrefix: `${channel}/versions/${version}` }, + releaseVersion: version, + status: "published", + version: 1, + }; + await writeFile( + join(manifestDir, "mac_arm64.json"), + JSON.stringify( + { + ...base, + arch: "arm64", + artifacts: { dmg: { url: "https://example.test/dmg" }, payload: { url: "https://example.test/mac-payload" } }, + feed: null, + legacyPlatformKey: "mac", + platformKey: "mac_arm64", + releaseTarget: "mac_arm64", + signed: true, + }, + null, + 2, + ), + "utf8", + ); + await writeFile( + join(manifestDir, "win_x64.json"), + JSON.stringify( + { + ...base, + arch: "x64", + artifacts: { installer: { url: "https://example.test/exe" }, payload: { url: "https://example.test/win-payload" } }, + feed: null, + legacyPlatformKey: "win", + platformKey: "win_x64", + releaseTarget: "win_x64", + signed: false, + }, + null, + 2, + ), + "utf8", + ); + + const env = { + ...process.env, + BASE_VERSION: "1.2.3", + ENABLE_LINUX_X64: "false", + ENABLE_MAC_ARM64: "true", + ENABLE_MAC_X64: "false", + ENABLE_WIN_X64: "true", + MAC_ARM64_RESULT: "success", + RELEASE_ASSET_SUFFIX: "", + RELEASE_CHANNEL: channel, + RELEASE_COMMIT: "abc123", + RELEASE_MANIFEST_DIR: manifestDir, + RELEASE_METADATA_DIR: metadataDir, + RELEASE_OUTPUTS_PATH: join(metadataDir, "outputs.json"), + RELEASE_PUBLIC_ORIGIN: "https://releases.example.test", + RELEASE_RUN_ID: "42", + RELEASE_SIGNED: "true", + RELEASE_STORAGE_ACCESS_KEY_ID: "ak", + RELEASE_STORAGE_BUCKET: server.info.bucket, + RELEASE_STORAGE_ENDPOINT: server.info.endpointUrl, + RELEASE_STORAGE_REGION: "auto", + RELEASE_STORAGE_SECRET_ACCESS_KEY: "sk", + RELEASE_VERSION: version, + STATE_SOURCE: "local-tools-serve", + WIN_X64_RESULT: "success", + ...(channel === "beta" ? { RELEASE_LATEST_CAS_REQUIRED: "true" } : {}), + }; + await runNode(["--experimental-strip-types", ".github/workflow/scripts/release/storage/publish-metadata.ts"], { + cwd: repoRoot, + env, + }); + + const metadata = JSON.parse(await readFile(join(metadataDir, "metadata.json"), "utf8")) as { + channel?: string; + releaseState?: string; + releaseTargets?: { + mac_arm64?: { artifacts?: { payload?: { url?: string } } }; + win_x64?: { artifacts?: { payload?: { url?: string } } }; + }; + allReadyTargetsSigned?: boolean; + signed?: boolean; + }; + expect(metadata.channel).toBe(channel); + expect(metadata.releaseState).toBe("complete"); + expect(metadata.signed).toBe(true); + expect(metadata.allReadyTargetsSigned).toBe(false); + expect(metadata.releaseTargets?.mac_arm64?.artifacts?.payload?.url).toBe("https://example.test/mac-payload"); + expect(metadata.releaseTargets?.win_x64?.artifacts?.payload?.url).toBe("https://example.test/win-payload"); + expect(server.getObject(`${channel}/latest/metadata.json`)).not.toBeNull(); + } + } finally { + await server.close(); + } + }); +}); diff --git a/tools/serve/tests/release-storage-fixture.test.ts b/tools/serve/tests/release-storage-fixture.test.ts new file mode 100644 index 0000000000..e3744f640f --- /dev/null +++ b/tools/serve/tests/release-storage-fixture.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { startReleaseStorageFixtureServer } from "../src/release-storage-fixture.js"; + +describe("release storage fixture server", () => { + it("accepts release storage PUT/GET requests and exposes ETags", async () => { + const server = await startReleaseStorageFixtureServer(); + try { + const objectUrl = `${server.info.endpointUrl}/${server.info.bucket}/preview/latest/metadata.json`; + const put = await fetch(objectUrl, { + body: "{\"ok\":true}\n", + headers: { + "cache-control": "public, max-age=60", + "content-type": "application/json; charset=utf-8", + }, + method: "PUT", + }); + expect(put.ok).toBe(true); + + const object = await fetch(objectUrl); + expect(object.headers.get("etag")).toMatch(/^"/); + expect(object.headers.get("cache-control")).toBe("public, max-age=60"); + expect(object.headers.get("content-type")).toContain("application/json"); + expect(await object.text()).toBe("{\"ok\":true}\n"); + expect(server.listObjectKeys()).toEqual(["preview/latest/metadata.json"]); + } finally { + await server.close(); + } + }); + + it("honors latest metadata compare-and-swap preconditions", async () => { + const server = await startReleaseStorageFixtureServer(); + try { + const objectUrl = `${server.info.endpointUrl}/${server.info.bucket}/beta/latest/metadata.json`; + const first = await fetch(objectUrl, { + body: "{\"version\":1}\n", + headers: { "if-none-match": "*" }, + method: "PUT", + }); + expect(first.ok).toBe(true); + + const second = await fetch(objectUrl, { + body: "{\"version\":2}\n", + headers: { "if-none-match": "*" }, + method: "PUT", + }); + expect(second.status).toBe(412); + + const current = await fetch(objectUrl); + expect(await current.text()).toBe("{\"version\":1}\n"); + + const third = await fetch(objectUrl, { + body: "{\"version\":2}\n", + headers: { "if-match": current.headers.get("etag") ?? "" }, + method: "PUT", + }); + expect(third.ok).toBe(true); + } finally { + await server.close(); + } + }); +}); diff --git a/tools/serve/tests/updater-fixture.test.ts b/tools/serve/tests/updater-fixture.test.ts index 0b1cb83228..7791ddcdee 100644 --- a/tools/serve/tests/updater-fixture.test.ts +++ b/tools/serve/tests/updater-fixture.test.ts @@ -140,6 +140,47 @@ describe("updater fixture server", () => { } }); + it("serves launcher payload bytes from a real archive path", async () => { + const root = await mkdtemp(join(tmpdir(), "od-tools-serve-payload-")); + const payloadPath = join(root, "Open Design-release-preview-payload.zip"); + await writeFile(payloadPath, "real payload bytes", "utf8"); + const server = await startUpdaterFixtureServer({ + channel: "preview", + payloadPath, + version: "2.0.0-preview.4", + }); + try { + const metadataResponse = await fetch(server.info.metadataUrl); + expect(metadataResponse.ok).toBe(true); + const metadata = await metadataResponse.json() as { + platforms?: { + mac?: { + artifacts?: { + payload?: { name?: string; sha256Url?: string; size?: number; url?: string }; + }; + }; + }; + }; + expect(metadata.platforms?.mac?.artifacts?.payload?.name).toBe("Open Design-release-preview-payload.zip"); + expect(metadata.platforms?.mac?.artifacts?.payload?.size).toBe("real payload bytes".length); + expect(metadata.platforms?.mac?.artifacts?.payload?.url).toBe(server.info.payloadUrl); + expect(metadata.platforms?.mac?.artifacts?.payload?.sha256Url).toBe(server.info.payloadChecksumUrl); + + const payload = await fetch(server.info.payloadUrl ?? "", { + headers: { range: "bytes=5-11" }, + }); + expect(payload.status).toBe(206); + expect(payload.headers.get("content-range")).toBe("bytes 5-11/18"); + expect(await payload.text()).toBe("payload"); + + const checksum = await fetch(server.info.payloadChecksumUrl ?? ""); + expect(await checksum.text()).toContain(server.info.payloadSha256); + } finally { + await server.close(); + await rm(root, { force: true, recursive: true }); + } + }); + it("serves a local launcher payload artifact file", async () => { const root = await mkdtemp(join(tmpdir(), "open-design-updater-payload-fixture-")); const payloadPath = join(root, "Open Design-release-beta-win-payload.7z");