Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -84,6 +84,8 @@ jobs:
project:
name: smoke
type: application
runtimes:
node: ">=20,<21"
agent:
bootstrap:
ota:
Expand All @@ -95,6 +97,7 @@ jobs:
"@ | Set-Content -Path "$dir/ota.yaml" -Encoding utf8

- name: Action smoke test
id: smoke
uses: ./
with:
command: doctor
Expand All @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
62 changes: 31 additions & 31 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down Expand Up @@ -130908,48 +130927,29 @@ 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()) {
if (cachedNodeExecutablePath.value !== undefined) {
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}`);
Expand Down
2 changes: 1 addition & 1 deletion ota.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 13 additions & 31 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
inferKind,
normalizeArchivePath,
prioritizeRuntimeNodePath,
selectFirstRunnableExecutable,
normalizeOtaBinInput,
normalizeOtaVersion,
normalizeSummary,
Expand Down Expand Up @@ -112,48 +113,29 @@ 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()) {
if (cachedNodeExecutablePath.value !== undefined) {
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}`);
Expand Down
20 changes: 20 additions & 0 deletions src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand Down Expand Up @@ -1122,6 +1141,7 @@ export {
normalizeOtaBinInput,
parseSourceMode,
prioritizeRuntimeNodePath,
selectFirstRunnableExecutable,
normalizeOtaVersion,
normalizeSummary,
otaBinaryName,
Expand Down
36 changes: 36 additions & 0 deletions test/lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
postInstallBinaryDirectories,
proofArtifactPaths,
prioritizeRuntimeNodePath,
selectFirstRunnableExecutable,
parseInstallMode,
parseOtaPayload,
receiptArchiveClosureFiles,
Expand Down Expand Up @@ -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"),
Expand Down
Loading