Skip to content

Commit f442ab2

Browse files
committed
fix(registry): scope authentication to the install process
1 parent a955236 commit f442ab2

12 files changed

Lines changed: 510 additions & 190 deletions

File tree

‎.github/workflows/test.yaml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,23 @@ jobs:
733733
fi
734734
shell: bash
735735

736+
registry-auth:
737+
name: 'Private registry auth (${{ matrix.os }} / pnpm ${{ matrix.version }})'
738+
runs-on: ${{ matrix.os }}
739+
strategy:
740+
fail-fast: false
741+
matrix:
742+
os: [ubuntu-latest, windows-latest]
743+
version: ['11.6.0', '12.5.1']
744+
steps:
745+
- uses: actions/checkout@v7
746+
- uses: ./
747+
with:
748+
version: ${{ matrix.version }}
749+
runtime: node@24.19.0
750+
- name: Install a package from an authenticated local registry
751+
run: node test/registry-auth.mjs
752+
736753
install-false:
737754
# `install: false` skips the auto-install step even though a manifest is
738755
# present.

‎README.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ Only one version of each runtime can be installed globally. If a runtime name is
3131
| `package-json-file` | **Deprecated** — use `working-directory`. Still honoured on its own; the directory containing the file becomes the working directory. |
3232
| `install` | Run `pnpm install` after setup. Default: `true`. Set to `false` for jobs that only need pnpm itself (e.g. `pnpm audit`, lockfile-only regeneration). |
3333
| `require-lockfile` | Fail unless a `pnpm-lock.yaml` already describes the install; runs `pnpm install --frozen-lockfile`. Default: `false`. |
34+
| `registry-url` | HTTPS URL of a private registry to authenticate against during the automatic install. Requires `registry-token` and pnpm 11.6.0 or newer. Project registry routing must already select this URL. |
35+
| `registry-token` | Token for `registry-url`, passed through a GitHub secret. Used only by the automatic install and its subprocesses; no persistent credential configuration is written. |
3436
| `token` | No longer used. pnpm is fetched from the npm registry and verified against npm's signature, so the action makes no GitHub API request. Kept so workflows that pass it keep working. |
3537

3638
## Outputs
@@ -265,6 +267,35 @@ Each save creates a new cache entry, even when the lockfile is unchanged.
265267
Large matrix workflows therefore use more cache storage and can evict older
266268
entries sooner.
267269

270+
### Install from a private registry
271+
272+
Keep registry routing in the project's `.npmrc`, for example:
273+
274+
```ini
275+
@myorg:registry=https://npm.pkg.github.com/
276+
```
277+
278+
Then supply credentials to the automatic install:
279+
280+
```yaml
281+
- uses: pnpm/setup@v2
282+
with:
283+
registry-url: https://npm.pkg.github.com/
284+
registry-token: ${{ secrets.PACKAGES_TOKEN }}
285+
```
286+
287+
Both inputs must be supplied together. Registry URLs must use HTTPS, except
288+
for local HTTP registries at `localhost`, `127.0.0.1`, or `[::1]`. Credentials,
289+
query parameters, fragments, and equals signs are not accepted in the URL.
290+
291+
Authentication requires pnpm 11.6.0 or newer. These inputs do not change the
292+
project's default or scoped registry routing. The token is masked in logs
293+
and provided only to the automatic install process and its subprocesses.
294+
Existing user and project configuration files are left unchanged.
295+
296+
With `install: false`, no registry authentication is configured. Later
297+
install or publish steps must provide their own credentials.
298+
268299
### Skip `pnpm install`
269300

270301
For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regeneration — set `install: false`:

‎action.yml‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,17 @@ inputs:
123123
default: 'true'
124124
registry-url:
125125
description: |
126-
Base URL of a private npm registry to authenticate against before
127-
installing, e.g. `https://myorg.jfrog.io/artifactory/api/npm/npm-local/`.
128-
Requires `registry-token` to also be set.
126+
HTTPS URL of a private npm registry to authenticate against during
127+
the automatic install. HTTP is supported only for loopback registries.
128+
Requires `registry-token` and pnpm 11.6.0 or newer. The project's registry
129+
and scope routing must already select this URL. Ignored with `install: false`.
129130
required: false
130131
registry-token:
131132
description: |
132133
Auth token for the private registry specified in `registry-url`.
133-
Pass this via a GitHub secret — it will not appear in logs.
134+
Pass this via a GitHub secret; the action masks it in logs.
135+
Used only by the automatic install and its subprocesses, without
136+
changing persistent configuration or authenticating later steps.
134137
Requires `registry-url` to also be set.
135138
required: false
136139
token:

‎dist/index.js‎

