Skip to content
Closed
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
17 changes: 17 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,23 @@ jobs:
fi
shell: bash

registry-auth:
name: 'Private registry auth (${{ matrix.os }} / pnpm ${{ matrix.version }})'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
version: ['11.6.0', '12.5.1']
steps:
- uses: actions/checkout@v7
- uses: ./
with:
version: ${{ matrix.version }}
runtime: node@24.19.0
- name: Install a package from an authenticated local registry
run: node test/registry-auth.mjs

install-false:
# `install: false` skips the auto-install step even though a manifest is
# present.
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Only one version of each runtime can be installed globally. If a runtime name is
| `package-json-file` | **Deprecated** — use `working-directory`. Still honoured on its own; the directory containing the file becomes the working directory. |
| `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). |
| `require-lockfile` | Fail unless a `pnpm-lock.yaml` already describes the install; runs `pnpm install --frozen-lockfile`. Default: `false`. |
| `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. |
| `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. |
| `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. |

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

### Install from a private registry

Keep registry routing in the project's `.npmrc`, for example:

```ini
@myorg:registry=https://npm.pkg.github.com/
```

Then supply credentials to the automatic install:

```yaml
- uses: pnpm/setup@v2
with:
registry-url: https://npm.pkg.github.com/
registry-token: ${{ secrets.PACKAGES_TOKEN }}
```

Both inputs must be supplied together. Registry URLs must use HTTPS, except
for local HTTP registries at `localhost`, `127.0.0.1`, or `[::1]`. Credentials,
query parameters, fragments, and equals signs are not accepted in the URL.

Authentication requires pnpm 11.6.0 or newer. These inputs do not change the
project's default or scoped registry routing. The token is masked in logs
and provided only to the automatic install process and its subprocesses.
Existing user and project configuration files are left unchanged.

With `install: false`, no registry authentication is configured. Later
install or publish steps must provide their own credentials.

### Skip `pnpm install`

For jobs that only need pnpm itself — e.g. `pnpm audit`, lockfile-only regeneration — set `install: false`:
Expand Down
15 changes: 15 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,21 @@ inputs:
need pnpm itself (e.g. `pnpm audit`, lockfile-only regeneration).
required: false
default: 'true'
registry-url:
description: |
HTTPS URL of a private npm registry to authenticate against during
the automatic install. HTTP is supported only for loopback registries.
Requires `registry-token` and pnpm 11.6.0 or newer. The project's registry
and scope routing must already select this URL. Ignored with `install: false`.
required: false
registry-token:
description: |
Auth token for the private registry specified in `registry-url`.
Pass this via a GitHub secret; the action masks it in logs.
Used only by the automatic install and its subprocesses, without
changing persistent configuration or authenticating later steps.
Requires `registry-url` to also be set.
required: false
token:
description: >
No longer used. pnpm is fetched from the npm registry and verified
Expand Down
304 changes: 152 additions & 152 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"build:bundle": "esbuild src/index.ts --bundle --platform=node --target=node24 --format=cjs --minify --outfile=dist/index.js",
"build": "pnpm run build:bundle",
"start": "pnpm run build && sh ./run.sh",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-runtime/*.test.mjs"
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/cache-restore/*.test.mjs src/install-runtime/*.test.mjs src/inputs/*.test.mjs src/pnpm-install/*.test.mjs"
},
"dependencies": {
"@actions/cache": "^6.2.0",
Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { setFailed, saveState, getState } from '@actions/core'
import { setFailed, setSecret, saveState, getState } from '@actions/core'
import restoreCache, { finalizeCache } from './cache-restore'
import saveCache from './cache-save'
import getInputs, { Inputs } from './inputs'
Expand Down Expand Up @@ -26,7 +26,9 @@ async function main() {

async function runMain() {
const inputs = getInputs()
saveState('inputs', inputs)
if (inputs.registry) setSecret(inputs.registry.registryToken)
delete process.env['INPUT_REGISTRY-TOKEN']
saveState('inputs', { ...inputs, registry: undefined })
saveState('is_post', 'true')

const result = await installPnpm(inputs)
Expand Down
59 changes: 59 additions & 0 deletions src/inputs/index.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { validateRegistryInputs } from './index.ts'

describe('validateRegistryInputs', () => {
it('throws when registry-url is set without registry-token', () => {
assert.throws(
() => validateRegistryInputs('https://example.jfrog.io/', ''),
/registry-token.*required.*registry-url/i,
)
})

it('throws when registry-token is set without registry-url', () => {
assert.throws(
() => validateRegistryInputs('', 'mytoken'),
/registry-url.*required.*registry-token/i,
)
})

it('returns undefined when neither is set', () => {
assert.equal(validateRegistryInputs('', ''), undefined)
})

it('returns registry config when both are set', () => {
const result = validateRegistryInputs('https://example.jfrog.io/', 'mytoken')
assert.deepEqual(result, {
registryUrl: 'https://example.jfrog.io/',
registryToken: 'mytoken',
})
})
})

for (const url of [
'not-a-url', 'http://registry.example.com/', 'ftp://registry.example.com/',
'https://user:password@registry.example.com/', 'https://registry.example.com/?secret=value',
'https://registry.example.com/#fragment', 'https://registry.example.com/path=value',
'https://registry.example.com/\n', 'http://localhost.attacker.example/',
]) {
it(`rejects invalid registry URL ${JSON.stringify(url)} without echoing it`, () => {
assert.throws(() => validateRegistryInputs(url, 'test-token'), error => {
assert.match(error.message, /registry-url/)
assert.ok(!error.message.includes(url))
assert.ok(!error.message.includes('test-token'))
return true
})
})
}

for (const url of ['http://localhost:4873/', 'http://127.0.0.1:4873/', 'http://[::1]:4873/']) {
it(`accepts local HTTP registry ${url}`, () => {
assert.equal(validateRegistryInputs(url, 'test-token').registryUrl, url)
})
}

it('rejects invalid token characters', () => {
for (const token of ['line\nbreak', 'line\rbreak', 'null\0byte']) {
assert.throws(() => validateRegistryInputs('https://registry.example.com', token), /registry-token/)
}
})
40 changes: 39 additions & 1 deletion src/inputs/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getBooleanInput, getInput, InputOptions } from '@actions/core'
import { getBooleanInput, getInput } from '@actions/core'
import type { InputOptions } from '@actions/core'
import expandTilde from 'expand-tilde'
import { existsSync } from 'fs'
import path from 'path'
Expand All @@ -12,6 +13,11 @@ export interface RuntimeInput {
readonly version?: string
}

export interface RegistryConfig {
readonly registryUrl: string
readonly registryToken: string
}

export interface Inputs {
readonly version?: string
readonly dest: string
Expand All @@ -27,6 +33,7 @@ export interface Inputs {
/** Whether a lockfile must already exist and fully describe the install. */
readonly requireLockfile: boolean
readonly token?: string
readonly registry?: RegistryConfig
}

