Skip to content
Merged
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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ range. For workspace members, setup finds the nearest `pnpm-workspace.yaml`
within the checkout, checks its `packages` patterns and exclusions, and reads
the root manifest and shared lockfile. A member whose root declares no pnpm
version keeps its own declaration. With `sharedWorkspaceLockfile: false`,
the member's lockfile supplies the locked version. Excluded projects keep their own manifest and
lockfile. An explicit `version` input still takes precedence. Custom lockfile
locations and workspace roots outside the checkout retain the existing
version-selection behavior. Frozen installs still validate the lockfile
the member's lockfile supplies the locked version. When `pnpm-workspace.yaml`
sets `lockfileDir`, or a local file its `include` delegates the setting to,
the lockfile is read from there, with a relative path measured from the file
that sets it, as pnpm does. Excluded projects keep their own manifest and
lockfile. An explicit `version` input still takes precedence. Workspace roots
outside the checkout retain the existing version-selection behavior. Frozen installs still validate the lockfile
against the manifest through pnpm.

## Outputs
Expand Down Expand Up @@ -283,7 +285,7 @@ install that was never going to succeed.
Where it looks is where pnpm reads one: at the workspace root when
`working-directory` is one of the workspace's projects, and in
`working-directory` itself when it is not, or when the workspace sets
`sharedWorkspaceLockfile: false`. The action asks the selected pnpm version
`sharedWorkspaceLockfile: false`. A `lockfileDir` setting overrides both. The action asks the selected pnpm version
whether a project belongs to a workspace, so excluded projects follow that
version's installation behavior.

Expand Down
234 changes: 117 additions & 117 deletions dist/index.js

Large diffs are not rendered by default.

53 changes: 52 additions & 1 deletion src/install-pnpm/project.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import os from 'node:os'
import path from 'node:path'
import { afterEach, test } from 'node:test'

import { packageManagerManifest } from './project.ts'
import { packageManagerLockfileDirectory, packageManagerManifest } from './project.ts'

const fixtures = []
afterEach(() => {
Expand Down Expand Up @@ -115,3 +115,54 @@ test('invalid workspace patterns fail with a clear error', () => {
const child = project(root, 'packages/app')
assert.throws(() => packageManagerManifest(child, root), /Invalid packages patterns/)
})

test('the lockfile directory follows the workspace lockfile settings', () => {
const { root, manifest } = workspace("packages: ['packages/*']\n")
const member = project(root, 'packages/app')
assert.equal(packageManagerLockfileDirectory(member, manifest), root)
assert.equal(packageManagerLockfileDirectory(manifest, manifest), root)

fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), "packages: ['packages/*']\nsharedWorkspaceLockfile: false\n")
assert.equal(packageManagerLockfileDirectory(member, manifest), path.dirname(member))
})

test('an explicit lockfileDir is resolved against the workspace root', () => {
const { root, manifest } = workspace("packages: ['packages/*']\nlockfileDir: locks\n")
const member = project(root, 'packages/app')
assert.equal(packageManagerLockfileDirectory(member, manifest), path.join(root, 'locks'))
assert.equal(packageManagerLockfileDirectory(manifest, manifest), path.join(root, 'locks'))

// It wins over a lockfile per project, as in pnpm.
fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), "packages: ['packages/*']\nsharedWorkspaceLockfile: false\nlockfileDir: ../locks\n")
assert.equal(packageManagerLockfileDirectory(member, manifest), path.join(path.dirname(root), 'locks'))
})

test('a project without pnpm-workspace.yaml reads its own lockfile', () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'setup-project-')))
fixtures.push(root)
const manifest = project(root, '.')
assert.equal(packageManagerLockfileDirectory(manifest, manifest), root)
})

