From 3e09ebf282f26e32c65c19cd5fd5918d3ded29df Mon Sep 17 00:00:00 2001 From: PerishFire <39043006+PerishCode@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:21:31 +0800 Subject: [PATCH] feat(release): publish localized changelog metadata (#5520) * feat(release): publish changelog metadata * fix(release): verify release notes from stored bytes Generated-By: looper 0.10.3 (runner=fixer, agent=codex) --------- Co-authored-by: Looper --- .github/workflows/release-beta-s.yml | 25 ++ .github/workflows/release-beta.yml | 19 ++ .github/workflows/release-prerelease.yml | 19 ++ .github/workflows/release-preview.yml | 19 ++ .github/workflows/release-stable.yml | 27 ++ docs/CHANGELOG/README.md | 23 ++ docs/CHANGELOG/v0.14.1/en.md | 14 + docs/CHANGELOG/v0.14.1/zh-CN.md | 10 + e2e/tests/packaged-smoke-workflow.test.ts | 32 +++ pnpm-lock.yaml | 3 + tools/pack/AGENTS.md | 2 + tools/release/package.json | 3 +- tools/release/src/index.ts | 18 ++ tools/release/src/release-note/policy.ts | 23 ++ tools/release/src/release-note/prepare.ts | 22 ++ tools/release/src/release-note/publication.ts | 206 +++++++++++++++ tools/release/src/release-note/publish.ts | 68 +++++ tools/release/src/release-note/source.ts | 246 ++++++++++++++++++ tools/release/src/release-note/verify.ts | 46 ++++ tools/release/src/storage/common.ts | 1 + tools/release/src/storage/publish-metadata.ts | 30 +++ tools/release/src/storage/s3-upload.ts | 14 +- tools/release/src/storage/verify-metadata.ts | 19 ++ tools/release/tests/release-note.test.ts | 184 +++++++++++++ .../tests/release-metadata-publish.test.ts | 150 +++++++++++ 25 files changed, 1218 insertions(+), 5 deletions(-) create mode 100644 docs/CHANGELOG/README.md create mode 100644 docs/CHANGELOG/v0.14.1/en.md create mode 100644 docs/CHANGELOG/v0.14.1/zh-CN.md create mode 100644 tools/release/src/release-note/policy.ts create mode 100644 tools/release/src/release-note/prepare.ts create mode 100644 tools/release/src/release-note/publication.ts create mode 100644 tools/release/src/release-note/publish.ts create mode 100644 tools/release/src/release-note/source.ts create mode 100644 tools/release/src/release-note/verify.ts create mode 100644 tools/release/tests/release-note.test.ts diff --git a/.github/workflows/release-beta-s.yml b/.github/workflows/release-beta-s.yml index 32850d42fb..3dc9f0f3f7 100644 --- a/.github/workflows/release-beta-s.yml +++ b/.github/workflows/release-beta-s.yml @@ -942,6 +942,30 @@ jobs: source "$OPEN_DESIGN_RELEASE_PROFILE" fnm exec --using=24 -- pnpm exec tools-release download-platform-manifest + - name: Prepare and publish betas release notes + env: + RELEASE_CHANNEL: betas + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json + RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-metadata/release-note-plan.json + RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin }} + 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 }} + RELEASE_STORAGE_REGION: ${{ secrets.NEXU_S3_REGION }} + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.NEXU_S3_SECRET_ACCESS_KEY }} + RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} + working-directory: _release-scripts + run: | + set -euo pipefail + if ! command -v rehash >/dev/null 2>&1; then + rehash() { hash -r; } + fi + # shellcheck disable=SC1090 + source "$OPEN_DESIGN_RELEASE_PROFILE" + fnm exec --using=24 -- pnpm exec tools-release prepare-release-note + fnm exec --using=24 -- pnpm exec tools-release publish-release-note + fnm exec --using=24 -- pnpm exec tools-release verify-release-note + - name: Publish betas metadata env: BASE_VERSION: ${{ needs.metadata.outputs.base_version }} @@ -957,6 +981,7 @@ jobs: RELEASE_CHANNEL: betas RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin }} RELEASE_SIGNED: ${{ inputs.enable_mac_arm64 && (inputs.mac_arm64_delivery_mode == 'internal-updater' || inputs.mac_arm64_sign_mode != 'no') && 'true' || 'false' }} diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index f995e5392f..59d83ec81f 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -1407,6 +1407,23 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Prepare and publish beta release notes + env: + RELEASE_CHANNEL: beta + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json + RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-metadata/release-note-plan.json + RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin != '' && inputs.release_public_origin || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + 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.beta_version }} + run: | + pnpm exec tools-release prepare-release-note + pnpm exec tools-release publish-release-note + pnpm exec tools-release verify-release-note + - name: Publish beta metadata env: BASE_VERSION: ${{ needs.metadata.outputs.base_version }} @@ -1421,6 +1438,7 @@ jobs: RELEASE_CHANNEL: beta RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin != '' && inputs.release_public_origin || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} RELEASE_SIGNED: "true" @@ -1454,6 +1472,7 @@ jobs: MAC_X64_RESULT: ${{ needs.build_mac_x64.result }} RELEASE_CHANNEL: beta RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} WIN_X64_RESULT: ${{ needs.build_win_x64.result }} run: pnpm exec tools-release verify-metadata diff --git a/.github/workflows/release-prerelease.yml b/.github/workflows/release-prerelease.yml index e9165d447f..8621632354 100644 --- a/.github/workflows/release-prerelease.yml +++ b/.github/workflows/release-prerelease.yml @@ -1192,6 +1192,23 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Prepare and publish prerelease release notes + env: + RELEASE_CHANNEL: prerelease + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json + RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-metadata/release-note-plan.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + 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 }} + run: | + pnpm exec tools-release prepare-release-note + pnpm exec tools-release publish-release-note + pnpm exec tools-release verify-release-note + - name: Publish prerelease metadata env: BASE_VERSION: ${{ needs.metadata.outputs.base_version }} @@ -1206,6 +1223,7 @@ jobs: RELEASE_CHANNEL: prerelease RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} RELEASE_SIGNED: "true" @@ -1237,6 +1255,7 @@ jobs: MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} RELEASE_CHANNEL: prerelease RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} WIN_X64_RESULT: ${{ needs.build_win.result }} run: pnpm exec tools-release verify-metadata diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index 7b486b7ca5..2867f3b3b1 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -1255,6 +1255,23 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Prepare and publish preview release notes + env: + RELEASE_CHANNEL: preview + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json + RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-metadata/release-note-plan.json + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + 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 }} + run: | + pnpm exec tools-release prepare-release-note + pnpm exec tools-release publish-release-note + pnpm exec tools-release verify-release-note + - name: Publish preview metadata env: BASE_VERSION: ${{ needs.metadata.outputs.base_version }} @@ -1269,6 +1286,7 @@ jobs: RELEASE_CHANNEL: preview RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} RELEASE_SIGNED: "true" @@ -1302,6 +1320,7 @@ jobs: MAC_X64_RESULT: ${{ needs.build_mac_intel.result }} RELEASE_CHANNEL: preview RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} WIN_X64_RESULT: ${{ needs.build_win.result }} run: pnpm exec tools-release verify-metadata diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 317748def7..27a56e1230 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -226,6 +226,13 @@ jobs: GITHUB_REF_NAME: ${{ inputs.ref != '' && inputs.ref || github.ref_name }} run: pnpm exec tools-release prepare stable + - name: Validate stable release note policy + env: + RELEASE_CHANNEL: stable + RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-note-preflight/plan.json + RELEASE_VERSION: ${{ steps.stable.outputs.release_version }} + run: pnpm exec tools-release prepare-release-note + - name: Validate R2 release access if: ${{ steps.stable.outputs.publish_side_effects_enabled == 'true' }} env: @@ -1284,6 +1291,24 @@ jobs: set -euo pipefail gh release upload "$VERSION_TAG" "$RUNNER_TEMP/github-release-assets"/* + - name: Prepare and publish stable release notes + env: + RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json + RELEASE_NOTE_PLAN_PATH: ${{ runner.temp }}/release-metadata/release-note-plan.json + RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }} + RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + 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 }} + run: | + pnpm exec tools-release prepare-release-note + pnpm exec tools-release publish-release-note + pnpm exec tools-release verify-release-note + - name: Prepare release metadata env: BASE_VERSION: ${{ needs.metadata.outputs.base_version }} @@ -1302,6 +1327,7 @@ jobs: RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }} RELEASE_MANIFEST_DIR: ${{ runner.temp }}/release-platform-manifests RELEASE_METADATA_DIR: ${{ runner.temp }}/release-metadata + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_OUTPUTS_PATH: ${{ runner.temp }}/release-metadata/outputs.json RELEASE_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} RELEASE_REPOSITORY: ${{ github.repository }} @@ -1343,6 +1369,7 @@ jobs: RELEASE_CHANNEL: ${{ needs.metadata.outputs.channel }} RELEASE_METADATA_URL: ${{ steps.outputs.outputs.version_metadata_url }} RELEASE_METADATA_PATH: ${{ needs.metadata.outputs.publish_side_effects_enabled == 'true' && '' || format('{0}/release-metadata/metadata.json', runner.temp) }} + RELEASE_NOTE_MANIFEST_PATH: ${{ runner.temp }}/release-metadata/release-note-publication.json RELEASE_VERSION: ${{ needs.metadata.outputs.release_version }} WIN_X64_RESULT: ${{ needs.build_win.result }} run: pnpm exec tools-release verify-metadata diff --git a/docs/CHANGELOG/README.md b/docs/CHANGELOG/README.md new file mode 100644 index 0000000000..c88bc2fab0 --- /dev/null +++ b/docs/CHANGELOG/README.md @@ -0,0 +1,23 @@ +# Release note source contract + +Release note content is stored under `docs/CHANGELOG/v/.md`. +The directory name must contain the exact full release version, including its +channel and counter when present. There is no fallback to a base version. + +Each locale file must: + +- use a canonical BCP 47 locale as its filename, such as `en.md` or `zh-CN.md`; +- be valid UTF-8 Markdown no larger than 1 MiB; +- begin with YAML front matter containing non-empty `title` and `description` + strings; and +- contain a non-empty Markdown body after the front matter. + +The `en` locale is required whenever a release-note directory is supplied. +Stable releases additionally require `zh-CN`. Other channels may omit the +version directory entirely. + +`tools-release` retains the front matter in the uploaded file and publishes it +as an immutable object at +`/versions//release-notes/.md`. Public release +metadata contains only locale transport descriptors (`url`, `mediaType`, +`sha256`, and `size`); render fields are read from the content itself. diff --git a/docs/CHANGELOG/v0.14.1/en.md b/docs/CHANGELOG/v0.14.1/en.md new file mode 100644 index 0000000000..a7408ecb0a --- /dev/null +++ b/docs/CHANGELOG/v0.14.1/en.md @@ -0,0 +1,14 @@ +--- +title: Open Design 0.14.1 +description: Opt in to silent updates that are applied the next time Open Design starts. +--- + +## Silent updates + +You can now allow Open Design to apply a downloaded update silently the next +time the app starts. Enable the option while installing an update or manage it +later in Settings. + +The preference remains off until you explicitly confirm an installation. It +only affects the automatic update check performed at startup; periodic checks +and manual installer actions keep their existing behavior. diff --git a/docs/CHANGELOG/v0.14.1/zh-CN.md b/docs/CHANGELOG/v0.14.1/zh-CN.md new file mode 100644 index 0000000000..f707b604c7 --- /dev/null +++ b/docs/CHANGELOG/v0.14.1/zh-CN.md @@ -0,0 +1,10 @@ +--- +title: Open Design 0.14.1 +description: 可选择允许 Open Design 在下次启动时静默应用已下载的更新。 +--- + +## 静默更新 + +现在可以允许 Open Design 在应用下次启动时静默应用已下载的更新。你可以在安装更新时启用该选项,也可以稍后在设置中管理。 + +只有明确点击安装后,这项偏好才会开启。它仅作用于启动时的自动更新检查,定期检查与手动安装流程仍保持原有行为。 diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index a8e89a295c..517999165a 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -1295,6 +1295,38 @@ process.stdin.on("end", () => { expect(prereleaseWorkflow).toContain("Required when ref is not release/vX.Y.Z"); }); + it("[P2] publishes release notes through one channel-neutral tools-release pipeline", async () => { + const workflows = await Promise.all([ + readFile(releaseBetaWorkflowPath, "utf8"), + readFile(releaseBetaSelfHostedWorkflowPath, "utf8"), + readFile(releasePrereleaseWorkflowPath, "utf8"), + readFile(releasePreviewWorkflowPath, "utf8"), + readFile(releaseStableWorkflowPath, "utf8"), + ]); + + for (const workflow of workflows) { + expect(workflow).toContain("tools-release prepare-release-note"); + expect(workflow).toContain("tools-release publish-release-note"); + expect(workflow).toContain("tools-release verify-release-note"); + expect(workflow).toContain("RELEASE_NOTE_MANIFEST_PATH:"); + expect(workflow.indexOf("tools-release prepare-release-note")).toBeLessThan( + workflow.indexOf("tools-release publish-release-note"), + ); + expect(workflow.indexOf("tools-release publish-release-note")).toBeLessThan( + workflow.indexOf("tools-release verify-release-note"), + ); + expect(workflow.indexOf("tools-release verify-release-note")).toBeLessThan( + workflow.indexOf("tools-release publish-metadata"), + ); + } + + const stableWorkflow = workflows[4] ?? ""; + expect(stableWorkflow).toContain("Validate stable release note policy"); + expect(stableWorkflow).toContain( + "RELEASE_PUBLISH_SIDE_EFFECTS: ${{ needs.metadata.outputs.publish_side_effects_enabled }}", + ); + }); + it("[P2] requires stable release dispatch to use the release version branch", async () => { const [workflow, script] = await Promise.all([ readFile(releaseStableWorkflowPath, "utf8"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12c8944eba..ed92ef6dc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -812,6 +812,9 @@ importers: cac: specifier: 6.7.14 version: 6.7.14 + yaml: + specifier: 2.9.0 + version: 2.9.0 devDependencies: '@types/node': specifier: 24.12.2 diff --git a/tools/pack/AGENTS.md b/tools/pack/AGENTS.md index 658f51df4c..221096c74c 100644 --- a/tools/pack/AGENTS.md +++ b/tools/pack/AGENTS.md @@ -55,6 +55,8 @@ The runtime updater reads `https://releases.open-design.ai//latest/meta - Windows selects `platforms.win.artifacts.installer`. - The artifact must have a checksum, preferably `sha256Url`; the updater verifies bytes before exposing an install action. - `OD_UPDATE_CURRENT_VERSION` may override the packaged version for tests, but user-flow package validation should prefer building the package with the intended `--app-version`. +- Release metadata may include `releaseNote.content`, with a `defaultLocale` and locale descriptors containing `url`, `mediaType`, `sha256`, and `size`. The updater does not currently consume this block; `tools-release` owns its publication and verification independently from updater UI behavior. +- Release-note source lives at `docs/CHANGELOG/v/.md`. All channels use the same publication pipeline, while stable additionally requires `en` and `zh-CN` before platform builds proceed. - Post-update "what's new" highlights are NOT carried in release `metadata.json`. The daemon's `/api/whats-new` fetches a single hand-curated document on a dedicated R2 bucket (`https://whatsnew.open-design.ai/whats-new.json`, overridable with `OD_WHATS_NEW_URL`); the web home surface shows a one-time card driven by that document's `id`, not the running version. Operators edit that one file after a release — there is no per-version publish tooling. ### Channel identity rules diff --git a/tools/release/package.json b/tools/release/package.json index da78ac21eb..95a773e8c3 100644 --- a/tools/release/package.json +++ b/tools/release/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "@open-design/release": "workspace:*", - "cac": "6.7.14" + "cac": "6.7.14", + "yaml": "2.9.0" }, "devDependencies": { "@types/node": "24.12.2", diff --git a/tools/release/src/index.ts b/tools/release/src/index.ts index 352f4ad435..76f992a01e 100644 --- a/tools/release/src/index.ts +++ b/tools/release/src/index.ts @@ -44,6 +44,24 @@ cli await import("./storage/publish-platform.ts"); }); +cli + .command("prepare-release-note", "Discover and validate release note sources") + .action(async () => { + await import("./release-note/prepare.ts"); + }); + +cli + .command("publish-release-note", "Publish immutable release note content") + .action(async () => { + await import("./release-note/publish.ts"); + }); + +cli + .command("verify-release-note", "Verify a release note publication") + .action(async () => { + await import("./release-note/verify.ts"); + }); + cli .command("publish-metadata", "Publish combined release metadata") .action(async () => { diff --git a/tools/release/src/release-note/policy.ts b/tools/release/src/release-note/policy.ts new file mode 100644 index 0000000000..d875e3f8a8 --- /dev/null +++ b/tools/release/src/release-note/policy.ts @@ -0,0 +1,23 @@ +import type { ReleaseChannel } from "@open-design/release"; + +import type { ReleaseNotePlan } from "./source.ts"; + +const STABLE_REQUIRED_LOCALES = ["en", "zh-CN"] as const; + +export function assertReleaseNotePlanPolicy(plan: ReleaseNotePlan, channel: ReleaseChannel): void { + if (plan.channel !== channel) { + throw new Error(`release note plan channel mismatch: expected ${channel}, got ${plan.channel}`); + } + if (plan.state === "ready" && !plan.entries.some((entry) => entry.locale === plan.defaultLocale)) { + throw new Error(`release notes require the default locale: ${plan.defaultLocale}`); + } + if (channel !== "stable") return; + if (plan.state === "absent") { + throw new Error("release notes are required for stable releases"); + } + const locales = new Set(plan.entries.map((entry) => entry.locale)); + const missing = STABLE_REQUIRED_LOCALES.filter((locale) => !locales.has(locale)); + if (missing.length > 0) { + throw new Error(`stable release notes require locales: ${missing.join(", ")}`); + } +} diff --git a/tools/release/src/release-note/prepare.ts b/tools/release/src/release-note/prepare.ts new file mode 100644 index 0000000000..1fc2abdd29 --- /dev/null +++ b/tools/release/src/release-note/prepare.ts @@ -0,0 +1,22 @@ +import { resolve } from "node:path"; + +import { releaseChannelDescriptor } from "@open-design/release"; + +import { optional, required, writeJson } from "../storage/common.ts"; +import { assertReleaseNotePlanPolicy } from "./policy.ts"; +import { discoverReleaseNotePlan } from "./source.ts"; + +const channel = releaseChannelDescriptor(required("RELEASE_CHANNEL")).channel; +const releaseVersion = required("RELEASE_VERSION"); +const sourceRoot = resolve(optional("RELEASE_NOTE_SOURCE_ROOT", "docs/CHANGELOG")); +const planPath = required("RELEASE_NOTE_PLAN_PATH"); + +const plan = discoverReleaseNotePlan({ channel, releaseVersion, sourceRoot }); +assertReleaseNotePlanPolicy(plan, channel); +writeJson(planPath, plan); + +if (plan.state === "absent") { + console.log(`release notes absent for ${channel} ${releaseVersion}`); +} else { + console.log(`prepared ${channel} ${releaseVersion} release notes: ${plan.entries.map((entry) => entry.locale).join(", ")}`); +} diff --git a/tools/release/src/release-note/publication.ts b/tools/release/src/release-note/publication.ts new file mode 100644 index 0000000000..4f6529d3d6 --- /dev/null +++ b/tools/release/src/release-note/publication.ts @@ -0,0 +1,206 @@ +import { isReleaseChannel, parseReleaseVersion } from "@open-design/release"; + +import { + RELEASE_NOTE_DEFAULT_LOCALE, + RELEASE_NOTE_MEDIA_TYPE, + type ReleaseNotePlan, +} from "./source.ts"; + +export type ReleaseNotePublicationEntry = { + locale: string; + mediaType: string; + name: string; + sha256: string; + size: number; + url: string; +}; + +export type ReleaseNotePublication = { + channel: ReleaseNotePlan["channel"]; + defaultLocale: string; + entries: ReleaseNotePublicationEntry[]; + kind: "open-design-release-note-publication"; + releaseVersion: string; + state: "absent" | "planned" | "published"; + version: 1; +}; + +export type ReleaseNoteMetadata = { + content: { + defaultLocale: string; + locales: Record; + }; +}; + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function requiredString(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`release note publication ${field} must be a non-empty string`); + } + return value; +} + +type CreateReleaseNotePublicationOptions = { + publicOrigin: string; + published: boolean; + versionPrefix: string; +}; + +function entryUrl(publicOrigin: string, versionPrefix: string, name: string): string { + const origin = publicOrigin.replace(/\/+$/, ""); + const prefix = versionPrefix.replace(/^\/+|\/+$/g, ""); + return `${origin}/${prefix}/release-notes/${encodeURIComponent(name)}`; +} + +export function createReleaseNotePublication( + plan: ReleaseNotePlan, + options: CreateReleaseNotePublicationOptions, +): ReleaseNotePublication { + return { + channel: plan.channel, + defaultLocale: plan.defaultLocale, + entries: plan.entries.map((entry) => ({ + locale: entry.locale, + mediaType: entry.mediaType, + name: entry.name, + sha256: entry.sha256, + size: entry.size, + url: entryUrl(options.publicOrigin, options.versionPrefix, entry.name), + })), + kind: "open-design-release-note-publication", + releaseVersion: plan.releaseVersion, + state: plan.state === "absent" ? "absent" : options.published ? "published" : "planned", + version: 1, + }; +} + +export function verifyReleaseNotePublication( + plan: ReleaseNotePlan, + publication: ReleaseNotePublication, + options: { publicOrigin?: string; requirePublished: boolean; versionPrefix?: string }, +): void { + if (publication.channel !== plan.channel || publication.releaseVersion !== plan.releaseVersion) { + throw new Error("release note publication identity does not match its plan"); + } + const expectedState = plan.state === "absent" ? "absent" : options.requirePublished ? "published" : "planned"; + if (publication.state !== expectedState) { + throw new Error(`release note publication must be ${expectedState}; got ${publication.state}`); + } + if (plan.state === "ready" && publication.state === "absent") { + throw new Error("release note publication cannot be absent when source notes are ready"); + } + if (publication.entries.length !== plan.entries.length) { + throw new Error("release note publication entry count does not match its plan"); + } + for (const [index, source] of plan.entries.entries()) { + const published = publication.entries[index]; + if ( + published == null + || published.locale !== source.locale + || published.name !== source.name + || published.mediaType !== source.mediaType + || published.sha256 !== source.sha256 + || published.size !== source.size + || published.url.length === 0 + ) { + throw new Error(`release note publication entry does not match plan: ${source.locale}`); + } + if (options.publicOrigin != null && options.versionPrefix != null) { + const expectedUrl = entryUrl(options.publicOrigin, options.versionPrefix, source.name); + if (published.url !== expectedUrl) { + throw new Error(`release note publication URL does not match plan: ${source.locale}`); + } + } + } +} + +export function releaseNoteMetadataFromPublication( + publication: ReleaseNotePublication, +): ReleaseNoteMetadata | null { + if (publication.state === "absent") return null; + const locales: ReleaseNoteMetadata["content"]["locales"] = {}; + for (const entry of publication.entries) { + locales[entry.locale] = { + mediaType: entry.mediaType, + sha256: entry.sha256, + size: entry.size, + url: entry.url, + }; + } + return { + content: { + defaultLocale: publication.defaultLocale, + locales, + }, + }; +} + +export function parseReleaseNotePublication(value: unknown): ReleaseNotePublication { + if (!isRecord(value) || value.kind !== "open-design-release-note-publication" || value.version !== 1) { + throw new Error("invalid release note publication header"); + } + if (value.state !== "absent" && value.state !== "planned" && value.state !== "published") { + throw new Error(`invalid release note publication state: ${String(value.state)}`); + } + if (!Array.isArray(value.entries)) throw new Error("release note publication entries must be an array"); + const entries = value.entries.map((entry, index): ReleaseNotePublicationEntry => { + if (!isRecord(entry)) throw new Error(`release note publication entries[${index}] must be an object`); + if (typeof entry.size !== "number" || !Number.isSafeInteger(entry.size) || entry.size < 0) { + throw new Error(`release note publication entries[${index}].size must be a non-negative integer`); + } + const sha256 = requiredString(entry.sha256, `entries[${index}].sha256`); + if (!/^[a-f0-9]{64}$/.test(sha256)) { + throw new Error(`release note publication entries[${index}].sha256 is invalid`); + } + const locale = requiredString(entry.locale, `entries[${index}].locale`); + const name = requiredString(entry.name, `entries[${index}].name`); + if (name !== `${locale}.md`) { + throw new Error(`release note publication entries[${index}] locale does not match its filename`); + } + if (entry.mediaType !== RELEASE_NOTE_MEDIA_TYPE) { + throw new Error(`release note publication entries[${index}].mediaType is unsupported`); + } + return { + locale, + mediaType: RELEASE_NOTE_MEDIA_TYPE, + name, + sha256, + size: entry.size, + url: requiredString(entry.url, `entries[${index}].url`), + }; + }); + if ((value.state === "absent") !== (entries.length === 0)) { + throw new Error("release note publication state does not match its entries"); + } + if (new Set(entries.map((entry) => entry.locale)).size !== entries.length) { + throw new Error("release note publication contains duplicate locales"); + } + if (!isReleaseChannel(value.channel)) { + throw new Error(`invalid release note publication channel: ${String(value.channel)}`); + } + if (value.defaultLocale !== RELEASE_NOTE_DEFAULT_LOCALE) { + throw new Error(`release note publication defaultLocale must be ${RELEASE_NOTE_DEFAULT_LOCALE}`); + } + if (entries.length > 0 && !entries.some((entry) => entry.locale === RELEASE_NOTE_DEFAULT_LOCALE)) { + throw new Error(`release note publication requires the default locale: ${RELEASE_NOTE_DEFAULT_LOCALE}`); + } + const releaseVersion = requiredString(value.releaseVersion, "releaseVersion"); + parseReleaseVersion(releaseVersion, value.channel); + return { + channel: value.channel, + defaultLocale: RELEASE_NOTE_DEFAULT_LOCALE, + entries, + kind: "open-design-release-note-publication", + releaseVersion, + state: value.state, + version: 1, + }; +} diff --git a/tools/release/src/release-note/publish.ts b/tools/release/src/release-note/publish.ts new file mode 100644 index 0000000000..c013d58233 --- /dev/null +++ b/tools/release/src/release-note/publish.ts @@ -0,0 +1,68 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { releaseChannelDescriptor } from "@open-design/release"; + +import { optional, required, storageConfigFromEnv, writeJson } from "../storage/common.ts"; +import { getStorageObject, putStorageObjectWithStatus } from "../storage/s3-upload.ts"; +import { assertReleaseNotePlanPolicy } from "./policy.ts"; +import { createReleaseNotePublication } from "./publication.ts"; +import { parseReleaseNotePlan } from "./source.ts"; + +const channel = releaseChannelDescriptor(required("RELEASE_CHANNEL")).channel; +const releaseVersion = required("RELEASE_VERSION"); +const publicOrigin = required("RELEASE_PUBLIC_ORIGIN"); +const planPath = required("RELEASE_NOTE_PLAN_PATH"); +const publicationPath = required("RELEASE_NOTE_MANIFEST_PATH"); +const publishSideEffectsEnabled = optional("RELEASE_PUBLISH_SIDE_EFFECTS", "true") !== "false"; +const versionPrefix = `${channel}/versions/${releaseVersion}`; +const plan = parseReleaseNotePlan(JSON.parse(readFileSync(planPath, "utf8")) as unknown); + +if (plan.channel !== channel || plan.releaseVersion !== releaseVersion) { + throw new Error(`release note plan identity mismatch for ${channel} ${releaseVersion}`); +} +assertReleaseNotePlanPolicy(plan, channel); + +const storage = publishSideEffectsEnabled && plan.state === "ready" ? storageConfigFromEnv() : null; + +for (const entry of plan.entries) { + const body = readFileSync(entry.sourcePath); + const sha256 = createHash("sha256").update(body).digest("hex"); + if (body.byteLength !== entry.size || sha256 !== entry.sha256) { + throw new Error(`release note source changed after planning: ${entry.sourcePath}`); + } + const objectKey = `${versionPrefix}/release-notes/${entry.name}`; + if (!publishSideEffectsEnabled) { + console.log(`[dry-run] would publish immutable release note ${objectKey}`); + continue; + } + if (storage == null) throw new Error("storage config is required to publish release notes"); + const result = await putStorageObjectWithStatus({ + ...storage, + body, + cacheControl: "public, max-age=31536000, immutable", + contentType: entry.mediaType, + headers: { "if-none-match": "*" }, + objectKey, + }); + if (result.ok) continue; + if (result.status !== 412) { + throw new Error(`PUT ${result.url} failed with HTTP ${result.status}${result.body.length > 0 ? `: ${result.body}` : ""}`); + } + const existing = await getStorageObject({ ...storage, objectKey }); + if (existing == null) throw new Error(`release note object disappeared after immutable PUT conflict: ${objectKey}`); + const existingSize = existing.bytes.byteLength; + const existingSha256 = createHash("sha256").update(existing.bytes).digest("hex"); + if (existingSize !== entry.size || existingSha256 !== entry.sha256) { + throw new Error(`immutable release note already exists with different content: ${objectKey}`); + } + console.log(`reused identical immutable release note ${objectKey}`); +} + +const publication = createReleaseNotePublication(plan, { + publicOrigin, + published: publishSideEffectsEnabled, + versionPrefix, +}); +writeJson(publicationPath, publication); +console.log(`${publication.state} release note publication for ${channel} ${releaseVersion}`); diff --git a/tools/release/src/release-note/source.ts b/tools/release/src/release-note/source.ts new file mode 100644 index 0000000000..f3f90f49f5 --- /dev/null +++ b/tools/release/src/release-note/source.ts @@ -0,0 +1,246 @@ +import { createHash } from "node:crypto"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { isReleaseChannel, parseReleaseVersion, type ReleaseChannel } from "@open-design/release"; +import { parseDocument } from "yaml"; + +export const RELEASE_NOTE_MEDIA_TYPE = "text/markdown; charset=utf-8"; +export const RELEASE_NOTE_DEFAULT_LOCALE = "en"; + +const MAX_RELEASE_NOTE_BYTES = 1024 * 1024; +const MAX_TITLE_LENGTH = 200; +const MAX_DESCRIPTION_LENGTH = 500; + +export type ReleaseNotePlanEntry = { + description: string; + locale: string; + mediaType: typeof RELEASE_NOTE_MEDIA_TYPE; + name: string; + sha256: string; + size: number; + sourcePath: string; + title: string; +}; + +export type ReleaseNotePlan = { + channel: ReleaseChannel; + defaultLocale: typeof RELEASE_NOTE_DEFAULT_LOCALE; + entries: ReleaseNotePlanEntry[]; + kind: "open-design-release-note-plan"; + releaseVersion: string; + sourceDirectory: string; + state: "absent" | "ready"; + version: 1; +}; + +type DiscoverReleaseNotePlanOptions = { + channel: ReleaseChannel; + releaseVersion: string; + sourceRoot: string; +}; + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} + +function requiredString(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`release note plan ${field} must be a non-empty string`); + } + return value; +} + +function requiredInteger(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`release note plan ${field} must be a non-negative integer`); + } + return value; +} + +function canonicalLocale(fileName: string): string { + const locale = fileName.slice(0, -".md".length); + if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale)) { + throw new Error(`release note filename must be a BCP 47 locale followed by .md; got ${fileName}`); + } + let canonical: string; + try { + canonical = Intl.getCanonicalLocales(locale)[0] ?? ""; + } catch { + throw new Error(`release note filename contains an invalid locale: ${fileName}`); + } + if (canonical !== locale) { + throw new Error(`release note locale must use canonical casing: expected ${canonical}.md, got ${fileName}`); + } + return locale; +} + +function requiredFrontMatterString( + frontMatter: Record, + field: "title" | "description", + maxLength: number, + path: string, +): string { + const value = frontMatter[field]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`release note ${path} front matter requires a non-empty ${field}`); + } + const normalized = value.trim(); + if (normalized.length > maxLength) { + throw new Error(`release note ${path} front matter ${field} exceeds ${maxLength} characters`); + } + return normalized; +} + +function parseMarkdown(path: string, buffer: Buffer): Pick { + if (buffer.byteLength === 0) throw new Error(`release note ${path} is empty`); + if (buffer.byteLength > MAX_RELEASE_NOTE_BYTES) { + throw new Error(`release note ${path} exceeds ${MAX_RELEASE_NOTE_BYTES} bytes`); + } + + const decoder = new TextDecoder("utf-8", { fatal: true }); + let raw: string; + try { + raw = decoder.decode(buffer); + } catch { + throw new Error(`release note ${path} must be valid UTF-8`); + } + const text = raw.startsWith("\uFEFF") ? raw.slice(1) : raw; + const match = /^---[\t ]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[\t ]*(?:\r?\n|$)([\s\S]*)$/.exec(text); + if (match?.[1] == null || match[2] == null) { + throw new Error(`release note ${path} requires YAML front matter delimited by ---`); + } + + const document = parseDocument(match[1], { prettyErrors: true, uniqueKeys: true }); + if (document.errors.length > 0) { + throw new Error(`release note ${path} has invalid YAML front matter: ${document.errors[0]?.message ?? "unknown error"}`); + } + const value = document.toJS() as unknown; + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`release note ${path} front matter must be a mapping`); + } + if (match[2].trim().length === 0) { + throw new Error(`release note ${path} requires a non-empty Markdown body`); + } + + const frontMatter = value as Record; + return { + description: requiredFrontMatterString(frontMatter, "description", MAX_DESCRIPTION_LENGTH, path), + title: requiredFrontMatterString(frontMatter, "title", MAX_TITLE_LENGTH, path), + }; +} + +export function discoverReleaseNotePlan(options: DiscoverReleaseNotePlanOptions): ReleaseNotePlan { + parseReleaseVersion(options.releaseVersion, options.channel); + const sourceDirectory = resolve(options.sourceRoot, `v${options.releaseVersion}`); + if (!existsSync(sourceDirectory)) { + return { + channel: options.channel, + defaultLocale: RELEASE_NOTE_DEFAULT_LOCALE, + entries: [], + kind: "open-design-release-note-plan", + releaseVersion: options.releaseVersion, + sourceDirectory, + state: "absent", + version: 1, + }; + } + if (!statSync(sourceDirectory).isDirectory()) { + throw new Error(`release note source must be a directory: ${sourceDirectory}`); + } + + const directoryEntries = readdirSync(sourceDirectory, { withFileTypes: true }); + if (directoryEntries.length === 0) { + throw new Error(`release note directory is present but empty: ${sourceDirectory}`); + } + const unsupported = directoryEntries.filter((entry) => !entry.isFile() || !entry.name.endsWith(".md")); + if (unsupported.length > 0) { + throw new Error( + `release note directory only supports flat locale Markdown files; found ${unsupported.map((entry) => entry.name).join(", ")}`, + ); + } + + const entries = directoryEntries + .map((entry): ReleaseNotePlanEntry => { + const locale = canonicalLocale(entry.name); + const sourcePath = join(sourceDirectory, entry.name); + const buffer = readFileSync(sourcePath); + const fields = parseMarkdown(sourcePath, buffer); + return { + ...fields, + locale, + mediaType: RELEASE_NOTE_MEDIA_TYPE, + name: entry.name, + sha256: createHash("sha256").update(buffer).digest("hex"), + size: buffer.byteLength, + sourcePath, + }; + }) + .sort((left, right) => left.locale < right.locale ? -1 : left.locale > right.locale ? 1 : 0); + + return { + channel: options.channel, + defaultLocale: RELEASE_NOTE_DEFAULT_LOCALE, + entries, + kind: "open-design-release-note-plan", + releaseVersion: options.releaseVersion, + sourceDirectory, + state: entries.length === 0 ? "absent" : "ready", + version: 1, + }; +} + +export function parseReleaseNotePlan(value: unknown): ReleaseNotePlan { + if (!isRecord(value) || value.kind !== "open-design-release-note-plan" || value.version !== 1) { + throw new Error("invalid release note plan header"); + } + if (!isReleaseChannel(value.channel)) throw new Error(`invalid release note plan channel: ${String(value.channel)}`); + if (value.state !== "absent" && value.state !== "ready") { + throw new Error(`invalid release note plan state: ${String(value.state)}`); + } + if (value.defaultLocale !== RELEASE_NOTE_DEFAULT_LOCALE) { + throw new Error(`release note plan defaultLocale must be ${RELEASE_NOTE_DEFAULT_LOCALE}`); + } + if (!Array.isArray(value.entries)) throw new Error("release note plan entries must be an array"); + const entries = value.entries.map((entry, index): ReleaseNotePlanEntry => { + if (!isRecord(entry)) throw new Error(`release note plan entries[${index}] must be an object`); + const sha256 = requiredString(entry.sha256, `entries[${index}].sha256`); + if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error(`release note plan entries[${index}].sha256 is invalid`); + if (entry.mediaType !== RELEASE_NOTE_MEDIA_TYPE) { + throw new Error(`release note plan entries[${index}].mediaType is unsupported`); + } + const name = requiredString(entry.name, `entries[${index}].name`); + const locale = requiredString(entry.locale, `entries[${index}].locale`); + if (canonicalLocale(name) !== locale) { + throw new Error(`release note plan entries[${index}] locale does not match its filename`); + } + return { + description: requiredString(entry.description, `entries[${index}].description`), + locale, + mediaType: RELEASE_NOTE_MEDIA_TYPE, + name, + sha256, + size: requiredInteger(entry.size, `entries[${index}].size`), + sourcePath: requiredString(entry.sourcePath, `entries[${index}].sourcePath`), + title: requiredString(entry.title, `entries[${index}].title`), + }; + }); + if ((value.state === "absent") !== (entries.length === 0)) { + throw new Error("release note plan state does not match its entries"); + } + if (new Set(entries.map((entry) => entry.locale)).size !== entries.length) { + throw new Error("release note plan contains duplicate locales"); + } + const releaseVersion = requiredString(value.releaseVersion, "releaseVersion"); + parseReleaseVersion(releaseVersion, value.channel); + return { + channel: value.channel, + defaultLocale: RELEASE_NOTE_DEFAULT_LOCALE, + entries, + kind: "open-design-release-note-plan", + releaseVersion, + sourceDirectory: requiredString(value.sourceDirectory, "sourceDirectory"), + state: value.state, + version: 1, + }; +} diff --git a/tools/release/src/release-note/verify.ts b/tools/release/src/release-note/verify.ts new file mode 100644 index 0000000000..f59e06ebc3 --- /dev/null +++ b/tools/release/src/release-note/verify.ts @@ -0,0 +1,46 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { releaseChannelDescriptor } from "@open-design/release"; + +import { optional, required, storageConfigFromEnv } from "../storage/common.ts"; +import { getStorageObject } from "../storage/s3-upload.ts"; +import { assertReleaseNotePlanPolicy } from "./policy.ts"; +import { parseReleaseNotePublication, verifyReleaseNotePublication } from "./publication.ts"; +import { parseReleaseNotePlan } from "./source.ts"; + +const channel = releaseChannelDescriptor(required("RELEASE_CHANNEL")).channel; +const releaseVersion = required("RELEASE_VERSION"); +const publicOrigin = required("RELEASE_PUBLIC_ORIGIN"); +const planPath = required("RELEASE_NOTE_PLAN_PATH"); +const publicationPath = required("RELEASE_NOTE_MANIFEST_PATH"); +const publishSideEffectsEnabled = optional("RELEASE_PUBLISH_SIDE_EFFECTS", "true") !== "false"; +const versionPrefix = `${channel}/versions/${releaseVersion}`; +const plan = parseReleaseNotePlan(JSON.parse(readFileSync(planPath, "utf8")) as unknown); +const publication = parseReleaseNotePublication(JSON.parse(readFileSync(publicationPath, "utf8")) as unknown); + +if (plan.channel !== channel || plan.releaseVersion !== releaseVersion) { + throw new Error(`release note plan identity mismatch for ${channel} ${releaseVersion}`); +} +assertReleaseNotePlanPolicy(plan, channel); +verifyReleaseNotePublication(plan, publication, { + publicOrigin, + requirePublished: publishSideEffectsEnabled && plan.state === "ready", + versionPrefix, +}); + +if (publishSideEffectsEnabled && publication.state === "published") { + const storage = storageConfigFromEnv(); + for (const entry of publication.entries) { + const objectKey = `${versionPrefix}/release-notes/${entry.name}`; + const object = await getStorageObject({ ...storage, objectKey }); + if (object == null) throw new Error(`published release note is missing: ${objectKey}`); + const size = object.bytes.byteLength; + const sha256 = createHash("sha256").update(object.bytes).digest("hex"); + if (size !== entry.size || sha256 !== entry.sha256) { + throw new Error(`published release note failed integrity verification: ${objectKey}`); + } + } +} + +console.log(`verified ${publication.state} release notes for ${channel} ${releaseVersion}`); diff --git a/tools/release/src/storage/common.ts b/tools/release/src/storage/common.ts index cdb44bc62b..b0904c2e90 100644 --- a/tools/release/src/storage/common.ts +++ b/tools/release/src/storage/common.ts @@ -59,6 +59,7 @@ export function contentType(name: string): string { if (name.endsWith(".yml") || name.endsWith(".yaml")) return "application/x-yaml; charset=utf-8"; if (name.endsWith(".json")) return "application/json; charset=utf-8"; if (name.endsWith(".html")) return "text/html; charset=utf-8"; + if (name.endsWith(".md")) return "text/markdown; charset=utf-8"; if (name.endsWith(".log") || name.endsWith(".txt")) return "text/plain; charset=utf-8"; if (name.endsWith(".png")) return "image/png"; if (name.endsWith(".xml")) return "application/xml; charset=utf-8"; diff --git a/tools/release/src/storage/publish-metadata.ts b/tools/release/src/storage/publish-metadata.ts index 47fc91f9e3..56f1692c97 100644 --- a/tools/release/src/storage/publish-metadata.ts +++ b/tools/release/src/storage/publish-metadata.ts @@ -18,6 +18,10 @@ import { releaseMetadataVersionFields, type CountedReleaseChannel, } from "@open-design/release"; +import { + parseReleaseNotePublication, + releaseNoteMetadataFromPublication, +} from "../release-note/publication.ts"; type PlatformManifest = { artifacts?: Record; @@ -56,6 +60,7 @@ 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 releaseNoteManifestPath = optional("RELEASE_NOTE_MANIFEST_PATH"); const outputsPath = required("RELEASE_OUTPUTS_PATH"); const requestedAssetVersionSuffix = optional("RELEASE_ASSET_SUFFIX"); const dryRunMode = optional("RELEASE_DRY_RUN_MODE"); @@ -71,6 +76,29 @@ const versionLockKey = optional( const latestCasRequired = process.env.RELEASE_LATEST_CAS_REQUIRED === "true"; const storage = publishSideEffectsEnabled || versionLockRequired ? storageConfigFromEnv() : null; +function readReleaseNoteMetadata(): ReturnType { + if (releaseNoteManifestPath.length === 0) { + if (releaseChannel === "stable") { + throw new Error("RELEASE_NOTE_MANIFEST_PATH is required for stable metadata publication"); + } + return null; + } + const publication = parseReleaseNotePublication(JSON.parse(readFileSync(releaseNoteManifestPath, "utf8")) as unknown); + if (publication.channel !== releaseChannel || publication.releaseVersion !== releaseVersion) { + throw new Error(`release note publication identity mismatch for ${releaseChannel} ${releaseVersion}`); + } + if (releaseChannel === "stable" && publication.state === "absent") { + throw new Error("release note publication is required for stable metadata"); + } + if (publication.state !== "absent") { + const expectedState = publishSideEffectsEnabled ? "published" : "planned"; + if (publication.state !== expectedState) { + throw new Error(`release note publication must be ${expectedState} before metadata publication; got ${publication.state}`); + } + } + return releaseNoteMetadataFromPublication(publication); +} + if (versionLockRequired) { if (countedReleaseChannel == null) { throw new Error("stable releases do not use counted version reservations"); @@ -294,6 +322,7 @@ if (assetVersionSuffix === "auto") { const versionPrefix = optional("RELEASE_VERSION_PREFIX", `${releaseChannel}/versions/${releaseVersion}${assetVersionSuffix}`); const latestMetadataUpdated = releaseState === "complete"; +const releaseNote = readReleaseNoteMetadata(); const releaseFields = releaseMetadataFields(); const metadata = { ...releaseFields, @@ -307,6 +336,7 @@ const metadata = { dryRun: !publishSideEffectsEnabled, dryRunMode, platforms, + ...(releaseNote == null ? {} : { releaseNote }), r2: { latestMetadataUrl: publicUrl(publicOrigin, latestPrefix, "metadata.json"), latestMetadataUpdated, diff --git a/tools/release/src/storage/s3-upload.ts b/tools/release/src/storage/s3-upload.ts index fc54bcd69b..4fefbcfaa2 100644 --- a/tools/release/src/storage/s3-upload.ts +++ b/tools/release/src/storage/s3-upload.ts @@ -11,7 +11,8 @@ export type StorageConfig = { }; type PutObjectOptions = StorageConfig & { - bodyPath: string; + body?: Buffer; + bodyPath?: string; cacheControl: string; contentType: string; headers?: Record; @@ -123,7 +124,10 @@ export async function putStorageObject(options: PutObjectOptions): Promise } export async function putStorageObjectWithStatus(options: PutObjectOptions): Promise<{ body: string; ok: boolean; status: number; url: string }> { - const body = readFileSync(options.bodyPath); + if (options.body == null && options.bodyPath == null) { + throw new Error("PUT storage object requires body or bodyPath"); + } + const body = options.body == null ? readFileSync(options.bodyPath ?? "") : Buffer.from(options.body); const payloadHash = hash(body); const { canonicalUri, url } = objectUrl(options, options.objectKey); // x-amz-date is filled in per attempt inside signedFetchWithRetry so the @@ -164,7 +168,7 @@ export async function putStorageObjectWithStatus(options: PutObjectOptions): Pro }; } -export async function getStorageObject(options: GetObjectOptions): Promise<{ etag: string; text: string } | null> { +export async function getStorageObject(options: GetObjectOptions): Promise<{ bytes: Buffer; etag: string; text: string } | null> { const payloadHash = hash(""); const { canonicalUri, url } = objectUrl(options, options.objectKey); const headers: Record = { @@ -195,9 +199,11 @@ export async function getStorageObject(options: GetObjectOptions): Promise<{ eta const text = await response.text().catch(() => ""); throw new Error(`GET ${url} failed with HTTP ${response.status}${text.length > 0 ? `: ${text}` : ""}`); } + const bytes = Buffer.from(await response.arrayBuffer()); return { + bytes, etag: response.headers.get("etag") ?? "", - text: await response.text(), + text: bytes.toString("utf8"), }; } diff --git a/tools/release/src/storage/verify-metadata.ts b/tools/release/src/storage/verify-metadata.ts index 7321d57baf..e49ec74466 100644 --- a/tools/release/src/storage/verify-metadata.ts +++ b/tools/release/src/storage/verify-metadata.ts @@ -1,6 +1,7 @@ import { optional, required } from "./common.ts"; import { releaseChannelDescriptor } from "@open-design/release"; import { readFile } from "node:fs/promises"; +import { parseReleaseNotePublication, releaseNoteMetadataFromPublication } from "../release-note/publication.ts"; const releaseDescriptor = releaseChannelDescriptor(required("RELEASE_CHANNEL")); const releaseChannel = releaseDescriptor.channel; @@ -8,6 +9,7 @@ const metadataPath = optional("RELEASE_METADATA_PATH"); const metadataUrl = metadataPath.length > 0 ? optional("RELEASE_METADATA_URL", `file://${metadataPath}`) : required("RELEASE_METADATA_URL"); const releaseVersion = required("RELEASE_VERSION"); const cacheBuster = optional("RELEASE_CACHE_BUSTER", "local"); +const releaseNoteManifestPath = optional("RELEASE_NOTE_MANIFEST_PATH"); const metadata = (metadataPath.length > 0 ? JSON.parse(await readFile(metadataPath, "utf8")) @@ -35,6 +37,23 @@ if (metadata[versionField] !== releaseVersion) { throw new Error(`metadata ${versionField} mismatch: expected ${releaseVersion}, got ${String(metadata[versionField])}`); } +if (releaseNoteManifestPath.length === 0) { + if (releaseChannel === "stable") { + throw new Error("RELEASE_NOTE_MANIFEST_PATH is required to verify stable metadata"); + } +} else { + const publication = parseReleaseNotePublication(JSON.parse(await readFile(releaseNoteManifestPath, "utf8")) as unknown); + if (publication.channel !== releaseChannel || publication.releaseVersion !== releaseVersion) { + throw new Error(`release note publication identity mismatch for ${releaseChannel} ${releaseVersion}`); + } + const expectedReleaseNote = releaseNoteMetadataFromPublication(publication); + if (expectedReleaseNote == null) { + if (metadata.releaseNote != null) throw new Error("metadata unexpectedly contains releaseNote"); + } else if (JSON.stringify(metadata.releaseNote) !== JSON.stringify(expectedReleaseNote)) { + throw new Error("metadata releaseNote does not match its publication manifest"); + } +} + 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]; diff --git a/tools/release/tests/release-note.test.ts b/tools/release/tests/release-note.test.ts new file mode 100644 index 0000000000..085a072bf1 --- /dev/null +++ b/tools/release/tests/release-note.test.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + createReleaseNotePublication, + releaseNoteMetadataFromPublication, + verifyReleaseNotePublication, +} from "../src/release-note/publication.js"; +import { assertReleaseNotePlanPolicy } from "../src/release-note/policy.js"; +import { discoverReleaseNotePlan } from "../src/release-note/source.js"; + +const roots: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "od-release-note-")); + roots.push(root); + return root; +} + +async function writeNote( + root: string, + releaseVersion: string, + locale: string, + options: { body?: string; description?: string; title?: string } = {}, +): Promise { + const directory = join(root, `v${releaseVersion}`); + await mkdir(directory, { recursive: true }); + const value = [ + "---", + `title: ${options.title ?? `Open Design ${releaseVersion}`}`, + `description: ${options.description ?? `Release notes for ${releaseVersion}.`}`, + "---", + "", + options.body ?? "## Improvements\n\nA deterministic release note body.", + "", + ].join("\n"); + const path = join(directory, `${locale}.md`); + await writeFile(path, value, "utf8"); + return value; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +describe("release note source discovery", () => { + it("uses the exact full release version and parses self-describing Markdown", async () => { + const root = await temporaryRoot(); + const raw = await writeNote(root, "1.2.3-beta.4", "en", { + description: "Beta release details.", + title: "Beta 4", + }); + await writeNote(root, "1.2.3-beta.4", "zh-CN"); + + const plan = discoverReleaseNotePlan({ + channel: "beta", + releaseVersion: "1.2.3-beta.4", + sourceRoot: root, + }); + + expect(plan.state).toBe("ready"); + expect(plan.entries.map((entry) => entry.locale)).toEqual(["en", "zh-CN"]); + expect(plan.entries[0]).toMatchObject({ + description: "Beta release details.", + mediaType: "text/markdown; charset=utf-8", + name: "en.md", + sha256: createHash("sha256").update(raw).digest("hex"), + title: "Beta 4", + }); + + expect(discoverReleaseNotePlan({ + channel: "stable", + releaseVersion: "1.2.3", + sourceRoot: root, + }).state).toBe("absent"); + }); + + it("rejects supplied Markdown without valid front matter or body", async () => { + const root = await temporaryRoot(); + const directory = join(root, "v1.2.3-preview.2"); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "en.md"), "# No front matter\n", "utf8"); + + expect(() => discoverReleaseNotePlan({ + channel: "preview", + releaseVersion: "1.2.3-preview.2", + sourceRoot: root, + })).toThrow(/front matter/i); + + await writeFile( + join(directory, "en.md"), + "---\ntitle: Preview\ndescription: Preview details.\n---\n\n", + "utf8", + ); + expect(() => discoverReleaseNotePlan({ + channel: "preview", + releaseVersion: "1.2.3-preview.2", + sourceRoot: root, + })).toThrow(/body/i); + }); + + it("rejects an explicitly supplied empty release-note directory", async () => { + const root = await temporaryRoot(); + await mkdir(join(root, "v1.2.3-beta.5"), { recursive: true }); + + expect(() => discoverReleaseNotePlan({ + channel: "beta", + releaseVersion: "1.2.3-beta.5", + sourceRoot: root, + })).toThrow(/present but empty/i); + }); +}); + +describe("release note channel policy", () => { + it("keeps discovery channel-neutral and makes stable require en and zh-CN", async () => { + const root = await temporaryRoot(); + await writeNote(root, "1.2.3", "en"); + const plan = discoverReleaseNotePlan({ + channel: "stable", + releaseVersion: "1.2.3", + sourceRoot: root, + }); + + expect(() => assertReleaseNotePlanPolicy(plan, "stable")).toThrow(/zh-CN/); + expect(() => assertReleaseNotePlanPolicy({ ...plan, channel: "beta" }, "beta")).not.toThrow(); + }); + + it("allows absent notes outside stable and rejects them for stable", async () => { + const root = await temporaryRoot(); + const absent = discoverReleaseNotePlan({ + channel: "prerelease", + releaseVersion: "1.2.3-prerelease.1", + sourceRoot: root, + }); + + expect(() => assertReleaseNotePlanPolicy(absent, "prerelease")).not.toThrow(); + expect(() => assertReleaseNotePlanPolicy({ ...absent, channel: "stable" }, "stable")).toThrow(/required/i); + }); +}); + +describe("release note publication contract", () => { + it("projects a planned publication into storage-neutral public metadata", async () => { + const root = await temporaryRoot(); + await writeNote(root, "1.2.3", "en"); + await writeNote(root, "1.2.3", "zh-CN"); + const plan = discoverReleaseNotePlan({ + channel: "stable", + releaseVersion: "1.2.3", + sourceRoot: root, + }); + const publication = createReleaseNotePublication(plan, { + publicOrigin: "https://releases.example.test", + published: false, + versionPrefix: "stable/versions/1.2.3", + }); + + expect(publication.state).toBe("planned"); + expect(() => verifyReleaseNotePublication(plan, publication, { requirePublished: false })).not.toThrow(); + expect(() => verifyReleaseNotePublication(plan, publication, { requirePublished: true })).toThrow(/published/i); + expect(releaseNoteMetadataFromPublication(publication)).toEqual({ + content: { + defaultLocale: "en", + locales: { + en: { + mediaType: "text/markdown; charset=utf-8", + sha256: plan.entries[0]?.sha256, + size: plan.entries[0]?.size, + url: "https://releases.example.test/stable/versions/1.2.3/release-notes/en.md", + }, + "zh-CN": { + mediaType: "text/markdown; charset=utf-8", + sha256: plan.entries[1]?.sha256, + size: plan.entries[1]?.size, + url: "https://releases.example.test/stable/versions/1.2.3/release-notes/zh-CN.md", + }, + }, + }, + }); + }); +}); diff --git a/tools/serve/tests/release-metadata-publish.test.ts b/tools/serve/tests/release-metadata-publish.test.ts index 77882fb66b..91d4ba1a9a 100644 --- a/tools/serve/tests/release-metadata-publish.test.ts +++ b/tools/serve/tests/release-metadata-publish.test.ts @@ -29,6 +29,22 @@ function runNode(args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }); } +async function writeReleaseNote( + root: string, + version: string, + locale: string, + body = "A fixture release note.", + options: { bom?: boolean } = {}, +): Promise { + const directory = join(root, `v${version}`); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, `${locale}.md`), + `${options.bom === true ? "\uFEFF" : ""}---\ntitle: Open Design ${version}\ndescription: Release notes for ${version}.\n---\n\n## Changes\n\n${body}\n`, + "utf8", + ); +} + describe("shared release metadata publisher", () => { it("publishes complete beta, prerelease, preview, and stable metadata through the release storage fixture", async () => { const repoRoot = resolve(import.meta.dirname, "../../.."); @@ -43,7 +59,12 @@ describe("shared release metadata publisher", () => { ] as const) { const manifestDir = join(root, channel, "manifests"); const metadataDir = join(root, channel, "metadata"); + const releaseNoteRoot = join(root, channel, "CHANGELOG"); + const releaseNotePlanPath = join(metadataDir, "release-note-plan.json"); + const releaseNoteManifestPath = join(metadataDir, "release-note-publication.json"); await mkdir(manifestDir, { recursive: true }); + await writeReleaseNote(releaseNoteRoot, version, "en", "A fixture release note.", { bom: channel === "beta" }); + await writeReleaseNote(releaseNoteRoot, version, "zh-CN"); const base = { channel, enabled: true, @@ -105,6 +126,9 @@ describe("shared release metadata publisher", () => { RELEASE_METADATA_DIR: metadataDir, RELEASE_OUTPUTS_PATH: join(metadataDir, "outputs.json"), RELEASE_PUBLIC_ORIGIN: "https://releases.example.test", + RELEASE_NOTE_MANIFEST_PATH: releaseNoteManifestPath, + RELEASE_NOTE_PLAN_PATH: releaseNotePlanPath, + RELEASE_NOTE_SOURCE_ROOT: releaseNoteRoot, RELEASE_RUN_ID: "42", RELEASE_SIGNED: "true", RELEASE_STORAGE_ACCESS_KEY_ID: "ak", @@ -117,10 +141,26 @@ describe("shared release metadata publisher", () => { WIN_X64_RESULT: "success", ...(channel === "beta" ? { RELEASE_LATEST_CAS_REQUIRED: "true" } : {}), }; + await runNode(["--experimental-strip-types", "tools/release/src/release-note/prepare.ts"], { + cwd: repoRoot, + env, + }); + await runNode(["--experimental-strip-types", "tools/release/src/release-note/publish.ts"], { + cwd: repoRoot, + env, + }); + await runNode(["--experimental-strip-types", "tools/release/src/release-note/verify.ts"], { + cwd: repoRoot, + env, + }); await runNode(["--experimental-strip-types", "tools/release/src/storage/publish-metadata.ts"], { cwd: repoRoot, env, }); + await runNode(["--experimental-strip-types", "tools/release/src/storage/verify-metadata.ts"], { + cwd: repoRoot, + env: { ...env, RELEASE_METADATA_PATH: join(metadataDir, "metadata.json") }, + }); const metadata = JSON.parse(await readFile(join(metadataDir, "metadata.json"), "utf8")) as { channel?: string; @@ -133,6 +173,12 @@ describe("shared release metadata publisher", () => { signed?: boolean; stableVersion?: string; github?: { commit?: string }; + releaseNote?: { + content?: { + defaultLocale?: string; + locales?: Record; + }; + }; }; expect(metadata.channel).toBe(channel); expect(metadata.releaseState).toBe("complete"); @@ -143,13 +189,117 @@ describe("shared release metadata publisher", () => { // github attribution must round-trip from the RELEASE_* env the workflow // passes; the stable promotion gate checks metadata.github.commit. expect(metadata.github?.commit).toBe("abc123"); + expect(metadata.releaseNote?.content?.defaultLocale).toBe("en"); + expect(Object.keys(metadata.releaseNote?.content?.locales ?? {})).toEqual(["en", "zh-CN"]); + expect(metadata.releaseNote?.content?.locales?.en?.url).toBe( + `https://releases.example.test/${channel}/versions/${version}/release-notes/en.md`, + ); + expect(metadata.releaseNote?.content?.locales?.en?.mediaType).toBe("text/markdown; charset=utf-8"); if (channel === "stable") { expect(metadata.stableVersion).toBe("1.2.3"); } expect(server.getObject(`${channel}/latest/metadata.json`)).not.toBeNull(); + expect(server.getObject(`${channel}/versions/${version}/release-notes/en.md`)?.toString("utf8")).toContain( + `title: Open Design ${version}`, + ); + + if (channel === "beta") { + await runNode(["--experimental-strip-types", "tools/release/src/release-note/publish.ts"], { + cwd: repoRoot, + env, + }); + await writeReleaseNote(releaseNoteRoot, version, "en", "Changed content must not replace the immutable object."); + await runNode(["--experimental-strip-types", "tools/release/src/release-note/prepare.ts"], { + cwd: repoRoot, + env, + }); + await expect(runNode(["--experimental-strip-types", "tools/release/src/release-note/publish.ts"], { + cwd: repoRoot, + env, + })).rejects.toThrow(/immutable release note already exists with different content/); + } } } finally { await server.close(); } }); + + it("builds planned release-note and metadata artifacts without storage access in dry-run mode", async () => { + const repoRoot = resolve(import.meta.dirname, "../../.."); + const root = await mkdtemp(join(tmpdir(), "od-release-metadata-dry-run-")); + const version = "1.2.3"; + const manifestDir = join(root, "manifests"); + const metadataDir = join(root, "metadata"); + const releaseNoteRoot = join(root, "CHANGELOG"); + const releaseNotePlanPath = join(metadataDir, "release-note-plan.json"); + const releaseNoteManifestPath = join(metadataDir, "release-note-publication.json"); + await mkdir(manifestDir, { recursive: true }); + await writeReleaseNote(releaseNoteRoot, version, "en"); + await writeReleaseNote(releaseNoteRoot, version, "zh-CN"); + await writeFile( + join(manifestDir, "mac_arm64.json"), + JSON.stringify({ + artifacts: { payload: { url: "https://releases.example.test/mac-payload" } }, + channel: "stable", + enabled: true, + github: { commit: "dry123", runId: 77 }, + legacyPlatformKey: "mac", + platformKey: "mac_arm64", + r2: { versionPrefix: `stable/versions/${version}` }, + releaseTarget: "mac_arm64", + releaseVersion: version, + signed: true, + status: "published", + }), + "utf8", + ); + + const env = { + ...process.env, + BASE_VERSION: version, + ENABLE_LINUX_X64: "false", + ENABLE_MAC_ARM64: "true", + ENABLE_MAC_X64: "false", + ENABLE_WIN_X64: "false", + MAC_ARM64_RESULT: "success", + RELEASE_CHANNEL: "stable", + RELEASE_COMMIT: "dry123", + RELEASE_DRY_RUN_MODE: "prepublish", + RELEASE_MANIFEST_DIR: manifestDir, + RELEASE_METADATA_DIR: metadataDir, + RELEASE_NOTE_MANIFEST_PATH: releaseNoteManifestPath, + RELEASE_NOTE_PLAN_PATH: releaseNotePlanPath, + RELEASE_NOTE_SOURCE_ROOT: releaseNoteRoot, + RELEASE_OUTPUTS_PATH: join(metadataDir, "outputs.json"), + RELEASE_PUBLIC_ORIGIN: "https://releases.example.test", + RELEASE_PUBLISH_SIDE_EFFECTS: "false", + RELEASE_RUN_ID: "77", + RELEASE_SIGNED: "true", + RELEASE_VERSION: version, + STATE_SOURCE: "local-dry-run", + STABLE_VERSION: version, + }; + + for (const script of [ + "tools/release/src/release-note/prepare.ts", + "tools/release/src/release-note/publish.ts", + "tools/release/src/release-note/verify.ts", + "tools/release/src/storage/publish-metadata.ts", + ]) { + await runNode(["--experimental-strip-types", script], { cwd: repoRoot, env }); + } + await runNode(["--experimental-strip-types", "tools/release/src/storage/verify-metadata.ts"], { + cwd: repoRoot, + env: { ...env, RELEASE_METADATA_PATH: join(metadataDir, "metadata.json") }, + }); + + const publication = JSON.parse(await readFile(releaseNoteManifestPath, "utf8")) as { state?: string }; + const metadata = JSON.parse(await readFile(join(metadataDir, "metadata.json"), "utf8")) as { + dryRun?: boolean; + releaseNote?: { content?: { locales?: Record } }; + }; + expect(publication.state).toBe("planned"); + expect(metadata.dryRun).toBe(true); + expect(Object.keys(metadata.releaseNote?.content?.locales ?? {})).toEqual(["en", "zh-CN"]); + }); });