From 84bf30fecf13c1c288fdb7d005152095c7230e88 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 27 Sep 2026 15:22:25 -0700 Subject: [PATCH] ci: honor the pinned Node version on hosted runners Resolve hosted 24.x requests through the workflow's existing same-major Node pin, preserving explicit compatibility inputs and self-hosted selection. This keeps runner moves from silently changing the tested runtime patch. No version value changes. Linux owning-config and setup guards, changed checks, boundary lint, and P2 review passed. The repository pin remains 24.21.0. --- .github/actions/setup-node-env/action.yml | 2 +- docs/ci/runners.md | 7 ++++ test/scripts/ci-workflow-guards.test.ts | 34 +++++++++++++++++++ ...setup-pnpm-store-cache-ensure-node.test.ts | 20 ++++++----- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index c2381670d2ff..5137769cb365 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -149,7 +149,7 @@ runs: id: setup-node shell: bash env: - REQUESTED_NODE_VERSION: ${{ inputs.node-version }} + REQUESTED_NODE_VERSION: ${{ runner.environment == 'github-hosted' && inputs.node-version == '24.x' && startsWith(env.NODE_VERSION, '24.') && env.NODE_VERSION || inputs.node-version }} OPENCLAW_NODE_TOOLCHAIN_ROOT: ${{ inputs.cache-mode != 'off' && runner.os != 'Windows' && format('{0}/openclaw-node-toolchain/node', runner.temp) || '' }} run: | set -euo pipefail diff --git a/docs/ci/runners.md b/docs/ci/runners.md index fdcbc59be97a..51c360d8a0b6 100644 --- a/docs/ci/runners.md +++ b/docs/ci/runners.md @@ -8,6 +8,13 @@ read_when: ## Runners +Hosted Node 24 setup honors the workflow's existing `NODE_VERSION` pin when the +setup input is `24.x`. This prevents runner-image refreshes from silently choosing +a different patch version. Explicit compatibility versions retain their own +selection, and self-hosted setup retains its existing range and toolchain-cache +policy. A restored cache key is not runtime proof: record the selected binary and +`node -v` when comparing runner backends. + Runner choice follows contributor trust, not whether a pull request came from a fork. Every `runs-on` expression admits Blacksmith only when `github.event.pull_request.author_association` is `OWNER`, `MEMBER`, `COLLABORATOR`, or `CONTRIBUTOR`, so a fork pull request from someone who has already landed a commit is routed exactly like a maintainer pull request. `FIRST_TIME_CONTRIBUTOR`, `FIRST_TIMER`, `NONE`, and `MANNEQUIN` stay on GitHub-hosted runners, which are free for public repositories, so an unreviewed author cannot spend Blacksmith capacity. Maintainers report `CONTRIBUTOR` here because org membership is concealed; keep `CONTRIBUTOR` in that list or maintainer pull requests lose Blacksmith. Pushes and manual dispatches are unaffected. Cache trust is a separate, stricter boundary: exact dependency restores require a pull request from `openclaw/openclaw`, and ordinary CI never publishes the shared archives. The separate trusted warmer owns publication. | Runner | Jobs | diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index ac3f51f48b6e..980a96bf5cf4 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4883,6 +4883,40 @@ setImmediate(() => { } }); + it("uses the workflow Node 24 pin for hosted default requests", () => { + const action = parse(readFileSync(".github/actions/setup-node-env/action.yml", "utf8")); + const setup: WorkflowStep = expectDefined( + action.runs.steps.find((step: WorkflowStep) => step.id === "setup-node"), + "Node setup", + ); + const expression = String( + expectDefined(setup.env?.REQUESTED_NODE_VERSION, "requested Node version"), + ) + .replace(/^\$\{\{\s*|\s*\}\}$/gu, "") + .replaceAll("inputs.node-version", 'inputs["node-version"]'); + for (const [environment, requested, pin, expected] of [ + ["github-hosted", "24.x", "24.21.0", "24.21.0"], + ["github-hosted", "24.x", undefined, "24.x"], + ["github-hosted", "24.x", "", "24.x"], + ["github-hosted", "24.x", "26.1.0", "24.x"], + ["github-hosted", "24.16.0", "24.21.0", "24.16.0"], + ["github-hosted", "26.x", "24.21.0", "26.x"], + ["self-hosted", "24.x", "24.21.0", "24.x"], + ["", "24.x", "24.21.0", "24.x"], + ] as const) { + expect( + runInNewContext(expression, { + runner: { environment }, + inputs: { "node-version": requested }, + env: pin === undefined ? {} : { NODE_VERSION: pin }, + startsWith: (value: unknown, prefix: string) => + typeof value === "string" && value.startsWith(prefix), + }), + `${environment}/${requested}/${pin ?? "unset"}`, + ).toBe(expected); + } + }); + it("owns one exact immutable semantic dependency cache", () => { const actionSource = readFileSync(".github/actions/setup-node-env/action.yml", "utf8"); const ciSource = readFileSync(".github/workflows/ci.yml", "utf8"); diff --git a/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts b/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts index 9bb7d4d47798..4cfd2e894eb4 100644 --- a/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts +++ b/test/scripts/setup-pnpm-store-cache-ensure-node.test.ts @@ -177,25 +177,29 @@ describe("setup-pnpm-store-cache ensure-node", () => { } }); - it("repairs PATH from the toolcache when setup-node leaves an old node active", () => { + it.each([ + { active: "20.20.0", requested: "24.16.0" }, + { active: "24.22.0", requested: "24.21.0" }, + ])("repairs PATH from active Node $active to requested $requested", ({ active, requested }) => { const root = mkdtempSync(join(tmpdir(), "openclaw-ensure-node-")); try { const activeBin = join(root, "active", "bin"); - writeFakeNode(activeBin, "20.20.0"); - const toolcacheBin = join(root, "toolcache", "node", "24.16.0", "x64", "bin"); - const toolcacheNode = writeFakeNode(toolcacheBin, "24.16.0"); + writeFakeNode(activeBin, active); + const toolcacheBin = join(root, "toolcache", "node", requested, "x64", "bin"); + const toolcacheNode = writeFakeNode(toolcacheBin, requested); writeFakeNode( join(root, "toolcache", "node", "26.1.0", "x64", "lib", "node_modules", "bundled", "bin"), - "24.16.0", + requested, ); - const result = runEnsureNode(root, "24.16.0", { + const result = runEnsureNode(root, requested, { PATH: `${activeBin}:${process.env.PATH ?? ""}`, + OPENCLAW_NODE_TOOLCHAIN_ROOT: join(root, "missing-owned-toolchain"), RUNNER_TOOL_CACHE: join(root, "toolcache"), }); expect(result.status).toBe(0); - expect(result.stdout).toContain(`Using Node 24.16.0 from ${toolcacheNode}`); - expect(result.stdout).toContain(`${toolcacheNode}\n24.16.0`); + expect(result.stdout).toContain(`Using Node ${requested} from ${toolcacheNode}`); + expect(result.stdout).toContain(`${toolcacheNode}\n${requested}`); } finally { rmSync(root, { recursive: true, force: true }); }