feat: only include the current changelog section in tarball (#88107)

* build: package current changelog section

* build: guard packaged changelog section size
This commit is contained in:
Dallin Romney
2026-05-29 17:18:35 -07:00
committed by GitHub
parent c8a733eae5
commit bd04d2db0d
6 changed files with 420 additions and 14 deletions

View File

@@ -1603,6 +1603,7 @@
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"plugins:sync:check": "node --import tsx scripts/sync-plugin-versions.ts --check",
"postinstall": "node scripts/postinstall-bundled-plugins.mjs",
"postpack": "node scripts/package-changelog.mjs restore",
"preinstall": "node scripts/preinstall-package-manager-warning.mjs",
"prepack": "node --import tsx scripts/openclaw-prepack.ts",
"prepare": "node scripts/prepare-git-hooks.mjs",

View File

@@ -5,6 +5,7 @@ import { existsSync, readdirSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { writePackageDistInventory } from "../src/infra/package-dist-inventory.ts";
import { preparePackageChangelog } from "./package-changelog.mjs";
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs";
const requiredPreparedPathGroups = [
["dist/index.js", "dist/index.mjs"],
@@ -158,6 +159,7 @@ async function main(): Promise<void> {
ensurePreparedArtifacts();
await writeDistInventory();
runBuildSmoke();
await preparePackageChangelog();
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const CHANGELOG_PATH = "CHANGELOG.md";
const PACKAGE_JSON_PATH = "package.json";
const BACKUP_PATH = path.join(".artifacts", "package-changelog", "CHANGELOG.md.prepack-backup");
const MAX_PACKAGED_CHANGELOG_BYTES = 500 * 1024;
const MIN_RELEASE_SECTION_BODY_BYTES = 32;
const UNRELEASED_HEADING = "Unreleased";
const RELEASE_HEADING_PATTERN =
/^##\s+([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(?:(?:-(?:alpha|beta)\.[1-9][0-9]*)|(?:-[1-9][0-9]*))?)(?:\s+.*)?$/u;
const RELEASE_VERSION_PATTERN =
/^([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*)(?:(?:-(?:alpha|beta)\.[1-9][0-9]*)|(?:-[1-9][0-9]*))?$/u;
const PRERELEASE_VERSION_PATTERN =
/^([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*)-(?:alpha|beta)\.[1-9][0-9]*$/u;
export function resolvePackageChangelogVersions(packageVersion) {
const match = RELEASE_VERSION_PATTERN.exec(packageVersion);
if (!match) {
throw new Error(
`Unsupported OpenClaw package version for changelog packaging: ${packageVersion}`,
);
}
if (PRERELEASE_VERSION_PATTERN.test(packageVersion)) {
return [packageVersion, match[1], UNRELEASED_HEADING];
}
return [packageVersion];
}
function splitLines(content) {
return content.replace(/^\uFEFF/u, "").split(/\r?\n/u);
}
function parseLevelTwoHeading(line) {
const releaseMatch = RELEASE_HEADING_PATTERN.exec(line);
if (releaseMatch) {
return releaseMatch[1];
}
return /^##\s+Unreleased(?:\s+.*)?$/u.test(line) ? UNRELEASED_HEADING : null;
}
function findLevelTwoHeadings(lines) {
return lines.flatMap((line, index) => {
const version = parseLevelTwoHeading(line);
return version ? [{ index, version }] : [];
});
}
function extractPreamble(lines, firstHeadingIndex) {
return lines.slice(0, firstHeadingIndex).join("\n").trimEnd();
}
export function extractCurrentPackageChangelog(content, packageVersion) {
const targetVersions = resolvePackageChangelogVersions(packageVersion);
const lines = splitLines(content);
const headings = findLevelTwoHeadings(lines);
const heading = targetVersions
.map((version) => headings.find((entry) => entry.version === version))
.find((entry) => entry !== undefined);
if (!heading) {
throw new Error(
`CHANGELOG.md does not contain a release section for ${targetVersions.join(" or ")}.`,
);
}
const nextHeading = headings.find((entry) => entry.index > heading.index);
const firstLevelTwoHeadingIndex = lines.findIndex((line) => line.startsWith("## "));
const preamble = extractPreamble(lines, firstLevelTwoHeadingIndex);
const releaseSection = lines
.slice(heading.index, nextHeading?.index ?? lines.length)
.join("\n")
.trimEnd();
const releaseBody = releaseSection.split(/\r?\n/u).slice(1).join("\n").trim();
const releaseBodyBytes = Buffer.byteLength(releaseBody, "utf8");
if (releaseBodyBytes < MIN_RELEASE_SECTION_BODY_BYTES) {
throw new Error(
`Packaged changelog section for ${heading.version} is only ${releaseBodyBytes} body bytes, which is below the ${MIN_RELEASE_SECTION_BODY_BYTES} byte safety minimum.`,
);
}
const packaged = `${preamble}\n\n${releaseSection}\n`;
const packagedBytes = Buffer.byteLength(packaged, "utf8");
if (packagedBytes > MAX_PACKAGED_CHANGELOG_BYTES) {
throw new Error(
`Packaged changelog is ${packagedBytes} bytes, which exceeds the ${MAX_PACKAGED_CHANGELOG_BYTES} byte safety limit.`,
);
}
return packaged;
}
async function readPackageVersion(cwd) {
const packageJsonPath = path.join(cwd, PACKAGE_JSON_PATH);
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
if (typeof packageJson.version !== "string") {
throw new Error("package.json version must be a string.");
}
return packageJson.version;
}
export async function restorePackageChangelog(cwd = process.cwd()) {
const backupPath = path.join(cwd, BACKUP_PATH);
if (!existsSync(backupPath)) {
return false;
}
const changelogPath = path.join(cwd, CHANGELOG_PATH);
const backup = await readFile(backupPath, "utf8");
await writeFile(changelogPath, backup, "utf8");
await rm(backupPath, { force: true });
return true;
}
export async function preparePackageChangelog(cwd = process.cwd()) {
await restorePackageChangelog(cwd);
const changelogPath = path.join(cwd, CHANGELOG_PATH);
const backupPath = path.join(cwd, BACKUP_PATH);
const original = await readFile(changelogPath, "utf8");
const packageVersion = await readPackageVersion(cwd);
const packaged = extractCurrentPackageChangelog(original, packageVersion);
if (packaged === original) {
return false;
}
await mkdir(path.dirname(backupPath), { recursive: true });
await writeFile(backupPath, original, "utf8");
await writeFile(changelogPath, packaged, "utf8");
return true;
}
async function main(argv = process.argv.slice(2)) {
const command = argv[0];
if (command === "prepare") {
const changed = await preparePackageChangelog();
console.error(
changed
? "package-changelog: wrote current release notes for package tarball."
: "package-changelog: source changelog already matches package notes.",
);
return;
}
if (command === "restore") {
const restored = await restorePackageChangelog();
console.error(
restored
? "package-changelog: restored source CHANGELOG.md."
: "package-changelog: no packaged changelog backup to restore.",
);
return;
}
console.error("Usage: node scripts/package-changelog.mjs <prepare|restore>");
process.exitCode = 1;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}

View File

@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { preparePackageChangelog, restorePackageChangelog } from "./package-changelog.mjs";
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_PACKAGE_BUILD_TIMEOUT_MS = 45 * 60 * 1000;
@@ -22,6 +23,13 @@ const SIGNAL_EXIT_CODES = {
};
let forwardedSignalExitCode;
class ForwardedSignalExitError extends Error {
constructor(exitCode) {
super(`forwarded signal requested exit ${exitCode}`);
this.exitCode = exitCode;
}
}
for (const signal of Object.keys(SIGNAL_EXIT_CODES)) {
process.on(signal, () => {
forwardedSignalExitCode ??= SIGNAL_EXIT_CODES[signal];
@@ -112,6 +120,10 @@ function run(command, args, cwd, options = {}) {
}
ACTIVE_CHILD_KILLERS.delete(killChild);
if (forwardedSignalExitCode !== undefined && ACTIVE_CHILD_KILLERS.size === 0) {
if (options.deferForwardedSignalExit) {
reject(new ForwardedSignalExitError(forwardedSignalExitCode));
return;
}
process.exit(forwardedSignalExitCode);
}
if (error) {
@@ -245,6 +257,32 @@ async function newestOpenClawTarball(outputDir, packOutput) {
return path.join(outputDir, packed);
}
export async function packOpenClawPackageForDocker(sourceDir, outputDir, options = {}) {
const runCaptureImpl = options.runCaptureImpl ?? runCapture;
const prepareChangelog = options.prepareChangelog ?? preparePackageChangelog;
const restoreChangelog = options.restoreChangelog ?? restorePackageChangelog;
console.error("==> Packing OpenClaw package");
await prepareChangelog(sourceDir);
let packOutput = "";
try {
packOutput = await runCaptureImpl(
"npm",
["pack", "--silent", "--ignore-scripts", "--pack-destination", outputDir],
sourceDir,
{
deferForwardedSignalExit: true,
timeoutMs: resolveTimeoutMs(
"OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS",
DEFAULT_PACKAGE_PACK_TIMEOUT_MS,
),
},
);
} finally {
await restoreChangelog(sourceDir);
}
return await newestOpenClawTarball(outputDir, packOutput);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const sourceDir = path.resolve(ROOT_DIR, options.sourceDir || ROOT_DIR);
@@ -277,19 +315,7 @@ async function main() {
},
);
console.error("==> Packing OpenClaw package");
const packOutput = await runCapture(
"npm",
["pack", "--silent", "--ignore-scripts", "--pack-destination", outputDir],
sourceDir,
{
timeoutMs: resolveTimeoutMs(
"OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS",
DEFAULT_PACKAGE_PACK_TIMEOUT_MS,
),
},
);
let tarball = await newestOpenClawTarball(outputDir, packOutput);
let tarball = await packOpenClawPackageForDocker(sourceDir, outputDir);
if (options.outputName) {
const target = path.join(outputDir, options.outputName);
@@ -323,6 +349,6 @@ async function main() {
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
process.exit(Number.isInteger(error?.exitCode) ? error.exitCode : 1);
});
}

View File

@@ -0,0 +1,170 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
extractCurrentPackageChangelog,
preparePackageChangelog,
resolvePackageChangelogVersions,
restorePackageChangelog,
} from "../../scripts/package-changelog.mjs";
function changelog(strings: TemplateStringsArray, ...values: string[]) {
return `${String.raw({ raw: strings }, ...values)
.replace(/^\n/u, "")
.trimEnd()}\n`;
}
const cumulativeChangelog = changelog`
# Changelog
Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- Pending note.
## 2026.5.28
### Highlights
- Current highlight.
### Changes
- Current change.
### Fixes
- Current fix.
## 2026.5.27
### Highlights
- Older highlight.
`;
describe("package-changelog", () => {
it("maps release-channel package versions to package changelog candidate headings", () => {
expect(resolvePackageChangelogVersions("2026.5.28")).toEqual(["2026.5.28"]);
expect(resolvePackageChangelogVersions("2026.5.28-1")).toEqual(["2026.5.28-1"]);
expect(resolvePackageChangelogVersions("2026.5.28-beta.1")).toEqual([
"2026.5.28-beta.1",
"2026.5.28",
"Unreleased",
]);
expect(resolvePackageChangelogVersions("2026.5.28-alpha.2")).toEqual([
"2026.5.28-alpha.2",
"2026.5.28",
"Unreleased",
]);
});
it("extracts only the package version stable release section", () => {
expect(extractCurrentPackageChangelog(cumulativeChangelog, "2026.5.28-beta.1")).toBe(
changelog`
# Changelog
Docs: https://docs.openclaw.ai
## 2026.5.28
### Highlights
- Current highlight.
### Changes
- Current change.
### Fixes
- Current fix.
`,
);
});
it("prefers an exact prerelease section when it exists", () => {
const source = changelog`
# Changelog
## 2026.5.28-beta.2
- Beta 2 package notes with enough release detail.
## 2026.5.28
- Stable.
`;
expect(extractCurrentPackageChangelog(source, "2026.5.28-beta.2")).toBe(changelog`
# Changelog
## 2026.5.28-beta.2
- Beta 2 package notes with enough release detail.
`);
});
it("uses Unreleased only as a prerelease fallback when no release heading exists", () => {
const source = changelog`
# Changelog
## Unreleased
- Pending beta package notes with enough release detail.
## 2026.5.27
- Older stable.
`;
expect(extractCurrentPackageChangelog(source, "2026.5.28-beta.1")).toBe(changelog`
# Changelog
## Unreleased
- Pending beta package notes with enough release detail.
`);
});
it("extracts exact correction release sections", () => {
const source = changelog`
# Changelog
## 2026.5.28-1
- Correction release notes with enough detail.
## 2026.5.28
- Stable.
`;
expect(extractCurrentPackageChangelog(source, "2026.5.28-1")).toBe(changelog`
# Changelog
## 2026.5.28-1
- Correction release notes with enough detail.
`);
});
it("fails closed when package version has no matching release section", () => {
expect(() => extractCurrentPackageChangelog(cumulativeChangelog, "2026.5.29")).toThrow(
"CHANGELOG.md does not contain a release section for 2026.5.29.",
);
});
it("fails closed when the packaged changelog is unexpectedly large", () => {
const source = changelog`
# Changelog
## 2026.5.28
${"é".repeat(260_000)}
`;
expect(() => extractCurrentPackageChangelog(source, "2026.5.28")).toThrow(
"exceeds the 512000 byte safety limit",
);
});
it("fails closed when the extracted release section is effectively empty", () => {
const source = changelog`
# Changelog
Docs: https://docs.openclaw.ai
## 2026.5.28
### Fixes
## 2026.5.27
- Older stable release notes with enough detail.
`;
expect(() => extractCurrentPackageChangelog(source, "2026.5.28")).toThrow(
"below the 32 byte safety minimum",
);
});
it("prepares and restores the packaged changelog without changing the source permanently", async () => {
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-package-changelog-"));
try {
writeFileSync(path.join(root, "package.json"), '{"version":"2026.5.28-beta.1"}\n', "utf8");
writeFileSync(path.join(root, "CHANGELOG.md"), cumulativeChangelog, "utf8");
await expect(preparePackageChangelog(root)).resolves.toBe(true);
expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).not.toContain("## Unreleased");
expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).not.toContain("## 2026.5.27");
expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).toContain("## 2026.5.28");
await expect(restorePackageChangelog(root)).resolves.toBe(true);
expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).toBe(cumulativeChangelog);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

View File

@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import {
buildPackageArtifacts,
packOpenClawPackageForDocker,
runCommandForTest,
} from "../../scripts/package-openclaw-for-docker.mjs";
@@ -115,6 +116,56 @@ describe("package-openclaw-for-docker", () => {
]);
});
it("trims and restores the changelog around ignore-scripts package artifacts", async () => {
const calls: string[] = [];
const tarball = await packOpenClawPackageForDocker("/repo", "/out", {
prepareChangelog: async (cwd: string) => {
calls.push(`prepare:${cwd}`);
},
restoreChangelog: async (cwd: string) => {
calls.push(`restore:${cwd}`);
},
runCaptureImpl: async (
command: string,
args: string[],
cwd: string,
options: { deferForwardedSignalExit?: boolean },
) => {
calls.push(`${command}:${args.join(" ")}:${cwd}`);
expect(options.deferForwardedSignalExit).toBe(true);
return "openclaw-2026.5.28.tgz\n";
},
});
expect(tarball).toBe(path.join("/out", "openclaw-2026.5.28.tgz"));
expect(calls).toEqual([
"prepare:/repo",
"npm:pack --silent --ignore-scripts --pack-destination /out:/repo",
"restore:/repo",
]);
});
it("restores the changelog when ignore-scripts packaging fails", async () => {
const calls: string[] = [];
await expect(
packOpenClawPackageForDocker("/repo", "/out", {
prepareChangelog: async (cwd: string) => {
calls.push(`prepare:${cwd}`);
},
restoreChangelog: async (cwd: string) => {
calls.push(`restore:${cwd}`);
},
runCaptureImpl: async () => {
calls.push("pack");
throw new Error("pack failed");
},
}),
).rejects.toThrow("pack failed");
expect(calls).toEqual(["prepare:/repo", "pack", "restore:/repo"]);
});
it("kills timed-out child process groups", async () => {
if (process.platform === "win32") {
return;