From eca63522568b19806d153ba4d8bf628a3ebe8be2 Mon Sep 17 00:00:00 2001 From: bobai Date: Tue, 22 Sep 2026 22:41:16 +0100 Subject: [PATCH 1/2] fix: preserve workflow selected node runtime --- .github/workflows/ci.yml | 21 +++++++++++++- CHANGELOG.md | 4 +++ dist/index.js | 62 ++++++++++++++++++++-------------------- src/index.js | 44 +++++++++------------------- src/lib.js | 20 +++++++++++++ test/lib.test.js | 36 +++++++++++++++++++++++ 6 files changed, 124 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1c52f..e0ccbd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,7 @@ jobs: - uses: actions/setup-node@v5 with: - node-version: 24 + node-version: 20 cache: npm cache-dependency-path: package-lock.json @@ -84,6 +84,8 @@ jobs: project: name: smoke type: application + runtimes: + node: ">=20,<21" agent: bootstrap: ota: @@ -95,6 +97,7 @@ jobs: "@ | Set-Content -Path "$dir/ota.yaml" -Encoding utf8 - name: Action smoke test + id: smoke uses: ./ with: command: doctor @@ -106,6 +109,22 @@ jobs: comment-pr: false artifact-name: ota-smoke-${{ matrix.os }} + - name: Confirm Ota preserved the workflow-selected Node runtime + shell: bash + env: + OTA_SMOKE_OUTPUT: ${{ steps.smoke.outputs.output-path }} + run: | + test "$(node --version | cut -d. -f1)" = "v20" + node --input-type=module <<'NODE' + import fs from "node:fs"; + + const payload = JSON.parse(fs.readFileSync(process.env.OTA_SMOKE_OUTPUT, "utf8")); + const codes = (payload.findings || []).map((finding) => finding.code); + if (codes.includes("OTA_RUNTIME_VERSION_MISMATCH")) { + throw new Error("Ota observed the Action runtime instead of the workflow-selected Node 20"); + } + NODE + release: name: auto release if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/CHANGELOG.md b/CHANGELOG.md index 64fdde8..1551e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ local receipt artifact and refusing incomplete historical baselines before comparison; a fresh current receipt is archived when no reusable baseline remains +- fixed `ota-run/action` so Ota child invocations preserve the workflow-selected `node` on PATH + and use the Action's Node 24 implementation runtime only as a fallback; this prevents an action + runtime from silently overriding a repository's declared Node toolchain. + - drift-only gates now annotate only contract-to-CI drift findings when `fail-on-error: false`, keeping unrelated Doctor readiness findings out of a passing gate. diff --git a/dist/index.js b/dist/index.js index 8914b7c..0a444e3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -130005,6 +130005,25 @@ function prioritizeRuntimeNodePath(env = process.env, runtimeExecPath = process. }; } +async function selectFirstRunnableExecutable(candidates, fallback, isRunnable) { + const seen = new Set(); + for (const candidate of candidates) { + const normalized = String(candidate || "").trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + try { + if (await isRunnable(normalized)) { + return normalized; + } + } catch { + // A failed probe is not a runnable PATH candidate; retain the next candidate or fallback. + } + } + return fallback; +} + function normalizeOtaVersion(value) { if (value === undefined || value === null || String(value).trim() === "") { return ""; @@ -130908,12 +130927,10 @@ function parseNodeMajorFromVersion(output) { return Number.parseInt(match[1], 10); } -async function nodeVersionFromExecutable(executablePath, cwd, env = process.env) { +async function isRunnableNodeExecutable(executablePath, cwd, env = process.env) { const result = await runCommand(executablePath, ["--version"], cwd, env); - if (result.exitCode !== 0) { - return null; - } - return parseNodeMajorFromVersion(`${result.stdout}\n${result.stderr}`); + return result.exitCode === 0 + && parseNodeMajorFromVersion(`${result.stdout}\n${result.stderr}`) !== null; } async function resolvePreferredNodeExecutable(env = process.env, cwd = process.cwd()) { @@ -130921,35 +130938,18 @@ async function resolvePreferredNodeExecutable(env = process.env, cwd = process.c return cachedNodeExecutablePath.value; } - const candidates = new Set(); - const candidateList = executableCandidates("node", env) - .filter((candidate) => external_node_path_.basename(candidate).toLowerCase().startsWith("node")); - - for (const candidate of candidateList) { - candidates.add(external_node_path_.resolve(candidate)); - } - - if (process.execPath) { - candidates.add(external_node_path_.resolve(process.execPath)); - } - - let preferred = process.execPath; - let preferredMajor = parseNodeMajorFromVersion(process.version) ?? 0; - - for (const candidate of candidates) { - try { + .filter((candidate) => external_node_path_.basename(candidate).toLowerCase().startsWith("node")) + .map((candidate) => external_node_path_.resolve(candidate)); + const fallback = process.execPath ? external_node_path_.resolve(process.execPath) : ""; + const preferred = await selectFirstRunnableExecutable( + candidateList, + fallback, + async (candidate) => { await promises_.access(candidate, external_node_fs_.constants.X_OK); - } catch { - continue; + return await isRunnableNodeExecutable(candidate, cwd, env); } - - const version = await nodeVersionFromExecutable(candidate, cwd, env); - if (version !== null && version > preferredMajor) { - preferredMajor = version; - preferred = candidate; - } - } + ); cachedNodeExecutablePath.value = preferred || process.execPath; debug(`Selected node executable for Ota invocations: ${cachedNodeExecutablePath.value}`); diff --git a/src/index.js b/src/index.js index 1d792dc..2ec4fb7 100644 --- a/src/index.js +++ b/src/index.js @@ -46,6 +46,7 @@ import { inferKind, normalizeArchivePath, prioritizeRuntimeNodePath, + selectFirstRunnableExecutable, normalizeOtaBinInput, normalizeOtaVersion, normalizeSummary, @@ -112,12 +113,10 @@ function parseNodeMajorFromVersion(output) { return Number.parseInt(match[1], 10); } -async function nodeVersionFromExecutable(executablePath, cwd, env = process.env) { +async function isRunnableNodeExecutable(executablePath, cwd, env = process.env) { const result = await runCommand(executablePath, ["--version"], cwd, env); - if (result.exitCode !== 0) { - return null; - } - return parseNodeMajorFromVersion(`${result.stdout}\n${result.stderr}`); + return result.exitCode === 0 + && parseNodeMajorFromVersion(`${result.stdout}\n${result.stderr}`) !== null; } async function resolvePreferredNodeExecutable(env = process.env, cwd = process.cwd()) { @@ -125,35 +124,18 @@ async function resolvePreferredNodeExecutable(env = process.env, cwd = process.c return cachedNodeExecutablePath.value; } - const candidates = new Set(); - const candidateList = executableCandidates("node", env) - .filter((candidate) => path.basename(candidate).toLowerCase().startsWith("node")); - - for (const candidate of candidateList) { - candidates.add(path.resolve(candidate)); - } - - if (process.execPath) { - candidates.add(path.resolve(process.execPath)); - } - - let preferred = process.execPath; - let preferredMajor = parseNodeMajorFromVersion(process.version) ?? 0; - - for (const candidate of candidates) { - try { + .filter((candidate) => path.basename(candidate).toLowerCase().startsWith("node")) + .map((candidate) => path.resolve(candidate)); + const fallback = process.execPath ? path.resolve(process.execPath) : ""; + const preferred = await selectFirstRunnableExecutable( + candidateList, + fallback, + async (candidate) => { await fs.access(candidate, fsSync.constants.X_OK); - } catch { - continue; + return await isRunnableNodeExecutable(candidate, cwd, env); } - - const version = await nodeVersionFromExecutable(candidate, cwd, env); - if (version !== null && version > preferredMajor) { - preferredMajor = version; - preferred = candidate; - } - } + ); cachedNodeExecutablePath.value = preferred || process.execPath; core.debug(`Selected node executable for Ota invocations: ${cachedNodeExecutablePath.value}`); diff --git a/src/lib.js b/src/lib.js index 118a9f7..15a4779 100644 --- a/src/lib.js +++ b/src/lib.js @@ -283,6 +283,25 @@ function prioritizeRuntimeNodePath(env = process.env, runtimeExecPath = process. }; } +async function selectFirstRunnableExecutable(candidates, fallback, isRunnable) { + const seen = new Set(); + for (const candidate of candidates) { + const normalized = String(candidate || "").trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + try { + if (await isRunnable(normalized)) { + return normalized; + } + } catch { + // A failed probe is not a runnable PATH candidate; retain the next candidate or fallback. + } + } + return fallback; +} + function normalizeOtaVersion(value) { if (value === undefined || value === null || String(value).trim() === "") { return ""; @@ -1122,6 +1141,7 @@ export { normalizeOtaBinInput, parseSourceMode, prioritizeRuntimeNodePath, + selectFirstRunnableExecutable, normalizeOtaVersion, normalizeSummary, otaBinaryName, diff --git a/test/lib.test.js b/test/lib.test.js index b816db0..aea6290 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -47,6 +47,7 @@ import { postInstallBinaryDirectories, proofArtifactPaths, prioritizeRuntimeNodePath, + selectFirstRunnableExecutable, parseInstallMode, parseOtaPayload, receiptArchiveClosureFiles, @@ -990,6 +991,41 @@ test("prioritizeRuntimeNodePath moves the runtime node directory to PATH front", ); }); +test("selectFirstRunnableExecutable preserves PATH precedence over an action runtime fallback", async () => { + const node20 = "/opt/hostedtoolcache/node/20/bin/node"; + const node24 = "/opt/actions/node24/bin/node"; + const selected = await selectFirstRunnableExecutable( + [node20], + node24, + async (candidate) => candidate === node20 + ); + + assert.equal(selected, node20); +}); + +test("selectFirstRunnableExecutable skips an invalid PATH candidate", async () => { + const invalid = "/opt/missing/node"; + const node20 = "/opt/hostedtoolcache/node/20/bin/node"; + const selected = await selectFirstRunnableExecutable( + [invalid, node20], + "/opt/actions/node24/bin/node", + async (candidate) => candidate === node20 + ); + + assert.equal(selected, node20); +}); + +test("selectFirstRunnableExecutable falls back to the action runtime when PATH has no Node", async () => { + const actionNode24 = "/opt/actions/node24/bin/node"; + const selected = await selectFirstRunnableExecutable( + ["/opt/missing/node"], + actionNode24, + async () => false + ); + + assert.equal(selected, actionNode24); +}); + test("normalizeOtaBinInput resolves path-like values from working directory", () => { assert.equal( normalizeOtaBinInput("./bin/ota", "/repo/subdir"), From 07ccb04b48b9318decba24841b8b5198652d649b Mon Sep 17 00:00:00 2001 From: bobai Date: Fri, 25 Sep 2026 23:29:23 +0100 Subject: [PATCH 2/2] chore: update action bootstrap core revision --- ota.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ota.yaml b/ota.yaml index 91a1f55..5fde6e1 100644 --- a/ota.yaml +++ b/ota.yaml @@ -126,7 +126,7 @@ agent: note: Only install ota if it is missing and installation is approved. source: kind: git_rev - rev: 04fc9a6e7a117f0f9c8abfcc87af5d7e9cb27318 + rev: aae1c6940e269f8d3020d28e3c46b1682cbaf12b notes: | Treat Ota as the default workflow for this repository. Use `ota validate` to verify the repo contract.