From 28d30e9daebf4eb3ba05243abaededa14398781b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 13:22:31 -0700 Subject: [PATCH] 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 --- .../workflows/openclaw-release-publish.yml | 122 +------------ .github/workflows/workflow-sanity.yml | 15 +- .../run-attempt.turn-watches.test.ts | 29 ++- scripts/compare-release-evidence-zip.py | 68 +++++++ scripts/openclaw-npm-resume-run.d.mts | 52 ++++++ scripts/openclaw-npm-resume-run.mjs | 170 ++++++++++++++++++ scripts/test-projects.test-support.mjs | 3 + test/scripts/openclaw-npm-resume-run.test.ts | 112 ++++++++++++ .../package-acceptance-workflow.test.ts | 33 ++-- 9 files changed, 459 insertions(+), 145 deletions(-) create mode 100644 scripts/compare-release-evidence-zip.py create mode 100644 scripts/openclaw-npm-resume-run.d.mts create mode 100644 scripts/openclaw-npm-resume-run.mjs create mode 100644 test/scripts/openclaw-npm-resume-run.test.ts diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index 5b61fbf36e7..a5a7b0f22f7 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -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() { diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 88abf5a2327..c665e81a788 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -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 diff --git a/extensions/codex/src/app-server/run-attempt.turn-watches.test.ts b/extensions/codex/src/app-server/run-attempt.turn-watches.test.ts index a42c84596bc..817ab82013a 100644 --- a/extensions/codex/src/app-server/run-attempt.turn-watches.test.ts +++ b/extensions/codex/src/app-server/run-attempt.turn-watches.test.ts @@ -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" }); diff --git a/scripts/compare-release-evidence-zip.py b/scripts/compare-release-evidence-zip.py new file mode 100644 index 00000000000..183837d2534 --- /dev/null +++ b/scripts/compare-release-evidence-zip.py @@ -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 ", 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:])) diff --git a/scripts/openclaw-npm-resume-run.d.mts b/scripts/openclaw-npm-resume-run.d.mts new file mode 100644 index 00000000000..d2bd29ab7d5 --- /dev/null +++ b/scripts/openclaw-npm-resume-run.d.mts @@ -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; diff --git a/scripts/openclaw-npm-resume-run.mjs b/scripts/openclaw-npm-resume-run.mjs new file mode 100644 index 00000000000..2d131593969 --- /dev/null +++ b/scripts/openclaw-npm-resume-run.mjs @@ -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); + } +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 819e61b3a20..47120ee57ce 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -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", diff --git a/test/scripts/openclaw-npm-resume-run.test.ts b/test/scripts/openclaw-npm-resume-run.test.ts new file mode 100644 index 00000000000..98805e66c93 --- /dev/null +++ b/test/scripts/openclaw-npm-resume-run.test.ts @@ -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 { + 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([ + [`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); + }); +}); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index ca27b0e1fb8..a8b4e2c25e5 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -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"', );