Lines changed: 152 additions & 152 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/index.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { setFailed, saveState, getState } from '@actions/core'
1+
import { setFailed, setSecret, saveState, getState } from '@actions/core'
22
import restoreCache, { finalizeCache } from './cache-restore'
33
import saveCache from './cache-save'
44
import getInputs, { Inputs } from './inputs'
@@ -26,7 +26,9 @@ async function main() {
2626

2727
async function runMain() {
2828
const inputs = getInputs()
29-
saveState('inputs', inputs)
29+
if (inputs.registry) setSecret(inputs.registry.registryToken)
30+
delete process.env['INPUT_REGISTRY-TOKEN']
31+
saveState('inputs', { ...inputs, registry: undefined })
3032
saveState('is_post', 'true')
3133

3234
const result = await installPnpm(inputs)

‎src/inputs/index.test.mjs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,31 @@ describe('validateRegistryInputs', () => {
2929
})
3030
})
3131
})
32+
33+
for (const url of [
34+
'not-a-url', 'http://registry.example.com/', 'ftp://registry.example.com/',
35+
'https://user:password@registry.example.com/', 'https://registry.example.com/?secret=value',
36+
'https://registry.example.com/#fragment', 'https://registry.example.com/path=value',
37+
'https://registry.example.com/\n', 'http://localhost.attacker.example/',
38+
]) {
39+
it(`rejects invalid registry URL ${JSON.stringify(url)} without echoing it`, () => {
40+
assert.throws(() => validateRegistryInputs(url, 'test-token'), error => {
41+
assert.match(error.message, /registry-url/)
42+
assert.ok(!error.message.includes(url))
43+
assert.ok(!error.message.includes('test-token'))
44+
return true
45+
})
46+
})
47+
}
48+
49+
for (const url of ['http://localhost:4873/', 'http://127.0.0.1:4873/', 'http://[::1]:4873/']) {
50+
it(`accepts local HTTP registry ${url}`, () => {
51+
assert.equal(validateRegistryInputs(url, 'test-token').registryUrl, url)
52+
})
53+
}
54+
55+
it('rejects invalid token characters', () => {
56+
for (const token of ['line\nbreak', 'line\rbreak', 'null\0byte']) {
57+
assert.throws(() => validateRegistryInputs('https://registry.example.com', token), /registry-token/)
58+
}
59+
})

‎src/inputs/index.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,23 @@ export function validateRegistryInputs(registryUrl: string, registryToken: strin
142142
throw new Error('`registry-url` is required when `registry-token` is set')
143143
}
144144
if (!registryUrl && !registryToken) return undefined
145-
return { registryUrl, registryToken }
145+
let parsed: URL
146+
try {
147+
parsed = new URL(registryUrl)
148+
} catch {
149+
throw new Error('`registry-url` must be an absolute HTTPS URL')
150+
}
151+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
152+
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
153+
throw new Error('`registry-url` must use HTTPS (HTTP is only allowed for loopback registries)')
154+
}
155+
if (parsed.username || parsed.password || parsed.search || parsed.hash || /[=\r\n\0]/.test(registryUrl)) {
156+
throw new Error('`registry-url` must not contain credentials, query parameters, fragments, or an equals sign')
157+
}
158+
if (/[\r\n\0]/.test(registryToken)) {
159+
throw new Error('`registry-token` must not contain line breaks or null bytes')
160+
}
161+
return { registryUrl: parsed.href, registryToken }
146162
}
147163