test('a lockfileDir delegated to an included file is measured from that file', () => {
const { root, manifest } = workspace("packages: ['packages/*']\ninclude:\n ./cfg/locks.yaml:\n - lockfileDir\n")
const member = project(root, 'packages/app')
fs.mkdirSync(path.join(root, 'cfg'))
fs.writeFileSync(path.join(root, 'cfg/locks.yaml'), 'lockfileDir: locks\n')
assert.equal(packageManagerLockfileDirectory(member, manifest), path.join(root, 'cfg/locks'))
assert.equal(packageManagerLockfileDirectory(manifest, manifest), path.join(root, 'cfg/locks'))

fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), "packages: ['packages/*']\ninclude:\n ./cfg/locks.yaml: '*'\n")
assert.equal(packageManagerLockfileDirectory(member, manifest), path.join(root, 'cfg/locks'))
})

test('only settings an include delegates are read from the included file', () => {
const { root, manifest } = workspace("packages: ['packages/*']\ninclude:\n ./cfg/policies.yaml:\n - minimumReleaseAge\n")
const member = project(root, 'packages/app')
fs.mkdirSync(path.join(root, 'cfg'))
fs.writeFileSync(path.join(root, 'cfg/policies.yaml'), 'lockfileDir: locks\nsharedWorkspaceLockfile: false\n')
assert.equal(packageManagerLockfileDirectory(member, manifest), root)

fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), "packages: ['packages/*']\ninclude:\n ./cfg/policies.yaml:\n - sharedWorkspaceLockfile\n")
assert.equal(packageManagerLockfileDirectory(member, manifest), path.dirname(member))
})
51 changes: 48 additions & 3 deletions src/install-pnpm/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,56 @@ function inside (parent: string, directory: string): boolean {
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
}

