fix: exercise same-version candidates in updater compatibility tests (#142811)

* fix: exercise same-version candidates in updater compatibility tests

* refactor: keep negative updater fixture packer private
This commit is contained in:
Peter Steinberger
2026-09-08 21:09:38 -07:00
committed by GitHub
parent 20d25a4f7c
commit ca3bc36e1b
5 changed files with 192 additions and 31 deletions
+6 -3
View File
@@ -364,9 +364,12 @@ candidate's bridges. Existing older compatibility aliases remain separately
owned by their original upgrade contracts.
The default `update-first-hop-compat` lane runs each recorded release against the
candidate, with separate artifacts per version. It requires the installed build
identity and a restarted service, including for same-version or lower-version
explicit candidate tarballs. The 2026.9.1 negative control demonstrates the
candidate, with separate artifacts per version. Published updaters may correctly
skip a same-version tarball, so the lane stamps only test-artifact version metadata:
first hop `2026.9.99-first-hop.0` retains compatibility bridges; second hop
`2026.9.99-first-hop.1` removes them. The original candidate stays unchanged, and
transformation receipts bind package digests and every changed or removed member.
Both hops still require the exact installed build identity and a restarted service. The 2026.9.1 negative control demonstrates the
missing restart import; releases that already preload that helper record the
negative control as not applicable while retaining the positive first-hop and
bridge-free future-hop checks. An explicit source tarball still selects one
@@ -176,9 +176,7 @@ function futureFixtureVersion(sequence) {
return FUTURE_FIXTURE_VERSION.replace(/0$/, String(sequence));
}
export function markFutureUpdateFixture(packageRoot, sequence = 0) {
const version = futureFixtureVersion(sequence);
removeLegacyUpdateCompatChunks(packageRoot);
function stampFixtureVersion(packageRoot, version) {
const paths = resolveFixturePaths(packageRoot);
const packageJson = readJson(paths.packageJson);
const buildInfo = readJson(paths.buildInfo);
@@ -189,6 +187,34 @@ export function markFutureUpdateFixture(packageRoot, sequence = 0) {
writeJson(paths.buildInfo, buildInfo);
}
export function markFutureUpdateFixture(packageRoot, sequence = 0) {
const version = futureFixtureVersion(sequence);
removeLegacyUpdateCompatChunks(packageRoot);
stampFixtureVersion(packageRoot, version);
}
function packageMembers(root) {
const members = new Map();
const visit = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
visit(file);
} else {
const bytes = entry.isSymbolicLink()
? `link:${fs.readlinkSync(file)}`
: fs.readFileSync(file);
members.set(
path.relative(root, file).split(path.sep).join("/"),
createHash("sha256").update(bytes).digest("hex"),
);
}
}
};
visit(root);
return new Map([...members].toSorted(([left], [right]) => left.localeCompare(right)));
}
function packTransformedFixture(candidateTarball, outputTarball, transform) {
const source = path.resolve(candidateTarball);
const output = path.resolve(outputTarball);
@@ -200,7 +226,9 @@ function packTransformedFixture(candidateTarball, outputTarball, transform) {
execFileSync("tar", ["-xzf", source, "-C", root]);
const packageRoot = path.join(root, "package");
const sourceVersion = readJson(path.join(packageRoot, "package.json")).version;
const before = packageMembers(packageRoot);
transform(packageRoot);
const after = packageMembers(packageRoot);
execFileSync("tar", ["-czf", output, "-C", root, "package"], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
});
@@ -209,12 +237,45 @@ function packTransformedFixture(candidateTarball, outputTarball, transform) {
targetVersion: readJson(path.join(packageRoot, "package.json")).version,
sourceSha256: createHash("sha256").update(fs.readFileSync(source)).digest("hex"),
targetSha256: createHash("sha256").update(fs.readFileSync(output)).digest("hex"),
members: {
sourceSha256: createHash("sha256")
.update(JSON.stringify([...before]))
.digest("hex"),
targetSha256: createHash("sha256")
.update(JSON.stringify([...after]))
.digest("hex"),
changes: [...new Set([...before.keys(), ...after.keys()])]
.toSorted((left, right) => left.localeCompare(right))
.filter((name) => before.get(name) !== after.get(name))
.map((name) => ({
path: name,
before: before.get(name) ?? null,
after: after.get(name) ?? null,
})),
},
};
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
export function packFirstHopUpdateFixture(candidateTarball, outputTarball, sequence = 0) {
const version = futureFixtureVersion(sequence);
return {
method: "candidate-same-schema-first-hop-fixture",
...packTransformedFixture(candidateTarball, outputTarball, (root) => {
stampFixtureVersion(root, version);
}),
};
}
function packNegativeUpdateFixture(candidateTarball, outputTarball) {
return {
method: "candidate-missing-compatibility-fixture",
...packTransformedFixture(candidateTarball, outputTarball, removeLegacyUpdateCompatChunks),
};
}
export function packFutureUpdateFixture(candidateTarball, outputTarball, sequence = 0) {
return {
method: "candidate-same-schema-self-update-fixture",
@@ -273,11 +334,19 @@ function main() {
return;
}
if (
(mode === "future-tarball" || mode === "future-runtime-tarball") &&
(mode === "first-hop-tarball" ||
mode === "negative-tarball" ||
mode === "future-tarball" ||
mode === "future-runtime-tarball") &&
packageRoot &&
outputTarball
) {
const pack = mode === "future-tarball" ? packFutureUpdateFixture : packFutureRuntimeFixture;
const pack = {
"first-hop-tarball": packFirstHopUpdateFixture,
"negative-tarball": packNegativeUpdateFixture,
"future-tarball": packFutureUpdateFixture,
"future-runtime-tarball": packFutureRuntimeFixture,
}[mode];
process.stdout.write(
`${JSON.stringify(pack(packageRoot, outputTarball, sequence === undefined ? 0 : Number(sequence)), null, 2)}\n`,
);
@@ -285,7 +354,7 @@ function main() {
}
if (!packageRoot || (mode !== "negative" && mode !== "future")) {
throw new Error(
"usage: update-first-hop-package-fixtures.mjs <negative|future> <package-root> OR <future-tarball|future-runtime-tarball> <source.tgz> <new-output.tgz> [sequence0–9]",
"usage: update-first-hop-package-fixtures.mjs <negative|future> <package-root> OR <first-hop-tarball|negative-tarball|future-tarball|future-runtime-tarball> <source.tgz> <new-output.tgz> [sequence0–9]",
);
}
if (mode === "negative") {
@@ -12,6 +12,7 @@ fi
SOURCE_PACKAGE=/tmp/openclaw-update-first-hop-source.tgz
CANDIDATE_PACKAGE=/tmp/openclaw-update-first-hop-candidate.tgz
ORIGINAL_CANDIDATE_PACKAGE=/tmp/openclaw-update-first-hop-original.tgz
NEGATIVE_PACKAGE=/tmp/openclaw-update-first-hop-negative.tgz
FUTURE_PACKAGE=/tmp/openclaw-update-first-hop-future.tgz
ARTIFACT_DIR="${OPENCLAW_UPDATE_FIRST_HOP_ARTIFACT_DIR:-/tmp/openclaw-update-first-hop-artifacts}"
@@ -30,7 +31,7 @@ export npm_config_audit=false
export npm_config_fund=false
export npm_config_loglevel=error
for package_path in "$SOURCE_PACKAGE" "$CANDIDATE_PACKAGE" "$NEGATIVE_PACKAGE" "$FUTURE_PACKAGE" "$ARTIFACT_DIR/source.json"; do
for package_path in "$SOURCE_PACKAGE" "$CANDIDATE_PACKAGE" "$ORIGINAL_CANDIDATE_PACKAGE" "$NEGATIVE_PACKAGE" "$FUTURE_PACKAGE" "$ARTIFACT_DIR/source.json"; do
if [ ! -f "$package_path" ]; then
echo "missing package input: $package_path" >&2
exit 2
@@ -38,7 +39,7 @@ for package_path in "$SOURCE_PACKAGE" "$CANDIDATE_PACKAGE" "$NEGATIVE_PACKAGE" "
done
mkdir -p "$ARTIFACT_DIR"
source_version="$(tar -xOf "$SOURCE_PACKAGE" package/package.json | node -pe 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).version')"
candidate_version="$(tar -xOf "$CANDIDATE_PACKAGE" package/package.json | node -pe 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).version')"
candidate_source_version="$(tar -xOf "$ORIGINAL_CANDIDATE_PACKAGE" package/package.json | node -pe 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).version')"
package_root() {
printf '%s/lib/node_modules/openclaw\n' "$npm_config_prefix"
@@ -196,7 +197,7 @@ run_positive_hops() {
run_update "$lane-first" "$CANDIDATE_PACKAGE"
assert_installed_build "$CANDIDATE_PACKAGE" "$ARTIFACT_DIR/$lane-first-build-info.json"
if [ "$candidate_version" = "2026.9.3" ]; then
if [ "$candidate_source_version" = "2026.9.3" ]; then
node scripts/e2e/lib/external-package-transition.mjs schema 16 \
>"$ARTIFACT_DIR/$lane-first-shared-schema.json"
fi
+20 -15
View File
@@ -50,17 +50,19 @@ PACKAGE_TGZ="$(
update-first-hop-compat \
"${OPENCLAW_UPDATE_FIRST_HOP_CANDIDATE_PACKAGE_TGZ:-}"
)"
docker_e2e_package_mount_args "$PACKAGE_TGZ" /tmp/openclaw-update-first-hop-candidate.tgz
# Published updaters can intentionally skip same-version payloads. Relabel only
# fixture metadata so every source performs a real hop with the candidate's bridges.
FIRST_HOP_TGZ="$FIXTURE_ROOT/first-hop.tgz"
node "$FIXTURE_HELPER" first-hop-tarball "$PACKAGE_TGZ" "$FIRST_HOP_TGZ" 0 \
>"$ARTIFACT_DIR/first-hop-fixture.json"
node "$FIXTURE_HELPER" negative-tarball "$FIRST_HOP_TGZ" "$FIXTURE_ROOT/negative.tgz" \
>"$ARTIFACT_DIR/negative-fixture.json"
node "$FIXTURE_HELPER" future-tarball "$FIRST_HOP_TGZ" "$FIXTURE_ROOT/future.tgz" 1 \
>"$ARTIFACT_DIR/second-hop-fixture.json"
docker_e2e_package_mount_args "$FIRST_HOP_TGZ" /tmp/openclaw-update-first-hop-candidate.tgz
mkdir -p "$FIXTURE_ROOT/packages/negative" "$FIXTURE_ROOT/packages/future"
tar -xzf "$PACKAGE_TGZ" -C "$FIXTURE_ROOT/packages/negative"
tar -xzf "$PACKAGE_TGZ" -C "$FIXTURE_ROOT/packages/future"
node "$FIXTURE_HELPER" negative "$FIXTURE_ROOT/packages/negative/package"
node "$FIXTURE_HELPER" future "$FIXTURE_ROOT/packages/future/package"
COPYFILE_DISABLE=1 tar --no-xattrs -czf "$FIXTURE_ROOT/negative.tgz" \
-C "$FIXTURE_ROOT/packages/negative" package
COPYFILE_DISABLE=1 tar --no-xattrs -czf "$FIXTURE_ROOT/future.tgz" \
-C "$FIXTURE_ROOT/packages/future" package
mkdir -p "$FIXTURE_ROOT/packages/original"
tar -xzf "$PACKAGE_TGZ" -C "$FIXTURE_ROOT/packages/original"
docker_e2e_build_or_reuse \
"$IMAGE_NAME" \
@@ -73,7 +75,7 @@ docker_e2e_build_or_reuse \
SOURCE_VERSIONS=("")
if [ -z "$SOURCE_PACKAGE" ]; then
SOURCE_VERSIONS=()
node "$FIXTURE_HELPER" sources "$FIXTURE_ROOT/packages/negative/package" \
node "$FIXTURE_HELPER" sources "$FIXTURE_ROOT/packages/original/package" \
>"$FIXTURE_ROOT/source-versions.txt"
while IFS= read -r version; do
SOURCE_VERSIONS+=("$version")
@@ -95,7 +97,7 @@ for version in "${SOURCE_VERSIONS[@]}"; do
' "$lane_artifact_dir/source-pack.json")"
fi
chmod a+rwx "$lane_artifact_dir"
node "$FIXTURE_HELPER" source "$FIXTURE_ROOT/packages/negative/package" \
node "$FIXTURE_HELPER" source "$FIXTURE_ROOT/packages/original/package" \
"$source_package" "$version" >"$lane_artifact_dir/source.json"
expected_missing_chunk="$(node -e '
const source = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
@@ -103,11 +105,13 @@ for version in "${SOURCE_VERSIONS[@]}"; do
' "$lane_artifact_dir/source.json")"
{
printf 'source=%s\n' "$source_package"
printf 'candidate=%s\n' "$PACKAGE_TGZ"
printf 'original_candidate=%s\n' "$PACKAGE_TGZ"
printf 'candidate=%s\n' "$FIRST_HOP_TGZ"
printf 'expected_missing_chunk=%s\n' "$expected_missing_chunk"
shasum -a 256 "$source_package" "$PACKAGE_TGZ" "$FIXTURE_ROOT/negative.tgz" "$FIXTURE_ROOT/future.tgz"
shasum -a 256 "$source_package" "$PACKAGE_TGZ" "$FIRST_HOP_TGZ" "$FIXTURE_ROOT/negative.tgz" "$FIXTURE_ROOT/future.tgz"
printf '\nsource_build_info=' && tar -xOf "$source_package" package/dist/build-info.json
printf '\ncandidate_build_info=' && tar -xOf "$PACKAGE_TGZ" package/dist/build-info.json
printf '\noriginal_candidate_build_info=' && tar -xOf "$PACKAGE_TGZ" package/dist/build-info.json
printf '\ncandidate_build_info=' && tar -xOf "$FIRST_HOP_TGZ" package/dist/build-info.json
printf '\nfuture_build_info=' && tar -xOf "$FIXTURE_ROOT/future.tgz" package/dist/build-info.json
} >"$lane_artifact_dir/inputs.txt"
@@ -119,6 +123,7 @@ for version in "${SOURCE_VERSIONS[@]}"; do
-v "$lane_artifact_dir:/tmp/openclaw-update-first-hop-artifacts" \
-v "$(docker_e2e_abs_path "$source_package"):/tmp/openclaw-update-first-hop-source.tgz:ro" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
-v "$PACKAGE_TGZ:/tmp/openclaw-update-first-hop-original.tgz:ro" \
-v "$FIXTURE_ROOT/negative.tgz:/tmp/openclaw-update-first-hop-negative.tgz:ro" \
-v "$FIXTURE_ROOT/future.tgz:/tmp/openclaw-update-first-hop-future.tgz:ro" \
"$IMAGE_NAME" \
@@ -10,6 +10,7 @@ import {
LEGACY_UPDATE_COMPAT_CHUNKS,
listFirstHopSourceVersions,
markFutureUpdateFixture,
packFirstHopUpdateFixture,
packFutureUpdateFixture,
removeLegacyUpdateCompatChunks,
} from "../../scripts/e2e/lib/update-first-hop-package-fixtures.mjs";
@@ -234,7 +235,11 @@ describe("first-hop package fixtures", () => {
const original = fs.readFileSync(candidate);
const receipts = [0, 1].map((sequence) => {
const output = path.join(root, `future-${sequence}.tgz`);
const receipt = packFutureUpdateFixture(candidate, output, sequence);
const input = sequence === 0 ? candidate : path.join(root, "future-0.tgz");
const receipt =
sequence === 0
? packFirstHopUpdateFixture(input, output, sequence)
: packFutureUpdateFixture(input, output, sequence);
const pkg = JSON.parse(
execFileSync("tar", ["-xOf", output, "package/package.json"], { encoding: "utf8" }),
);
@@ -243,7 +248,40 @@ describe("first-hop package fixtures", () => {
expect(
execFileSync("tar", ["-xOf", output, "package/dist/index.js"], { encoding: "utf8" }),
).toBe("export {};\n");
expect(receipt.sourceVersion).toBe("2026.8.1");
expect(receipt.sourceVersion).toBe(sequence === 0 ? "2026.8.1" : FUTURE_FIXTURE_VERSION);
expect(receipt.members.changes.map((entry: { path: string }) => entry.path)).toEqual(
[
"dist/build-info.json",
"package.json",
...(sequence === 0
? []
: [
"dist/postinstall-inventory.json",
...LEGACY_UPDATE_COMPAT_CHUNKS.map((name) => `dist/${name}`),
]),
].toSorted((left, right) => left.localeCompare(right)),
);
const members = execFileSync("tar", ["-tzf", output], { encoding: "utf8" });
for (const change of receipt.members.changes) {
const entry = `package/${change.path}`;
const beforeBytes = execFileSync("tar", ["-xOf", input, entry]);
expect(change.before).toBe(createHash("sha256").update(beforeBytes).digest("hex"));
expect(change.after).toBe(
members.split("\n").includes(entry)
? createHash("sha256")
.update(execFileSync("tar", ["-xOf", output, entry]))
.digest("hex")
: null,
);
}
for (const bridge of LEGACY_UPDATE_COMPAT_CHUNKS) {
expect(members.includes(`package/dist/${bridge}`)).toBe(sequence === 0);
if (sequence === 0) {
expect(execFileSync("tar", ["-xOf", output, `package/dist/${bridge}`])).toEqual(
execFileSync("tar", ["-xOf", candidate, `package/dist/${bridge}`]),
);
}
}
const unpacked = path.join(root, `unpacked-${sequence}`);
fs.mkdirSync(unpacked);
execFileSync("tar", ["-xzf", output, "-C", unpacked]);
@@ -265,6 +303,7 @@ describe("first-hop package fixtures", () => {
"2026.9.99-first-hop.1",
]);
expect(new Set(receipts.map((receipt) => receipt.targetSha256)).size).toBe(2);
expect(receipts[1]?.sourceSha256).toBe(receipts[0]?.targetSha256);
expect(fs.readFileSync(candidate)).toEqual(original);
expect(() => packFutureUpdateFixture(candidate, candidate)).toThrow("new tarball path");
expect(fs.readFileSync(candidate)).toEqual(original);
@@ -444,12 +483,22 @@ describe("first-hop package fixtures", () => {
`#!${process.execPath}
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
if (process.argv[2] === "run") {
const args = process.argv.slice(3);
fs.appendFileSync(process.env.DOCKER_ARGS_FILE, JSON.stringify(args) + "\\n");
const artifact = args.find(arg => arg.endsWith(":/tmp/openclaw-update-first-hop-artifacts")).split(":")[0];
const source = JSON.parse(fs.readFileSync(path.join(artifact, "source.json"), "utf8"));
fs.writeFileSync(path.join(artifact, "summary.json"), JSON.stringify({ source }));
const inspect = (name) => {
const mount = args.find(arg => arg.endsWith(":/tmp/openclaw-update-first-hop-" + name + ".tgz:ro"));
if (!mount) return undefined;
const archive = mount.split(":")[0];
const manifest = JSON.parse(execFileSync("tar", ["-xOf", archive, "package/package.json"], { encoding: "utf8" }));
const entries = execFileSync("tar", ["-tzf", archive], { encoding: "utf8" }).trim().split("\\n");
return { version: manifest.version, entries };
};
fs.writeFileSync(path.join(artifact, "summary.json"), JSON.stringify({ source,
candidate: inspect("candidate"), negative: inspect("negative"), future: inspect("future"), original: inspect("original") }));
}
`,
{ mode: 0o755 },
@@ -493,12 +542,46 @@ process.stdout.write(JSON.stringify([{ filename }]));
.split("\n")
.map((line) => JSON.parse(line));
expect(invocations).toHaveLength(sourceMode === "explicit" ? 1 : 3);
const firstFixture = JSON.parse(
fs.readFileSync(path.join(root, "artifacts/first-hop-fixture.json"), "utf8"),
);
const negativeFixture = JSON.parse(
fs.readFileSync(path.join(root, "artifacts/negative-fixture.json"), "utf8"),
);
const secondFixture = JSON.parse(
fs.readFileSync(path.join(root, "artifacts/second-hop-fixture.json"), "utf8"),
);
expect(firstFixture.sourceSha256).toBe(
createHash("sha256").update(fs.readFileSync(tarball)).digest("hex"),
);
expect(firstFixture.members.changes.map((entry: { path: string }) => entry.path)).toEqual([
"dist/build-info.json",
"package.json",
]);
expect(negativeFixture.sourceSha256).toBe(firstFixture.targetSha256);
expect(secondFixture.sourceSha256).toBe(firstFixture.targetSha256);
expect(negativeFixture.sourceVersion).toBe(negativeFixture.targetVersion);
const recorded = JSON.parse(
fs.readFileSync(path.join(root, "artifacts/summary.json"), "utf8"),
);
const packages = sourceMode === "recorded" ? recorded.sources : [recorded];
for (const artifact of packages) {
expect(artifact.candidate.version).toBe("2026.9.99-first-hop.0");
expect(artifact.negative.version).toBe("2026.9.99-first-hop.0");
expect(artifact.future.version).toBe("2026.9.99-first-hop.1");
expect(artifact.original.version).toBe("2026.8.1");
for (const bridge of LEGACY_UPDATE_COMPAT_CHUNKS) {
expect(artifact.candidate.entries).toContain(`package/dist/${bridge}`);
expect(artifact.negative.entries).not.toContain(`package/dist/${bridge}`);
expect(artifact.future.entries).not.toContain(`package/dist/${bridge}`);
}
}
for (const args of invocations) {
expect(args[args.indexOf("--entrypoint") + 1]).toBe(
"/opt/openclaw-e2e/scripts/e2e/lib/prepublish-plugin-registry.sh",
);
expect(args).toContain(`${registry}:/tmp/openclaw-prepublish-plugin-registry:ro`);
expect(args).toContain(`${tarball}:/tmp/openclaw-update-first-hop-candidate.tgz:ro`);
expect(args).toContain(`${tarball}:/tmp/openclaw-update-first-hop-original.tgz:ro`);
expect(args).toContain("OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION=2026.8.1");
expect(args).toContain("bash");
expect(args).toContain("scripts/e2e/lib/upgrade-survivor/update-first-hop-compat.sh");