mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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 <looper@noreply.github.com>
This commit is contained in:
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Release note source contract
|
||||
|
||||
Release note content is stored under `docs/CHANGELOG/v<releaseVersion>/<locale>.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
|
||||
`<channel>/versions/<releaseVersion>/release-notes/<locale>.md`. Public release
|
||||
metadata contains only locale transport descriptors (`url`, `mediaType`,
|
||||
`sha256`, and `size`); render fields are read from the content itself.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Open Design 0.14.1
|
||||
description: 可选择允许 Open Design 在下次启动时静默应用已下载的更新。
|
||||
---
|
||||
|
||||
## 静默更新
|
||||
|
||||
现在可以允许 Open Design 在应用下次启动时静默应用已下载的更新。你可以在安装更新时启用该选项,也可以稍后在设置中管理。
|
||||
|
||||
只有明确点击安装后,这项偏好才会开启。它仅作用于启动时的自动更新检查,定期检查与手动安装流程仍保持原有行为。
|
||||
@@ -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"),
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -55,6 +55,8 @@ The runtime updater reads `https://releases.open-design.ai/<channel>/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<full-releaseVersion>/<locale>.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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
}
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
@@ -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<string, {
|
||||
mediaType: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>,
|
||||
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<ReleaseNotePlanEntry, "description" | "title"> {
|
||||
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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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";
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
releaseMetadataVersionFields,
|
||||
type CountedReleaseChannel,
|
||||
} from "@open-design/release";
|
||||
import {
|
||||
parseReleaseNotePublication,
|
||||
releaseNoteMetadataFromPublication,
|
||||
} from "../release-note/publication.ts";
|
||||
|
||||
type PlatformManifest = {
|
||||
artifacts?: Record<string, { url?: string }>;
|
||||
@@ -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<typeof releaseNoteMetadataFromPublication> {
|
||||
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,
|
||||
|
||||
@@ -11,7 +11,8 @@ export type StorageConfig = {
|
||||
};
|
||||
|
||||
type PutObjectOptions = StorageConfig & {
|
||||
bodyPath: string;
|
||||
body?: Buffer;
|
||||
bodyPath?: string;
|
||||
cacheControl: string;
|
||||
contentType: string;
|
||||
headers?: Record<string, string>;
|
||||
@@ -123,7 +124,10 @@ export async function putStorageObject(options: PutObjectOptions): Promise<void>
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string, { mediaType?: string; sha256?: string; size?: number; url?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
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<string, unknown> } };
|
||||
};
|
||||
expect(publication.state).toBe("planned");
|
||||
expect(metadata.dryRun).toBe(true);
|
||||
expect(Object.keys(metadata.releaseNote?.content?.locales ?? {})).toEqual(["en", "zh-CN"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user