/** The inherited manifest and the dependency lockfile can belong to different directories. */
/**
* The inherited manifest and the dependency lockfile can belong to different
* directories. An explicit `lockfileDir` wins, its relative path measured from
* the file that sets it, as pnpm does. Otherwise a workspace keeps one lockfile
* at its root unless `sharedWorkspaceLockfile` is off.
*/
export function packageManagerLockfileDirectory (manifest: string, selectedManifest: string): string {
const project = path.dirname(manifest)
const root = path.dirname(selectedManifest)
const lockfileDir = readWorkspaceSetting(root, 'lockfileDir')
if (typeof lockfileDir?.value === 'string') return path.resolve(lockfileDir.directory, lockfileDir.value)
if (project === root) return project
const workspace = parse(readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'), { merge: true })
return workspace?.sharedWorkspaceLockfile === false ? project : root
return readWorkspaceSetting(root, 'sharedWorkspaceLockfile')?.value === false ? project : root
}

interface WorkspaceSetting {
readonly value: unknown
/** The directory of the file that sets it, which its relative paths are measured from. */
readonly directory: string
}

/**
* A setting of the workspace at `root`, from pnpm-workspace.yaml or from a local
* file its `include` delegates the setting to. A config dependency can be
* delegated settings too, but it is not installed before pnpm is.
*/
function readWorkspaceSetting (root: string, name: string): WorkspaceSetting | undefined {
const workspace = readYamlMapping(path.join(root, 'pnpm-workspace.yaml'))
if (workspace === undefined) return undefined
if (name in workspace) return { value: workspace[name], directory: root }
for (const file of delegatedFiles(root, workspace.include, name)) {
const settings = readYamlMapping(file)
if (settings !== undefined && name in settings) return { value: settings[name], directory: path.dirname(file) }
}
return undefined
}

/** The local files `include` delegates `name` to, listed by name or with "*". */
function delegatedFiles (root: string, include: unknown, name: string): string[] {
if (include === null || typeof include !== 'object' || Array.isArray(include)) return []
return Object.entries(include)
.filter(([source, delegation]) => /^\.\.?\//.test(source) &&
(delegation === '*' || (Array.isArray(delegation) && delegation.includes(name))))
.map(([source]) => path.resolve(root, source))
}

function readYamlMapping (file: string): Record<string, unknown> | undefined {
if (!existsSync(file)) return undefined
const value: unknown = parse(readFileSync(file, 'utf8'), { merge: true })
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
8 changes: 8 additions & 0 deletions src/install-pnpm/run.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ importers: {}
rmSync(path.join(root, 'packages/app/pnpm-lock.yaml'))
await bundled.exports.runSelfInstaller({ ...inputs, packageJsonFile: 'packages/app/package.json' })
assert.equal(globalThis.selectedSpec, '>=12.0.0 <13.0.0')
// The pin is read from `lockfileDir`, for a member and for the root itself.
writeFileSync(path.join(root, 'pnpm-workspace.yaml'), "packages: ['packages/*']\nlockfileDir: locks\n")
mkdirSync(path.join(root, 'locks'))
writeFileSync(path.join(root, 'locks/pnpm-lock.yaml'), '---\nimporters:\n .:\n packageManagerDependencies:\n pnpm:\n version: 12.6.0\n')
await bundled.exports.runSelfInstaller({ ...inputs, packageJsonFile: 'packages/app/package.json' })
assert.equal(globalThis.selectedSpec, '12.6.0')
await bundled.exports.runSelfInstaller(inputs)
assert.equal(globalThis.selectedSpec, '12.6.0')
writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages: [')
await bundled.exports.runSelfInstaller({ ...inputs, version: '12.8.2', packageJsonFile: 'packages/app/package.json' })
assert.equal(globalThis.selectedSpec, '12.8.2')
Expand Down
26 changes: 25 additions & 1 deletion src/pnpm-commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ writeFileSync(path.join(project, 'root'), `
process.exitCode = 1
}
`)
writeFileSync(path.join(project, 'config'), 'console.log(process.env.PNPM_TEST_SHARED_LOCKFILE || \'true\')')
// Answers `pnpm config get <key>` for the settings the action reads.
writeFileSync(path.join(project, 'config'), `
console.log(process.argv[3] === 'lockfileDir'
? process.env.PNPM_TEST_LOCKFILE_DIR || 'undefined'
: process.env.PNPM_TEST_SHARED_LOCKFILE || 'true')
`)
const shimScript = path.join(dest, 'record.cjs')
writeFileSync(shimScript, recorder)
const quoteShell = value => `'${value.replaceAll("'", "'\\''")}'`
Expand Down Expand Up @@ -93,6 +98,7 @@ beforeEach(testContext => {
PNPM_TEST_EXIT_CODE: '0',
PNPM_TEST_WORKSPACE_ROOT: '',
PNPM_TEST_SHARED_LOCKFILE: 'true',
PNPM_TEST_LOCKFILE_DIR: '',
})
// Simulate the PATH prepared by setup, with self-update's shim first.
const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH'
Expand Down Expand Up @@ -180,6 +186,24 @@ test('a workspace with per-project lockfiles installs using the member lockfile'
assert.deepEqual(JSON.parse(readFileSync(record, 'utf8')).args, ['--frozen-lockfile'])
})

test('a workspace with lockfileDir requires, installs from and caches the lockfile there', testContext => {
process.env.PNPM_TEST_WORKSPACE_ROOT = root
process.env.PNPM_TEST_LOCKFILE_DIR = 'locks'
const locks = path.join(root, 'locks')
mkdirSync(locks, { recursive: true })
testContext.after(() => rmSync(locks, { recursive: true, force: true }))

// The root lockfile is not the one pnpm reads.
runPnpmInstall(getInputs())
assert.equal(process.exitCode, 1)
assert.throws(() => readFileSync(record), { code: 'ENOENT' })

writeFileSync(path.join(locks, 'pnpm-lock.yaml'), '')
runPnpmInstall(getInputs())
assert.deepEqual(JSON.parse(readFileSync(record, 'utf8')).args, ['--frozen-lockfile'])
assert.equal(resolveCacheDependencyPath(getInputs()), path.join('locks', 'pnpm-lock.yaml'))
})

test('pruning awaits the self-updated shim ahead of the original executable on PATH', async () => {
await pruneStore(getInputs())
assert.deepEqual(JSON.parse(readFileSync(record, 'utf8')).args, ['store', 'prune'])
Expand Down
18 changes: 18 additions & 0 deletions src/pnpm-install/lockfile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, test } from 'node:test'

import {
chooseLockfileDir,
configuredLockfileDir,
findWorkspaceRoot,
keepsLockfilePerProject,
lockfileDir,
Expand Down Expand Up @@ -84,3 +85,20 @@ test('the lockfile directory follows sharedWorkspaceLockfile', () => {
path.join(perProject, 'packages/pkg-1')
)
})

test('an explicit lockfileDir wins and is resolved against the workspace root', () => {
const root = path.resolve('/workspace')
const member = path.join(root, 'packages/app')
assert.equal(chooseLockfileDir(member, root, false, 'locks'), path.join(root, 'locks'))
assert.equal(chooseLockfileDir(member, root, true, 'locks'), path.join(root, 'locks'))
assert.equal(chooseLockfileDir(member, root, false, path.resolve('/elsewhere')), path.resolve('/elsewhere'))
})

test('the lockfile directory follows lockfileDir as pnpm reports it', () => {
const workspace = prepareWorkspace({ settings: 'lockfileDir: locks\n' })
const member = path.join(workspace, 'packages/pkg-1')

assert.equal(configuredLockfileDir(member, pnpmBin), 'locks')
assert.equal(lockfileDir(member, pnpmBin), path.join(workspace, 'locks'))
assert.equal(configuredLockfileDir(path.join(prepareWorkspace(), 'packages/pkg-1'), pnpmBin), undefined)
})
27 changes: 20 additions & 7 deletions src/pnpm-install/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,37 @@ export function keepsLockfilePerProject (from: string, pnpmBin: string): boolean
return askPnpm(from, pnpmBin, ['config', 'get', 'sharedWorkspaceLockfile']) === 'false'
}

/**
* The `lockfileDir` setting as pnpm reports it: as written, so a relative one is
* still relative to the workspace root, and `undefined` when it is not set.
*/
export function configuredLockfileDir (from: string, pnpmBin: string): string | undefined {
const value = askPnpm(from, pnpmBin, ['config', 'get', 'lockfileDir'])
return value && value !== 'undefined' ? value : undefined
}

/**
* The directory an install in `from` reads `pnpm-lock.yaml` from.
*
* A workspace keeps one lockfile at its root, so a project it contains installs
* from there rather than from beside itself — which is why a lockfile sitting
* in the project directory is not evidence that the install has one.
* An explicit `lockfileDir` wins. Otherwise a workspace keeps one lockfile at
* its root, so a project it contains installs from there rather than from
* beside itself — which is why a lockfile sitting in the project directory is
* not evidence that the install has one.
*/
export function chooseLockfileDir (
from: string,
workspaceRoot: string | undefined,
perProjectLockfile: boolean
perProjectLockfile: boolean,
configured?: string
): string {
if (configured) return path.resolve(workspaceRoot ?? from, configured)
return workspaceRoot && !perProjectLockfile ? workspaceRoot : from
}

/** [`chooseLockfileDir`] with both answers asked of the installed pnpm. */
/** [`chooseLockfileDir`] with every answer asked of the installed pnpm. */
export function lockfileDir (from: string, pnpmBin: string): string {
const workspaceRoot = findWorkspaceRoot(from, pnpmBin)
const perProjectLockfile = workspaceRoot !== undefined && keepsLockfilePerProject(from, pnpmBin)
return chooseLockfileDir(from, workspaceRoot, perProjectLockfile)
const configured = configuredLockfileDir(from, pnpmBin)
const perProjectLockfile = !configured && workspaceRoot !== undefined && keepsLockfilePerProject(from, pnpmBin)
return chooseLockfileDir(from, workspaceRoot, perProjectLockfile, configured)
}
Loading