From d86ebcd40b3cb50b156bfa44dd277faf38282d12 Mon Sep 17 00:00:00 2001
From: Manta Anantachai S <anantachai.s@taskworld.com>
Date: Fri, 29 Dec 2023 16:31:21 +0700
Subject: [PATCH] Add support for `volta.extends` (#921)

* Add support for `volta.extends`

* Code review
---
 .github/workflows/versions.yml            |  20 +++-
 __tests__/data/package-volta-extends.json |   5 +
 __tests__/data/package-volta.json         |   8 ++
 __tests__/data/package.json               |   3 -
 __tests__/main.test.ts                    | 138 +++++++++-------------
 dist/cache-save/index.js                  |  61 ++++++----
 dist/setup/index.js                       |  67 ++++++-----
 docs/advanced-usage.md                    |   2 +
 src/main.ts                               |  13 +-
 src/util.ts                               |  59 +++++----
 10 files changed, 197 insertions(+), 179 deletions(-)
 create mode 100644 __tests__/data/package-volta-extends.json
 create mode 100644 __tests__/data/package-volta.json

diff --git a/.github/workflows/versions.yml b/.github/workflows/versions.yml
index b937b383a..acc500fc1 100644
--- a/.github/workflows/versions.yml
+++ b/.github/workflows/versions.yml
@@ -162,9 +162,6 @@ jobs:
           [.nvmrc, .tool-versions, .tool-versions-node, package.json]
     steps:
       - uses: actions/checkout@v4
-      - name: Remove volta from package.json
-        shell: bash
-        run: cat <<< "$(jq 'del(.volta)' ./__tests__/data/package.json)" > ./__tests__/data/package.json
       - name: Setup node from node version file
         uses: ./
         with:
@@ -183,7 +180,22 @@ jobs:
       - name: Setup node from node version file
         uses: ./
         with:
-          node-version-file: '__tests__/data/package.json'
+          node-version-file: '__tests__/data/package-volta.json'
+      - name: Verify node
+        run: __tests__/verify-node.sh 16
+
+  version-file-volta-extends:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, windows-latest, macos-latest]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Setup node from node version file
+        uses: ./
+        with:
+          node-version-file: '__tests__/data/package-volta-extends.json'
       - name: Verify node
         run: __tests__/verify-node.sh 16
 
diff --git a/__tests__/data/package-volta-extends.json b/__tests__/data/package-volta-extends.json
new file mode 100644
index 000000000..ee6d7451c
--- /dev/null
+++ b/__tests__/data/package-volta-extends.json
@@ -0,0 +1,5 @@
+{
+  "volta": {
+    "extends": "./package-volta.json"
+  }
+}
diff --git a/__tests__/data/package-volta.json b/__tests__/data/package-volta.json
new file mode 100644
index 000000000..ebee7dfc7
--- /dev/null
+++ b/__tests__/data/package-volta.json
@@ -0,0 +1,8 @@
+{
+  "engines": {
+    "node": "^14.0.0"
+  },
+  "volta": {
+    "node": "16.0.0"
+  }
+}
diff --git a/__tests__/data/package.json b/__tests__/data/package.json
index ebee7dfc7..b201009d6 100644
--- a/__tests__/data/package.json
+++ b/__tests__/data/package.json
@@ -1,8 +1,5 @@
 {
   "engines": {
     "node": "^14.0.0"
-  },
-  "volta": {
-    "node": "16.0.0"
   }
 }
diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts
index 36024e650..e54e5ea90 100644
--- a/__tests__/main.test.ts
+++ b/__tests__/main.test.ts
@@ -24,11 +24,9 @@ describe('main tests', () => {
   let startGroupSpy: jest.SpyInstance;
   let endGroupSpy: jest.SpyInstance;
 
-  let existsSpy: jest.SpyInstance;
-
   let getExecOutputSpy: jest.SpyInstance;
 
-  let parseNodeVersionSpy: jest.SpyInstance;
+  let getNodeVersionFromFileSpy: jest.SpyInstance;
   let cnSpy: jest.SpyInstance;
   let findSpy: jest.SpyInstance;
   let isCacheActionAvailable: jest.SpyInstance;
@@ -41,6 +39,7 @@ describe('main tests', () => {
     // node
     os = {};
     console.log('::stop-commands::stoptoken');
+    process.env['GITHUB_WORKSPACE'] = path.join(__dirname, 'data');
     process.env['GITHUB_PATH'] = ''; // Stub out ENV file functionality so we can verify it writes to standard out
     process.env['GITHUB_OUTPUT'] = ''; // Stub out ENV file functionality so we can verify it writes to standard out
     infoSpy = jest.spyOn(core, 'info');
@@ -62,12 +61,10 @@ describe('main tests', () => {
 
     isCacheActionAvailable = jest.spyOn(cache, 'isFeatureAvailable');
 
-    existsSpy = jest.spyOn(fs, 'existsSync');
-
     cnSpy = jest.spyOn(process.stdout, 'write');
     cnSpy.mockImplementation(line => {
       // uncomment to debug
-      // process.stderr.write('write:' + line + '\n');
+      process.stderr.write('write:' + line + '\n');
     });
 
     setupNodeJsSpy = jest.spyOn(OfficialBuilds.prototype, 'setupNodeJs');
@@ -85,7 +82,7 @@ describe('main tests', () => {
     jest.restoreAllMocks();
   }, 100000);
 
-  describe('parseNodeVersionFile', () => {
+  describe('getNodeVersionFromFile', () => {
     each`
       contents                                     | expected
       ${'12'}                                      | ${'12'}
@@ -100,10 +97,27 @@ describe('main tests', () => {
       ${'unknown format'}                          | ${'unknown format'}
       ${'  14.1.0  '}                              | ${'14.1.0'}
       ${'{"volta": {"node": ">=14.0.0 <=17.0.0"}}'}| ${'>=14.0.0 <=17.0.0'}
+      ${'{"volta": {"extends": "./package.json"}}'}| ${'18.0.0'}
       ${'{"engines": {"node": "17.0.0"}}'}         | ${'17.0.0'}
       ${'{}'}                                      | ${null}
     `.it('parses "$contents"', ({contents, expected}) => {
-      expect(util.parseNodeVersionFile(contents)).toBe(expected);
+      const existsSpy = jest.spyOn(fs, 'existsSync');
+      existsSpy.mockImplementation(() => true);
+
+      const readFileSpy = jest.spyOn(fs, 'readFileSync');
+      readFileSpy.mockImplementation(filePath => {
+        if (
+          typeof filePath === 'string' &&
+          path.basename(filePath) === 'package.json'
+        ) {
+          // Special case for volta.extends
+          return '{"volta": {"node": "18.0.0"}}';
+        }
+
+        return contents;
+      });
+
+      expect(util.getNodeVersionFromFile('file')).toBe(expected);
     });
   });
 
@@ -142,10 +156,17 @@ describe('main tests', () => {
 
   describe('node-version-file flag', () => {
     beforeEach(() => {
-      parseNodeVersionSpy = jest.spyOn(util, 'parseNodeVersionFile');
+      delete inputs['node-version'];
+      inputs['node-version-file'] = '.nvmrc';
+
+      getNodeVersionFromFileSpy = jest.spyOn(util, 'getNodeVersionFromFile');
+    });
+
+    afterEach(() => {
+      getNodeVersionFromFileSpy.mockRestore();
     });
 
-    it('not used if node-version is provided', async () => {
+    it('does not read node-version-file if node-version is provided', async () => {
       // Arrange
       inputs['node-version'] = '12';
 
@@ -153,107 +174,54 @@ describe('main tests', () => {
       await main.run();
 
       // Assert
-      expect(parseNodeVersionSpy).toHaveBeenCalledTimes(0);
-    }, 10000);
-
-    it('not used if node-version-file not provided', async () => {
-      // Act
-      await main.run();
-
-      // Assert
-      expect(parseNodeVersionSpy).toHaveBeenCalledTimes(0);
+      expect(inputs['node-version']).toBeDefined();
+      expect(inputs['node-version-file']).toBeDefined();
+      expect(getNodeVersionFromFileSpy).not.toHaveBeenCalled();
+      expect(warningSpy).toHaveBeenCalledWith(
+        'Both node-version and node-version-file inputs are specified, only node-version will be used'
+      );
     });
 
-    it('reads node-version-file if provided', async () => {
+    it('does not read node-version-file if node-version-file is not provided', async () => {
       // Arrange
-      const versionSpec = 'v14';
-      const versionFile = '.nvmrc';
-      const expectedVersionSpec = '14';
-      process.env['GITHUB_WORKSPACE'] = path.join(__dirname, 'data');
-      inputs['node-version-file'] = versionFile;
-
-      parseNodeVersionSpy.mockImplementation(() => expectedVersionSpec);
-      existsSpy.mockImplementationOnce(
-        input => input === path.join(__dirname, 'data', versionFile)
-      );
+      delete inputs['node-version-file'];
 
       // Act
       await main.run();
 
       // Assert
-      expect(existsSpy).toHaveBeenCalledTimes(1);
-      expect(existsSpy).toHaveReturnedWith(true);
-      expect(parseNodeVersionSpy).toHaveBeenCalledWith(versionSpec);
-      expect(infoSpy).toHaveBeenCalledWith(
-        `Resolved ${versionFile} as ${expectedVersionSpec}`
-      );
-    }, 10000);
+      expect(getNodeVersionFromFileSpy).not.toHaveBeenCalled();
+    });
 
-    it('reads package.json as node-version-file if provided', async () => {
+    it('reads node-version-file', async () => {
       // Arrange
-      const versionSpec = fs.readFileSync(
-        path.join(__dirname, 'data/package.json'),
-        'utf-8'
-      );
-      const versionFile = 'package.json';
       const expectedVersionSpec = '14';
-      process.env['GITHUB_WORKSPACE'] = path.join(__dirname, 'data');
-      inputs['node-version-file'] = versionFile;
+      getNodeVersionFromFileSpy.mockImplementation(() => expectedVersionSpec);
 
-      parseNodeVersionSpy.mockImplementation(() => expectedVersionSpec);
-      existsSpy.mockImplementationOnce(
-        input => input === path.join(__dirname, 'data', versionFile)
-      );
       // Act
       await main.run();
 
       // Assert
-      expect(existsSpy).toHaveBeenCalledTimes(1);
-      expect(existsSpy).toHaveReturnedWith(true);
-      expect(parseNodeVersionSpy).toHaveBeenCalledWith(versionSpec);
+      expect(getNodeVersionFromFileSpy).toHaveBeenCalled();
       expect(infoSpy).toHaveBeenCalledWith(
-        `Resolved ${versionFile} as ${expectedVersionSpec}`
+        `Resolved ${inputs['node-version-file']} as ${expectedVersionSpec}`
       );
     }, 10000);
 
-    it('both node-version-file and node-version are provided', async () => {
-      inputs['node-version'] = '12';
-      const versionSpec = 'v14';
-      const versionFile = '.nvmrc';
-      const expectedVersionSpec = '14';
-      process.env['GITHUB_WORKSPACE'] = path.join(__dirname, '..');
-      inputs['node-version-file'] = versionFile;
-
-      parseNodeVersionSpy.mockImplementation(() => expectedVersionSpec);
-
-      // Act
-      await main.run();
-
-      // Assert
-      expect(existsSpy).toHaveBeenCalledTimes(0);
-      expect(parseNodeVersionSpy).not.toHaveBeenCalled();
-      expect(warningSpy).toHaveBeenCalledWith(
-        'Both node-version and node-version-file inputs are specified, only node-version will be used'
-      );
-    });
-
-    it('should throw an error if node-version-file is not found', async () => {
-      const versionFile = '.nvmrc';
-      const versionFilePath = path.join(__dirname, '..', versionFile);
-      inputs['node-version-file'] = versionFile;
-
-      inSpy.mockImplementation(name => inputs[name]);
-      existsSpy.mockImplementationOnce(
-        input => input === path.join(__dirname, 'data', versionFile)
+    it('should throw an error if node-version-file is not accessible', async () => {
+      // Arrange
+      inputs['node-version-file'] = 'non-existing-file';
+      const versionFilePath = path.join(
+        __dirname,
+        'data',
+        inputs['node-version-file']
       );
 
       // Act
       await main.run();
 
       // Assert
-      expect(existsSpy).toHaveBeenCalled();
-      expect(existsSpy).toHaveReturnedWith(false);
-      expect(parseNodeVersionSpy).not.toHaveBeenCalled();
+      expect(getNodeVersionFromFileSpy).toHaveBeenCalled();
       expect(cnSpy).toHaveBeenCalledWith(
         `::error::The specified node version file at: ${versionFilePath} does not exist${osm.EOL}`
       );
diff --git a/dist/cache-save/index.js b/dist/cache-save/index.js
index ece5ae39a..35d549498 100644
--- a/dist/cache-save/index.js
+++ b/dist/cache-save/index.js
@@ -83329,49 +83329,60 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
         step((generator = generator.apply(thisArg, _arguments || [])).next());
     });
 };
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
 Object.defineProperty(exports, "__esModule", ({ value: true }));
-exports.unique = exports.printEnvDetailsAndSetOutput = exports.parseNodeVersionFile = void 0;
+exports.unique = exports.printEnvDetailsAndSetOutput = exports.getNodeVersionFromFile = void 0;
 const core = __importStar(__nccwpck_require__(2186));
 const exec = __importStar(__nccwpck_require__(1514));
-function parseNodeVersionFile(contents) {
-    var _a, _b, _c;
-    let nodeVersion;
+const fs_1 = __importDefault(__nccwpck_require__(7147));
+const path_1 = __importDefault(__nccwpck_require__(1017));
+function getNodeVersionFromFile(versionFilePath) {
+    var _a, _b, _c, _d, _e;
+    if (!fs_1.default.existsSync(versionFilePath)) {
+        throw new Error(`The specified node version file at: ${versionFilePath} does not exist`);
+    }
+    const contents = fs_1.default.readFileSync(versionFilePath, 'utf8');
     // Try parsing the file as an NPM `package.json` file.
     try {
         const manifest = JSON.parse(contents);
-        // JSON can parse numbers, but that's handled later
-        if (typeof manifest === 'object') {
-            nodeVersion = (_a = manifest.volta) === null || _a === void 0 ? void 0 : _a.node;
-            if (!nodeVersion)
-                nodeVersion = (_b = manifest.engines) === null || _b === void 0 ? void 0 : _b.node;
-            // if contents are an object, we parsed JSON
+        // Presume package.json file.
+        if (typeof manifest === 'object' && !!manifest) {
+            // Support Volta.
+            // See https://docs.volta.sh/guide/understanding#managing-your-project
+            if ((_a = manifest.volta) === null || _a === void 0 ? void 0 : _a.node) {
+                return manifest.volta.node;
+            }
+            if ((_b = manifest.engines) === null || _b === void 0 ? void 0 : _b.node) {
+                return manifest.engines.node;
+            }
+            // Support Volta workspaces.
+            // See https://docs.volta.sh/advanced/workspaces
+            if ((_c = manifest.volta) === null || _c === void 0 ? void 0 : _c.extends) {
+                const extendedFilePath = path_1.default.resolve(path_1.default.dirname(versionFilePath), manifest.volta.extends);
+                core.info('Resolving node version from ' + extendedFilePath);
+                return getNodeVersionFromFile(extendedFilePath);
+            }
+            // If contents are an object, we parsed JSON
             // this can happen if node-version-file is a package.json
             // yet contains no volta.node or engines.node
             //
-            // if node-version file is _not_ json, control flow
+            // If node-version file is _not_ JSON, control flow
             // will not have reached these lines.
             //
             // And because we've reached here, we know the contents
             // *are* JSON, so no further string parsing makes sense.
-            if (!nodeVersion) {
-                return null;
-            }
+            return null;
         }
     }
-    catch (_d) {
+    catch (_f) {
         core.info('Node version file is not JSON file');
     }
-    if (!nodeVersion) {
-        const found = contents.match(/^(?:node(js)?\s+)?v?(?<version>[^\s]+)$/m);
-        nodeVersion = (_c = found === null || found === void 0 ? void 0 : found.groups) === null || _c === void 0 ? void 0 : _c.version;
-    }
-    // In the case of an unknown format,
-    // return as is and evaluate the version separately.
-    if (!nodeVersion)
-        nodeVersion = contents.trim();
-    return nodeVersion;
+    const found = contents.match(/^(?:node(js)?\s+)?v?(?<version>[^\s]+)$/m);
+    return (_e = (_d = found === null || found === void 0 ? void 0 : found.groups) === null || _d === void 0 ? void 0 : _d.version) !== null && _e !== void 0 ? _e : contents.trim();
 }
-exports.parseNodeVersionFile = parseNodeVersionFile;
+exports.getNodeVersionFromFile = getNodeVersionFromFile;
 function printEnvDetailsAndSetOutput() {
     return __awaiter(this, void 0, void 0, function* () {
         core.startGroup('Environment details');
diff --git a/dist/setup/index.js b/dist/setup/index.js
index c4b448b1d..c0eade6da 100644
--- a/dist/setup/index.js
+++ b/dist/setup/index.js
@@ -93650,7 +93650,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
 Object.defineProperty(exports, "__esModule", ({ value: true }));
 exports.run = void 0;
 const core = __importStar(__nccwpck_require__(2186));
-const fs_1 = __importDefault(__nccwpck_require__(7147));
 const os_1 = __importDefault(__nccwpck_require__(2037));
 const auth = __importStar(__nccwpck_require__(7573));
 const path = __importStar(__nccwpck_require__(1017));
@@ -93725,10 +93724,7 @@ function resolveVersionInput() {
     }
     if (versionFileInput) {
         const versionFilePath = path.join(process.env.GITHUB_WORKSPACE, versionFileInput);
-        if (!fs_1.default.existsSync(versionFilePath)) {
-            throw new Error(`The specified node version file at: ${versionFilePath} does not exist`);
-        }
-        const parsedVersion = (0, util_1.parseNodeVersionFile)(fs_1.default.readFileSync(versionFilePath, 'utf8'));
+        const parsedVersion = (0, util_1.getNodeVersionFromFile)(versionFilePath);
         if (parsedVersion) {
             version = parsedVersion;
         }
@@ -93780,49 +93776,60 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
         step((generator = generator.apply(thisArg, _arguments || [])).next());
     });
 };
+var __importDefault = (this && this.__importDefault) || function (mod) {
+    return (mod && mod.__esModule) ? mod : { "default": mod };
+};
 Object.defineProperty(exports, "__esModule", ({ value: true }));
-exports.unique = exports.printEnvDetailsAndSetOutput = exports.parseNodeVersionFile = void 0;
+exports.unique = exports.printEnvDetailsAndSetOutput = exports.getNodeVersionFromFile = void 0;
 const core = __importStar(__nccwpck_require__(2186));
 const exec = __importStar(__nccwpck_require__(1514));
-function parseNodeVersionFile(contents) {
-    var _a, _b, _c;
-    let nodeVersion;
+const fs_1 = __importDefault(__nccwpck_require__(7147));
+const path_1 = __importDefault(__nccwpck_require__(1017));
+function getNodeVersionFromFile(versionFilePath) {
+    var _a, _b, _c, _d, _e;
+    if (!fs_1.default.existsSync(versionFilePath)) {
+        throw new Error(`The specified node version file at: ${versionFilePath} does not exist`);
+    }
+    const contents = fs_1.default.readFileSync(versionFilePath, 'utf8');
     // Try parsing the file as an NPM `package.json` file.
     try {
         const manifest = JSON.parse(contents);
-        // JSON can parse numbers, but that's handled later
-        if (typeof manifest === 'object') {
-            nodeVersion = (_a = manifest.volta) === null || _a === void 0 ? void 0 : _a.node;
-            if (!nodeVersion)
-                nodeVersion = (_b = manifest.engines) === null || _b === void 0 ? void 0 : _b.node;
-            // if contents are an object, we parsed JSON
+        // Presume package.json file.
+        if (typeof manifest === 'object' && !!manifest) {
+            // Support Volta.
+            // See https://docs.volta.sh/guide/understanding#managing-your-project
+            if ((_a = manifest.volta) === null || _a === void 0 ? void 0 : _a.node) {
+                return manifest.volta.node;
+            }
+            if ((_b = manifest.engines) === null || _b === void 0 ? void 0 : _b.node) {
+                return manifest.engines.node;
+            }
+            // Support Volta workspaces.
+            // See https://docs.volta.sh/advanced/workspaces
+            if ((_c = manifest.volta) === null || _c === void 0 ? void 0 : _c.extends) {
+                const extendedFilePath = path_1.default.resolve(path_1.default.dirname(versionFilePath), manifest.volta.extends);
+                core.info('Resolving node version from ' + extendedFilePath);
+                return getNodeVersionFromFile(extendedFilePath);
+            }
+            // If contents are an object, we parsed JSON
             // this can happen if node-version-file is a package.json
             // yet contains no volta.node or engines.node
             //
-            // if node-version file is _not_ json, control flow
+            // If node-version file is _not_ JSON, control flow
             // will not have reached these lines.
             //
             // And because we've reached here, we know the contents
             // *are* JSON, so no further string parsing makes sense.
-            if (!nodeVersion) {
-                return null;
-            }
+            return null;
         }
     }
-    catch (_d) {
+    catch (_f) {
         core.info('Node version file is not JSON file');
     }
-    if (!nodeVersion) {
-        const found = contents.match(/^(?:node(js)?\s+)?v?(?<version>[^\s]+)$/m);
-        nodeVersion = (_c = found === null || found === void 0 ? void 0 : found.groups) === null || _c === void 0 ? void 0 : _c.version;
-    }
-    // In the case of an unknown format,
-    // return as is and evaluate the version separately.
-    if (!nodeVersion)
-        nodeVersion = contents.trim();
-    return nodeVersion;
+    const found = contents.match(/^(?:node(js)?\s+)?v?(?<version>[^\s]+)$/m);
+    return (_e = (_d = found === null || found === void 0 ? void 0 : found.groups) === null || _d === void 0 ? void 0 : _d.version) !== null && _e !== void 0 ? _e : contents.trim();
 }
-exports.parseNodeVersionFile = parseNodeVersionFile;
+exports.getNodeVersionFromFile = getNodeVersionFromFile;
 function printEnvDetailsAndSetOutput() {
     return __awaiter(this, void 0, void 0, function* () {
         core.startGroup('Environment details');
diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md
index 079b8bfac..bf62e0713 100644
--- a/docs/advanced-usage.md
+++ b/docs/advanced-usage.md
@@ -84,6 +84,8 @@ When using the `package.json` input, the action will look for `volta.node` first
 }
 ```
 
+Otherwise, when [`volta.extends`](https://docs.volta.sh/advanced/workspaces) is defined, then it will resolve the corresponding file and look for `volta.node` or `engines.node` recursively.
+
 ## Architecture
 
 You can use any of the [supported operating systems](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners), and the compatible `architecture` can be selected using `architecture`. Values are `x86`, `x64`, `arm64`, `armv6l`, `armv7l`, `ppc64le`, `s390x` (not all of the architectures are available on all platforms).
diff --git a/src/main.ts b/src/main.ts
index 34f943104..c55c3b005 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,5 @@
 import * as core from '@actions/core';
 
-import fs from 'fs';
 import os from 'os';
 
 import * as auth from './authutil';
@@ -8,7 +7,7 @@ import * as path from 'path';
 import {restoreCache} from './cache-restore';
 import {isCacheFeatureAvailable} from './cache-utils';
 import {getNodejsDistribution} from './distributions/installer-factory';
-import {parseNodeVersionFile, printEnvDetailsAndSetOutput} from './util';
+import {getNodeVersionFromFile, printEnvDetailsAndSetOutput} from './util';
 import {State} from './constants';
 
 export async function run() {
@@ -99,15 +98,7 @@ function resolveVersionInput(): string {
       versionFileInput
     );
 
-    if (!fs.existsSync(versionFilePath)) {
-      throw new Error(
-        `The specified node version file at: ${versionFilePath} does not exist`
-      );
-    }
-
-    const parsedVersion = parseNodeVersionFile(
-      fs.readFileSync(versionFilePath, 'utf8')
-    );
+    const parsedVersion = getNodeVersionFromFile(versionFilePath);
 
     if (parsedVersion) {
       version = parsedVersion;
diff --git a/src/util.ts b/src/util.ts
index 0b2b14906..cc6ac3107 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -1,45 +1,62 @@
 import * as core from '@actions/core';
 import * as exec from '@actions/exec';
 
-export function parseNodeVersionFile(contents: string): string | null {
-  let nodeVersion: string | undefined;
+import fs from 'fs';
+import path from 'path';
+
+export function getNodeVersionFromFile(versionFilePath: string): string | null {
+  if (!fs.existsSync(versionFilePath)) {
+    throw new Error(
+      `The specified node version file at: ${versionFilePath} does not exist`
+    );
+  }
+
+  const contents = fs.readFileSync(versionFilePath, 'utf8');
 
   // Try parsing the file as an NPM `package.json` file.
   try {
     const manifest = JSON.parse(contents);
 
-    // JSON can parse numbers, but that's handled later
-    if (typeof manifest === 'object') {
-      nodeVersion = manifest.volta?.node;
-      if (!nodeVersion) nodeVersion = manifest.engines?.node;
+    // Presume package.json file.
+    if (typeof manifest === 'object' && !!manifest) {
+      // Support Volta.
+      // See https://docs.volta.sh/guide/understanding#managing-your-project
+      if (manifest.volta?.node) {
+        return manifest.volta.node;
+      }
+
+      if (manifest.engines?.node) {
+        return manifest.engines.node;
+      }
 
-      // if contents are an object, we parsed JSON
+      // Support Volta workspaces.
+      // See https://docs.volta.sh/advanced/workspaces
+      if (manifest.volta?.extends) {
+        const extendedFilePath = path.resolve(
+          path.dirname(versionFilePath),
+          manifest.volta.extends
+        );
+        core.info('Resolving node version from ' + extendedFilePath);
+        return getNodeVersionFromFile(extendedFilePath);
+      }
+
+      // If contents are an object, we parsed JSON
       // this can happen if node-version-file is a package.json
       // yet contains no volta.node or engines.node
       //
-      // if node-version file is _not_ json, control flow
+      // If node-version file is _not_ JSON, control flow
       // will not have reached these lines.
       //
       // And because we've reached here, we know the contents
       // *are* JSON, so no further string parsing makes sense.
-      if (!nodeVersion) {
-        return null;
-      }
+      return null;
     }
   } catch {
     core.info('Node version file is not JSON file');
   }
 
-  if (!nodeVersion) {
-    const found = contents.match(/^(?:node(js)?\s+)?v?(?<version>[^\s]+)$/m);
-    nodeVersion = found?.groups?.version;
-  }
-
-  // In the case of an unknown format,
-  // return as is and evaluate the version separately.
-  if (!nodeVersion) nodeVersion = contents.trim();
-
-  return nodeVersion as string;
+  const found = contents.match(/^(?:node(js)?\s+)?v?(?<version>[^\s]+)$/m);
+  return found?.groups?.version ?? contents.trim();
 }
 
 export async function printEnvDetailsAndSetOutput() {