mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 15:13:48 -06:00
9a2f91a79a
* fix(release): install exact cross-os companion artifacts * fix(release): derive companion package type from resolver * fix(release): produce provider-owned companion registries * fix(release): preserve companions for npm candidates * refactor(release): simplify companion registry flow * test(release): align package acceptance contracts * fix(release): guard optional registry JSON parsing --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { resolveCrossOsCompanionPackages } from "../../scripts/lib/cross-os-release-checks/companions.ts";
|
|
import {
|
|
PREPUBLISH_PLUGIN_REGISTRY_MANIFEST,
|
|
createPrepublishPluginRegistryArtifact,
|
|
validatePrepublishPluginRegistryArtifact,
|
|
} from "../../scripts/prepublish-plugin-registry-artifact.mjs";
|
|
|
|
const SOURCE_SHA = "a".repeat(40);
|
|
const VERSION = "2026.8.1-beta.1";
|
|
const PACKAGE_NAME = "@openclaw/discord";
|
|
const TARBALL = "openclaw-discord-2026.8.1-beta.1.tgz";
|
|
const SCRIPT = path.resolve("scripts/prepublish-plugin-registry-artifact.mjs");
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
function sha256(file: string): string {
|
|
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
}
|
|
|
|
function fixture() {
|
|
const root = mkdtempSync(path.join(tmpdir(), "openclaw-prepublish-plugin-registry-"));
|
|
tempDirs.push(root);
|
|
const packageRoot = path.join(root, "package");
|
|
const artifactDir = path.join(root, "artifact");
|
|
mkdirSync(packageRoot);
|
|
mkdirSync(artifactDir);
|
|
writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
`${JSON.stringify({ name: PACKAGE_NAME, version: VERSION })}\n`,
|
|
);
|
|
const tarballPath = path.join(artifactDir, TARBALL);
|
|
execFileSync("tar", ["-czf", tarballPath, "-C", root, "package"]);
|
|
const manifestPath = path.join(artifactDir, PREPUBLISH_PLUGIN_REGISTRY_MANIFEST);
|
|
const manifest = {
|
|
schema: "openclaw.prepublish-plugin-registry/v1",
|
|
schemaVersion: 1,
|
|
sourceSha: SOURCE_SHA,
|
|
candidateVersion: VERSION,
|
|
packages: [
|
|
{
|
|
name: PACKAGE_NAME,
|
|
version: VERSION,
|
|
tarball: TARBALL,
|
|
sha256: sha256(tarballPath),
|
|
},
|
|
],
|
|
};
|
|
const writeManifest = () => {
|
|
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
};
|
|
writeManifest();
|
|
return { artifactDir, manifest, manifestPath, tarballPath, writeManifest };
|
|
}
|
|
|
|
function validate(paths: ReturnType<typeof fixture>, overrides = {}) {
|
|
return validatePrepublishPluginRegistryArtifact({
|
|
artifactDir: paths.artifactDir,
|
|
expectedCandidateVersion: VERSION,
|
|
expectedManifestSha256: sha256(paths.manifestPath),
|
|
expectedSourceSha: SOURCE_SHA,
|
|
requiredPackages: [PACKAGE_NAME],
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function firstPackage(paths: ReturnType<typeof fixture>) {
|
|
const [entry] = paths.manifest.packages;
|
|
if (!entry) {
|
|
throw new Error("fixture manifest must contain one package");
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
function addCompanionPackage(paths: ReturnType<typeof fixture>) {
|
|
const name = "@openclaw/feishu";
|
|
const tarball = "openclaw-feishu-2026.8.1-beta.1.tgz";
|
|
const archiveRoot = path.join(path.dirname(paths.artifactDir), "feishu-package");
|
|
const packageRoot = path.join(archiveRoot, "package");
|
|
mkdirSync(packageRoot, { recursive: true });
|
|
writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
`${JSON.stringify({ name, version: VERSION })}\n`,
|
|
);
|
|
const tarballPath = path.join(paths.artifactDir, tarball);
|
|
execFileSync("tar", ["-czf", tarballPath, "-C", archiveRoot, "package"]);
|
|
paths.manifest.packages.push({
|
|
name,
|
|
version: VERSION,
|
|
tarball,
|
|
sha256: sha256(tarballPath),
|
|
});
|
|
paths.writeManifest();
|
|
}
|
|
|
|
function cliFixture() {
|
|
const repoRoot = mkdtempSync(path.join(tmpdir(), "openclaw-prepublish-plugin-cli-"));
|
|
tempDirs.push(repoRoot);
|
|
const packageDir = path.join(repoRoot, "extensions", "discord");
|
|
const scriptsDir = path.join(repoRoot, "scripts", "lib");
|
|
mkdirSync(packageDir, { recursive: true });
|
|
mkdirSync(scriptsDir, { recursive: true });
|
|
writeFileSync(
|
|
path.join(repoRoot, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: VERSION })}\n`,
|
|
);
|
|
writeFileSync(
|
|
path.join(packageDir, "package.json"),
|
|
`${JSON.stringify({
|
|
name: PACKAGE_NAME,
|
|
version: VERSION,
|
|
openclaw: { release: { publishToNpm: true } },
|
|
})}\n`,
|
|
);
|
|
writeFileSync(
|
|
path.join(scriptsDir, "plugin-npm-runtime-build.mjs"),
|
|
'console.log("runtime build stdout");\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(scriptsDir, "plugin-npm-package-manifest.mjs"),
|
|
`import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
const repoRoot = process.cwd();
|
|
const packageDir = process.argv[process.argv.indexOf("--run") + 1];
|
|
const outputDir = process.argv[process.argv.indexOf("--pack-destination") + 1];
|
|
const staging = path.join(repoRoot, ".pack-fixture");
|
|
fs.mkdirSync(path.join(staging, "package"), { recursive: true });
|
|
fs.copyFileSync(path.join(repoRoot, packageDir, "package.json"), path.join(staging, "package", "package.json"));
|
|
execFileSync("tar", ["-czf", path.join(outputDir, "${TARBALL}"), "-C", staging, "package"]);
|
|
console.log("package manifest stdout");
|
|
`,
|
|
);
|
|
execFileSync("git", ["init"], { cwd: repoRoot });
|
|
execFileSync("git", ["config", "user.email", "release-test@example.invalid"], {
|
|
cwd: repoRoot,
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Release Test"], { cwd: repoRoot });
|
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoRoot });
|
|
execFileSync("git", ["add", "."], { cwd: repoRoot });
|
|
execFileSync("git", ["commit", "-m", "test: seed release source"], { cwd: repoRoot });
|
|
const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
return { repoRoot, sourceSha };
|
|
}
|
|
|
|
describe("prepublish plugin registry artifact", () => {
|
|
it("validates the immutable manifest, package set, hashes, and packed identity", () => {
|
|
const paths = fixture();
|
|
const result = validate(paths);
|
|
expect(result.manifest.packages.map((entry) => entry.name)).toEqual([PACKAGE_NAME]);
|
|
});
|
|
|
|
it("requires the complete immutable identity tuple", () => {
|
|
const paths = fixture();
|
|
const common = {
|
|
artifactDir: paths.artifactDir,
|
|
expectedCandidateVersion: VERSION,
|
|
expectedManifestSha256: sha256(paths.manifestPath),
|
|
expectedSourceSha: SOURCE_SHA,
|
|
requiredPackages: [PACKAGE_NAME],
|
|
};
|
|
for (const field of [
|
|
"expectedCandidateVersion",
|
|
"expectedManifestSha256",
|
|
"expectedSourceSha",
|
|
] as const) {
|
|
expect(() =>
|
|
validatePrepublishPluginRegistryArtifact({ ...common, [field]: undefined }),
|
|
).toThrow(field);
|
|
}
|
|
});
|
|
|
|
it("accepts immutable companion packages beyond the selected Docker plan", () => {
|
|
const paths = fixture();
|
|
addCompanionPackage(paths);
|
|
|
|
expect(validate(paths).manifest.packages.map((entry) => entry.name)).toEqual([
|
|
"@openclaw/discord",
|
|
"@openclaw/feishu",
|
|
]);
|
|
});
|
|
|
|
it("extracts only required cross-OS companions from the validated registry", () => {
|
|
const paths = fixture();
|
|
addCompanionPackage(paths);
|
|
|
|
expect(
|
|
resolveCrossOsCompanionPackages({
|
|
artifactDir: paths.artifactDir,
|
|
candidateVersion: VERSION,
|
|
manifestSha256: sha256(paths.manifestPath),
|
|
requiredPackages: ["@openclaw/feishu"],
|
|
sourceSha: SOURCE_SHA,
|
|
}),
|
|
).toEqual([
|
|
{
|
|
name: "@openclaw/feishu",
|
|
tarballPath: path.join(paths.artifactDir, "openclaw-feishu-2026.8.1-beta.1.tgz"),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("rejects mismatched cross-OS companion registry identities", () => {
|
|
const paths = fixture();
|
|
const common = {
|
|
artifactDir: paths.artifactDir,
|
|
candidateVersion: VERSION,
|
|
manifestSha256: sha256(paths.manifestPath),
|
|
requiredPackages: [PACKAGE_NAME],
|
|
sourceSha: SOURCE_SHA,
|
|
};
|
|
|
|
expect(() => resolveCrossOsCompanionPackages({ ...common, sourceSha: "b".repeat(40) })).toThrow(
|
|
"source SHA differs",
|
|
);
|
|
expect(() =>
|
|
resolveCrossOsCompanionPackages({ ...common, candidateVersion: "2026.8.1-beta.2" }),
|
|
).toThrow("version differs");
|
|
expect(() =>
|
|
resolveCrossOsCompanionPackages({ ...common, manifestSha256: "c".repeat(64) }),
|
|
).toThrow("manifest SHA-256 differs");
|
|
|
|
writeFileSync(paths.tarballPath, "tampered");
|
|
expect(() => resolveCrossOsCompanionPackages(common)).toThrow("tarball SHA-256 mismatch");
|
|
});
|
|
|
|
it("refuses to create an artifact from tracked changes under the same HEAD", () => {
|
|
const repoRoot = mkdtempSync(path.join(tmpdir(), "openclaw-prepublish-plugin-source-"));
|
|
tempDirs.push(repoRoot);
|
|
writeFileSync(
|
|
path.join(repoRoot, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: VERSION })}\n`,
|
|
);
|
|
execFileSync("git", ["init"], { cwd: repoRoot });
|
|
execFileSync("git", ["config", "user.email", "release-test@example.invalid"], {
|
|
cwd: repoRoot,
|
|
});
|
|
execFileSync("git", ["config", "user.name", "Release Test"], { cwd: repoRoot });
|
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoRoot });
|
|
execFileSync("git", ["add", "package.json"], { cwd: repoRoot });
|
|
execFileSync("git", ["commit", "-m", "test: seed release source"], { cwd: repoRoot });
|
|
const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
writeFileSync(
|
|
path.join(repoRoot, "package.json"),
|
|
`${JSON.stringify({ name: "openclaw", version: `${VERSION}-dirty` })}\n`,
|
|
);
|
|
|
|
expect(() =>
|
|
createPrepublishPluginRegistryArtifact({
|
|
repoRoot,
|
|
outputDir: path.join(repoRoot, "artifact"),
|
|
sourceSha,
|
|
candidateVersion: VERSION,
|
|
requiredPackages: [],
|
|
}),
|
|
).toThrow("tracked changes");
|
|
});
|
|
|
|
it("keeps noisy package commands off the CLI JSON stdout contract", () => {
|
|
const { repoRoot, sourceSha } = cliFixture();
|
|
const artifactDir = path.join(repoRoot, "artifact");
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
SCRIPT,
|
|
"create",
|
|
"--repo-root",
|
|
repoRoot,
|
|
"--artifact-dir",
|
|
artifactDir,
|
|
"--source-sha",
|
|
sourceSha,
|
|
"--candidate-version",
|
|
VERSION,
|
|
"--required-packages-json",
|
|
JSON.stringify([PACKAGE_NAME]),
|
|
],
|
|
{ cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
manifestSha256: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
|
packages: [PACKAGE_NAME],
|
|
});
|
|
expect(result.stderr).toContain("runtime build stdout");
|
|
expect(result.stderr).toContain("package manifest stdout");
|
|
});
|
|
|
|
it("rejects traversal and duplicate package entries", () => {
|
|
const traversal = fixture();
|
|
firstPackage(traversal).tarball = "../escape.tgz";
|
|
traversal.writeManifest();
|
|
expect(() => validate(traversal)).toThrow("invalid package entry");
|
|
|
|
const duplicate = fixture();
|
|
duplicate.manifest.packages.push({ ...firstPackage(duplicate) });
|
|
duplicate.writeManifest();
|
|
expect(() =>
|
|
validate(duplicate, { requiredPackages: [PACKAGE_NAME, "@openclaw/feishu"] }),
|
|
).toThrow("duplicate package");
|
|
});
|
|
|
|
it("rejects missing and extra artifact files", () => {
|
|
const missing = fixture();
|
|
unlinkSync(missing.tarballPath);
|
|
expect(() => validate(missing)).toThrow("missing, extra, or non-file");
|
|
|
|
const extra = fixture();
|
|
writeFileSync(path.join(extra.artifactDir, "extra.txt"), "unexpected");
|
|
expect(() => validate(extra)).toThrow("missing, extra, or non-file");
|
|
});
|
|
|
|
it("rejects hash, identity, version, source SHA, and required-set mismatches", () => {
|
|
const hash = fixture();
|
|
writeFileSync(hash.tarballPath, "tampered");
|
|
expect(() => validate(hash)).toThrow("tarball SHA-256 mismatch");
|
|
|
|
const identity = fixture();
|
|
firstPackage(identity).name = "@openclaw/feishu";
|
|
identity.writeManifest();
|
|
expect(() => validate(identity, { requiredPackages: ["@openclaw/feishu"] })).toThrow(
|
|
"tarball identity mismatch",
|
|
);
|
|
|
|
const version = fixture();
|
|
expect(() => validate(version, { expectedCandidateVersion: "2026.8.1-beta.2" })).toThrow(
|
|
"version differs",
|
|
);
|
|
|
|
const source = fixture();
|
|
expect(() => validate(source, { expectedSourceSha: "b".repeat(40) })).toThrow(
|
|
"source SHA differs",
|
|
);
|
|
|
|
const required = fixture();
|
|
expect(() => validate(required, { requiredPackages: ["@openclaw/feishu"] })).toThrow(
|
|
"missing Docker-plan package",
|
|
);
|
|
});
|
|
});
|