Visitar URL original
feat: add force-mirror input to bypass GitHub-hosted Node.js downloads · actions/setup-node@76ae92e · GitHub
Skip to content

Commit 76ae92e

Browse files
KB1RMACopilot
andcommitted
feat: add force-mirror input to bypass GitHub-hosted Node.js downloads
When force-mirror is true, setup-node skips the actions/node-versions manifest and downloads official builds only from the mirror (or nodejs.org). LTS aliases, latest syntax, and check-latest are resolved from the mirror's index.json. If the mirror download fails, the step fails instead of falling back to GitHub. Adds unit tests for force-mirror failure, parsing, and nightly behavior, plus e2e coverage in the versions workflow. Fixes #1412 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 101b347 commit 76ae92e

10 files changed

Lines changed: 497 additions & 17 deletions

File tree

‎.github/workflows/versions.yml‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,44 @@ jobs:
230230
run: __tests__/verify-node.sh "${{ matrix.node-version }}"
231231
shell: bash
232232

233+
force-mirror:
234+
runs-on: ${{ matrix.os }}
235+
strategy:
236+
fail-fast: false
237+
matrix:
238+
os: [ubuntu-latest, windows-latest, macos-latest, macos-15-intel]
239+
# Versions are not preinstalled on hosted runners, so they must be downloaded
240+
node-version: [22.0.0, 24.9.0]
241+
steps:
242+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
243+
- name: Setup Node from mirror only
244+
uses: ./
245+
with:
246+
node-version: ${{ matrix.node-version }}
247+
mirror: https://nodejs.org
248+
force-mirror: true
249+
- name: Verify node and npm
250+
run: __tests__/verify-node.sh "${{ matrix.node-version }}"
251+
shell: bash
252+
253+
force-mirror-lts:
254+
runs-on: ubuntu-latest
255+
steps:
256+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
257+
- name: Setup Node LTS from mirror only
258+
uses: ./
259+
with:
260+
node-version: lts/-1
261+
check-latest: true
262+
mirror: https://nodejs.org
263+
force-mirror: true
264+
- name: Verify node and npm
265+
run: |
266+
. "$NVM_DIR/nvm.sh"
267+
[[ $(nvm version-remote "lts/-1") =~ ^v([^.]+) ]]
268+
__tests__/verify-node.sh "${BASH_REMATCH[1]}"
269+
shell: bash
270+
233271
old-versions:
234272
runs-on: ${{ matrix.os }}
235273
strategy:

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ See [action.yml](action.yml)
116116
# The token will be used as a bearer token in the Authorization header
117117
# Default: ''
118118
mirror-token: ''
119+
120+
# Set to true to download Node.js only from the mirror (or https://nodejs.org
121+
# if no mirror is set), skipping the GitHub-hosted actions/node-versions
122+
# manifest and binaries.
123+
# Default: false
124+
force-mirror: false
119125
```
120126
<!-- end usage -->
121127

‎__tests__/nightly-installer.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,36 @@ describe('setup-node', () => {
589589
}
590590
}, 100000);
591591

592+
it('ignores force-mirror and downloads nightly builds from the mirror', async () => {
593+
os.platform = 'linux';
594+
os.arch = 'x64';
595+
const version = '17.5.0-nightly20220209e43808936a';
596+
inputs['node-version'] = version;
597+
inputs['mirror'] = 'https://my-mirror.org';
598+
inputs['mirror-token'] = 'my-mirror-token';
599+
inputs['force-mirror'] = 'true';
600+
601+
findSpy.mockImplementation(() => '');
602+
findAllVersionsSpy.mockImplementation(() => []);
603+
dlSpy.mockImplementation(async () => '/some/temp/path');
604+
exSpy.mockImplementation(async () => '/some/other/temp/path');
605+
cacheSpy.mockImplementation(async () =>
606+
path.normalize(`/cache/node/${version}/x64`)
607+
);
608+
609+
await main.run();
610+
611+
expect(dlSpy).toHaveBeenCalledWith(
612+
`https://my-mirror.org/download/nightly/v${version}/node-v${version}-linux-x64.tar.gz`,
613+
undefined,
614+
'my-mirror-token'
615+
);
616+
expect(logSpy).not.toHaveBeenCalledWith(
617+
expect.stringContaining("'force-mirror' is enabled")
618+
);
619+
expect(setFailedSpy).not.toHaveBeenCalled();
620+
});
621+
592622
describe('nightly versions', () => {
593623
it.each([
594624
[

‎__tests__/official-installer.test.ts‎

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,4 +1367,274 @@ describe('setup-node', () => {
13671367
);
13681368
});
13691369
});
1370+
1371+
describe('force-mirror', () => {
1372+
const mirror = 'https://my_mirror_url';
1373+
1374+
beforeEach(() => {
1375+
os.platform = 'linux';
1376+
os.arch = 'x64';
1377+
inputs['token'] = 'faketoken';
1378+
inputs['mirror'] = mirror;
1379+
inputs['mirror-token'] = 'fake-mirror-token';
1380+
inputs['force-mirror'] = 'true';
1381+
});
1382+
1383+
function mockNodeVersion(version: string) {
1384+
getExecOutputSpy.mockImplementation(async () => ({
1385+
stdout: `v${version}\n`,
1386+
stderr: '',
1387+
exitCode: 0
1388+
}));
1389+
}
1390+
1391+
it('downloads from the mirror without using the manifest, even if the version is in the manifest', async () => {
1392+
// 12.16.2 is present in the test manifest
1393+
const version = '12.16.2';
1394+
inputs['node-version'] = version;
1395+
findSpy.mockImplementation(() => '');
1396+
dlSpy.mockImplementation(async () => '/some/temp/path');
1397+
exSpy.mockImplementation(async () => '/some/other/temp/path');
1398+
const toolPath = path.normalize(`/cache/node/${version}/x64`);
1399+
cacheSpy.mockImplementation(async () => toolPath);
1400+
mockNodeVersion(version);
1401+
1402+
await main.run();
1403+
1404+
expect(getManifestSpy).not.toHaveBeenCalled();
1405+
expect(getJsonSpy).not.toHaveBeenCalledWith(
1406+
nodeVersionsManifestUrl,
1407+
expect.anything()
1408+
);
1409+
expect(dlSpy).toHaveBeenCalledTimes(1);
1410+
expect(dlSpy).toHaveBeenCalledWith(
1411+
`${mirror}/dist/v${version}/node-v${version}-linux-x64.tar.gz`,
1412+
undefined,
1413+
'fake-mirror-token'
1414+
);
1415+
expect(getJsonSpy).toHaveBeenCalledWith(`${mirror}/dist/index.json`, {
1416+
Authorization: 'fake-mirror-token'
1417+
});
1418+
expect(addPathSpy).toHaveBeenCalledWith(path.join(toolPath, 'bin'));
1419+
expect(setFailedSpy).not.toHaveBeenCalled();
1420+
});
1421+
1422+
it('uses the local tool cache without contacting the mirror', async () => {
1423+
inputs['node-version'] = '12';
1424+
const toolPath = path.normalize('/cache/node/12.16.2/x64');
1425+
findSpy.mockReturnValue(toolPath);
1426+
mockNodeVersion('12.16.2');
1427+
1428+
await main.run();
1429+
1430+
expect(getManifestSpy).not.toHaveBeenCalled();
1431+
expect(getJsonSpy).not.toHaveBeenCalled();
1432+
expect(dlSpy).not.toHaveBeenCalled();
1433+
expect(logSpy).toHaveBeenCalledWith(`Found in cache @ ${toolPath}`);
1434+
expect(addPathSpy).toHaveBeenCalledWith(path.join(toolPath, 'bin'));
1435+
});
1436+
1437+
it.each([
1438+
['erbium', '12'],
1439+
['*', '12'],
1440+
['-1', '10'],
1441+
['dubnium', '10']
1442+
])(
1443+
'resolves LTS alias lts/%s from the mirror index',
1444+
async (lts, expectedMajor) => {
1445+
inputs['node-version'] = `lts/${lts}`;
1446+
const toolPath = path.normalize(`/cache/node/${expectedMajor}.0.0/x64`);
1447+
findSpy.mockReturnValue(toolPath);
1448+
mockNodeVersion(`${expectedMajor}.0.0`);
1449+
1450+
await main.run();
1451+
1452+
expect(getManifestSpy).not.toHaveBeenCalled();
1453+
expect(logSpy).toHaveBeenCalledWith(
1454+
'Attempt to resolve LTS alias from mirror...'
1455+
);
1456+
expect(findSpy).toHaveBeenCalledWith('node', expectedMajor, 'x64');
1457+
expect(setFailedSpy).not.toHaveBeenCalled();
1458+
}
1459+
);
1460+
1461+
it('fails to resolve an unknown LTS alias', async () => {
1462+
inputs['node-version'] = 'lts/unknown';
1463+
1464+
await main.run();
1465+
1466+
expect(getManifestSpy).not.toHaveBeenCalled();
1467+
expect(setFailedSpy).toHaveBeenCalledWith(
1468+
`Unable to find LTS release 'unknown' for Node version 'lts/unknown'.`
1469+
);
1470+
});
1471+
1472+
it.each([
1473+
['10', '10.20.1'],
1474+
['latest', '14.1.0']
1475+
])(
1476+
'resolves %s to the latest version from the mirror index',
1477+
async (versionSpec, expectedVersion) => {
1478+
inputs['node-version'] = versionSpec;
1479+
inputs['check-latest'] = 'true';
1480+
const toolPath = path.normalize(`/cache/node/${expectedVersion}/x64`);
1481+
findSpy.mockReturnValue(toolPath);
1482+
mockNodeVersion(expectedVersion);
1483+
1484+
await main.run();
1485+
1486+
expect(getManifestSpy).not.toHaveBeenCalled();
1487+
expect(logSpy).toHaveBeenCalledWith(`Resolved as '${expectedVersion}'`);
1488+
expect(findSpy).toHaveBeenCalledWith('node', expectedVersion, 'x64');
1489+
expect(setFailedSpy).not.toHaveBeenCalled();
1490+
}
1491+
);
1492+
1493+
function mockDownload(version: string) {
1494+
findSpy.mockImplementation(() => '');
1495+
dlSpy.mockImplementation(async () => '/some/temp/path');
1496+
exSpy.mockImplementation(async () => '/some/other/temp/path');
1497+
const toolPath = path.normalize(`/cache/node/${version}/x64`);
1498+
cacheSpy.mockImplementation(async () => toolPath);
1499+
mockNodeVersion(version);
1500+
return toolPath;
1501+
}
1502+
1503+
it.each([500, 404, 403])(
1504+
'fails without falling back to GitHub when the mirror download returns HTTP %s',
1505+
async status => {
1506+
inputs['node-version'] = '12.16.2';
1507+
mockDownload('12.16.2');
1508+
dlSpy.mockImplementation(async () => {
1509+
throw new tc.HTTPError(status);
1510+
});
1511+
1512+
await main.run();
1513+
1514+
expect(dlSpy).toHaveBeenCalledTimes(1);
1515+
expect(dlSpy).toHaveBeenCalledWith(
1516+
`${mirror}/dist/v12.16.2/node-v12.16.2-linux-x64.tar.gz`,
1517+
undefined,
1518+
'fake-mirror-token'
1519+
);
1520+
expect(getManifestSpy).not.toHaveBeenCalled();
1521+
expect(setFailedSpy).toHaveBeenCalledWith(
1522+
`Unexpected HTTP response: ${status}`
1523+
);
1524+
}
1525+
);
1526+
1527+
it('fails without falling back to GitHub when the mirror index cannot be fetched', async () => {
1528+
inputs['node-version'] = '12.16.2';
1529+
mockDownload('12.16.2');
1530+
getJsonSpy.mockImplementation(async () => {
1531+
throw new Error('mirror unavailable');
1532+
});
1533+
1534+
await main.run();
1535+
1536+
expect(getManifestSpy).not.toHaveBeenCalled();
1537+
expect(getJsonSpy).toHaveBeenCalledTimes(1);
1538+
expect(getJsonSpy).toHaveBeenCalledWith(
1539+
`${mirror}/dist/index.json`,
1540+
expect.anything()
1541+
);
1542+
expect(dlSpy).not.toHaveBeenCalled();
1543+
expect(setFailedSpy).toHaveBeenCalledWith('mirror unavailable');
1544+
});
1545+
1546+
it('fails when the version is not available on the mirror', async () => {
1547+
inputs['node-version'] = '99.0.0';
1548+
mockDownload('99.0.0');
1549+
1550+
await main.run();
1551+
1552+
expect(getManifestSpy).not.toHaveBeenCalled();
1553+
expect(dlSpy).not.toHaveBeenCalled();
1554+
expect(setFailedSpy).toHaveBeenCalledWith(
1555+
`Unable to find Node version '99.0.0' for platform linux and architecture x64.`
1556+
);
1557+
});
1558+
1559+
it('resolves an LTS alias with check-latest using a single mirror index request', async () => {
1560+
inputs['node-version'] = 'lts/dubnium';
1561+
inputs['check-latest'] = 'true';
1562+
const toolPath = mockDownload('10.20.1');
1563+
1564+
await main.run();
1565+
1566+
expect(getManifestSpy).not.toHaveBeenCalled();
1567+
expect(getJsonSpy).toHaveBeenCalledTimes(1);
1568+
expect(logSpy).toHaveBeenCalledWith(`Resolved as '10.20.1'`);
1569+
expect(dlSpy).toHaveBeenCalledWith(
1570+
`${mirror}/dist/v10.20.1/node-v10.20.1-linux-x64.tar.gz`,
1571+
undefined,
1572+
'fake-mirror-token'
1573+
);
1574+
expect(addPathSpy).toHaveBeenCalledWith(path.join(toolPath, 'bin'));
1575+
expect(setFailedSpy).not.toHaveBeenCalled();
1576+
});
1577+
1578+
it.each(['true', 'TRUE', 'True'])(
1579+
'treats force-mirror=%s as enabled',
1580+
async value => {
1581+
inputs['force-mirror'] = value;
1582+
inputs['node-version'] = '12.16.2';
1583+
mockDownload('12.16.2');
1584+
1585+
await main.run();
1586+
1587+
expect(getManifestSpy).not.toHaveBeenCalled();
1588+
expect(logSpy).toHaveBeenCalledWith(
1589+
`'force-mirror' is enabled. Skipping the actions/node-versions manifest and downloading from ${mirror}/dist`
1590+
);
1591+
}
1592+
);
1593+
1594+
it.each(['false', '', undefined])(
1595+
'uses the manifest first when force-mirror is %p, even with a mirror set',
1596+
async value => {
1597+
inputs['force-mirror'] = value;
1598+
inputs['node-version'] = '12.16.2';
1599+
mockDownload('12.16.2');
1600+
1601+
await main.run();
1602+
1603+
expect(getManifestSpy).toHaveBeenCalled();
1604+
expect(dlSpy).toHaveBeenCalledWith(
1605+
'https://github.com/actions/node-versions/releases/download/12.16.2-20200507.95/node-12.16.2-linux-x64.tar.gz',
1606+
undefined,
1607+
'fake-mirror-token'
1608+
);
1609+
expect(warningSpy).not.toHaveBeenCalledWith(
1610+
expect.stringContaining('force-mirror')
1611+
);
1612+
}
1613+
);
1614+
1615+
it('warns and uses nodejs.org when no mirror is set', async () => {
1616+
const version = '12.16.2';
1617+
inputs['node-version'] = version;
1618+
inputs['mirror'] = '';
1619+
inputs['mirror-token'] = '';
1620+
findSpy.mockImplementation(() => '');
1621+
dlSpy.mockImplementation(async () => '/some/temp/path');
1622+
exSpy.mockImplementation(async () => '/some/other/temp/path');
1623+
const toolPath = path.normalize(`/cache/node/${version}/x64`);
1624+
cacheSpy.mockImplementation(async () => toolPath);
1625+
mockNodeVersion(version);
1626+
1627+
await main.run();
1628+
1629+
expect(warningSpy).toHaveBeenCalledWith(
1630+
'`force-mirror` is enabled but `mirror` is not set. Node.js will be downloaded directly from https://nodejs.org.'
1631+
);
1632+
expect(getManifestSpy).not.toHaveBeenCalled();
1633+
expect(dlSpy).toHaveBeenCalledWith(
1634+
`https://nodejs.org/dist/v${version}/node-v${version}-linux-x64.tar.gz`,
1635+
undefined,
1636+
''
1637+
);
1638+
});
1639+
});
13701640
});

‎action.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ inputs:
2929
description: 'Used to specify an alternative mirror to download Node.js binaries from'
3030
mirror-token:
3131
description: 'The token used as Authorization header when fetching from the mirror'
32-
# TODO: add input to control forcing to pull from cloud or dist.
33-
# escape valve for someone having issues or needing the absolute latest which isn't cached yet
32+
force-mirror:
33+
description: 'Set to true to download Node.js only from the mirror (or https://nodejs.org if no mirror is set), skipping the GitHub-hosted actions/node-versions manifest and binaries. Only applies to official (non-nightly, non-rc, non-canary) builds.'
34+
default: false
3435
outputs:
3536
cache-hit:
3637
description: 'A boolean value to indicate if a cache was hit.'

0 commit comments

Comments
 (0)