mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 13:41:35 +00:00
ci: stabilize release validation tests (#108420)
* test(codex): remove turn-watch timing race * ci: pin workflow sanity shellcheck * ci: run workflow sanity on blacksmith * ci: serialize workflow sanity lint * ci: bound workflow lint stalls * ci: cap actionlint process fanout * ci: report stalled workflow shellcheck * ci: stabilize release validation checks * ci: restore release workflow shellcheck * test: preserve codex turn watch reset proof
This commit is contained in:
committed by
GitHub
parent
ff8a015981
commit
28d30e9dae
122
.github/workflows/openclaw-release-publish.yml
vendored
122
.github/workflows/openclaw-release-publish.yml
vendored
@@ -1290,7 +1290,7 @@ jobs:
|
||||
|
||||
resolve_openclaw_npm_publish_state() {
|
||||
local artifact_name manifest_dir manifest_path manifest_sha manifest_tarball_sha published_sha published_tarball_path published_tarball_url release_version
|
||||
local canonical_workflow_id resume_branch resume_compare_status resume_conclusion resume_event resume_jobs resume_json resume_path resume_path_trusted resume_sha resume_tag_commit_sha resume_tag_json resume_tag_object_sha resume_tag_verified resume_url resume_workflow_id
|
||||
local resume_state resume_url
|
||||
|
||||
openclaw_npm_already_published="false"
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
|
||||
@@ -1339,59 +1339,12 @@ jobs:
|
||||
echo "openclaw@${release_version} is already published; openclaw_npm_resume_run_id is required to bind postpublish proof to the original workflow identity." >&2
|
||||
exit 1
|
||||
fi
|
||||
resume_json="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${OPENCLAW_NPM_RESUME_RUN_ID}")"
|
||||
canonical_workflow_id="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/openclaw-npm-release.yml" --jq '.id')"
|
||||
resume_branch="$(printf '%s' "${resume_json}" | jq -er '.head_branch | select(type == "string" and length > 0)')"
|
||||
resume_sha="$(printf '%s' "${resume_json}" | jq -er '.head_sha | select(test("^[a-f0-9]{40}$"))')"
|
||||
resume_conclusion="$(printf '%s' "${resume_json}" | jq -er '.conclusion')"
|
||||
resume_event="$(printf '%s' "${resume_json}" | jq -er '.event')"
|
||||
resume_path="$(printf '%s' "${resume_json}" | jq -er '.path')"
|
||||
resume_workflow_id="$(printf '%s' "${resume_json}" | jq -er '.workflow_id')"
|
||||
resume_url="$(printf '%s' "${resume_json}" | jq -er '.html_url')"
|
||||
if [[ ! "${resume_branch}" =~ ^release-publish/([a-f0-9]{12})-[1-9][0-9]*$ ]]; then
|
||||
echo "OpenClaw npm resume run has an untrusted workflow ref: ${resume_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
resume_path_trusted="false"
|
||||
case "${resume_path}" in
|
||||
".github/workflows/openclaw-npm-release.yml" | \
|
||||
".github/workflows/openclaw-npm-release.yml@${resume_branch}" | \
|
||||
".github/workflows/openclaw-npm-release.yml@refs/tags/${resume_branch}")
|
||||
resume_path_trusted="true"
|
||||
;;
|
||||
esac
|
||||
if [[ "${resume_conclusion}" != "success" ||
|
||||
"${resume_event}" != "workflow_dispatch" ||
|
||||
"${resume_path_trusted}" != "true" ||
|
||||
"${resume_workflow_id}" != "${canonical_workflow_id}" ||
|
||||
"${resume_sha:0:12}" != "${BASH_REMATCH[1]}" ]]; then
|
||||
echo "OpenClaw npm resume run has an untrusted workflow identity: ${resume_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
resume_tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${resume_branch}")"
|
||||
resume_tag_object_sha="$(printf '%s' "${resume_tag_json}" | jq -er '.object.sha | select(test("^[a-f0-9]{40}$"))')"
|
||||
if [[ "$(printf '%s' "${resume_tag_json}" | jq -er '.object.type')" != "tag" ]]; then
|
||||
echo "OpenClaw npm resume run tooling ref is not a signed annotated tag: ${resume_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
resume_tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${resume_tag_object_sha}")"
|
||||
resume_tag_commit_sha="$(printf '%s' "${resume_tag_json}" | jq -er '.object.sha | select(test("^[a-f0-9]{40}$"))')"
|
||||
resume_tag_verified="$(printf '%s' "${resume_tag_json}" | jq -er '.verification.verified')"
|
||||
resume_compare_status="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${resume_sha}...main" --jq '.status')"
|
||||
if [[ "$(printf '%s' "${resume_tag_json}" | jq -er '.object.type')" != "commit" ||
|
||||
"${resume_tag_commit_sha}" != "${resume_sha}" ||
|
||||
"${resume_tag_verified}" != "true" ||
|
||||
( "${resume_compare_status}" != "ahead" && "${resume_compare_status}" != "identical" ) ]]; then
|
||||
echo "OpenClaw npm resume run is not bound to a real, main-reachable protected tooling tag: ${resume_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
resume_jobs="$(gh run view "${OPENCLAW_NPM_RESUME_RUN_ID}" --repo "${GITHUB_REPOSITORY}" --json jobs --jq '.jobs')"
|
||||
if ! printf '%s' "${resume_jobs}" | jq -e '.[] | select(.name == "validate_publish_request" and .conclusion == "success")' >/dev/null; then
|
||||
echo "OpenClaw npm resume run lacks successful parent release approval validation: ${resume_url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
openclaw_npm_expected_workflow_ref="refs/tags/${resume_branch}"
|
||||
openclaw_npm_expected_workflow_sha="${resume_sha}"
|
||||
resume_state="$(node "${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-npm-resume-run.mjs" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--run-id "${OPENCLAW_NPM_RESUME_RUN_ID}")"
|
||||
resume_url="$(printf '%s' "${resume_state}" | jq -er '.url')"
|
||||
openclaw_npm_expected_workflow_ref="$(printf '%s' "${resume_state}" | jq -er '.workflowRef')"
|
||||
openclaw_npm_expected_workflow_sha="$(printf '%s' "${resume_state}" | jq -er '.workflowSha')"
|
||||
|
||||
openclaw_npm_already_published="true"
|
||||
echo "openclaw@${release_version} is already published on npm with this tag's preflight tarball; resuming from ${resume_url}."
|
||||
@@ -1802,65 +1755,8 @@ jobs:
|
||||
release_evidence_zip_trees_match() {
|
||||
local source_path="$1"
|
||||
local existing_path="$2"
|
||||
python3 - "${source_path}" "${existing_path}" <<'PY'
|
||||
import hashlib
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
MAX_ENTRIES = 4096
|
||||
MAX_FILE_BYTES = 64 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 256 * 1024 * 1024
|
||||
|
||||
def archive_tree(path):
|
||||
files = {}
|
||||
total_bytes = 0
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
infos = archive.infolist()
|
||||
if len(infos) > MAX_ENTRIES:
|
||||
raise ValueError("too many dependency evidence archive entries")
|
||||
seen = set()
|
||||
for info in infos:
|
||||
name = info.filename
|
||||
pure = PurePosixPath(name)
|
||||
normalized = str(pure) + ("/" if info.is_dir() else "")
|
||||
if (
|
||||
not name
|
||||
or "\\" in name
|
||||
or name != normalized
|
||||
or len(pure.parts) < 2
|
||||
or pure.parts[0] != "dependency-evidence"
|
||||
or any(part in (".", "..") for part in pure.parts)
|
||||
or name in seen
|
||||
):
|
||||
raise ValueError(f"unsafe dependency evidence archive entry: {name!r}")
|
||||
seen.add(name)
|
||||
file_type = stat.S_IFMT(info.external_attr >> 16)
|
||||
allowed_types = (0, stat.S_IFDIR) if info.is_dir() else (0, stat.S_IFREG)
|
||||
if file_type not in allowed_types or info.flag_bits & 0x1:
|
||||
raise ValueError(f"unsupported dependency evidence archive entry: {name!r}")
|
||||
if info.is_dir():
|
||||
continue
|
||||
if info.file_size > MAX_FILE_BYTES:
|
||||
raise ValueError(f"oversized dependency evidence archive entry: {name!r}")
|
||||
total_bytes += info.file_size
|
||||
if total_bytes > MAX_TOTAL_BYTES:
|
||||
raise ValueError("dependency evidence archive exceeds the size limit")
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info) as source:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
files[name] = (info.file_size, digest.hexdigest())
|
||||
return files
|
||||
|
||||
try:
|
||||
matches = archive_tree(sys.argv[1]) == archive_tree(sys.argv[2])
|
||||
except (OSError, ValueError, zipfile.BadZipFile, RuntimeError) as error:
|
||||
print(f"dependency evidence ZIP comparison failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0 if matches else 1)
|
||||
PY
|
||||
python3 "${GITHUB_WORKSPACE}/.release-harness/scripts/compare-release-evidence-zip.py" \
|
||||
"${source_path}" "${existing_path}"
|
||||
}
|
||||
|
||||
attach_or_verify_release_asset() {
|
||||
|
||||
15
.github/workflows/workflow-sanity.yml
vendored
15
.github/workflows/workflow-sanity.yml
vendored
@@ -162,6 +162,19 @@ jobs:
|
||||
- name: Install pre-commit
|
||||
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
|
||||
|
||||
- name: Install ShellCheck
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHELLCHECK_VERSION="0.11.0"
|
||||
archive="shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
|
||||
curl --retry 5 --retry-delay 2 --retry-all-errors -sSfL \
|
||||
-o "${archive}" \
|
||||
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/${archive}"
|
||||
echo "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 ${archive}" | sha256sum -c -
|
||||
tar -xJf "${archive}" "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
|
||||
sudo install -m 0755 "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -179,7 +192,7 @@ jobs:
|
||||
sudo install -m 0755 actionlint /usr/local/bin/actionlint
|
||||
|
||||
- name: Lint workflows
|
||||
run: actionlint -shellcheck=
|
||||
run: actionlint
|
||||
|
||||
- name: Audit all workflows with zizmor
|
||||
shell: bash
|
||||
|
||||
@@ -1021,7 +1021,10 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(true);
|
||||
});
|
||||
|
||||
it("counts handled nullable-turn elicitations as turn attempt progress", async () => {
|
||||
it("refreshes the turn attempt watch for handled nullable-turn elicitations", async () => {
|
||||
let nowMs = 1_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => nowMs);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const harness = createStartedThreadHarness();
|
||||
vi.spyOn(elicitationBridge, "handleCodexAppServerElicitationRequest").mockResolvedValue({
|
||||
action: "accept",
|
||||
@@ -1032,7 +1035,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.timeoutMs = 100;
|
||||
params.timeoutMs = 10_000;
|
||||
const onRunProgress = vi.fn();
|
||||
params.onRunProgress = onRunProgress;
|
||||
|
||||
@@ -1049,10 +1052,14 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
),
|
||||
fastWait,
|
||||
);
|
||||
const initialAttemptWatch = setTimeoutSpy.mock.calls.find(
|
||||
([callback]) => typeof callback === "function" && callback.name === "fireAttemptIdleTimeout",
|
||||
)?.[0];
|
||||
if (typeof initialAttemptWatch !== "function") {
|
||||
throw new Error("Expected the initial turn attempt watch timer");
|
||||
}
|
||||
nowMs += 6_000;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
await harness.handleServerRequest({
|
||||
id: "request-null-turn-elicitation",
|
||||
method: "mcpServer/elicitation/request",
|
||||
@@ -1066,9 +1073,15 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
_meta: null,
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(onRunProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "request:mcpServer/elicitation/request:start" }),
|
||||
),
|
||||
fastWait,
|
||||
);
|
||||
nowMs += 6_000;
|
||||
initialAttemptWatch();
|
||||
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
|
||||
68
scripts/compare-release-evidence-zip.py
Normal file
68
scripts/compare-release-evidence-zip.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
MAX_ENTRIES = 4096
|
||||
MAX_FILE_BYTES = 64 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 256 * 1024 * 1024
|
||||
|
||||
|
||||
def archive_tree(path: str) -> dict[str, tuple[int, str]]:
|
||||
files: dict[str, tuple[int, str]] = {}
|
||||
total_bytes = 0
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
infos = archive.infolist()
|
||||
if len(infos) > MAX_ENTRIES:
|
||||
raise ValueError("too many dependency evidence archive entries")
|
||||
seen: set[str] = set()
|
||||
for info in infos:
|
||||
name = info.filename
|
||||
pure = PurePosixPath(name)
|
||||
normalized = str(pure) + ("/" if info.is_dir() else "")
|
||||
if (
|
||||
not name
|
||||
or "\\" in name
|
||||
or name != normalized
|
||||
or len(pure.parts) < 2
|
||||
or pure.parts[0] != "dependency-evidence"
|
||||
or any(part in (".", "..") for part in pure.parts)
|
||||
or name in seen
|
||||
):
|
||||
raise ValueError(f"unsafe dependency evidence archive entry: {name!r}")
|
||||
seen.add(name)
|
||||
file_type = stat.S_IFMT(info.external_attr >> 16)
|
||||
allowed_types = (0, stat.S_IFDIR) if info.is_dir() else (0, stat.S_IFREG)
|
||||
if file_type not in allowed_types or info.flag_bits & 0x1:
|
||||
raise ValueError(f"unsupported dependency evidence archive entry: {name!r}")
|
||||
if info.is_dir():
|
||||
continue
|
||||
if info.file_size > MAX_FILE_BYTES:
|
||||
raise ValueError(f"oversized dependency evidence archive entry: {name!r}")
|
||||
total_bytes += info.file_size
|
||||
if total_bytes > MAX_TOTAL_BYTES:
|
||||
raise ValueError("dependency evidence archive exceeds the size limit")
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info) as source:
|
||||
while chunk := source.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
files[name] = (info.file_size, digest.hexdigest())
|
||||
return files
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("usage: compare-release-evidence-zip.py <source.zip> <existing.zip>", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
matches = archive_tree(argv[0]) == archive_tree(argv[1])
|
||||
except (OSError, ValueError, zipfile.BadZipFile, RuntimeError) as error:
|
||||
print(f"dependency evidence ZIP comparison failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0 if matches else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
52
scripts/openclaw-npm-resume-run.d.mts
Normal file
52
scripts/openclaw-npm-resume-run.d.mts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface OpenClawNpmResumeRunRecord {
|
||||
conclusion?: unknown;
|
||||
event?: unknown;
|
||||
head_branch?: unknown;
|
||||
head_sha?: unknown;
|
||||
html_url?: unknown;
|
||||
path?: unknown;
|
||||
workflow_id?: unknown;
|
||||
}
|
||||
|
||||
export interface OpenClawNpmResumeTagRecord {
|
||||
object?: {
|
||||
sha?: unknown;
|
||||
type?: unknown;
|
||||
};
|
||||
verification?: {
|
||||
verified?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenClawNpmResumeJobRecord {
|
||||
conclusion?: unknown;
|
||||
name?: unknown;
|
||||
}
|
||||
|
||||
export interface OpenClawNpmResumeValidationInput {
|
||||
canonicalWorkflowId: unknown;
|
||||
compareStatus: unknown;
|
||||
jobs: OpenClawNpmResumeJobRecord[];
|
||||
run: OpenClawNpmResumeRunRecord;
|
||||
tag: OpenClawNpmResumeTagRecord;
|
||||
tagRef: OpenClawNpmResumeTagRecord;
|
||||
}
|
||||
|
||||
export interface OpenClawNpmResumeIdentity {
|
||||
tagObjectSha: string;
|
||||
url: string;
|
||||
workflowRef: string;
|
||||
workflowSha: string;
|
||||
}
|
||||
|
||||
export function validateOpenClawNpmResumeRun(
|
||||
input: OpenClawNpmResumeValidationInput,
|
||||
): OpenClawNpmResumeIdentity;
|
||||
|
||||
export function resolveOpenClawNpmResumeRun(options: {
|
||||
repo: string;
|
||||
runId: string;
|
||||
runGh?: (args: string[]) => string;
|
||||
}): OpenClawNpmResumeIdentity;
|
||||
|
||||
export function main(argv?: string[]): void;
|
||||
170
scripts/openclaw-npm-resume-run.mjs
Normal file
170
scripts/openclaw-npm-resume-run.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
const RELEASE_PUBLISH_REF_PATTERN = /^release-publish\/([a-f0-9]{12})-([1-9][0-9]*)$/u;
|
||||
const WORKFLOW_PATH = ".github/workflows/openclaw-npm-release.yml";
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function parseJson(raw, label) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} returned invalid JSON.`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(value, label) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
fail(`OpenClaw npm resume run is missing ${label}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredSha(value, label) {
|
||||
const sha = requiredString(value, label);
|
||||
if (!SHA_PATTERN.test(sha)) {
|
||||
fail(`OpenClaw npm resume run has invalid ${label}.`);
|
||||
}
|
||||
return sha;
|
||||
}
|
||||
|
||||
function trustedWorkflowPath(path, branch) {
|
||||
return new Set([
|
||||
WORKFLOW_PATH,
|
||||
`${WORKFLOW_PATH}@${branch}`,
|
||||
`${WORKFLOW_PATH}@refs/tags/${branch}`,
|
||||
]).has(path);
|
||||
}
|
||||
|
||||
export function validateOpenClawNpmResumeRun({
|
||||
canonicalWorkflowId,
|
||||
compareStatus,
|
||||
jobs,
|
||||
run,
|
||||
tag,
|
||||
tagRef,
|
||||
}) {
|
||||
const url = requiredString(run?.html_url, "html_url");
|
||||
const branch = requiredString(run?.head_branch, "head_branch");
|
||||
const branchMatch = RELEASE_PUBLISH_REF_PATTERN.exec(branch);
|
||||
if (!branchMatch) {
|
||||
fail(`OpenClaw npm resume run has an untrusted workflow ref: ${url}`);
|
||||
}
|
||||
|
||||
const sha = requiredSha(run?.head_sha, "head_sha");
|
||||
const path = requiredString(run?.path, "path");
|
||||
if (
|
||||
run?.conclusion !== "success" ||
|
||||
run?.event !== "workflow_dispatch" ||
|
||||
!trustedWorkflowPath(path, branch) ||
|
||||
run?.workflow_id !== canonicalWorkflowId ||
|
||||
sha.slice(0, 12) !== branchMatch[1]
|
||||
) {
|
||||
fail(`OpenClaw npm resume run has an untrusted workflow identity: ${url}`);
|
||||
}
|
||||
|
||||
const tagObjectSha = requiredSha(tagRef?.object?.sha, "tooling tag object SHA");
|
||||
if (tagRef?.object?.type !== "tag") {
|
||||
fail(`OpenClaw npm resume run tooling ref is not a signed annotated tag: ${url}`);
|
||||
}
|
||||
|
||||
const tagCommitSha = requiredSha(tag?.object?.sha, "tooling tag commit SHA");
|
||||
if (
|
||||
tag?.object?.type !== "commit" ||
|
||||
tagCommitSha !== sha ||
|
||||
tag?.verification?.verified !== true ||
|
||||
(compareStatus !== "ahead" && compareStatus !== "identical")
|
||||
) {
|
||||
fail(
|
||||
`OpenClaw npm resume run is not bound to a real, main-reachable protected tooling tag: ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(jobs) ||
|
||||
!jobs.some((job) => job?.name === "validate_publish_request" && job?.conclusion === "success")
|
||||
) {
|
||||
fail(`OpenClaw npm resume run lacks successful parent release approval validation: ${url}`);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
workflowRef: `refs/tags/${branch}`,
|
||||
workflowSha: sha,
|
||||
tagObjectSha,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultRunGh(args) {
|
||||
return execFileSync("gh", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveOpenClawNpmResumeRun({ repo, runId, runGh = defaultRunGh }) {
|
||||
if (!/^[1-9][0-9]*$/u.test(runId)) {
|
||||
fail("OpenClaw npm resume run id must be a positive integer.");
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo)) {
|
||||
fail("OpenClaw npm resume repository must be owner/name.");
|
||||
}
|
||||
|
||||
const api = (endpoint) =>
|
||||
parseJson(runGh(["api", `repos/${repo}/${endpoint}`, "--method", "GET"]), endpoint);
|
||||
const run = api(`actions/runs/${runId}`);
|
||||
const canonicalWorkflow = api(`actions/workflows/${WORKFLOW_PATH.split("/").at(-1)}`);
|
||||
const branch = requiredString(run?.head_branch, "head_branch");
|
||||
const tagRef = api(`git/ref/tags/${branch}`);
|
||||
const tagObjectSha = requiredSha(tagRef?.object?.sha, "tooling tag object SHA");
|
||||
const tag = api(`git/tags/${tagObjectSha}`);
|
||||
const sha = requiredSha(run?.head_sha, "head_sha");
|
||||
const comparison = api(`compare/${sha}...main`);
|
||||
const jobs = parseJson(
|
||||
runGh(["run", "view", runId, "--repo", repo, "--json", "jobs", "--jq", ".jobs"]),
|
||||
"resume run jobs",
|
||||
);
|
||||
|
||||
return validateOpenClawNpmResumeRun({
|
||||
canonicalWorkflowId: canonicalWorkflow?.id,
|
||||
compareStatus: comparison?.status,
|
||||
jobs,
|
||||
run,
|
||||
tag,
|
||||
tagRef,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { repo: "", runId: "" };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--repo") {
|
||||
options.repo = argv[(index += 1)] ?? "";
|
||||
} else if (arg === "--run-id") {
|
||||
options.runId = argv[(index += 1)] ?? "";
|
||||
} else {
|
||||
fail(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
const result = resolveOpenClawNpmResumeRun(parseArgs(argv));
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1058,6 +1058,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
],
|
||||
["scripts/mobile-release-ref.ts", ["test/scripts/mobile-release-ref.test.ts"]],
|
||||
["scripts/apple-release-source-check.sh", ["test/scripts/apple-release-source-check.test.ts"]],
|
||||
["scripts/compare-release-evidence-zip.py", ["test/scripts/package-acceptance-workflow.test.ts"]],
|
||||
["scripts/android-release.sh", ["test/scripts/android-release-wrapper-args.test.ts"]],
|
||||
["scripts/android-release-signing.mjs", ["test/scripts/android-release-signing.test.ts"]],
|
||||
["scripts/android-release-upload.sh", ["test/scripts/android-release-wrapper-args.test.ts"]],
|
||||
@@ -1092,6 +1093,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["test/scripts/release-workflow-matrix-plan.test.ts"],
|
||||
],
|
||||
["scripts/release-fast-pretag-check.sh", ["test/scripts/package-acceptance-workflow.test.ts"]],
|
||||
["scripts/openclaw-npm-resume-run.mjs", ["test/scripts/openclaw-npm-resume-run.test.ts"]],
|
||||
["scripts/plugin-clawhub-release-check.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
["scripts/plugin-clawhub-release-plan.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
["scripts/plugin-npm-release-check.ts", ["test/scripts/release-wrapper-scripts.test.ts"]],
|
||||
@@ -2108,6 +2110,7 @@ const TOOLING_DECLARATION_SOURCE_MIRRORS = [
|
||||
["scripts/ci-changed-scope.d.mts", "scripts/ci-changed-scope.mjs"],
|
||||
["scripts/copy-bundled-plugin-metadata.d.mts", "scripts/copy-bundled-plugin-metadata.mjs"],
|
||||
["scripts/docs-link-audit.d.mts", "scripts/docs-link-audit.mjs"],
|
||||
["scripts/openclaw-npm-resume-run.d.mts", "scripts/openclaw-npm-resume-run.mjs"],
|
||||
["scripts/periphery-intersection.d.mts", "scripts/periphery-intersection.mjs"],
|
||||
[
|
||||
"scripts/lib/bundled-plugin-build-entries.d.mts",
|
||||
|
||||
112
test/scripts/openclaw-npm-resume-run.test.ts
Normal file
112
test/scripts/openclaw-npm-resume-run.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveOpenClawNpmResumeRun,
|
||||
validateOpenClawNpmResumeRun,
|
||||
} from "../../scripts/openclaw-npm-resume-run.mjs";
|
||||
import type { OpenClawNpmResumeValidationInput } from "../../scripts/openclaw-npm-resume-run.mjs";
|
||||
|
||||
const SHA = "a".repeat(40);
|
||||
const TAG_OBJECT_SHA = "b".repeat(40);
|
||||
const BRANCH = `release-publish/${SHA.slice(0, 12)}-123`;
|
||||
const URL = "https://github.com/openclaw/openclaw/actions/runs/456";
|
||||
|
||||
function fixture(
|
||||
overrides: Partial<OpenClawNpmResumeValidationInput> = {},
|
||||
): OpenClawNpmResumeValidationInput {
|
||||
return {
|
||||
canonicalWorkflowId: 101,
|
||||
compareStatus: "identical",
|
||||
jobs: [{ conclusion: "success", name: "validate_publish_request" }],
|
||||
run: {
|
||||
conclusion: "success",
|
||||
event: "workflow_dispatch",
|
||||
head_branch: BRANCH,
|
||||
head_sha: SHA,
|
||||
html_url: URL,
|
||||
path: `.github/workflows/openclaw-npm-release.yml@refs/tags/${BRANCH}`,
|
||||
workflow_id: 101,
|
||||
},
|
||||
tag: {
|
||||
object: { sha: SHA, type: "commit" },
|
||||
verification: { verified: true },
|
||||
},
|
||||
tagRef: { object: { sha: TAG_OBJECT_SHA, type: "tag" } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("openclaw npm resume run identity", () => {
|
||||
it("accepts a successful run bound to a signed main-reachable tooling tag", () => {
|
||||
expect(validateOpenClawNpmResumeRun(fixture())).toEqual({
|
||||
tagObjectSha: TAG_OBJECT_SHA,
|
||||
url: URL,
|
||||
workflowRef: `refs/tags/${BRANCH}`,
|
||||
workflowSha: SHA,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["branch", { run: { ...fixture().run, head_branch: "main" } }, "untrusted workflow ref"],
|
||||
["workflow", { run: { ...fixture().run, workflow_id: 999 } }, "untrusted workflow identity"],
|
||||
["event", { run: { ...fixture().run, event: "push" } }, "untrusted workflow identity"],
|
||||
[
|
||||
"conclusion",
|
||||
{ run: { ...fixture().run, conclusion: "failure" } },
|
||||
"untrusted workflow identity",
|
||||
],
|
||||
[
|
||||
"path",
|
||||
{ run: { ...fixture().run, path: ".github/workflows/ci.yml" } },
|
||||
"untrusted workflow identity",
|
||||
],
|
||||
[
|
||||
"tag kind",
|
||||
{ tagRef: { object: { sha: TAG_OBJECT_SHA, type: "commit" } } },
|
||||
"not a signed annotated tag",
|
||||
],
|
||||
[
|
||||
"tag target",
|
||||
{ tag: { ...fixture().tag, object: { sha: "c".repeat(40), type: "commit" } } },
|
||||
"not bound to a real",
|
||||
],
|
||||
[
|
||||
"signature",
|
||||
{ tag: { ...fixture().tag, verification: { verified: false } } },
|
||||
"not bound to a real",
|
||||
],
|
||||
["main ancestry", { compareStatus: "diverged" }, "not bound to a real"],
|
||||
[
|
||||
"approval",
|
||||
{ jobs: [{ conclusion: "failure", name: "validate_publish_request" }] },
|
||||
"lacks successful parent release approval",
|
||||
],
|
||||
])("rejects an untrusted %s", (_label, overrides, message) => {
|
||||
expect(() => validateOpenClawNpmResumeRun(fixture(overrides))).toThrow(message);
|
||||
});
|
||||
|
||||
it("loads the exact run, workflow, signed tag, ancestry, and approval job", () => {
|
||||
const responses = new Map<string, unknown>([
|
||||
[`api repos/openclaw/openclaw/actions/runs/456 --method GET`, fixture().run],
|
||||
[
|
||||
`api repos/openclaw/openclaw/actions/workflows/openclaw-npm-release.yml --method GET`,
|
||||
{ id: 101 },
|
||||
],
|
||||
[`api repos/openclaw/openclaw/git/ref/tags/${BRANCH} --method GET`, fixture().tagRef],
|
||||
[`api repos/openclaw/openclaw/git/tags/${TAG_OBJECT_SHA} --method GET`, fixture().tag],
|
||||
[`api repos/openclaw/openclaw/compare/${SHA}...main --method GET`, { status: "identical" }],
|
||||
[`run view 456 --repo openclaw/openclaw --json jobs --jq .jobs`, fixture().jobs],
|
||||
]);
|
||||
const runGh = vi.fn((args: string[]) => {
|
||||
const response = responses.get(args.join(" "));
|
||||
if (!response) {
|
||||
throw new Error(`Unexpected gh invocation: ${args.join(" ")}`);
|
||||
}
|
||||
return JSON.stringify(response);
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveOpenClawNpmResumeRun({ repo: "openclaw/openclaw", runGh, runId: "456" }),
|
||||
).toMatchObject({ workflowRef: `refs/tags/${BRANCH}`, workflowSha: SHA });
|
||||
expect(runGh).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
@@ -593,7 +593,6 @@ describe("package acceptance workflow", () => {
|
||||
if (!orchestration) {
|
||||
throw new Error("Expected release publish orchestration script");
|
||||
}
|
||||
const matcher = shellFunctionSource(orchestration, "release_evidence_zip_trees_match");
|
||||
const tempDir = tempDirs.make("release-evidence-zip-");
|
||||
const sourceDir = `${tempDir}/source`;
|
||||
const existingDir = `${tempDir}/existing`;
|
||||
@@ -621,20 +620,9 @@ describe("package acceptance workflow", () => {
|
||||
writeFileSync(corruptZip, sourceArchive.subarray(0, sourceArchive.length - 10));
|
||||
|
||||
const compare = (left: string, right: string) =>
|
||||
spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`set -euo pipefail\n${matcher}\nrelease_evidence_zip_trees_match "$1" "$2"`,
|
||||
"bash",
|
||||
left,
|
||||
right,
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, RUNNER_TEMP: tempDir },
|
||||
},
|
||||
);
|
||||
spawnSync("python3", ["scripts/compare-release-evidence-zip.py", left, right], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const result = compare(sourceZip, existingZip);
|
||||
const symlinkResult = compare(symlinkZip, symlinkZip);
|
||||
@@ -649,6 +637,9 @@ describe("package acceptance workflow", () => {
|
||||
expect(orchestration).toContain(
|
||||
'attach_or_verify_release_asset "${asset_path}" "${asset_name}" zip-tree',
|
||||
);
|
||||
expect(orchestration).toContain(
|
||||
'"${GITHUB_WORKSPACE}/.release-harness/scripts/compare-release-evidence-zip.py"',
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies immutable postpublish evidence before stable closeout reads it", () => {
|
||||
@@ -4111,15 +4102,11 @@ describe("package artifact reuse", () => {
|
||||
expect(releaseWorkflow).toContain("Approve child release gate after parent release approval");
|
||||
expect(releaseWorkflow).toContain("openclaw_npm_resume_run_id");
|
||||
expect(releaseWorkflow).toContain(
|
||||
'.name == "validate_publish_request" and .conclusion == "success"',
|
||||
'"${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-npm-resume-run.mjs"',
|
||||
);
|
||||
expect(releaseWorkflow).toContain("actions/workflows/openclaw-npm-release.yml\" --jq '.id'");
|
||||
expect(releaseWorkflow).toContain(
|
||||
'".github/workflows/openclaw-npm-release.yml@refs/tags/${resume_branch}"',
|
||||
);
|
||||
expect(releaseWorkflow).toContain("git/ref/tags/${resume_branch}");
|
||||
expect(releaseWorkflow).toContain(".verification.verified");
|
||||
expect(releaseWorkflow).toContain("compare/${resume_sha}...main");
|
||||
expect(releaseWorkflow).toContain('--run-id "${OPENCLAW_NPM_RESUME_RUN_ID}"');
|
||||
expect(releaseWorkflow).toContain("openclaw_npm_expected_workflow_ref=\"$(printf '%s'");
|
||||
expect(releaseWorkflow).toContain("openclaw_npm_expected_workflow_sha=\"$(printf '%s'");
|
||||
expect(releaseWorkflow).toContain(
|
||||
'"${GITHUB_WORKSPACE}/.release-harness/scripts/openclaw-npm-postpublish-verify.ts"',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user