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:
PerishFire
2026-07-13 08:21:31 +00:00
committed by GitHub
co-authored by Looper
parent 7e302031d3
commit 3e09ebf282
25 changed files with 1218 additions and 5 deletions
+25
View File
@@ -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' }}
+19
View File
@@ -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
+19
View File
@@ -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
+19
View File
@@ -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
+27
View File
@@ -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
+23
View File
@@ -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.
+14
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
---
title: Open Design 0.14.1
description: 可选择允许 Open Design 在下次启动时静默应用已下载的更新。
---
## 静默更新
现在可以允许 Open Design 在应用下次启动时静默应用已下载的更新。你可以在安装更新时启用该选项,也可以稍后在设置中管理。
只有明确点击安装后,这项偏好才会开启。它仅作用于启动时的自动更新检查,定期检查与手动安装流程仍保持原有行为。
+32
View File
@@ -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"),
+3
View File
@@ -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
+2
View File
@@ -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
+2 -1
View File
@@ -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",
+18
View File
@@ -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 () => {
+23
View File
@@ -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(", ")}`);
}
}
+22
View File
@@ -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,
};
}
+68
View File
@@ -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}`);
+246
View File
@@ -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,
};
}
+46
View File
@@ -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}`);
+1
View File
@@ -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,
+10 -4
View File
@@ -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];
+184
View File
@@ -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"]);
});
});