mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(ci): enforce reusable workload plans * fix(ci): skip incompatible convergence upgrades
480 lines
22 KiB
TypeScript
480 lines
22 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { afterEach, describe, expect, test } from "vitest";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
const convergenceScript = path.join(repoRoot, ".github/scripts/convergence.py");
|
|
const temporaryRoots: string[] = [];
|
|
|
|
function createRepository() {
|
|
const root = mkdtempSync(path.join(tmpdir(), "convergence-contract-"));
|
|
temporaryRoots.push(root);
|
|
for (const [name, content] of [["control.txt", "control"], ["a.txt", "a"], ["b.txt", "b"]] as const) {
|
|
writeFileSync(path.join(root, name), content);
|
|
}
|
|
const configPath = path.join(root, "convergence.json");
|
|
writeFileSync(configPath, JSON.stringify({
|
|
schema: { version: 1 },
|
|
suites: { "convergence-control": ["control.txt"], web: ["a.txt"] },
|
|
workflows: {
|
|
ci: {
|
|
policy: "test-v1",
|
|
workloads: {
|
|
a: { inputs: ["suite://web"], runnerClass: "worker", products: "none", reusable: true },
|
|
b: { inputs: ["suite://web", "b.txt"], runnerClass: "worker", products: "none", reusable: true },
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
const scopePlanPath = path.join(root, "scope-plan.json");
|
|
writeFileSync(scopePlanPath, JSON.stringify({ enabled: { a: true, b: true } }));
|
|
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
execFileSync("git", ["add", "."], { cwd: root });
|
|
execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-qm", "fixture"], { cwd: root });
|
|
return { root, configPath, scopePlanPath, pendingPath: path.join(root, "pending.json") };
|
|
}
|
|
|
|
function runPlan(fixture: ReturnType<typeof createRepository>, runner = ["ubuntu-24.04"]) {
|
|
const outputPath = path.join(fixture.root, "github-output.txt");
|
|
writeFileSync(outputPath, "");
|
|
const stdout = execFileSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath,
|
|
"github-output", "--workflow", "ci", "--scope-plan", fixture.scopePlanPath,
|
|
"--runner-plan-json", JSON.stringify({ worker: runner }), "--repository-id", "42",
|
|
"--repository", "example/repo", "--mode", "shadow", "--pending", fixture.pendingPath,
|
|
], { cwd: fixture.root, encoding: "utf8", env: { ...process.env, GITHUB_OUTPUT: outputPath } });
|
|
return {
|
|
decision: JSON.parse(stdout) as { run: Record<string, boolean>; hit: Record<string, boolean> },
|
|
pending: JSON.parse(readFileSync(fixture.pendingPath, "utf8")) as {
|
|
workloads: Record<string, { digest: string; wouldRun: boolean }>;
|
|
},
|
|
};
|
|
}
|
|
|
|
function workload(
|
|
plan: ReturnType<typeof runPlan>["pending"]["workloads"],
|
|
name: string,
|
|
) {
|
|
const result = plan[name];
|
|
if (!result) throw new Error(`missing workload ${name}`);
|
|
return result;
|
|
}
|
|
|
|
function candidate(products: Record<string, unknown>) {
|
|
const digest = "d".repeat(64);
|
|
const provenance = {
|
|
event: "pull_request", runId: 12, runAttempt: 1,
|
|
headSha: "a".repeat(40), baseSha: "b".repeat(40), treeSha: "c".repeat(40),
|
|
validatedAt: "2026-08-21T00:00:00Z",
|
|
};
|
|
return {
|
|
schemaVersion: 1,
|
|
protocol: "nexu-workload-result-v1",
|
|
repositoryId: 42,
|
|
repository: "example/repo",
|
|
workflow: "ci",
|
|
policy: "test-v1",
|
|
provenance,
|
|
results: [{
|
|
key: `workload-results/v1/repos/42/workflows/ci/policies/test-v1/workloads/a/digests/${digest}.json`,
|
|
receipt: {
|
|
schemaVersion: 1, protocol: "nexu-workload-result-v1", repositoryId: 42,
|
|
workflow: "ci", policy: "test-v1", workload: "a", digest,
|
|
executionClass: { runnerClass: "worker", labels: ["ubuntu-24.04"] }, products, validated: provenance,
|
|
},
|
|
}],
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("workload convergence", () => {
|
|
test("rejects restoring a miss instead of manufacturing successful output", () => {
|
|
const fixture = createRepository();
|
|
runPlan(fixture);
|
|
const result = spawnSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath,
|
|
"restore", "--pending", fixture.pendingPath, "--workload", "a",
|
|
"--output-dir", path.join(fixture.root, "restored"),
|
|
], { cwd: fixture.root, encoding: "utf8" });
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("restore requires a selected reusable-result hit");
|
|
});
|
|
|
|
test("keeps invalid result bindings fatal when product misses may fall back", () => {
|
|
const fixture = createRepository();
|
|
runPlan(fixture);
|
|
const pending = JSON.parse(readFileSync(fixture.pendingPath, "utf8")) as any;
|
|
const expected = pending.workloads.a;
|
|
expected.resultHit = true;
|
|
expected.result = {
|
|
schemaVersion: 1,
|
|
protocol: "nexu-workload-result-v1",
|
|
repositoryId: 42,
|
|
workflow: "ci",
|
|
policy: "test-v1",
|
|
workload: "b",
|
|
digest: expected.digest,
|
|
executionClass: expected.executionClass,
|
|
products: {},
|
|
validated: {
|
|
event: "pull_request", runId: 12, runAttempt: 1,
|
|
headSha: "a".repeat(40), baseSha: "b".repeat(40), treeSha: "c".repeat(40),
|
|
validatedAt: "2026-08-21T00:00:00Z",
|
|
},
|
|
};
|
|
writeFileSync(fixture.pendingPath, JSON.stringify(pending));
|
|
|
|
const result = spawnSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath,
|
|
"restore", "--pending", fixture.pendingPath, "--workload", "a", "--allow-miss",
|
|
"--output-dir", path.join(fixture.root, "restored"),
|
|
], { cwd: fixture.root, encoding: "utf8" });
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain("workload result producer is not explicitly trusted");
|
|
});
|
|
|
|
test("checks complete product restoration and failed-set isolation without network", () => {
|
|
const fixture = createRepository();
|
|
const result = spawnSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath, "validate",
|
|
], { cwd: fixture.root, encoding: "utf8" });
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("convergence configuration is valid");
|
|
});
|
|
|
|
test("keeps shadow coverage while calculating stable workload identities", () => {
|
|
const fixture = createRepository();
|
|
const first = runPlan(fixture);
|
|
const second = runPlan(fixture);
|
|
expect(first.decision.run).toEqual({ a: true, b: true });
|
|
expect(first.decision.hit).toEqual({ a: false, b: false });
|
|
expect(workload(second.pending.workloads, "a").digest).toBe(workload(first.pending.workloads, "a").digest);
|
|
expect(workload(second.pending.workloads, "b").digest).toBe(workload(first.pending.workloads, "b").digest);
|
|
expect(workload(first.pending.workloads, "a").digest)
|
|
.toBe("aa009c98463dae565677f5671bbe86e7f51922814a5facdd4952d476846cb20b");
|
|
expect(workload(first.pending.workloads, "b").digest)
|
|
.toBe("68d78e50ea5aa02be29dd00ad1f0396006600f05eb81f720e7f464ccafd7e54c");
|
|
});
|
|
|
|
test("composes suites without coupling unrelated workload inputs", () => {
|
|
const fixture = createRepository();
|
|
const before = runPlan(fixture).pending.workloads;
|
|
writeFileSync(path.join(fixture.root, "b.txt"), "b2");
|
|
execFileSync("git", ["add", "b.txt"], { cwd: fixture.root });
|
|
const afterB = runPlan(fixture).pending.workloads;
|
|
expect(workload(afterB, "a").digest).toBe(workload(before, "a").digest);
|
|
expect(workload(afterB, "b").digest).not.toBe(workload(before, "b").digest);
|
|
|
|
writeFileSync(path.join(fixture.root, "a.txt"), "a2");
|
|
execFileSync("git", ["add", "a.txt"], { cwd: fixture.root });
|
|
const afterA = runPlan(fixture).pending.workloads;
|
|
expect(workload(afterA, "a").digest).not.toBe(workload(afterB, "a").digest);
|
|
expect(workload(afterA, "b").digest).not.toBe(workload(afterB, "b").digest);
|
|
});
|
|
|
|
test("includes the execution class in the reusable-result digest", () => {
|
|
const fixture = createRepository();
|
|
const hostedPlan = runPlan(fixture, ["ubuntu-24.04"]).pending.workloads;
|
|
const arcPlan = runPlan(fixture, ["nexu-runners-medium"]).pending.workloads;
|
|
const hosted = workload(hostedPlan, "a").digest;
|
|
const arc = workload(arcPlan, "a").digest;
|
|
expect(arc).not.toBe(hosted);
|
|
});
|
|
|
|
test("binds schema-v10 workloads to canonical postinstall delivery plans", () => {
|
|
const fixture = createRepository();
|
|
const config = JSON.parse(readFileSync(fixture.configPath, "utf8"));
|
|
config.schema.version = 10;
|
|
config.workflows.ci.workloads.a.postinstallIntent = "fixture";
|
|
config.workflows.ci.workloads.a.success = { "Fixture A": ["Run A"] };
|
|
config.workflows.ci.workloads.b.success = { "Fixture B": ["Run B"] };
|
|
writeFileSync(fixture.configPath, JSON.stringify(config));
|
|
mkdirSync(path.join(fixture.root, ".github/config"), { recursive: true });
|
|
mkdirSync(path.join(fixture.root, "scripts"), { recursive: true });
|
|
mkdirSync(path.join(fixture.root, "packages/release"), { recursive: true });
|
|
writeFileSync(path.join(fixture.root, ".github/config/postinstall.json"), JSON.stringify({
|
|
schemaVersion: 2,
|
|
targets: ["packages/release"],
|
|
intents: { fixture: { installProfile: "workspace", requestedTargets: ["packages/release"] } },
|
|
}));
|
|
writeFileSync(path.join(fixture.root, "packages/release/package.json"), JSON.stringify({
|
|
name: "@open-design/release",
|
|
}));
|
|
execFileSync("git", ["add", "."], { cwd: fixture.root });
|
|
|
|
const before = runPlan(fixture).pending.workloads;
|
|
expect((workload(before, "a") as any).postinstallPlan).toEqual({
|
|
intent: "fixture",
|
|
digest: expect.stringMatching(/^[0-9a-f]{64}$/),
|
|
});
|
|
expect((workload(before, "b") as any).postinstallPlan).toBeUndefined();
|
|
|
|
const planConfigPath = path.join(fixture.root, ".github/config/postinstall.json");
|
|
const planConfig = JSON.parse(readFileSync(planConfigPath, "utf8"));
|
|
planConfig.intents.fixture.installProfile = "source-web";
|
|
writeFileSync(planConfigPath, JSON.stringify(planConfig));
|
|
execFileSync("git", ["add", planConfigPath], { cwd: fixture.root });
|
|
const after = runPlan(fixture).pending.workloads;
|
|
expect(workload(after, "a").digest).not.toBe(workload(before, "a").digest);
|
|
expect(workload(after, "b").digest).toBe(workload(before, "b").digest);
|
|
|
|
config.schema.version = 1;
|
|
writeFileSync(fixture.configPath, JSON.stringify(config));
|
|
const stale = spawnSync("python3", [convergenceScript, "--root", fixture.root,
|
|
"--config", fixture.configPath, "validate"], { encoding: "utf8" });
|
|
expect(stale.status).toBe(2);
|
|
expect(stale.stderr).toContain("postinstallIntent requires convergence schema.version 10");
|
|
});
|
|
|
|
test("declares schema-v10 CI shards with trusted evidence and canonical setup plans", () => {
|
|
const config = JSON.parse(readFileSync(
|
|
path.join(repoRoot, ".github", "config", "convergence.json"),
|
|
"utf8",
|
|
)) as any;
|
|
|
|
expect(config.schema.version).toBe(10);
|
|
expect(config.workflows.ci.policy).toBe("ci-v2");
|
|
for (const shard of [1, 2, 3, 4]) {
|
|
const workload = config.workflows.ci.workloads[`daemon_unit_${shard}`];
|
|
expect(workload.inputs).toEqual(["suite://daemon"]);
|
|
expect(workload.postinstallIntent).toBe("ci-daemon");
|
|
expect(workload.success).toEqual({ [`Daemon tests (${shard}/4)`]: ["Run daemon test shard"] });
|
|
}
|
|
expect(config.workflows.ci.workloads.e2e_vitest).toMatchObject({
|
|
inputs: ["suite://e2e-runtime"],
|
|
postinstallIntent: "ci-e2e",
|
|
success: { "E2E Vitest": ["Prebuild workspace type declarations", "E2E Vitest"] },
|
|
});
|
|
expect(config.workflows.ci.matrices.daemon).toHaveLength(4);
|
|
expect(config.workflows.ci.matrices.web).toHaveLength(2);
|
|
expect(config.workflows.ci.matrices.ui_p0).toHaveLength(6);
|
|
});
|
|
|
|
test("authorizes isolated CI publication only on the repository default branch", () => {
|
|
const result = spawnSync("python3", ["-c", `
|
|
import os, sys
|
|
from unittest.mock import patch
|
|
sys.path.insert(0, sys.argv[1])
|
|
import convergence as c
|
|
payload = {"repository":{"id":42,"full_name":"nexu-io/open-design","default_branch":"main"}}
|
|
env = {"GITHUB_EVENT_NAME":"workflow_dispatch", "GITHUB_REPOSITORY":"nexu-io/open-design",
|
|
"GITHUB_REPOSITORY_ID":"42", "GITHUB_RUN_ID":"12", "GITHUB_RUN_ATTEMPT":"1",
|
|
"GITHUB_REF":"refs/heads/main"}
|
|
with patch.dict(os.environ, env), patch("convergence.event_payload", return_value=payload):
|
|
context = c.producer_context(payload)
|
|
candidate = {**context, "workflow":"ci", "policy":"ci-v2"}
|
|
with patch("convergence.prepare_publication") as validation:
|
|
c.require_isolated_candidate(candidate)
|
|
validation.assert_called_once()
|
|
with patch.dict(os.environ, {"GITHUB_REF":"refs/heads/topic"}):
|
|
try: c.require_isolated_candidate(candidate)
|
|
except c.ConfigError: pass
|
|
else: raise AssertionError("accepted CI publication outside the default branch")
|
|
`, path.dirname(convergenceScript)], { cwd: repoRoot, encoding: "utf8" });
|
|
expect(result.status, result.stderr).toBe(0);
|
|
});
|
|
|
|
test("selects the newest same-name artifact after a failed-job rerun", () => {
|
|
const result = spawnSync("python3", ["-c", `
|
|
import sys
|
|
from unittest.mock import patch
|
|
sys.path.insert(0, sys.argv[1])
|
|
from lib import github as g
|
|
older = {"id": 20, "name": "handoff-convergence-ci-results", "expired": False, "created_at": "2026-09-21T08:16:09Z"}
|
|
newer = {"id": 10, "name": "handoff-convergence-ci-results", "expired": False, "created_at": "2026-09-21T08:22:11Z"}
|
|
with patch("lib.github.run_artifacts", return_value=[older, newer]):
|
|
assert g.latest_run_artifact("example/repo", 12, newer["name"])["id"] == 10
|
|
with patch("lib.github.run_artifacts", return_value=[older, {**newer, "created_at": ""}]):
|
|
try: g.latest_run_artifact("example/repo", 12, newer["name"])
|
|
except g.GitHubError: pass
|
|
else: raise AssertionError("accepted artifact without creation time")
|
|
`, path.dirname(convergenceScript)], { cwd: repoRoot, encoding: "utf8" });
|
|
expect(result.status, result.stderr).toBe(0);
|
|
});
|
|
|
|
test("materializes the convergence handoff from the GitHub event context", () => {
|
|
const fixture = createRepository();
|
|
runPlan(fixture);
|
|
const eventPath = path.join(fixture.root, "event.json");
|
|
const outputPath = path.join(fixture.root, "handoff-output.txt");
|
|
const handoffRoot = path.join(fixture.root, "handoff-root");
|
|
const headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: fixture.root, encoding: "utf8" }).trim();
|
|
writeFileSync(eventPath, JSON.stringify({
|
|
repository: { id: 42, full_name: "example/repo" },
|
|
pull_request: { head: { sha: headSha }, base: { sha: headSha } },
|
|
}));
|
|
writeFileSync(outputPath, "");
|
|
execFileSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath,
|
|
"handoff", "--pending", fixture.pendingPath,
|
|
"--products-root", path.join(fixture.root, "products"),
|
|
"--handoff-root", handoffRoot,
|
|
], {
|
|
cwd: fixture.root,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_EVENT_NAME: "pull_request",
|
|
GITHUB_EVENT_PATH: eventPath,
|
|
GITHUB_REPOSITORY: "example/repo",
|
|
GITHUB_REPOSITORY_ID: "42",
|
|
GITHUB_RUN_ID: "12",
|
|
GITHUB_RUN_ATTEMPT: "1",
|
|
GITHUB_OUTPUT: outputPath,
|
|
},
|
|
});
|
|
const metadata = JSON.parse(readFileSync(
|
|
path.join(handoffRoot, "handoff", "convergence", "ci-results", "metadata.json"),
|
|
"utf8",
|
|
)) as Record<string, unknown>;
|
|
expect(metadata).toMatchObject({
|
|
repository_id: 42, repository: "example/repo", workflow: "ci", policy: "test-v1",
|
|
event: "pull_request", run_id: 12, run_attempt: 1, head_sha: headSha,
|
|
});
|
|
expect(readFileSync(outputPath, "utf8")).toContain("name=handoff-convergence-ci-results");
|
|
|
|
writeFileSync(eventPath, JSON.stringify({
|
|
repository: { id: 42, full_name: "example/repo" },
|
|
workflow_run: {
|
|
id: 12, run_attempt: 1, name: "ci", event: "pull_request", head_sha: headSha,
|
|
head_repository: { full_name: "example/repo" },
|
|
},
|
|
}));
|
|
writeFileSync(outputPath, "");
|
|
execFileSync("git", ["remote", "add", "origin", fixture.root], { cwd: fixture.root });
|
|
execFileSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath,
|
|
"admit", "--handoff-root", handoffRoot,
|
|
], {
|
|
cwd: fixture.root,
|
|
env: { ...process.env, GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath },
|
|
});
|
|
expect(readFileSync(outputPath, "utf8")).toContain("publish=true");
|
|
});
|
|
|
|
test("skips a changed producer control plane before comparing its policy", () => {
|
|
const fixture = createRepository();
|
|
const baseSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: fixture.root, encoding: "utf8",
|
|
}).trim();
|
|
const config = JSON.parse(readFileSync(fixture.configPath, "utf8"));
|
|
config.workflows.ci.policy = "test-v2";
|
|
writeFileSync(fixture.configPath, JSON.stringify(config));
|
|
writeFileSync(path.join(fixture.root, "control.txt"), "changed control");
|
|
execFileSync("git", ["add", "."], { cwd: fixture.root });
|
|
execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com",
|
|
"commit", "-qm", "change control policy"], { cwd: fixture.root });
|
|
const headSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: fixture.root, encoding: "utf8",
|
|
}).trim();
|
|
runPlan(fixture);
|
|
|
|
const eventPath = path.join(fixture.root, "event.json");
|
|
const outputPath = path.join(fixture.root, "github-output.txt");
|
|
const handoffRoot = path.join(fixture.root, "handoff-root");
|
|
writeFileSync(eventPath, JSON.stringify({
|
|
repository: { id: 42, full_name: "example/repo" },
|
|
pull_request: { head: { sha: headSha }, base: { sha: baseSha } },
|
|
}));
|
|
execFileSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath,
|
|
"handoff", "--pending", fixture.pendingPath,
|
|
"--products-root", path.join(fixture.root, "products"),
|
|
"--handoff-root", handoffRoot,
|
|
], {
|
|
cwd: fixture.root,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_EVENT_NAME: "pull_request",
|
|
GITHUB_EVENT_PATH: eventPath,
|
|
GITHUB_REPOSITORY: "example/repo",
|
|
GITHUB_REPOSITORY_ID: "42",
|
|
GITHUB_RUN_ID: "12",
|
|
GITHUB_RUN_ATTEMPT: "1",
|
|
GITHUB_OUTPUT: outputPath,
|
|
},
|
|
});
|
|
|
|
execFileSync("git", ["checkout", "--detach", baseSha], { cwd: fixture.root });
|
|
execFileSync("git", ["remote", "add", "origin", fixture.root], { cwd: fixture.root });
|
|
writeFileSync(eventPath, JSON.stringify({
|
|
repository: { id: 42, full_name: "example/repo" },
|
|
workflow_run: {
|
|
id: 12, run_attempt: 1, name: "ci", event: "pull_request", head_sha: headSha,
|
|
head_repository: { full_name: "example/repo" },
|
|
},
|
|
}));
|
|
writeFileSync(outputPath, "");
|
|
execFileSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath,
|
|
"admit", "--handoff-root", handoffRoot,
|
|
], {
|
|
cwd: fixture.root,
|
|
env: { ...process.env, GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath },
|
|
});
|
|
expect(readFileSync(outputPath, "utf8")).toContain("publish=false");
|
|
expect(readFileSync(outputPath, "utf8")).toContain("reason=producer-control-plane-changed");
|
|
});
|
|
|
|
test("rejects dependency cycles and dangling suites before planning", () => {
|
|
const fixture = createRepository();
|
|
const config = JSON.parse(readFileSync(fixture.configPath, "utf8")) as any;
|
|
config.suites.web = ["suite://web"];
|
|
writeFileSync(fixture.configPath, JSON.stringify(config));
|
|
const cycle = spawnSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath, "validate",
|
|
], { cwd: fixture.root, encoding: "utf8" });
|
|
expect(cycle.status).toBe(2);
|
|
expect(cycle.stderr).toContain("convergence dependency cycle");
|
|
|
|
config.suites.web = ["suite://missing"];
|
|
writeFileSync(fixture.configPath, JSON.stringify(config));
|
|
const dangling = spawnSync("python3", [
|
|
convergenceScript, "--root", fixture.root, "--config", fixture.configPath, "validate",
|
|
], { cwd: fixture.root, encoding: "utf8" });
|
|
expect(dangling.status).toBe(2);
|
|
expect(dangling.stderr).toContain("references unknown suite://missing");
|
|
});
|
|
|
|
test("publishes a multi-product manifest atomically only after every product is a URL", () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), "convergence-products-"));
|
|
temporaryRoots.push(root);
|
|
const candidatePath = path.join(root, "candidate.json");
|
|
writeFileSync(candidatePath, JSON.stringify(candidate({
|
|
bundle: { type: "url", source: "https://results.example/bundle.zip", data: { sha256: "a".repeat(64) } },
|
|
report: { type: "url", source: "https://results.example/report.json", data: { sha256: "b".repeat(64) } },
|
|
})));
|
|
execFileSync("python3", [
|
|
convergenceScript, "prepare-publication", "--candidate", candidatePath,
|
|
"--output-dir", path.join(root, "receipts"),
|
|
], { cwd: repoRoot });
|
|
|
|
writeFileSync(candidatePath, JSON.stringify(candidate({
|
|
bundle: { type: "job", source: "build-products" },
|
|
report: { type: "url", source: "https://results.example/report.json" },
|
|
})));
|
|
const rejected = spawnSync("python3", [
|
|
convergenceScript, "prepare-publication", "--candidate", candidatePath,
|
|
"--output-dir", path.join(root, "rejected"),
|
|
], { cwd: repoRoot, encoding: "utf8" });
|
|
expect(rejected.status).toBe(2);
|
|
expect(rejected.stderr).toContain("must be promoted to url before publication");
|
|
|
|
writeFileSync(candidatePath, JSON.stringify(candidate({
|
|
bundle: { type: "url", source: "https://results.example/bundle.zip" },
|
|
})));
|
|
const missingDigest = spawnSync("python3", [
|
|
convergenceScript, "prepare-publication", "--candidate", candidatePath,
|
|
"--output-dir", path.join(root, "missing-digest"),
|
|
], { cwd: repoRoot, encoding: "utf8" });
|
|
expect(missingDigest.status).toBe(2);
|
|
expect(missingDigest.stderr).toContain("data.sha256 is required");
|
|
});
|
|
});
|