const options: InputOptions = {
Expand Down Expand Up @@ -127,6 +134,33 @@ function isSupportedRuntime(name: string): name is RuntimeName {
return (SUPPORTED_RUNTIMES as readonly string[]).includes(name)
}

export function validateRegistryInputs(registryUrl: string, registryToken: string): RegistryConfig | undefined {
if (registryUrl && !registryToken) {
Comment thread
zkochan marked this conversation as resolved.
throw new Error('`registry-token` is required when `registry-url` is set')
}
if (registryToken && !registryUrl) {
throw new Error('`registry-url` is required when `registry-token` is set')
}
if (!registryUrl && !registryToken) return undefined
let parsed: URL
try {
parsed = new URL(registryUrl)
} catch {
throw new Error('`registry-url` must be an absolute HTTPS URL')
}
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
throw new Error('`registry-url` must use HTTPS (HTTP is only allowed for loopback registries)')
}
if (parsed.username || parsed.password || parsed.search || parsed.hash || /[=\r\n\0]/.test(registryUrl)) {
throw new Error('`registry-url` must not contain credentials, query parameters, fragments, or an equals sign')
}
if (/[\r\n\0]/.test(registryToken)) {
throw new Error('`registry-token` must not contain line breaks or null bytes')
}
return { registryUrl: parsed.href, registryToken }
}

export const getInputs = (): Inputs => ({
version: getInput('version'),
dest: parseInputPath('dest'),
Expand All @@ -137,6 +171,10 @@ export const getInputs = (): Inputs => ({
install: getBooleanInput('install'),
requireLockfile: getBooleanInput('require-lockfile'),
token: getInput('token') || undefined,
registry: validateRegistryInputs(
getInput('registry-url').trim(),
getInput('registry-token').trim(),
),
})

function parseNodeVersionFileInput(): string | false | undefined {
Expand Down
20 changes: 18 additions & 2 deletions src/pnpm-install/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { info, setFailed, startGroup, endGroup } from '@actions/core'
import { info, setFailed, setSecret, startGroup, endGroup } from '@actions/core'
import { spawnSync } from 'child_process'
import { existsSync } from 'fs'
import path from 'path'
import { valid, gte } from 'semver'
import { Inputs } from '../inputs'
import { registryInstallEnv } from './registry'

export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs.runtime)) {
const args = ['install']
Expand Down Expand Up @@ -48,14 +50,28 @@ export function runPnpmInstall(inputs: Inputs, runtimeInstalled = Boolean(inputs
return
}

if (inputs.registry) {
setSecret(inputs.registry.registryToken)
const versionResult = spawnSync('pnpm', ['--version'], {
cwd: workingDirectory,
env: registryInstallEnv(undefined, process.env),
encoding: 'utf8',
})
const version = versionResult.stdout?.trim()
if (versionResult.status !== 0 || !version || !valid(version) || !gte(version, '11.6.0')) {
setFailed('Private registry authentication requires pnpm 11.6.0 or newer.')
return
}
}

// spawnSync inherits process.env, which already has $PNPM_HOME/bin and
// $PNPM_HOME prepended via addPath() in install-pnpm — so the pnpm this
// action installed (or a self-updated one) is the one that resolves.
startGroup(`Running ${command}...`)
const { error, status, signal } = spawnSync('pnpm', args, {
stdio: 'inherit',
cwd: workingDirectory,
shell: true,
env: registryInstallEnv(inputs.registry, process.env),
})
endGroup()

Expand Down
Loading
Loading