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.
This commit is contained in:
Peter Steinberger 2026-09-27 15:22:25 -07:00
parent 11c32c4313
commit 84bf30fecf
No known key found for this signature in database
4 changed files with 54 additions and 9 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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");

View file

@ -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 });
}