From b3a9c64ba88a653694da2940f78d5a7b56e2c028 Mon Sep 17 00:00:00 2001 From: Peter McEvoy Date: Sun, 20 Oct 2024 12:32:44 -0400 Subject: [PATCH 1/3] Add .npmrc unit and E2E tests --- .github/workflows/versions.yml | 2 +- __tests__/data/.npmrc | 1 + __tests__/main.test.ts | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 __tests__/data/.npmrc diff --git a/.github/workflows/versions.yml b/.github/workflows/versions.yml index 8078deac..e0bf1ecc 100644 --- a/.github/workflows/versions.yml +++ b/.github/workflows/versions.yml @@ -159,7 +159,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] node-version-file: - [.nvmrc, .tool-versions, .tool-versions-node, package.json] + [.nvmrc, .tool-versions, .tool-versions-node, package.json, .npmrc] steps: - uses: actions/checkout@v4 - name: Setup node from node version file diff --git a/__tests__/data/.npmrc b/__tests__/data/.npmrc new file mode 100644 index 00000000..911d4fc2 --- /dev/null +++ b/__tests__/data/.npmrc @@ -0,0 +1 @@ +use-node-version=20.0.0 diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 501741a6..17537394 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -103,10 +103,13 @@ describe('main tests', () => { ${''} | ${''} ${'unknown format'} | ${'unknown format'} ${' 14.1.0 '} | ${'14.1.0'} + ${'use-node-version=lts/iron'} | ${'lts/iron'} ${'{"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} + ${'[section]use-node-version=16'} | ${null} + ${'[section]\nuse-node-version=20'} | ${null} `.it('parses "$contents"', ({contents, expected}) => { const existsSpy = jest.spyOn(fs, 'existsSync'); existsSpy.mockImplementation(() => true); From 477ffd47fc9cc8c71c2bc8d6f1922d6fa8e81a4d Mon Sep 17 00:00:00 2001 From: Peter McEvoy Date: Sun, 20 Oct 2024 12:39:07 -0400 Subject: [PATCH 2/3] Install ini package for parsing INI files --- package-lock.json | 9 +++++++++ package.json | 1 + src/ini.d.ts | 3 +++ 3 files changed, 13 insertions(+) create mode 100644 src/ini.d.ts diff --git a/package-lock.json b/package-lock.json index 56eb064a..d133fbb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@actions/http-client": "^2.2.1", "@actions/io": "^1.0.2", "@actions/tool-cache": "^2.0.1", + "ini": "^5.0.0", "semver": "^7.6.0", "uuid": "^9.0.1" }, @@ -3513,6 +3514,14 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", diff --git a/package.json b/package.json index cbfb2ef9..e6b04b36 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@actions/http-client": "^2.2.1", "@actions/io": "^1.0.2", "@actions/tool-cache": "^2.0.1", + "ini": "^5.0.0", "semver": "^7.6.0", "uuid": "^9.0.1" }, diff --git a/src/ini.d.ts b/src/ini.d.ts new file mode 100644 index 00000000..896e846d --- /dev/null +++ b/src/ini.d.ts @@ -0,0 +1,3 @@ +declare module 'ini' { + function parse(ini: string): Record; +} From 6f7d311033589797eb88968ee012790a1a42f779 Mon Sep 17 00:00:00 2001 From: Peter McEvoy Date: Sun, 20 Oct 2024 12:58:31 -0400 Subject: [PATCH 3/3] Parse use-node-version key from .npmrc --- dist/cache-save/index.js | 301 +++++++++++++++++++++++++++++++++++++++ dist/setup/index.js | 301 +++++++++++++++++++++++++++++++++++++++ src/util.ts | 20 +++ 3 files changed, 622 insertions(+) diff --git a/dist/cache-save/index.js b/dist/cache-save/index.js index 8918271a..d1c1c7f7 100644 --- a/dist/cache-save/index.js +++ b/dist/cache-save/index.js @@ -49050,6 +49050,293 @@ DelayedStream.prototype._checkIfMaxDataSizeExceeded = function() { }; +/***/ }), + +/***/ 45: +/***/ ((module) => { + +const { hasOwnProperty } = Object.prototype + +const encode = (obj, opt = {}) => { + if (typeof opt === 'string') { + opt = { section: opt } + } + opt.align = opt.align === true + opt.newline = opt.newline === true + opt.sort = opt.sort === true + opt.whitespace = opt.whitespace === true || opt.align === true + // The `typeof` check is required because accessing the `process` directly fails on browsers. + /* istanbul ignore next */ + opt.platform = opt.platform || (typeof process !== 'undefined' && process.platform) + opt.bracketedArray = opt.bracketedArray !== false + + /* istanbul ignore next */ + const eol = opt.platform === 'win32' ? '\r\n' : '\n' + const separator = opt.whitespace ? ' = ' : '=' + const children = [] + + const keys = opt.sort ? Object.keys(obj).sort() : Object.keys(obj) + + let padToChars = 0 + // If aligning on the separator, then padToChars is determined as follows: + // 1. Get the keys + // 2. Exclude keys pointing to objects unless the value is null or an array + // 3. Add `[]` to array keys + // 4. Ensure non empty set of keys + // 5. Reduce the set to the longest `safe` key + // 6. Get the `safe` length + if (opt.align) { + padToChars = safe( + ( + keys + .filter(k => obj[k] === null || Array.isArray(obj[k]) || typeof obj[k] !== 'object') + .map(k => Array.isArray(obj[k]) ? `${k}[]` : k) + ) + .concat(['']) + .reduce((a, b) => safe(a).length >= safe(b).length ? a : b) + ).length + } + + let out = '' + const arraySuffix = opt.bracketedArray ? '[]' : '' + + for (const k of keys) { + const val = obj[k] + if (val && Array.isArray(val)) { + for (const item of val) { + out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol + } + } else if (val && typeof val === 'object') { + children.push(k) + } else { + out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol + } + } + + if (opt.section && out.length) { + out = '[' + safe(opt.section) + ']' + (opt.newline ? eol + eol : eol) + out + } + + for (const k of children) { + const nk = splitSections(k, '.').join('\\.') + const section = (opt.section ? opt.section + '.' : '') + nk + const child = encode(obj[k], { + ...opt, + section, + }) + if (out.length && child.length) { + out += eol + } + + out += child + } + + return out +} + +function splitSections (str, separator) { + var lastMatchIndex = 0 + var lastSeparatorIndex = 0 + var nextIndex = 0 + var sections = [] + + do { + nextIndex = str.indexOf(separator, lastMatchIndex) + + if (nextIndex !== -1) { + lastMatchIndex = nextIndex + separator.length + + if (nextIndex > 0 && str[nextIndex - 1] === '\\') { + continue + } + + sections.push(str.slice(lastSeparatorIndex, nextIndex)) + lastSeparatorIndex = nextIndex + separator.length + } + } while (nextIndex !== -1) + + sections.push(str.slice(lastSeparatorIndex)) + + return sections +} + +const decode = (str, opt = {}) => { + opt.bracketedArray = opt.bracketedArray !== false + const out = Object.create(null) + let p = out + let section = null + // section |key = value + const re = /^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i + const lines = str.split(/[\r\n]+/g) + const duplicates = {} + + for (const line of lines) { + if (!line || line.match(/^\s*[;#]/) || line.match(/^\s*$/)) { + continue + } + const match = line.match(re) + if (!match) { + continue + } + if (match[1] !== undefined) { + section = unsafe(match[1]) + if (section === '__proto__') { + // not allowed + // keep parsing the section, but don't attach it. + p = Object.create(null) + continue + } + p = out[section] = out[section] || Object.create(null) + continue + } + const keyRaw = unsafe(match[2]) + let isArray + if (opt.bracketedArray) { + isArray = keyRaw.length > 2 && keyRaw.slice(-2) === '[]' + } else { + duplicates[keyRaw] = (duplicates?.[keyRaw] || 0) + 1 + isArray = duplicates[keyRaw] > 1 + } + const key = isArray && keyRaw.endsWith('[]') + ? keyRaw.slice(0, -2) : keyRaw + + if (key === '__proto__') { + continue + } + const valueRaw = match[3] ? unsafe(match[4]) : true + const value = valueRaw === 'true' || + valueRaw === 'false' || + valueRaw === 'null' ? JSON.parse(valueRaw) + : valueRaw + + // Convert keys with '[]' suffix to an array + if (isArray) { + if (!hasOwnProperty.call(p, key)) { + p[key] = [] + } else if (!Array.isArray(p[key])) { + p[key] = [p[key]] + } + } + + // safeguard against resetting a previously defined + // array by accidentally forgetting the brackets + if (Array.isArray(p[key])) { + p[key].push(value) + } else { + p[key] = value + } + } + + // {a:{y:1},"a.b":{x:2}} --> {a:{y:1,b:{x:2}}} + // use a filter to return the keys that have to be deleted. + const remove = [] + for (const k of Object.keys(out)) { + if (!hasOwnProperty.call(out, k) || + typeof out[k] !== 'object' || + Array.isArray(out[k])) { + continue + } + + // see if the parent section is also an object. + // if so, add it to that, and mark this one for deletion + const parts = splitSections(k, '.') + p = out + const l = parts.pop() + const nl = l.replace(/\\\./g, '.') + for (const part of parts) { + if (part === '__proto__') { + continue + } + if (!hasOwnProperty.call(p, part) || typeof p[part] !== 'object') { + p[part] = Object.create(null) + } + p = p[part] + } + if (p === out && nl === l) { + continue + } + + p[nl] = out[k] + remove.push(k) + } + for (const del of remove) { + delete out[del] + } + + return out +} + +const isQuoted = val => { + return (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) +} + +const safe = val => { + if ( + typeof val !== 'string' || + val.match(/[=\r\n]/) || + val.match(/^\[/) || + (val.length > 1 && isQuoted(val)) || + val !== val.trim() + ) { + return JSON.stringify(val) + } + return val.split(';').join('\\;').split('#').join('\\#') +} + +const unsafe = val => { + val = (val || '').trim() + if (isQuoted(val)) { + // remove the single quotes before calling JSON.parse + if (val.charAt(0) === "'") { + val = val.slice(1, -1) + } + try { + val = JSON.parse(val) + } catch { + // ignore errors + } + } else { + // walk the val to find the first not-escaped ; character + let esc = false + let unesc = '' + for (let i = 0, l = val.length; i < l; i++) { + const c = val.charAt(i) + if (esc) { + if ('\\;#'.indexOf(c) !== -1) { + unesc += c + } else { + unesc += '\\' + c + } + + esc = false + } else if (';#'.indexOf(c) !== -1) { + break + } else if (c === '\\') { + esc = true + } else { + unesc += c + } + } + if (esc) { + unesc += '\\' + } + + return unesc.trim() + } + return val +} + +module.exports = { + parse: decode, + decode, + stringify: encode, + encode, + safe, + unsafe, +} + + /***/ }), /***/ 7426: @@ -84069,6 +84356,7 @@ const core = __importStar(__nccwpck_require__(2186)); const exec = __importStar(__nccwpck_require__(1514)); const io = __importStar(__nccwpck_require__(7436)); const fs_1 = __importDefault(__nccwpck_require__(7147)); +const INI = __importStar(__nccwpck_require__(45)); const path_1 = __importDefault(__nccwpck_require__(1017)); function getNodeVersionFromFile(versionFilePath) { var _a, _b, _c, _d, _e; @@ -84111,6 +84399,19 @@ function getNodeVersionFromFile(versionFilePath) { catch (_f) { core.info('Node version file is not JSON file'); } + // Try parsing the file as an NPM `.npmrc` file. + if (contents.match(/use-node-version *=/)) { + const manifest = INI.parse(contents); + const key = 'use-node-version'; + if (key in manifest && typeof manifest[key] === 'string') { + const version = manifest[key]; + core.info(`Using node version ${version} from global INI ${key}`); + return version; + } + // We didn't find the key `use-node-version` in the global scope of the + // `.npmrc` file, so we return. + return null; + } const found = contents.match(/^(?:node(js)?\s+)?v?(?[^\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(); } diff --git a/dist/setup/index.js b/dist/setup/index.js index e1b7296f..66633a4a 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -54472,6 +54472,293 @@ class Deprecation extends Error { exports.Deprecation = Deprecation; +/***/ }), + +/***/ 45: +/***/ ((module) => { + +const { hasOwnProperty } = Object.prototype + +const encode = (obj, opt = {}) => { + if (typeof opt === 'string') { + opt = { section: opt } + } + opt.align = opt.align === true + opt.newline = opt.newline === true + opt.sort = opt.sort === true + opt.whitespace = opt.whitespace === true || opt.align === true + // The `typeof` check is required because accessing the `process` directly fails on browsers. + /* istanbul ignore next */ + opt.platform = opt.platform || (typeof process !== 'undefined' && process.platform) + opt.bracketedArray = opt.bracketedArray !== false + + /* istanbul ignore next */ + const eol = opt.platform === 'win32' ? '\r\n' : '\n' + const separator = opt.whitespace ? ' = ' : '=' + const children = [] + + const keys = opt.sort ? Object.keys(obj).sort() : Object.keys(obj) + + let padToChars = 0 + // If aligning on the separator, then padToChars is determined as follows: + // 1. Get the keys + // 2. Exclude keys pointing to objects unless the value is null or an array + // 3. Add `[]` to array keys + // 4. Ensure non empty set of keys + // 5. Reduce the set to the longest `safe` key + // 6. Get the `safe` length + if (opt.align) { + padToChars = safe( + ( + keys + .filter(k => obj[k] === null || Array.isArray(obj[k]) || typeof obj[k] !== 'object') + .map(k => Array.isArray(obj[k]) ? `${k}[]` : k) + ) + .concat(['']) + .reduce((a, b) => safe(a).length >= safe(b).length ? a : b) + ).length + } + + let out = '' + const arraySuffix = opt.bracketedArray ? '[]' : '' + + for (const k of keys) { + const val = obj[k] + if (val && Array.isArray(val)) { + for (const item of val) { + out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol + } + } else if (val && typeof val === 'object') { + children.push(k) + } else { + out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol + } + } + + if (opt.section && out.length) { + out = '[' + safe(opt.section) + ']' + (opt.newline ? eol + eol : eol) + out + } + + for (const k of children) { + const nk = splitSections(k, '.').join('\\.') + const section = (opt.section ? opt.section + '.' : '') + nk + const child = encode(obj[k], { + ...opt, + section, + }) + if (out.length && child.length) { + out += eol + } + + out += child + } + + return out +} + +function splitSections (str, separator) { + var lastMatchIndex = 0 + var lastSeparatorIndex = 0 + var nextIndex = 0 + var sections = [] + + do { + nextIndex = str.indexOf(separator, lastMatchIndex) + + if (nextIndex !== -1) { + lastMatchIndex = nextIndex + separator.length + + if (nextIndex > 0 && str[nextIndex - 1] === '\\') { + continue + } + + sections.push(str.slice(lastSeparatorIndex, nextIndex)) + lastSeparatorIndex = nextIndex + separator.length + } + } while (nextIndex !== -1) + + sections.push(str.slice(lastSeparatorIndex)) + + return sections +} + +const decode = (str, opt = {}) => { + opt.bracketedArray = opt.bracketedArray !== false + const out = Object.create(null) + let p = out + let section = null + // section |key = value + const re = /^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i + const lines = str.split(/[\r\n]+/g) + const duplicates = {} + + for (const line of lines) { + if (!line || line.match(/^\s*[;#]/) || line.match(/^\s*$/)) { + continue + } + const match = line.match(re) + if (!match) { + continue + } + if (match[1] !== undefined) { + section = unsafe(match[1]) + if (section === '__proto__') { + // not allowed + // keep parsing the section, but don't attach it. + p = Object.create(null) + continue + } + p = out[section] = out[section] || Object.create(null) + continue + } + const keyRaw = unsafe(match[2]) + let isArray + if (opt.bracketedArray) { + isArray = keyRaw.length > 2 && keyRaw.slice(-2) === '[]' + } else { + duplicates[keyRaw] = (duplicates?.[keyRaw] || 0) + 1 + isArray = duplicates[keyRaw] > 1 + } + const key = isArray && keyRaw.endsWith('[]') + ? keyRaw.slice(0, -2) : keyRaw + + if (key === '__proto__') { + continue + } + const valueRaw = match[3] ? unsafe(match[4]) : true + const value = valueRaw === 'true' || + valueRaw === 'false' || + valueRaw === 'null' ? JSON.parse(valueRaw) + : valueRaw + + // Convert keys with '[]' suffix to an array + if (isArray) { + if (!hasOwnProperty.call(p, key)) { + p[key] = [] + } else if (!Array.isArray(p[key])) { + p[key] = [p[key]] + } + } + + // safeguard against resetting a previously defined + // array by accidentally forgetting the brackets + if (Array.isArray(p[key])) { + p[key].push(value) + } else { + p[key] = value + } + } + + // {a:{y:1},"a.b":{x:2}} --> {a:{y:1,b:{x:2}}} + // use a filter to return the keys that have to be deleted. + const remove = [] + for (const k of Object.keys(out)) { + if (!hasOwnProperty.call(out, k) || + typeof out[k] !== 'object' || + Array.isArray(out[k])) { + continue + } + + // see if the parent section is also an object. + // if so, add it to that, and mark this one for deletion + const parts = splitSections(k, '.') + p = out + const l = parts.pop() + const nl = l.replace(/\\\./g, '.') + for (const part of parts) { + if (part === '__proto__') { + continue + } + if (!hasOwnProperty.call(p, part) || typeof p[part] !== 'object') { + p[part] = Object.create(null) + } + p = p[part] + } + if (p === out && nl === l) { + continue + } + + p[nl] = out[k] + remove.push(k) + } + for (const del of remove) { + delete out[del] + } + + return out +} + +const isQuoted = val => { + return (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) +} + +const safe = val => { + if ( + typeof val !== 'string' || + val.match(/[=\r\n]/) || + val.match(/^\[/) || + (val.length > 1 && isQuoted(val)) || + val !== val.trim() + ) { + return JSON.stringify(val) + } + return val.split(';').join('\\;').split('#').join('\\#') +} + +const unsafe = val => { + val = (val || '').trim() + if (isQuoted(val)) { + // remove the single quotes before calling JSON.parse + if (val.charAt(0) === "'") { + val = val.slice(1, -1) + } + try { + val = JSON.parse(val) + } catch { + // ignore errors + } + } else { + // walk the val to find the first not-escaped ; character + let esc = false + let unesc = '' + for (let i = 0, l = val.length; i < l; i++) { + const c = val.charAt(i) + if (esc) { + if ('\\;#'.indexOf(c) !== -1) { + unesc += c + } else { + unesc += '\\' + c + } + + esc = false + } else if (';#'.indexOf(c) !== -1) { + break + } else if (c === '\\') { + esc = true + } else { + unesc += c + } + } + if (esc) { + unesc += '\\' + } + + return unesc.trim() + } + return val +} + +module.exports = { + parse: decode, + decode, + stringify: encode, + encode, + safe, + unsafe, +} + + /***/ }), /***/ 3287: @@ -94551,6 +94838,7 @@ const core = __importStar(__nccwpck_require__(2186)); const exec = __importStar(__nccwpck_require__(1514)); const io = __importStar(__nccwpck_require__(7436)); const fs_1 = __importDefault(__nccwpck_require__(7147)); +const INI = __importStar(__nccwpck_require__(45)); const path_1 = __importDefault(__nccwpck_require__(1017)); function getNodeVersionFromFile(versionFilePath) { var _a, _b, _c, _d, _e; @@ -94593,6 +94881,19 @@ function getNodeVersionFromFile(versionFilePath) { catch (_f) { core.info('Node version file is not JSON file'); } + // Try parsing the file as an NPM `.npmrc` file. + if (contents.match(/use-node-version *=/)) { + const manifest = INI.parse(contents); + const key = 'use-node-version'; + if (key in manifest && typeof manifest[key] === 'string') { + const version = manifest[key]; + core.info(`Using node version ${version} from global INI ${key}`); + return version; + } + // We didn't find the key `use-node-version` in the global scope of the + // `.npmrc` file, so we return. + return null; + } const found = contents.match(/^(?:node(js)?\s+)?v?(?[^\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(); } diff --git a/src/util.ts b/src/util.ts index bbe25ddf..b6c61330 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,6 +3,7 @@ import * as exec from '@actions/exec'; import * as io from '@actions/io'; import fs from 'fs'; +import * as INI from 'ini'; import path from 'path'; export function getNodeVersionFromFile(versionFilePath: string): string | null { @@ -56,6 +57,25 @@ export function getNodeVersionFromFile(versionFilePath: string): string | null { core.info('Node version file is not JSON file'); } + // Try parsing the file as an NPM `.npmrc` file. + // + // If the file contents contain the use-node-version key, we conclude it's an + // `.npmrc` file. + if (contents.match(/use-node-version *=/)) { + const manifest = INI.parse(contents); + const key = 'use-node-version'; + + if (key in manifest && typeof manifest[key] === 'string') { + const version = manifest[key]; + core.info(`Using node version ${version} from global INI ${key}`); + return version; + } + + // We didn't find the key `use-node-version` in the global scope of the + // `.npmrc` file, so we return. + return null; + } + const found = contents.match(/^(?:node(js)?\s+)?v?(?[^\s]+)$/m); return found?.groups?.version ?? contents.trim(); }