diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cfc72551724..61346f7de803 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -965,6 +965,7 @@ jobs: node scripts/build-all.mjs qaRuntime pnpm ui:build node scripts/package-openclaw-for-docker.mjs \ + --allow-unreleased-changelog \ --skip-build \ --output-dir .artifacts/qa-e2e/smoke-ci-package \ --output-name openclaw-current.tgz diff --git a/scripts/package-changelog.mjs b/scripts/package-changelog.mjs index 5865e2b4789f..74f48f126f74 100644 --- a/scripts/package-changelog.mjs +++ b/scripts/package-changelog.mjs @@ -22,7 +22,7 @@ const PRERELEASE_VERSION_PATTERN = /** * Resolves acceptable changelog headings for a package version. */ -export function resolvePackageChangelogVersions(packageVersion) { +export function resolvePackageChangelogVersions(packageVersion, options = {}) { const match = RELEASE_VERSION_PATTERN.exec(packageVersion); if (!match) { throw new Error( @@ -32,7 +32,7 @@ export function resolvePackageChangelogVersions(packageVersion) { if (PRERELEASE_VERSION_PATTERN.test(packageVersion)) { return [packageVersion, match[1], UNRELEASED_HEADING]; } - return [packageVersion]; + return options.allowUnreleased ? [packageVersion, UNRELEASED_HEADING] : [packageVersion]; } function splitLines(content) { @@ -61,8 +61,8 @@ function extractPreamble(lines, firstHeadingIndex) { /** * Extracts the current release changelog section for package publishing. */ -export function extractCurrentPackageChangelog(content, packageVersion) { - const targetVersions = resolvePackageChangelogVersions(packageVersion); +export function extractCurrentPackageChangelog(content, packageVersion, options = {}) { + const targetVersions = resolvePackageChangelogVersions(packageVersion, options); const lines = splitLines(content); const headings = findLevelTwoHeadings(lines); const heading = targetVersions @@ -125,11 +125,17 @@ export async function restorePackageChangelog(cwd = process.cwd()) { try { expectedPackaged = extractCurrentPackageChangelog(backup, packageVersion); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error( - `Refusing to restore stale packaged changelog backup from ${BACKUP_PATH}: ${message}`, - { cause: error }, - ); + try { + expectedPackaged = extractCurrentPackageChangelog(backup, packageVersion, { + allowUnreleased: true, + }); + } catch { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Refusing to restore stale packaged changelog backup from ${BACKUP_PATH}: ${message}`, + { cause: error }, + ); + } } if (current !== expectedPackaged) { throw new Error( @@ -145,13 +151,13 @@ export async function restorePackageChangelog(cwd = process.cwd()) { /** * Writes packaged changelog content while preserving a restorable backup. */ -export async function preparePackageChangelog(cwd = process.cwd()) { +export async function preparePackageChangelog(cwd = process.cwd(), options = {}) { 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); + const packaged = extractCurrentPackageChangelog(original, packageVersion, options); if (packaged === original) { return false; } diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 333d77a04af3..b6ecc6730484 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -137,6 +137,7 @@ function resolvePackedOpenClawFileName(value) { export function parseArgs(argv) { const options = { + allowUnreleasedChangelog: false, outputDir: "", outputName: "", packJson: "", @@ -154,7 +155,9 @@ export function parseArgs(argv) { }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; - if (arg === "--output-dir") { + if (arg === "--allow-unreleased-changelog") { + setOnce(arg, "allowUnreleasedChangelog", true); + } else if (arg === "--output-dir") { setOnce("--output-dir", "outputDir", readOptionValue(argv, index, arg)); index += 1; } else if (arg?.startsWith("--output-dir=")) { @@ -653,7 +656,12 @@ export async function prepareBundledAiRuntimePackage( export async function packOpenClawPackageForDocker(sourceDir, outputDir, options = {}) { const runCaptureImpl = options.runCaptureImpl ?? runCapture; - const prepareChangelog = options.prepareChangelog ?? preparePackageChangelog; + const prepareChangelog = + options.prepareChangelog ?? + ((cwd) => + preparePackageChangelog(cwd, { + allowUnreleased: options.allowUnreleasedChangelog, + })); const restoreChangelog = options.restoreChangelog ?? restorePackageChangelog; const prepareBundledAiRuntime = options.prepareBundledAiRuntime ?? prepareBundledAiRuntimePackage; const packTool = options.pnpmPack ? "pnpm" : "npm"; @@ -740,6 +748,7 @@ async function main() { ); const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { + allowUnreleasedChangelog: options.allowUnreleasedChangelog, outputName: options.outputName, packJsonPath: options.packJson, pnpmPack: options.pnpmPack, diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 0b57950ea7c2..c479ea1f8edf 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -91,9 +91,11 @@ describe("package-openclaw-for-docker", () => { ".artifacts/docker/pack.json", "--source-dir", "/repo", + "--allow-unreleased-changelog", "--skip-build", ]), ).toEqual({ + allowUnreleasedChangelog: true, outputDir: ".artifacts/docker", outputName: "openclaw-current.tgz", packJson: ".artifacts/docker/pack.json", @@ -118,6 +120,10 @@ describe("package-openclaw-for-docker", () => { ["--output-dir", ["--output-dir", "one", "--output-dir=two"]], ["--output-name", ["--output-name", "one.tgz", "--output-name=two.tgz"]], ["--pack-json", ["--pack-json", "one.json", "--pack-json=two.json"]], + [ + "--allow-unreleased-changelog", + ["--allow-unreleased-changelog", "--allow-unreleased-changelog"], + ], ["--pnpm-pack", ["--pnpm-pack", "--pnpm-pack"]], ["--source-dir", ["--source-dir", "/repo-a", "--source-dir=/repo-b"]], ["--skip-build", ["--skip-build", "--skip-build"]], @@ -423,6 +429,48 @@ describe("package-openclaw-for-docker", () => { ]); }); + it("packages Unreleased notes for explicitly non-publish stable artifacts", async () => { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-unreleased-package-")); + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-unreleased-output-")); + const sourceChangelog = [ + "# Changelog", + "", + "## Unreleased", + "### Fixes", + "- Pending release notes with enough detail.", + "", + "## 2026.5.28", + "- Previous release notes with enough detail.", + "", + ].join("\n"); + fs.writeFileSync( + path.join(sourceDir, "package.json"), + '{"name":"openclaw","version":"2026.5.29"}\n', + ); + fs.writeFileSync(path.join(sourceDir, "CHANGELOG.md"), sourceChangelog); + + try { + const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { + allowUnreleasedChangelog: true, + prepareBundledAiRuntime: skipBundledAiRuntime, + runCaptureImpl: async () => { + const packagedChangelog = fs.readFileSync(path.join(sourceDir, "CHANGELOG.md"), "utf8"); + expect(packagedChangelog).toContain("## Unreleased"); + expect(packagedChangelog).not.toContain("## 2026.5.28"); + const packedPath = path.join(outputDir, "openclaw-2026.5.29.tgz"); + fs.writeFileSync(packedPath, "package"); + return "openclaw-2026.5.29.tgz\n"; + }, + }); + + expect(tarball).toBe(path.join(outputDir, "openclaw-2026.5.29.tgz")); + expect(fs.readFileSync(path.join(sourceDir, "CHANGELOG.md"), "utf8")).toBe(sourceChangelog); + } finally { + fs.rmSync(sourceDir, { recursive: true, force: true }); + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); + it("uses pnpm pack when requested", async () => { const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-pack-")); const calls: string[] = []; diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 68ae05e613ca..68d3b663a621 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -2166,6 +2166,7 @@ describe("ci workflow guards", () => { expect(smokeBuildStep.run).toContain("pnpm ui:build"); expect(smokeBuildStep.env.OPENCLAW_BUILD_PRIVATE_QA).toBe("1"); expect(smokeBuildStep.run).toContain("--skip-build"); + expect(smokeBuildStep.run).toContain("--allow-unreleased-changelog"); expect(workflow.jobs["qa-smoke-ci-artifacts"]).toBeUndefined(); expect(workflow.jobs["qa-smoke-ci"]).toBeUndefined(); expect(smokeProfileJob.needs).toEqual(["preflight"]); diff --git a/test/scripts/package-changelog.test.ts b/test/scripts/package-changelog.test.ts index 776a48bc8614..3361f6d51aa2 100644 --- a/test/scripts/package-changelog.test.ts +++ b/test/scripts/package-changelog.test.ts @@ -124,6 +124,25 @@ Docs: https://docs.openclaw.ai ); }); + it("allows Unreleased notes for explicitly non-publish stable artifacts", () => { + const unreleasedChangelog = cumulativeChangelog.replace( + "- Pending note.", + "- Pending release note with enough detail.", + ); + expect( + extractCurrentPackageChangelog(unreleasedChangelog, "2026.5.29", { + allowUnreleased: true, + }), + ).toBe(changelog` +# Changelog +Docs: https://docs.openclaw.ai + +## Unreleased +### Fixes +- Pending release note with enough detail. +`); + }); + it("fails closed when the packaged changelog is unexpectedly large", () => { const source = changelog` # Changelog