From 2571ae3d9ff485cd8e760e7bca5de90ea0e0391f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 22:31:42 -0700 Subject: [PATCH] fix: current-tree package checks support unreleased versions (#104186) * fix(ci): package unreleased QA builds safely * style(ci): format QA changelog intent (cherry picked from commit 6e9e64bd307677fe5321a0c86d425b7d2aeb10c5) --- .../openclaw-live-and-e2e-checks-reusable.yml | 23 ++++- .../openclaw-scheduled-live-checks.yml | 1 + .github/workflows/plugin-prerelease.yml | 1 + scripts/docker-e2e-rerun.mjs | 13 ++- scripts/e2e/bun-global-install-smoke.sh | 1 + scripts/e2e/parallels/package-artifact.ts | 37 ++++--- scripts/lib/docker-e2e-package.sh | 1 + scripts/test-docker-all.mjs | 97 +++++++++++-------- scripts/test-install-sh-docker.sh | 1 + test/scripts/docker-all-scheduler.test.ts | 24 +++++ test/scripts/docker-build-helper.test.ts | 2 + test/scripts/docker-e2e-helper-cli.test.ts | 6 +- test/scripts/package-changelog.test.ts | 38 ++++++++ test/scripts/parallels-smoke-model.test.ts | 1 + .../plugin-prerelease-test-plan.test.ts | 1 + test/scripts/release-no-push-workflow.test.ts | 5 + .../release-workflow-matrix-plan.test.ts | 18 ++++ test/scripts/test-install-sh-docker.test.ts | 2 + 18 files changed, 207 insertions(+), 65 deletions(-) diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 6f375827173e..3b614f7e4cff 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -117,6 +117,11 @@ on: required: false default: false type: boolean + allow_unreleased_changelog: + description: Allow current-tree QA packaging to use Unreleased notes when no exact version section exists + required: false + default: false + type: boolean workflow_call: inputs: advisory: @@ -129,6 +134,11 @@ on: required: false default: false type: boolean + allow_unreleased_changelog: + description: Allow current-tree QA packaging to use Unreleased notes when no exact version section exists + required: false + default: false + type: boolean ref: description: Ref, tag, or SHA to validate required: true @@ -330,6 +340,7 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24.15.0" + OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.allow_unreleased_changelog }} jobs: validate_selected_ref: @@ -1467,13 +1478,21 @@ jobs: - name: Pack OpenClaw package for Docker E2E if: steps.plan.outputs.needs_package == '1' && inputs.package_artifact_name == '' && inputs.package_artifact_run_id == '' + env: + ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.allow_unreleased_changelog }} shell: bash run: | set -euo pipefail mkdir -p .artifacts/docker-e2e-package - node scripts/package-openclaw-for-docker.mjs \ - --output-dir .artifacts/docker-e2e-package \ + package_args=( + --output-dir .artifacts/docker-e2e-package --output-name openclaw-current.tgz + ) + # Only current-tree QA callers opt in; release and package-candidate callers stay strict. + if [[ "$ALLOW_UNRELEASED_CHANGELOG" == "true" ]]; then + package_args+=(--allow-unreleased-changelog) + fi + node scripts/package-openclaw-for-docker.mjs "${package_args[@]}" - name: Validate OpenClaw Docker E2E package id: package diff --git a/.github/workflows/openclaw-scheduled-live-checks.yml b/.github/workflows/openclaw-scheduled-live-checks.yml index add09dab78e0..7f99309db445 100644 --- a/.github/workflows/openclaw-scheduled-live-checks.yml +++ b/.github/workflows/openclaw-scheduled-live-checks.yml @@ -32,6 +32,7 @@ jobs: include_release_path_suites: true include_openwebui: true include_live_suites: true + allow_unreleased_changelog: true secrets: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} diff --git a/.github/workflows/plugin-prerelease.yml b/.github/workflows/plugin-prerelease.yml index d25f8a934588..608d49e3fd1e 100644 --- a/.github/workflows/plugin-prerelease.yml +++ b/.github/workflows/plugin-prerelease.yml @@ -549,6 +549,7 @@ jobs: include_openwebui: false docker_lanes: ${{ needs.preflight.outputs.plugin_prerelease_docker_lanes }} targeted_docker_lane_group_size: 4 + allow_unreleased_changelog: true include_live_suites: false live_models_only: false diff --git a/scripts/docker-e2e-rerun.mjs b/scripts/docker-e2e-rerun.mjs index cb025c3b2d40..73c77d504d94 100644 --- a/scripts/docker-e2e-rerun.mjs +++ b/scripts/docker-e2e-rerun.mjs @@ -100,6 +100,7 @@ const TRUSTED_WORKFLOW_INPUTS = new Map([ ["published_upgrade_survivor_baseline", "publishedUpgradeSurvivorBaseline"], ["published_upgrade_survivor_baselines", "publishedUpgradeSurvivorBaselines"], ["published_upgrade_survivor_scenarios", "publishedUpgradeSurvivorScenarios"], + ["allow_unreleased_changelog", "allowUnreleasedChangelog"], ]); const REUSE_INPUT_KEYS = [ @@ -111,6 +112,7 @@ const REUSE_INPUT_KEYS = [ "publishedUpgradeSurvivorBaseline", "publishedUpgradeSurvivorBaselines", "publishedUpgradeSurvivorScenarios", + "allowUnreleasedChangelog", ]; const WORKFLOW_INPUT_RE = /(?:^|\s)-f\s+([a-z0-9_]+)=('([^']*)'|[^\s]+)/gu; @@ -132,8 +134,12 @@ function trustedReuseInputsFromCommand(command) { if (!target || !value) { continue; } - const normalized = - target === "bareImage" || target === "functionalImage" ? maybeGhcrImage(value) : value; + let normalized = value; + if (target === "bareImage" || target === "functionalImage") { + normalized = maybeGhcrImage(value); + } else if (target === "allowUnreleasedChangelog" && value !== "true") { + normalized = ""; + } if (normalized) { inputs[target] = normalized; } @@ -224,6 +230,9 @@ function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) { if (reuseInputs.functionalImage) { fields.push("-f", `docker_e2e_functional_image=${shellQuote(reuseInputs.functionalImage)}`); } + if (reuseInputs.allowUnreleasedChangelog === "true") { + fields.push("-f", "allow_unreleased_changelog=true"); + } if (reuseInputs.publishedUpgradeSurvivorBaseline) { fields.push( "-f", diff --git a/scripts/e2e/bun-global-install-smoke.sh b/scripts/e2e/bun-global-install-smoke.sh index 18a14f2c4f73..fcbf31d6767c 100755 --- a/scripts/e2e/bun-global-install-smoke.sh +++ b/scripts/e2e/bun-global-install-smoke.sh @@ -107,6 +107,7 @@ resolve_package_tgz() { echo "==> Pack OpenClaw tarball" PACKAGE_TGZ="$( node scripts/package-openclaw-for-docker.mjs \ + --allow-unreleased-changelog \ --skip-build \ --output-dir "$PACK_DIR" \ --output-name openclaw-current.tgz diff --git a/scripts/e2e/parallels/package-artifact.ts b/scripts/e2e/parallels/package-artifact.ts index ab1b24a9f5e2..9516834baf2e 100644 --- a/scripts/e2e/parallels/package-artifact.ts +++ b/scripts/e2e/parallels/package-artifact.ts @@ -158,24 +158,29 @@ export async function packOpenClaw(input: { checkDirty: true, requireControlUi: input.requireControlUi, }); - run("node", [ - "--import", - "tsx", - "--input-type=module", - "--eval", - "import { writePackageDistInventory } from './src/infra/package-dist-inventory.ts'; await writePackageDistInventory(process.cwd());", - ]); const shortHead = run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(); - const output = run( - "npm", - ["pack", "--ignore-scripts", "--json", "--pack-destination", input.destination], - { - quiet: true, - }, - ).stdout; - const packed = resolveNpmPackTarballFilename(JSON.parse(output).at(-1)?.filename); const tgzPath = path.join(input.destination, `openclaw-main-${shortHead}.tgz`); - await copyFile(path.join(input.destination, packed), tgzPath); + // The canonical helper inventories the package, bundles private workspace runtime code, + // and rejects tarballs that still depend on unpublished workspace packages. + const packedPath = run( + "node", + [ + "scripts/package-openclaw-for-docker.mjs", + "--allow-unreleased-changelog", + "--skip-build", + "--source-dir", + repoRoot, + "--output-dir", + input.destination, + "--output-name", + path.basename(tgzPath), + "--pnpm-pack", + ], + { quiet: true }, + ).stdout.trim(); + if (path.resolve(packedPath) !== path.resolve(tgzPath)) { + die(`package helper wrote an unexpected tarball: ${packedPath}`); + } const buildCommit = await packageBuildCommitFromTgz(tgzPath); if (!buildCommit) { die(`failed to read packed build commit from ${tgzPath}`); diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index ae58ec4a1132..a46abc0113fc 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -315,6 +315,7 @@ docker_e2e_prepare_package_tgz() { local pack_status=0 package_tgz="$( node "$ROOT_DIR/scripts/package-openclaw-for-docker.mjs" \ + --allow-unreleased-changelog \ --output-dir "$pack_dir" \ --output-name openclaw-current.tgz )" || pack_status="$?" diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 5e3f6960a210..0f4526ea1969 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -265,16 +265,16 @@ function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } -function githubWorkflowRef() { - const explicit = process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF; +function githubWorkflowRef(env = process.env) { + const explicit = env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF; if (explicit) { return explicit; } - const refName = process.env.GITHUB_REF_NAME; + const refName = env.GITHUB_REF_NAME; if (refName) { return refName; } - const ref = process.env.GITHUB_REF; + const ref = env.GITHUB_REF; if (ref?.startsWith("refs/heads/")) { return ref.slice("refs/heads/".length); } @@ -284,12 +284,13 @@ function githubWorkflowRef() { return undefined; } -function githubWorkflowRerunCommand(laneNames, ref) { - const workflowRef = githubWorkflowRef(); - const releasePath = process.env.OPENCLAW_DOCKER_ALL_PROFILE === RELEASE_PATH_PROFILE; +export function githubWorkflowRerunCommand(laneNames, ref, env = process.env) { + const workflowRef = githubWorkflowRef(env); + const releasePath = env.OPENCLAW_DOCKER_ALL_PROFILE === RELEASE_PATH_PROFILE; + const allowUnreleasedChangelog = env.OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG === "true"; const fields = [ "gh workflow run", - shellQuote(process.env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW), + shellQuote(env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW), ...(workflowRef ? ["--ref", shellQuote(workflowRef)] : []), "-f", `ref=${shellQuote(ref)}`, @@ -306,43 +307,46 @@ function githubWorkflowRerunCommand(laneNames, ref) { "-f", "live_models_only=false", ]; - if (process.env.GITHUB_RUN_ID) { - fields.push("-f", `package_artifact_run_id=${shellQuote(process.env.GITHUB_RUN_ID)}`); + if (allowUnreleasedChangelog) { + fields.push("-f", "allow_unreleased_changelog=true"); + } + if (env.GITHUB_RUN_ID) { + fields.push("-f", `package_artifact_run_id=${shellQuote(env.GITHUB_RUN_ID)}`); fields.push( "-f", `package_artifact_name=${shellQuote( - process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || "docker-e2e-package", + env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || "docker-e2e-package", )}`, ); } - if (process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC) { + if (env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC) { fields.push( "-f", - `published_upgrade_survivor_baseline=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}`, + `published_upgrade_survivor_baseline=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC)}`, ); } - if (process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS) { + if (env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS) { fields.push( "-f", - `published_upgrade_survivor_baselines=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}`, + `published_upgrade_survivor_baselines=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS)}`, ); } - if (process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS) { + if (env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS) { fields.push( "-f", - `published_upgrade_survivor_scenarios=${shellQuote(process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS)}`, + `published_upgrade_survivor_scenarios=${shellQuote(env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS)}`, ); } - if (process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE) { + if (env.OPENCLAW_DOCKER_E2E_BARE_IMAGE) { fields.push( "-f", - `docker_e2e_bare_image=${shellQuote(process.env.OPENCLAW_DOCKER_E2E_BARE_IMAGE)}`, + `docker_e2e_bare_image=${shellQuote(env.OPENCLAW_DOCKER_E2E_BARE_IMAGE)}`, ); } - if (process.env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE) { + if (env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE) { fields.push( "-f", - `docker_e2e_functional_image=${shellQuote(process.env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE)}`, + `docker_e2e_functional_image=${shellQuote(env.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE)}`, ); } return fields.join(" "); @@ -438,38 +442,38 @@ async function writeTimingStore(timingStore, results) { console.log(`==> Docker lane timings: ${timingStore.file}`); } -export async function writeRunSummary(logDir, summary) { +export async function writeRunSummary(logDir, summary, env = process.env) { const file = path.join(logDir, "summary.json"); const payload = { ...summary, - packageArtifactName: process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined, + packageArtifactName: env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined, finishedAt: new Date().toISOString(), github: { - ref: process.env.GITHUB_REF_NAME || undefined, - repository: process.env.GITHUB_REPOSITORY || undefined, - runId: process.env.GITHUB_RUN_ID || undefined, + ref: env.GITHUB_REF_NAME || undefined, + repository: env.GITHUB_REPOSITORY || undefined, + runId: env.GITHUB_RUN_ID || undefined, runUrl: - process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID - ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` + env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID + ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : undefined, - selectedSha: process.env.OPENCLAW_DOCKER_E2E_SELECTED_SHA || undefined, - sha: process.env.GITHUB_SHA || undefined, - workflow: process.env.GITHUB_WORKFLOW || undefined, + selectedSha: env.OPENCLAW_DOCKER_E2E_SELECTED_SHA || undefined, + sha: env.GITHUB_SHA || undefined, + workflow: env.GITHUB_WORKFLOW || undefined, }, version: 1, }; await fs.promises.writeFile(file, `${JSON.stringify(payload, null, 2)}\n`); - await writeFailureIndex(logDir, payload); + await writeFailureIndex(logDir, payload, env); console.log(`==> Docker run summary: ${file}`); } -async function writeFailureIndex(logDir, summary) { +async function writeFailureIndex(logDir, summary, env) { const ref = summary.github?.selectedSha || - process.env.OPENCLAW_DOCKER_E2E_SELECTED_SHA || + env.OPENCLAW_DOCKER_E2E_SELECTED_SHA || summary.github?.sha || summary.github?.ref || - process.env.GITHUB_SHA || + env.GITHUB_SHA || "HEAD"; const failures = Array.isArray(summary.failures) ? summary.failures @@ -477,7 +481,9 @@ async function writeFailureIndex(logDir, summary) { const workflowRerunFailures = failures.filter((failure) => failure.targetable !== false); const lanes = failures.map((failure) => ({ ghWorkflowCommand: - failure.targetable === false ? undefined : githubWorkflowRerunCommand([failure.name], ref), + failure.targetable === false + ? undefined + : githubWorkflowRerunCommand([failure.name], ref, env), image: failure.image, imageKind: failure.imageKind, lane: failure.name, @@ -495,18 +501,19 @@ async function writeFailureIndex(logDir, summary) { ? githubWorkflowRerunCommand( workflowRerunFailures.map((failure) => failure.name), ref, + env, ) : undefined, generatedAt: new Date().toISOString(), lanes, note: "Targeted GitHub reruns reuse this run's package artifact and shared Docker images when the generated command includes package_artifact_run_id and docker_e2e_*_image inputs.", images: summary.images, - packageArtifactName: process.env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined, + packageArtifactName: env.OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME || undefined, ref, runUrl: summary.github?.runUrl, status: summary.status, version: 1, - workflow: process.env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW, + workflow: env.OPENCLAW_DOCKER_E2E_WORKFLOW || DEFAULT_GITHUB_WORKFLOW, }; await fs.promises.writeFile( path.join(logDir, "failures.json"), @@ -1002,10 +1009,13 @@ async function prepareOpenClawPackage(baseEnv, logDir) { const packageTgz = path.join(packDir, "openclaw-current.tgz"); await runForeground( "Prepare OpenClaw package once", - `node scripts/package-openclaw-for-docker.mjs --output-dir ${shellQuote(packDir)} --output-name openclaw-current.tgz`, + `node scripts/package-openclaw-for-docker.mjs --allow-unreleased-changelog --output-dir ${shellQuote(packDir)} --output-name openclaw-current.tgz`, baseEnv, ); await fs.promises.access(packageTgz); + // Preserve current-tree package intent in generated GitHub reruns; otherwise a local QA + // failure would retry through the strict release path and fail before reaching its lane. + baseEnv.OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG = "true"; baseEnv.OPENCLAW_CURRENT_PACKAGE_TGZ = packageTgz; baseEnv.OPENCLAW_BUNDLED_CHANNEL_HOST_BUILD = "0"; baseEnv.OPENCLAW_NPM_ONBOARD_HOST_BUILD = "0"; @@ -1513,6 +1523,7 @@ async function main() { }); baseEnv.OPENCLAW_DOCKER_E2E_IMAGE = process.env.OPENCLAW_DOCKER_E2E_IMAGE || baseEnv.OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE; + const writeSummary = (summary) => writeRunSummary(logDir, summary, baseEnv); appendExtension(baseEnv, "matrix"); appendExtension(baseEnv, "acpx"); appendExtension(baseEnv, "codex"); @@ -1674,7 +1685,7 @@ async function main() { const allResults = [...mainResult.results]; await writeTimingStore(timingStore, mainResult.results); if (failFast && failures.length > 0) { - await writeRunSummary(logDir, { + await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, @@ -1713,7 +1724,7 @@ async function main() { console.log("==> Provider-sensitive Docker tail lanes: none"); } if (failures.length > 0) { - await writeRunSummary(logDir, { + await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, @@ -1742,7 +1753,7 @@ async function main() { } await writeTimingStore(timingStore, allResults); if (failures.length > 0) { - await writeRunSummary(logDir, { + await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, @@ -1760,7 +1771,7 @@ async function main() { await printFailureSummary(failures, tailLines); process.exit(1); } - await writeRunSummary(logDir, { + await writeSummary({ chunk: releaseChunk || undefined, failures, image: baseEnv.OPENCLAW_DOCKER_E2E_IMAGE, diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index 52dac6a297b2..19cc9932d02a 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -324,6 +324,7 @@ prepare_update_tarball() { fi UPDATE_EXPECT_VERSION="$(read_candidate_version)" package_args=( + --allow-unreleased-changelog --source-dir "$ROOT_DIR" --output-dir "$UPDATE_DIR" --pack-json "$pack_json_file" diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index 49c2c023366e..d6d0735bc67c 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -149,6 +149,30 @@ describe("scripts/test-docker-all scheduler", () => { expect(result.stderr).not.toContain("at "); }); + it("preserves ephemeral package intent in generated failure reruns", async () => { + const logDir = createTempDir("openclaw-docker-all-rerun-intent-"); + try { + await writeRunSummary( + logDir, + { + failures: [{ name: "install-e2e", status: 1 }], + lanes: [], + status: "failed", + }, + { + ...process.env, + GITHUB_SHA: "c".repeat(40), + OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: "true", + }, + ); + + const failureIndex = JSON.parse(readFileSync(path.join(logDir, "failures.json"), "utf8")); + expect(failureIndex.combinedGhWorkflowCommand).toContain("allow_unreleased_changelog=true"); + } finally { + rmSync(logDir, { force: true, recursive: true }); + } + }); + it("rejects loose numeric resource limit env vars before scheduling lanes", () => { const logDir = mkdtempSync(`${tmpdir()}/openclaw-docker-all-`); try { diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 3110d01c011a..d9254732f72e 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -3881,6 +3881,7 @@ heartbeat_elapsed="\${BASH_REMATCH[1]}" it("mounts root helper modules imported by bare Docker E2E scripts", () => { const helper = readFileSync(DOCKER_E2E_PACKAGE_HELPER_PATH, "utf8"); + expect(helper).toContain("--allow-unreleased-changelog"); expect(helper).toContain( '-v "$ROOT_DIR/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"', ); @@ -3891,6 +3892,7 @@ heartbeat_elapsed="\${BASH_REMATCH[1]}" it("preserves pnpm lookup paths for scheduled Docker child lanes", () => { const scheduler = readFileSync(DOCKER_ALL_SCHEDULER_PATH, "utf8"); + expect(scheduler).toContain("--allow-unreleased-changelog"); expect(scheduler).toContain("env.PNPM_HOME"); expect(scheduler).toContain("env.npm_execpath ? path.dirname(env.npm_execpath)"); expect(scheduler).toContain("path.dirname(process.execPath)"); diff --git a/test/scripts/docker-e2e-helper-cli.test.ts b/test/scripts/docker-e2e-helper-cli.test.ts index a08e5d1b0014..a38bcb71bb0b 100644 --- a/test/scripts/docker-e2e-helper-cli.test.ts +++ b/test/scripts/docker-e2e-helper-cli.test.ts @@ -259,7 +259,7 @@ describe("Docker E2E helper CLIs", () => { lanes: [ { ghWorkflowCommand: - "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f package_artifact_run_id='12345' -f package_artifact_name='docker-e2e-package' -f docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f published_upgrade_survivor_scenarios='plugin-dependency-cleanup' -f unsafe_input='do-not-copy'", + "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f package_artifact_run_id='12345' -f package_artifact_name='docker-e2e-package' -f docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f published_upgrade_survivor_scenarios='plugin-dependency-cleanup' -f allow_unreleased_changelog=true -f unsafe_input='do-not-copy'", name: "published-upgrade-survivor-openclaw-2026-5-3", status: 1, }, @@ -288,6 +288,7 @@ describe("Docker E2E helper CLIs", () => { expect(combinedCommand).toContain( "published_upgrade_survivor_scenarios='plugin-dependency-cleanup'", ); + expect(combinedCommand).toContain("allow_unreleased_changelog=true"); expect(combinedCommand).not.toContain("unsafe_input"); expect(result.stdout).toContain("package_artifact_run_id='12345'"); expect(result.stdout).toContain( @@ -317,7 +318,7 @@ describe("Docker E2E helper CLIs", () => { lanes: [ { ghWorkflowCommand: - "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3'", + "gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f allow_unreleased_changelog=1", name: "published-upgrade-survivor-openclaw-2026-5-3", status: 1, }, @@ -356,6 +357,7 @@ describe("Docker E2E helper CLIs", () => { expect(result.stdout).not.toContain( "docker_lanes='published-upgrade-survivor-openclaw-2026-5-3 published-upgrade-survivor-openclaw-2026-5-2'", ); + expect(result.stdout).not.toContain("allow_unreleased_changelog"); } finally { rmSync(root, { force: true, recursive: true }); } diff --git a/test/scripts/package-changelog.test.ts b/test/scripts/package-changelog.test.ts index 3361f6d51aa2..0626d33430b7 100644 --- a/test/scripts/package-changelog.test.ts +++ b/test/scripts/package-changelog.test.ts @@ -48,6 +48,10 @@ describe("package-changelog", () => { "2026.5.28", "Unreleased", ]); + expect(resolvePackageChangelogVersions("2026.5.29", { allowUnreleased: true })).toEqual([ + "2026.5.29", + "Unreleased", + ]); }); it("extracts only the package version stable release section", () => { @@ -143,6 +147,22 @@ Docs: https://docs.openclaw.ai `); }); + it("does not fall back when exact non-publish notes fail safety checks", () => { + const source = changelog` +# Changelog +## Unreleased +- Pending development package notes with enough release detail. +## 2026.5.29 +- Tiny. +## 2026.5.28 +- Older stable release notes with enough detail. +`; + + expect(() => + extractCurrentPackageChangelog(source, "2026.5.29", { allowUnreleased: true }), + ).toThrow("Packaged changelog section for 2026.5.29 is only 7 body bytes"); + }); + it("fails closed when the packaged changelog is unexpectedly large", () => { const source = changelog` # Changelog @@ -188,6 +208,24 @@ Docs: https://docs.openclaw.ai } }); + it("recovers an interrupted ephemeral QA package with the default restore path", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-package-changelog-")); + const unreleasedChangelog = cumulativeChangelog.replace( + "- Pending note.", + "- Pending release note with enough detail.", + ); + try { + writeFileSync(path.join(root, "package.json"), '{"version":"2026.5.29"}\n', "utf8"); + writeFileSync(path.join(root, "CHANGELOG.md"), unreleasedChangelog, "utf8"); + + await expect(preparePackageChangelog(root, { allowUnreleased: true })).resolves.toBe(true); + await expect(restorePackageChangelog(root)).resolves.toBe(true); + expect(readFileSync(path.join(root, "CHANGELOG.md"), "utf8")).toBe(unreleasedChangelog); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("refuses to restore stale backups over current changelog edits", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-package-changelog-")); const backupPath = path.join( diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 16e6c08a8e6a..26decf9b440c 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -402,6 +402,7 @@ describe("Parallels smoke model selection", () => { expect(packageArtifact).toContain("Wait for Parallels package lock"); expect(packageArtifact).toContain("export async function packageVersionFromTgz"); expect(packageArtifact).toContain("export async function packOpenClaw"); + expect(packageArtifact).toContain('"--allow-unreleased-changelog"'); expect(packageArtifact).toContain("function resolveNpmPackTarballFilename"); expect(packageArtifact).toContain("filename !== path.basename(filename)"); expect(packageArtifact).toContain("filename !== path.win32.basename(filename)"); diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 3247b658ec97..cb865743d7f5 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -498,6 +498,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { include_release_path_suites: false, include_repo_e2e: false, live_models_only: false, + allow_unreleased_changelog: true, ref: "${{ needs.preflight.outputs.checkout_revision }}", targeted_docker_lane_group_size: 4, }, diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 4a467ffed371..cec0dc2dbf49 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -345,6 +345,7 @@ describe("release validation no-push transport", () => { shared_image_artifact_namespace: "release-live", shared_image_policy: "no-push-artifact", }); + expect(live.with).not.toHaveProperty("allow_unreleased_changelog"); expect(docker.with).toMatchObject({ package_artifact_digest: "${{ needs.prepare_release_package.outputs.artifact_digest }}", package_artifact_id: "${{ needs.prepare_release_package.outputs.artifact_id }}", @@ -359,6 +360,7 @@ describe("release validation no-push transport", () => { shared_image_artifact_namespace: "release-docker", shared_image_policy: "no-push-artifact", }); + expect(docker.with).not.toHaveProperty("allow_unreleased_changelog"); expect(acceptance.with).toMatchObject({ artifact_digest: "${{ needs.prepare_release_package.outputs.artifact_digest }}", artifact_id: "${{ needs.prepare_release_package.outputs.artifact_id }}", @@ -399,6 +401,8 @@ describe("release validation no-push transport", () => { package_source_sha: "${{ needs.resolve_package.outputs.package_source_sha }}", package_version: "${{ needs.resolve_package.outputs.package_version }}", }); + expect(standardAcceptance.with).not.toHaveProperty("allow_unreleased_changelog"); + expect(registryAcceptance.with).not.toHaveProperty("allow_unreleased_changelog"); expect(standardAcceptance.if).toContain("shared_image_policy == 'no-push-artifact'"); expectReadOnlyPackagePermission(standardAcceptance); expect(registryAcceptance.if).toContain("shared_image_policy == 'existing-only'"); @@ -832,6 +836,7 @@ describe("release validation no-push transport", () => { expect(permissionAt(scheduled.permissions, "packages", "none")).toBe("read"); expectReadOnlyPackagePermission(scheduledValidation); expect(scheduledValidation.with).toMatchObject({ + allow_unreleased_changelog: true, shared_image_artifact_namespace: "scheduled-live", shared_image_policy: "no-push-artifact", }); diff --git a/test/scripts/release-workflow-matrix-plan.test.ts b/test/scripts/release-workflow-matrix-plan.test.ts index 4944a8c9c81b..d88bfa95515e 100644 --- a/test/scripts/release-workflow-matrix-plan.test.ts +++ b/test/scripts/release-workflow-matrix-plan.test.ts @@ -111,6 +111,24 @@ describe("scripts/plan-release-workflow-matrix.mjs", () => { required: false, type: "boolean", }); + expect(definition.on.workflow_dispatch.inputs.allow_unreleased_changelog).toEqual( + definition.on.workflow_call.inputs.allow_unreleased_changelog, + ); + expect(definition.on.workflow_call.inputs.allow_unreleased_changelog).toMatchObject({ + default: false, + required: false, + type: "boolean", + }); + expect(definition.env.OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG).toBe( + "${{ inputs.allow_unreleased_changelog }}", + ); + const packageStep = definition.jobs.prepare_docker_e2e_image.steps.find( + (step) => step.name === "Pack OpenClaw package for Docker E2E", + ); + expect(packageStep.env.ALLOW_UNRELEASED_CHANGELOG).toBe( + "${{ inputs.allow_unreleased_changelog }}", + ); + expect(packageStep.run).toContain("package_args+=(--allow-unreleased-changelog)"); }); it.each(PROFILE_EXPECTATIONS)( diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index c670638906be..bbedfc9179bb 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -858,6 +858,7 @@ printf 'status=%s\\n' "$status" const script = readFileSync(SCRIPT_PATH, "utf8"); expect(script).toContain('node "$HARNESS_ROOT/scripts/package-openclaw-for-docker.mjs"'); + expect(script).toContain("--allow-unreleased-changelog"); expect(script).toContain('--source-dir "$ROOT_DIR"'); expect(script).toContain('--pack-json "$pack_json_file"'); expect(script).toContain("--skip-build"); @@ -1157,6 +1158,7 @@ describe("bun global install smoke", () => { const packageHelper = readFileSync(DOCKER_E2E_PACKAGE_HELPER_PATH, "utf8"); expect(script).toContain("node scripts/package-openclaw-for-docker.mjs"); + expect(script).toContain("--allow-unreleased-changelog"); expect(script).toContain("--skip-build"); expect(script).toContain("--output-name openclaw-current.tgz"); expect(script).not.toContain("npm pack --ignore-scripts --json --pack-destination");