fix(release): restore direct package Telegram acceptance (#129912)

* fix(release): provision package acceptance plugin registry

* fix(release): accept registry tuple in package dispatch

* fix(release): default Docker advisory input

* fix(release): bind artifact registry to package source

* fix(release): preserve package advisory dispatch

* fix(ci): bound hosted release gate checks

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>

* test(ci): update release-gate manifest fixture

* fix(ci): avoid duplicate release gate lint

* test(release): restore Docker helper fixture path

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-08-27 08:32:19 +08:00
committed by GitHub
parent 0b60846fa4
commit 8afa8f339e
11 changed files with 835 additions and 77 deletions
+62 -9
View File
@@ -99,6 +99,7 @@ function evaluateWorkflowExpression(
headRepository?: string;
matrix?: Record<string, unknown>;
repository: string;
runCheck?: boolean;
runnerBackend?: "" | "blacksmith" | "github" | "hybrid";
runAttempt: number;
},
@@ -136,6 +137,13 @@ function evaluateWorkflowExpression(
: {},
},
matrix: context.matrix ?? {},
needs: {
preflight: {
outputs: {
run_check: String(context.runCheck ?? true),
},
},
},
vars: {
OPENCLAW_CI_RUNNER_BACKEND: context.runnerBackend ?? "",
},
@@ -216,6 +224,7 @@ function runCiManifestFixture(options: {
qaSmokePlan?: boolean;
formatCheck?: boolean;
releaseCandidateCompatibility?: boolean;
releaseGate?: boolean;
targetContextCompatibility?: boolean;
nodeFastOnly?: boolean;
nodeFastPluginContracts?: boolean;
@@ -404,6 +413,7 @@ function runCiManifestFixture(options: {
(options.eventName ?? "workflow_dispatch") === "workflow_dispatch"
? "true"
: "false",
OPENCLAW_CI_RELEASE_GATE: String(options.releaseGate ?? false),
OPENCLAW_CI_RELEASE_CANDIDATE_TARGET:
options.releaseCandidateCompatibility === true ? "true" : "false",
OPENCLAW_CI_TARGET_CONTEXT_TARGET:
@@ -6468,6 +6478,13 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
);
const checkShardRun = checkShardStep.run;
const hostedCoreLint = workflow.jobs["check-lint-hosted-core-shard"];
const hostedCoreTypes = workflow.jobs["check-test-types-hosted-core-shard"];
const manualDispatch = {
eventName: "workflow_dispatch",
repository: "openclaw/openclaw",
runnerBackend: "blacksmith",
runAttempt: 1,
} as const;
const untrustedForkPullRequest = {
authorAssociation: "NONE",
eventName: "pull_request",
@@ -6478,8 +6495,11 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
} as const;
expect(manifestStep.env.OPENCLAW_CI_RUNNER_BACKEND).toBe(
"${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}",
"${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}",
);
expect(
evaluateWorkflowExpression(manifestStep.env.OPENCLAW_CI_RUNNER_BACKEND, manualDispatch),
).toBe("github");
expect(
evaluateWorkflowExpression(
manifestStep.env.OPENCLAW_CI_RUNNER_BACKEND,
@@ -6489,9 +6509,15 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
expect(
evaluateWorkflowExpression(checkShardStep.env.RUNNER_BACKEND, untrustedForkPullRequest),
).toBe("github");
expect(evaluateWorkflowExpression(checkShardStep.env.RUNNER_BACKEND, manualDispatch)).toBe(
"github",
);
expect(checkShardStep.env.RELEASE_GATE).toBe("${{ inputs.release_gate && 'true' || 'false' }}");
expect(manifestStep.run).toContain("runnerBackend: process.env.OPENCLAW_CI_RUNNER_BACKEND");
expect(checkShardRun).toContain('if [ "$RUNNER_BACKEND" = "github" ]; then');
expect(checkShardRun).toContain("lint_args=(--only=extensions --only=scripts --threads=1)");
expect(checkShardRun).toContain('if [ "$RELEASE_GATE" = "true" ]; then');
expect(checkShardRun).toContain("lint_args=(--only=scripts --threads=1)");
expect(checkShardRun).toContain('elif [ "$(nproc)" -lt 8 ]; then');
expect(checkShardRun).toContain("lint_args=(--threads=1)");
expect(checkShardRun).not.toContain("lint_args=(--split-core --threads=1)");
@@ -6504,9 +6530,11 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
expect(hostedCoreLint.if).toContain(
"github.event.pull_request.head.repo.full_name != github.repository",
);
expect(workflow.jobs["check-test-types-hosted-core-shard"].if).toContain(
expect(hostedCoreTypes.if).toContain(
"github.event.pull_request.head.repo.full_name != github.repository",
);
expect(evaluateWorkflowExpression(hostedCoreLint.if, manualDispatch)).toBe(true);
expect(evaluateWorkflowExpression(hostedCoreTypes.if, manualDispatch)).toBe(true);
expect(hostedCoreLint["runs-on"]).toBe("ubuntu-24.04");
expect(hostedCoreLint.strategy).toEqual({
"fail-fast": false,
@@ -6544,14 +6572,14 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
"pull-requests": "read",
});
expect(checksFastJob.env.CHECKOUT_BASE_SHA).toBe(
"${{ matrix.task == 'baseline-ratchets' && needs.preflight.outputs.diff_base_revision || '' }}",
"${{ (matrix.task == 'baseline-ratchets' || startsWith(matrix.task, 'release-lint-')) && needs.preflight.outputs.diff_base_revision || '' }}",
);
expect(checkout.run).toContain(
'fetch_refs+=("+${CHECKOUT_BASE_SHA}:refs/remotes/origin/ci-ratchet-base")',
);
expect(checkout.run).toContain('"${fetch_refs[@]}" || return 1');
expect(releaseGateMerge.if).toBe(
"matrix.task == 'baseline-ratchets' && github.event_name == 'workflow_dispatch' && inputs.release_gate",
"(matrix.task == 'baseline-ratchets' || startsWith(matrix.task, 'release-lint-')) && github.event_name == 'workflow_dispatch' && inputs.release_gate",
);
expect(checksFastRun.run).toContain("baseline-ratchets)");
expect(checksFastRun.run).toContain("coercion-helpers)");
@@ -6603,9 +6631,6 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
expect(releaseGateMerge.run).toContain(
'echo "RATCHET_BASE_REF=${frozen_base_sha}" >> "$GITHUB_ENV"',
);
expect(releaseGateMerge.run).toContain(
'echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV"',
);
expect(checksFastRun.run).not.toContain("PROTOCOL_MANUAL_BASE_SHA");
expect(checksFastRun.run).toContain(
'"+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base"',
@@ -6631,10 +6656,10 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
);
expect(maxLinesRatchet).toContain("checkEnvVarCount(envVarCountArgs(argv), root);");
expect(checksFastRun.run).toContain(
'if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then',
'--only=core --split-core --core-stripe="${stripe}/5" --threads=1',
);
expect(checksFastRun.run).toContain(
"node --import tsx scripts/run-oxlint-shards.mts --only=core --only=extensions --threads=1",
"node --import tsx scripts/run-oxlint-shards.mts --only=extensions --threads=1",
);
expect(checksFastRun.run).not.toContain(
"node scripts/run-oxlint.mjs src ui/src packages extensions",
@@ -6665,6 +6690,34 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
task: "coercion-helpers",
},
]);
const releaseGate = runCiManifestFixture({
bundledPlanner: true,
eventName: "workflow_dispatch",
historicalCompatibility: false,
releaseGate: true,
});
expect(releaseGate.status, releaseGate.output).toBe(0);
expect(
JSON.parse(
expectDefined(releaseGate.outputs.checks_fast_core_matrix, "release-gate checks matrix"),
).include.filter((entry: { task: string }) => entry.task.startsWith("release-lint-")),
).toEqual([
...Array.from({ length: 5 }, (_, index) => {
const stripe = index + 1;
return {
check_name: `checks-fast-release-lint-core-${stripe}`,
runtime: "node",
stripe,
task: `release-lint-core-${stripe}`,
};
}),
{
check_name: "checks-fast-release-lint-extensions",
runtime: "node",
task: "release-lint-extensions",
},
]);
});
it("uses target-owned CI plans and capabilities for older release checkouts", () => {
+23
View File
@@ -4759,6 +4759,29 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
'-v "$harness_root/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"',
'-v "$harness_root/test/helpers:/app/test/helpers:ro"',
]);
const script = repoRootShell`
export DOCKER_E2E_HARNESS_ROOT_DIR=/trusted-harness
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
docker_e2e_harness_mount_args
for ((index = 1; index < \${#DOCKER_E2E_HARNESS_ARGS[@]}; index += 2)); do
printf "%s\\n" "\${DOCKER_E2E_HARNESS_ARGS[$index]}"
done
`;
const mounts = execFileSync("bash", ["-lc", script], { encoding: "utf8" }).trim().split("\n");
expect(mounts).toEqual([
"/trusted-harness/scripts/e2e:/app/scripts/e2e:ro",
"/trusted-harness/scripts/lib:/app/scripts/lib:ro",
"/trusted-harness/packages/gateway-client/src:/app/packages/gateway-client/src:ro",
"/trusted-harness/packages/normalization-core/package.json:/app/packages/normalization-core/package.json:ro",
"/trusted-harness/packages/normalization-core/src:/app/packages/normalization-core/src:ro",
"/trusted-harness/tsconfig.json:/app/tsconfig.json:ro",
"/trusted-harness/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro",
"/trusted-harness/test/helpers:/app/test/helpers:ro",
"/trusted-harness/scripts/prepublish-plugin-registry-artifact.mjs:/app/scripts/prepublish-plugin-registry-artifact.mjs:ro",
"/trusted-harness/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro",
]);
});
it("preserves pnpm lookup paths for scheduled Docker child lanes", () => {
@@ -1,5 +1,6 @@
// Package Acceptance Workflow tests cover package acceptance workflow script behavior.
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
mkdirSync,
@@ -813,6 +814,101 @@ function runPackageAcceptanceProfile(params: {
return { outputs, result };
}
function runPackageAcceptanceRegistryInputValidation(params: {
candidateArtifactJson?: string;
prepublishPluginRegistryJson?: string;
}) {
const job = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "resolve_package");
const script = workflowStep(job, "Validate prerelease plugin registry input").run;
if (!script) {
throw new Error("Expected package acceptance registry input validation script");
}
const workdir = tempDirs.make("package-acceptance-registry-input-");
const outputPath = resolve(workdir, "github-output");
const result = spawnSync("bash", ["-c", script], {
encoding: "utf8",
env: {
CANDIDATE_ARTIFACT_JSON: params.candidateArtifactJson ?? "",
GITHUB_OUTPUT: outputPath,
PATH: process.env.PATH,
PREPUBLISH_PLUGIN_REGISTRY_JSON: params.prepublishPluginRegistryJson ?? "",
},
});
const output = result.status === 0 ? readFileSync(outputPath, "utf8") : "";
return { output, result };
}
function packageAcceptanceRegistryTuple(overrides: Record<string, string> = {}) {
return {
prepublishPluginRegistryArtifactName: "docker-e2e-prepublish-plugin-registry-123-2",
prepublishPluginRegistryArtifactId: "456",
prepublishPluginRegistryArtifactDigest: "a".repeat(64),
prepublishPluginRegistryArtifactRunId: "123",
prepublishPluginRegistryArtifactRunAttempt: "2",
prepublishPluginRegistryManifestSha256: "b".repeat(64),
...overrides,
};
}
function runPackageAcceptanceResolveScript(params: {
prepublishPluginRegistryJson?: string;
source: "artifact" | "npm" | "ref" | "trusted-url" | "url";
telegramMode: "mock-openai" | "none";
}) {
const job = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "resolve_package");
const script = workflowStep(job, "Resolve package candidate").run;
if (!script) {
throw new Error("Expected package acceptance resolve script");
}
const workdir = tempDirs.make("package-acceptance-resolve-");
const binDir = resolve(workdir, "bin");
const capturePath = resolve(workdir, "node-args");
const outputPath = resolve(workdir, "github-output");
const artifactDir = resolve(workdir, ".artifacts/package-candidate-input");
mkdirSync(binDir, { recursive: true });
mkdirSync(artifactDir, { recursive: true });
const nodePath = resolve(binDir, "node");
const artifactPath = resolve(artifactDir, "openclaw-current.tgz");
const artifactBody = "package acceptance artifact";
writeFileSync(artifactPath, artifactBody);
writeFileSync(
nodePath,
`#!/bin/sh
printf "%s\\n" "$@" > "$CAPTURE_PATH"
if [ "$SOURCE" = "artifact" ]; then
mkdir -p .artifacts/docker-e2e-package
printf '{"name":"openclaw","sha256":"%s","packageSourceSha":"%s","version":"%s"}\\n' \
"$PACKAGE_SHA256" "$PACKAGE_SOURCE_SHA" "$PACKAGE_VERSION" \
> .artifacts/docker-e2e-package/package-candidate.json
fi
`,
);
chmodSync(nodePath, 0o755);
const result = spawnSync("bash", ["-c", script], {
cwd: workdir,
encoding: "utf8",
env: {
CAPTURE_PATH: capturePath,
GITHUB_OUTPUT: outputPath,
OPENCLAW_TRUSTED_PACKAGE_TOKEN: "",
PACKAGE_FILE_NAME: "openclaw-current.tgz",
PACKAGE_REF: "HEAD",
PACKAGE_SHA256: createHash("sha256").update(artifactBody).digest("hex"),
PACKAGE_SOURCE_SHA: "a".repeat(40),
PACKAGE_SPEC: "openclaw@beta",
PACKAGE_URL: "https://example.invalid/openclaw.tgz",
PACKAGE_VERSION: "2026.8.26",
PATH: `${binDir}:${process.env.PATH}`,
PREPUBLISH_PLUGIN_REGISTRY_JSON: params.prepublishPluginRegistryJson ?? "",
SOURCE: params.source,
TELEGRAM_MODE: params.telegramMode,
TRUSTED_SOURCE_ID: "",
},
});
const args = result.status === 0 ? readFileSync(capturePath, "utf8") : "";
return { args, result };
}
function runNpmTelegramInputValidation(overrides: Record<string, string>) {
const job = workflowJob(NPM_TELEGRAM_WORKFLOW, "run_package_telegram_e2e");
const script = workflowStep(job, "Validate inputs and secrets").run;
@@ -2605,6 +2701,17 @@ describe("package acceptance workflow", () => {
description: "Acceptance profile: smoke, package, telegram, product, full, or custom",
options: ["smoke", "package", "telegram", "product", "full", "custom"],
});
const dispatchInputs = parsedWorkflow.on?.workflow_dispatch?.inputs;
const callInputs = parsedWorkflow.on?.workflow_call?.inputs;
expect(dispatchInputs?.prepublish_plugin_registry_json).toBeUndefined();
expect(dispatchInputs?.advisory).toEqual(callInputs?.advisory);
expect(callInputs?.advisory).toEqual({
description: "Treat acceptance failures as advisory for the caller",
required: false,
default: false,
type: "boolean",
});
expect(Object.keys(dispatchInputs ?? {})).toHaveLength(25);
expect(parsedWorkflow.on?.workflow_dispatch?.inputs?.telegram_advisory).toBeUndefined();
expect(parsedWorkflow.on?.workflow_call?.inputs?.suite_profile).toMatchObject({
default: "package",
@@ -2684,17 +2791,17 @@ describe("package acceptance workflow", () => {
);
const registryInputs = {
prepublish_plugin_registry_artifact_name:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactName || '' }}",
"${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '' }}",
prepublish_plugin_registry_artifact_id:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactId || '' }}",
"${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactId || '' }}",
prepublish_plugin_registry_artifact_digest:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}",
"${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactDigest || '' }}",
prepublish_plugin_registry_artifact_run_id:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}",
"${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunId || '' }}",
prepublish_plugin_registry_artifact_run_attempt:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}",
"${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactRunAttempt || '' }}",
prepublish_plugin_registry_manifest_sha256:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}",
"${{ fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}",
};
expect(packageTelegram.with).toMatchObject(registryInputs);
const registryInputSchema = Object.fromEntries(
@@ -2713,7 +2820,11 @@ describe("package acceptance workflow", () => {
"Prerelease plugin registry inputs require an artifact-backed OpenClaw package.",
);
expect(npmTelegramWorkflow).toContain(
'expected_registry_name="docker-e2e-prepublish-plugin-registry-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID}-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT}"',
'expected_registry_suffix="-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ID}-${PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_RUN_ATTEMPT}"',
);
expect(npmTelegramWorkflow).toContain(
'"docker-e2e-prepublish-plugin-registry${expected_registry_suffix}" | \\\n' +
' "package-acceptance-telegram-plugin-registry${expected_registry_suffix}"',
);
expect(npmTelegramWorkflow).not.toContain(
"Prerelease plugin registry and package artifacts must come from the same workflow run attempt.",
@@ -2729,6 +2840,17 @@ describe("package acceptance workflow", () => {
expect(dockerAcceptance.with?.ref).toBe(
"${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}",
);
expect(dockerAcceptance.with?.advisory).toBe("${{ inputs.advisory || false }}");
expect(dockerAcceptanceRegistry.with?.advisory).toBe("${{ inputs.advisory || false }}");
expect(dockerAcceptance.with?.prepublish_plugin_registry_artifact_name).toContain(
"startsWith(",
);
expect(dockerAcceptance.with?.prepublish_plugin_registry_artifact_name).toContain(
"'docker-e2e-prepublish-plugin-registry-'",
);
expect(packageTelegram.with?.prepublish_plugin_registry_artifact_name).not.toContain(
"startsWith(",
);
expect(npm12Install.if).toBe("inputs.suite_profile != 'telegram'");
expect(dockerAcceptance.if).toBe(
"inputs.suite_profile != 'telegram' && inputs.shared_image_policy == 'no-push-artifact'",
@@ -2771,6 +2893,101 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain("Published upgrade survivor scenarios:");
});
it("normalizes one closed prerelease registry tuple before child workflows", () => {
const tuple = packageAcceptanceRegistryTuple();
const direct = runPackageAcceptanceRegistryInputValidation({
prepublishPluginRegistryJson: JSON.stringify(tuple),
});
expect(direct.result.status, direct.result.stderr).toBe(0);
expect(direct.output).toContain(`json=${JSON.stringify(tuple)}\n`);
const candidate = runPackageAcceptanceRegistryInputValidation({
candidateArtifactJson: JSON.stringify({
imageArtifactName: "image-123-2",
...tuple,
}),
});
expect(candidate.result.status, candidate.result.stderr).toBe(0);
expect(candidate.output).toContain(`json=${JSON.stringify(tuple)}\n`);
const identical = runPackageAcceptanceRegistryInputValidation({
candidateArtifactJson: JSON.stringify(tuple),
prepublishPluginRegistryJson: JSON.stringify(tuple),
});
expect(identical.result.status, identical.result.stderr).toBe(0);
expect(identical.output).toContain(`json=${JSON.stringify(tuple)}\n`);
});
it("rejects partial or ambiguous prerelease registry tuples before child workflows", () => {
const tuple = packageAcceptanceRegistryTuple();
const partial = runPackageAcceptanceRegistryInputValidation({
prepublishPluginRegistryJson: JSON.stringify({
prepublishPluginRegistryArtifactId: tuple.prepublishPluginRegistryArtifactId,
}),
});
expect(partial.result.status).toBe(1);
expect(partial.result.stderr).toContain(
"Prerelease plugin registry JSON must contain one complete immutable tuple.",
);
const ambiguous = runPackageAcceptanceRegistryInputValidation({
candidateArtifactJson: JSON.stringify(tuple),
prepublishPluginRegistryJson: JSON.stringify(
packageAcceptanceRegistryTuple({
prepublishPluginRegistryArtifactId: "789",
}),
),
});
expect(ambiguous.result.status).toBe(1);
expect(ambiguous.result.stderr).toContain("Prerelease plugin registry inputs disagree.");
});
it("generates a Telegram-only registry only for direct ref or artifact candidates", () => {
for (const source of ["ref", "artifact"] as const) {
const generated = runPackageAcceptanceResolveScript({
source,
telegramMode: "mock-openai",
});
expect(generated.result.status, generated.result.stderr).toBe(0);
expect(generated.args).toContain("--plugin-registry-output-dir\n");
expect(generated.args).toContain(".artifacts/package-acceptance-telegram-plugin-registry\n");
expect(generated.args).toContain('--required-plugin-packages-json\n["@openclaw/codex"]\n');
}
for (const source of ["npm", "url", "trusted-url"] as const) {
const skipped = runPackageAcceptanceResolveScript({
source,
telegramMode: "mock-openai",
});
expect(skipped.result.status, skipped.result.stderr).toBe(0);
expect(skipped.args).not.toContain("--plugin-registry-output-dir");
}
const telegramDisabled = runPackageAcceptanceResolveScript({
source: "ref",
telegramMode: "none",
});
expect(telegramDisabled.result.status, telegramDisabled.result.stderr).toBe(0);
expect(telegramDisabled.args).not.toContain("--plugin-registry-output-dir");
});
it("reuses a supplied registry and keeps generated artifact names distinct from Docker", () => {
const tuple = packageAcceptanceRegistryTuple();
const reused = runPackageAcceptanceResolveScript({
prepublishPluginRegistryJson: JSON.stringify(tuple),
source: "ref",
telegramMode: "mock-openai",
});
expect(reused.result.status, reused.result.stderr).toBe(0);
expect(reused.args).not.toContain("--plugin-registry-output-dir");
const workflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8");
expect(workflow).toContain(
"package-acceptance-telegram-plugin-registry-${{ github.run_id }}-${{ github.run_attempt }}",
);
expect(workflow).toContain('"docker-e2e-prepublish-plugin-registry-" +');
});
it("selects one normalized Telegram scenario without enabling broad acceptance lanes", () => {
const { outputs, result } = runPackageAcceptanceProfile({
suiteProfile: "telegram",
@@ -4544,6 +4761,8 @@ describe("package artifact reuse", () => {
"${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.package_mode != 'published') && needs.prepare_release_package.outputs.package_sha256 || '' }}",
package_source_sha: "${{ needs.prepare_release_package.outputs.source_sha }}",
package_version: "${{ needs.prepare_release_package.outputs.package_version }}",
prepublish_plugin_registry_json:
"${{ needs.prepare_release_package.outputs.prepublish_plugin_registry_json }}",
suite_profile: "custom",
});
expect(releaseChecksTargetSummary.env).toMatchObject({
@@ -4556,7 +4775,7 @@ describe("package artifact reuse", () => {
enable_prepublish_plugin_registry:
'${{ contains(fromJSON(\'["artifact","ref"]\'), inputs.source) }}',
prepublish_plugin_registry_manifest_sha256:
"${{ fromJSON(inputs.candidate_artifact_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}",
"${{ startsWith(fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryArtifactName || '', 'docker-e2e-prepublish-plugin-registry-') && fromJSON(needs.resolve_package.outputs.prepublish_plugin_registry_json || '{}').prepublishPluginRegistryManifestSha256 || '' }}",
});
expect(workflow).toContain(
"candidate_artifact_json cannot be combined with release package specs.",
@@ -5872,6 +6091,14 @@ describe("package artifact reuse", () => {
expect(runNpmTelegramInputValidation(packageTuple).status).toBe(0);
expect(runNpmTelegramInputValidation({ ...packageTuple, ...registryTuple }).status).toBe(0);
expect(
runNpmTelegramInputValidation({
...packageTuple,
...registryTuple,
PREPUBLISH_PLUGIN_REGISTRY_ARTIFACT_NAME:
"package-acceptance-telegram-plugin-registry-123-1",
}).status,
).toBe(0);
const partial = runNpmTelegramInputValidation({
...packageTuple,
@@ -588,13 +588,14 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => {
"${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }}",
OPENCLAW_CI_EVENT_NAME: "${{ github.event_name }}",
OPENCLAW_CI_HISTORICAL_TARGET: "${{ steps.historical_target.outputs.eligible || 'false' }}",
OPENCLAW_CI_RELEASE_GATE: "${{ inputs.release_gate && 'true' || 'false' }}",
OPENCLAW_CI_RELEASE_CANDIDATE_TARGET:
"${{ steps.release_candidate_target.outputs.eligible || 'false' }}",
OPENCLAW_CI_TARGET_CONTEXT_TARGET:
"${{ steps.target_context_target.outputs.eligible || 'false' }}",
OPENCLAW_CI_REPOSITORY: "${{ github.repository }}",
OPENCLAW_CI_RUNNER_BACKEND:
"${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}",
"${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND }}",
OPENCLAW_CI_RUN_ANDROID:
"${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}",
OPENCLAW_CI_RUN_CONTROL_UI_I18N:
@@ -1,7 +1,7 @@
// Resolve Openclaw Package Candidate tests cover resolve openclaw package candidate script behavior.
import { execFile, spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -14,6 +14,7 @@ import {
downloadUrl,
findSingleTarballForTest,
loadTrustedPackageSource,
main,
moveNewestPackedTarballForTest,
parseArgs,
readArtifactPackageCandidateMetadata,
@@ -22,8 +23,10 @@ import {
runCommandForTest,
validateOpenClawPackageSpec,
} from "../../scripts/resolve-openclaw-package-candidate.mts";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs: string[] = [];
const autoTempDirs = useAutoCleanupTempDirTracker(afterEach);
type LookupAddress = { address: string; family: number };
@@ -42,6 +45,99 @@ async function missing(file: string): Promise<boolean> {
);
}
async function createPackageTarball(
dir: string,
buildInfo?: string | { commit: string },
): Promise<string> {
const root = path.join(dir, "package");
await mkdir(path.join(root, "dist"), { recursive: true });
await writeFile(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.8.1" }),
);
if (buildInfo !== undefined) {
await writeFile(
path.join(root, "dist", "build-info.json"),
typeof buildInfo === "string" ? buildInfo : JSON.stringify(buildInfo),
);
}
const tarball = path.join(dir, "openclaw.tgz");
await new Promise<void>((resolve, reject) => {
execFile("tar", ["-czf", tarball, "-C", dir, "package"], (error) => {
if (error) {
reject(toLintErrorObject(error, "Non-Error rejection"));
return;
}
resolve();
});
});
return tarball;
}
async function createArtifactFixture(
prefix: string,
{
buildInfo,
packageSourceSha,
}: { buildInfo?: string | { commit: string }; packageSourceSha?: string },
) {
const dir = autoTempDirs.make(prefix);
const artifactDir = path.join(dir, "artifact");
const binDir = path.join(dir, "bin");
const gitLog = path.join(dir, "git.log");
const nodeLog = path.join(dir, "node.log");
await mkdir(artifactDir);
await mkdir(binDir);
await createPackageTarball(artifactDir, buildInfo);
if (packageSourceSha !== undefined) {
await writeFile(
path.join(artifactDir, "package-candidate.json"),
JSON.stringify({ packageSourceSha }),
);
}
await writeFile(
path.join(binDir, "git"),
`#!/bin/sh
printf '%s\\n' "$*" >> "$FAKE_GIT_LOG"
exit 99
`,
);
await writeFile(
path.join(binDir, "node"),
`#!/bin/sh
printf '%s\\n' "$*" >> "$FAKE_NODE_LOG"
exit 0
`,
);
await chmod(path.join(binDir, "git"), 0o755);
await chmod(path.join(binDir, "node"), 0o755);
return {
artifactDir,
binDir,
dir,
gitLog,
nodeLog,
registryDir: path.join(dir, "registry"),
};
}
async function withArtifactFixtureCommands<T>(
fixture: Awaited<ReturnType<typeof createArtifactFixture>>,
run: () => Promise<T>,
): Promise<T> {
const previousPath = process.env.PATH;
process.env.FAKE_GIT_LOG = fixture.gitLog;
process.env.FAKE_NODE_LOG = fixture.nodeLog;
process.env.PATH = `${fixture.binDir}:${previousPath}`;
try {
return await run();
} finally {
process.env.PATH = previousPath;
delete process.env.FAKE_GIT_LOG;
delete process.env.FAKE_NODE_LOG;
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -1267,6 +1363,191 @@ describe("resolve-openclaw-package-candidate", () => {
});
});
it.each([
["without a registry", false],
["with a registry", true],
])("rejects artifact provenance mismatches %s before side effects", async (_label, registry) => {
const metadataSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2";
const buildInfoSha = "77df743c0c8d6d80ee4f77d84a798e62749be7f3";
const fixture = await createArtifactFixture("openclaw-artifact-provenance-mismatch-", {
buildInfo: { commit: buildInfoSha.toUpperCase() },
packageSourceSha: metadataSha.toUpperCase(),
});
await withArtifactFixtureCommands(fixture, async () => {
await expect(
main([
"--source",
"artifact",
"--artifact-dir",
fixture.artifactDir,
"--output-dir",
path.join(fixture.dir, "output"),
...(registry
? [
"--plugin-registry-output-dir",
fixture.registryDir,
"--required-plugin-packages-json",
'["@openclaw/codex"]',
]
: []),
]),
).rejects.toThrow(
`artifact packageSourceSha ${metadataSha} does not match package build-info commit ${buildInfoSha}`,
);
});
await expect(missing(fixture.gitLog)).resolves.toBe(true);
await expect(missing(fixture.registryDir)).resolves.toBe(true);
});
it("uses normalized artifact build-info provenance when metadata is absent", async () => {
const sourceSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2";
const fixture = await createArtifactFixture("openclaw-artifact-build-info-fallback-", {
buildInfo: { commit: sourceSha.toUpperCase() },
});
const metadataPath = path.join(fixture.dir, "resolved.json");
await withArtifactFixtureCommands(fixture, async () => {
await main([
"--source",
"artifact",
"--artifact-dir",
fixture.artifactDir,
"--output-dir",
path.join(fixture.dir, "output"),
"--metadata",
metadataPath,
]);
});
const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Record<string, unknown>;
expect(metadata.packageSourceSha).toBe(sourceSha);
expect(metadata.packageTrustedReason).toBe("package-build-info");
});
it("requires artifact build-info only when preparing a registry", async () => {
const sourceSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2";
const fixture = await createArtifactFixture("openclaw-artifact-missing-build-info-", {
packageSourceSha: sourceSha,
});
const metadataPath = path.join(fixture.dir, "resolved.json");
await withArtifactFixtureCommands(fixture, async () => {
await main([
"--source",
"artifact",
"--artifact-dir",
fixture.artifactDir,
"--output-dir",
path.join(fixture.dir, "output-without-registry"),
"--metadata",
metadataPath,
]);
await expect(
main([
"--source",
"artifact",
"--artifact-dir",
fixture.artifactDir,
"--output-dir",
path.join(fixture.dir, "output-with-registry"),
"--plugin-registry-output-dir",
fixture.registryDir,
"--required-plugin-packages-json",
'["@openclaw/codex"]',
]),
).rejects.toThrow(
"source=artifact requires a valid package build-info commit for prerelease plugin registry creation",
);
});
const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Record<string, unknown>;
expect(metadata.packageSourceSha).toBe(sourceSha);
await expect(missing(fixture.gitLog)).resolves.toBe(true);
await expect(missing(fixture.registryDir)).resolves.toBe(true);
});
it("rejects malformed artifact build-info before package validation", async () => {
const fixture = await createArtifactFixture("openclaw-artifact-malformed-build-info-", {
buildInfo: "{not-json",
packageSourceSha: "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2",
});
await withArtifactFixtureCommands(fixture, async () => {
await expect(
main([
"--source",
"artifact",
"--artifact-dir",
fixture.artifactDir,
"--output-dir",
path.join(fixture.dir, "output"),
]),
).rejects.toBeInstanceOf(SyntaxError);
});
await expect(missing(fixture.nodeLog)).resolves.toBe(true);
});
it("validates the normalized artifact source SHA before registry preparation", async () => {
const dir = autoTempDirs.make("openclaw-artifact-registry-source-");
const artifactDir = path.join(dir, "artifact");
const binDir = path.join(dir, "bin");
const gitLog = path.join(dir, "git.log");
const sourceSha = "66ce632b9b7c5c7fdd3e66c739687d51638ad6e2";
await mkdir(artifactDir);
await mkdir(binDir);
await createPackageTarball(artifactDir, { commit: sourceSha });
await writeFile(
path.join(artifactDir, "package-candidate.json"),
JSON.stringify({ packageSourceSha: sourceSha.toUpperCase() }),
);
const fakeGit = path.join(binDir, "git");
await writeFile(
fakeGit,
`#!/bin/sh
printf '%s\\n' "$*" >> "$FAKE_GIT_LOG"
case "$1" in
fetch) exit 0 ;;
rev-parse) printf '%s\\n' "$FAKE_SOURCE_SHA" ;;
merge-base) exit 1 ;;
tag | for-each-ref) exit 0 ;;
*) exit 99 ;;
esac
`,
);
await chmod(fakeGit, 0o755);
const previousPath = process.env.PATH;
process.env.FAKE_GIT_LOG = gitLog;
process.env.FAKE_SOURCE_SHA = sourceSha;
process.env.PATH = `${binDir}:${previousPath}`;
try {
await expect(
main([
"--source",
"artifact",
"--artifact-dir",
artifactDir,
"--output-dir",
path.join(dir, "output"),
"--plugin-registry-output-dir",
path.join(dir, "registry"),
"--required-plugin-packages-json",
'["@openclaw/codex"]',
]),
).rejects.toThrow(
`package_ref ${sourceSha} resolved to ${sourceSha}, which is not reachable from an OpenClaw branch or release tag`,
);
} finally {
process.env.PATH = previousPath;
delete process.env.FAKE_GIT_LOG;
delete process.env.FAKE_SOURCE_SHA;
}
await expect(readFile(gitLog, "utf8")).resolves.toContain(
`rev-parse --verify ${sourceSha}^{commit}`,
);
});
it("normalizes whitespace-only artifact package source SHAs to absent", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-candidate-empty-sha-"));
tempDirs.push(dir);
@@ -1341,22 +1622,8 @@ describe("resolve-openclaw-package-candidate", () => {
it("reads the source SHA from packed npm build metadata", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "openclaw-package-build-info-"));
tempDirs.push(dir);
const root = path.join(dir, "package");
await mkdir(path.join(root, "dist"), { recursive: true });
await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }));
await writeFile(
path.join(root, "dist", "build-info.json"),
JSON.stringify({ commit: "66CE632B9B7C5C7FDD3E66C739687D51638AD6E2" }),
);
const tarball = path.join(dir, "openclaw.tgz");
await new Promise<void>((resolve, reject) => {
execFile("tar", ["-czf", tarball, "-C", dir, "package"], (error) => {
if (error) {
reject(toLintErrorObject(error, "Non-Error rejection"));
return;
}
resolve();
});
const tarball = await createPackageTarball(dir, {
commit: "66CE632B9B7C5C7FDD3E66C739687D51638AD6E2",
});
await expect(readPackageBuildSourceSha(tarball)).resolves.toBe(