148164
export const getInputs = (): Inputs => ({

‎src/pnpm-install/index.ts‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { info, setFailed, setSecret, startGroup, endGroup } from '@actions/core'
22
import { spawnSync } from 'child_process'
33
import { existsSync } from 'fs'
44
import path from 'path'
5+
import { valid, gte } from 'semver'
56
import { Inputs } from '../inputs'
6-
import { buildRegistryAuthArgs } from './registry'
7+
import { registryInstallEnv } from './registry'
78

89
export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs.runtime)) {
910
const args = ['install']
@@ -51,16 +52,14 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs
5152

5253
if (inputs.registry) {
5354
setSecret(inputs.registry.registryToken)
54-
const configArgs = buildRegistryAuthArgs(inputs.registry.registryUrl, inputs.registry.registryToken)
55-
startGroup('Configuring private registry auth...')
56-
const configResult = spawnSync('pnpm', configArgs, { stdio: 'inherit' })
57-
endGroup()
58-
if (configResult.error) {
59-
setFailed(configResult.error)
60-
return
61-
}
62-
if (configResult.status !== 0) {
63-
setFailed(`pnpm config set for private registry exited with status ${configResult.status}`)
55+
const versionResult = spawnSync('pnpm', ['--version'], {
56+
cwd: workingDirectory,
57+
env: registryInstallEnv(undefined, process.env),
58+
encoding: 'utf8',
59+
})
60+
const version = versionResult.stdout?.trim()
61+
if (versionResult.status !== 0 || !version || !valid(version) || !gte(version, '11.6.0')) {
62+
setFailed('Private registry authentication requires pnpm 11.6.0 or newer.')
6463
return
6564
}
6665
}
@@ -72,7 +71,7 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs
7271
const { error, status, signal } = spawnSync('pnpm', args, {
7372
stdio: 'inherit',
7473
cwd: workingDirectory,
75-
shell: true,
74+
env: registryInstallEnv(inputs.registry, process.env),
7675
})
7776
endGroup()
7877

‎src/pnpm-install/install.test.mjs‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import assert from 'node:assert/strict'
2+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import path from 'node:path'
5+
import { beforeEach, test } from 'node:test'
6+
import { fileURLToPath } from 'node:url'
7+
import { build } from 'esbuild'
8+
9+
const mocks = {
10+
'@actions/core': `
11+
import { mock } from 'node:test'
12+
export const info = mock.fn()
13+
export const setFailed = mock.fn()
14+
export const setSecret = mock.fn()
15+
export const startGroup = mock.fn()
16+
export const endGroup = mock.fn()
17+
`,
18+
child_process: `
19+
import { mock } from 'node:test'
20+
export const spawnSync = mock.fn()
21+
`,
22+
}
23+
const { outputFiles } = await build({
24+
stdin: {
25+
contents: `export { runPnpmInstall } from './index.ts'; export * as core from '@actions/core'; export { spawnSync } from 'child_process'`,
26+
resolveDir: fileURLToPath(new URL('.', import.meta.url)),
27+
},
28+
bundle: true, platform: 'node', format: 'esm', write: false,
29+
plugins: [{
30+
name: 'mock-process-boundary',
31+
setup(builder) {
32+
builder.onResolve({ filter: /.*/ }, args => {
33+
if (Object.hasOwn(mocks, args.path)) return { path: args.path, namespace: 'mock' }
34+
})
35+
builder.onLoad({ filter: /.*/, namespace: 'mock' }, args => ({ contents: mocks[args.path] }))
36+
},
37+
}],
38+
})
39+
const { runPnpmInstall, core, spawnSync } = await import(
40+
`data:text/javascript;base64,${Buffer.from(outputFiles[0].text).toString('base64')}`
41+
)
42+
const token = 'test-token-$(must-not-run)'
43+
const inputs = {
44+
workingDirectory: '.', packageJsonFile: 'package.json',
45+
registry: { registryUrl: 'https://registry.example/npm/', registryToken: token },
46+
}
47+
48+
beforeEach(t => {
49+
const workspace = mkdtempSync(path.join(tmpdir(), 'setup-registry-install-'))
50+
writeFileSync(path.join(workspace, 'package.json'), '{}')
51+
const previous = [process.env.GITHUB_WORKSPACE, process.env['INPUT_REGISTRY-TOKEN']]
52+
process.env.GITHUB_WORKSPACE = workspace
53+
process.env['INPUT_REGISTRY-TOKEN'] = token
54+
t.after(() => {
55+
for (const [i, key] of ['GITHUB_WORKSPACE', 'INPUT_REGISTRY-TOKEN'].entries()) {
56+
if (previous[i] === undefined) delete process.env[key]
57+
else process.env[key] = previous[i]
58+
}
59+
rmSync(workspace, { recursive: true, force: true })
60+
})
61+
for (const fn of Object.values(core)) fn.mock.resetCalls()
62+
spawnSync.mock.resetCalls()
63+
spawnSync.mock.mockImplementation((_bin, args) => args[0] === '--version'
64+
? { status: 0, stdout: '12.5.1\n' }
65+
: { status: 0 })
66+
})
67+
68+
for (const [name, result, failed] of [
69+
['success', { status: 0 }, false],
70+
['failure', { status: 1 }, true],
71+
['signal', { status: null, signal: 'SIGTERM' }, true],
72+
['spawn error', { error: new Error('Unable to spawn pnpm') }, true],
73+
]) {
74+
test(`install ${name} keeps credentials in the install child only`, () => {
75+
spawnSync.mock.mockImplementation((_bin, args) => args[0] === '--version'
76+
? { status: 0, stdout: '11.6.0' } : result)
77+
runPnpmInstall(inputs)
78+
assert.equal(spawnSync.mock.callCount(), 2)
79+
const versionEnv = spawnSync.mock.calls[0].arguments[2].env
80+
const [bin, args, options] = spawnSync.mock.calls[1].arguments
81+
assert.equal(bin, 'pnpm')
82+
assert.deepEqual(args, ['install'])
83+
assert.equal(options.shell, undefined)
84+
assert.equal(options.env['pnpm_config_//registry.example/npm/:_authToken'], token)
85+
assert.equal(options.env['INPUT_REGISTRY-TOKEN'], undefined)
86+
assert.ok(!Object.values(versionEnv).includes(token))
87+
assert.equal(process.env['pnpm_config_//registry.example/npm/:_authToken'], undefined)
88+
assert.equal(core.setFailed.mock.callCount(), failed ? 1 : 0)
89+
assert.deepEqual(core.setSecret.mock.calls[0].arguments, [token])
90+
for (const fn of [core.info, core.startGroup, core.setFailed]) {
91+
assert.ok(!JSON.stringify(fn.mock.calls).includes(token))
92+
}
93+
})
94+
}
95+
96+
test('older pnpm cannot silently attempt an unauthenticated install', () => {
97+
spawnSync.mock.mockImplementation(() => ({ status: 0, stdout: '11.5.0' }))
98+
runPnpmInstall(inputs)
99+
assert.equal(spawnSync.mock.callCount(), 1)
100+
assert.match(core.setFailed.mock.calls[0].arguments[0], /11\.6\.0/)
101+
})
102+
103+
test('an install without registry credentials retains existing flags and environment', () => {
104+
runPnpmInstall({ ...inputs, registry: undefined, requireLockfile: false }, true)
105+
assert.equal(spawnSync.mock.callCount(), 1)
106+
assert.deepEqual(spawnSync.mock.calls[0].arguments[1], ['install', '--no-runtime'])
107+
assert.equal(spawnSync.mock.calls[0].arguments[2].env.PATH, process.env.PATH)
108+
})
109+
110+
test('a missing required lockfile never starts an authenticated process', () => {
111+
runPnpmInstall({ ...inputs, requireLockfile: true })
112+
assert.equal(spawnSync.mock.callCount(), 0)
113+
assert.equal(core.setFailed.mock.callCount(), 1)
114+
})

‎src/pnpm-install/registry.test.mjs‎

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import { describe, it } from 'node:test'
1+
import test from 'node:test'
22
import assert from 'node:assert/strict'
3-
import { buildRegistryAuthArgs } from './registry.ts'
3+
import { registryInstallEnv } from './registry.ts'
44

5-
describe('buildRegistryAuthArgs', () => {
6-
it('uses protocol-relative key form with trailing slash', () => {
7-
const args = buildRegistryAuthArgs('https://example.jfrog.io/npm/', 'mytoken')
8-
assert.deepEqual(args, ['config', 'set', '//example.jfrog.io/npm/:_authToken', 'mytoken'])
5+
for (const [url, key] of [
6+
['https://example.jfrog.io/npm/', 'pnpm_config_//example.jfrog.io/npm/:_authToken'],
7+
['https://example.jfrog.io/npm', 'pnpm_config_//example.jfrog.io/npm/:_authToken'],
8+
['https://registry.example.com/', 'pnpm_config_//registry.example.com/:_authToken'],
9+
['https://registry.example.com:8443/MyRegistry/', 'pnpm_config_//registry.example.com:8443/MyRegistry/:_authToken'],
10+
]) {
11+
test(`scopes authentication to ${url}`, () => {
12+
const environment = { PATH: '/bin', 'INPUT_REGISTRY-TOKEN': 'secret', unrelated: 'keep' }
13+
assert.deepEqual(registryInstallEnv({ registryUrl: url, registryToken: 'secret' }, environment), {
14+
PATH: '/bin', unrelated: 'keep', [key]: 'secret',
15+
})
16+
assert.equal(environment['INPUT_REGISTRY-TOKEN'], 'secret')
17+
assert.equal(environment[key], undefined)
918
})
19+
}
1020

11-
it('normalises missing trailing slash on the path', () => {
12-
const args = buildRegistryAuthArgs('https://example.jfrog.io/npm', 'mytoken')
13-
assert.deepEqual(args, ['config', 'set', '//example.jfrog.io/npm/:_authToken', 'mytoken'])
14-
})
15-
16-
it('works with a bare hostname registry', () => {
17-
const args = buildRegistryAuthArgs('https://registry.example.com/', 'tok')
18-
assert.deepEqual(args, ['config', 'set', '//registry.example.com/:_authToken', 'tok'])
19-
})
21+
test('removes the raw input even when no registry is configured', () => {
22+
assert.deepEqual(registryInstallEnv(undefined, { 'INPUT_REGISTRY-TOKEN': 'secret', 'input_registry-token': 'secret', PATH: '/bin' }), { PATH: '/bin' })
2023
})

0 commit comments

Comments
 (0)