diff --git a/README.md b/README.md index 1251802..14a96e3 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ prereleases while propagating updated versions into other files): token: ${{ secrets.UPDATE_TOKEN }} ``` +The refresh also discards the current installation lockfile for the loaded +linker and explicitly configured installation directories. Other files in +those directories are preserved. + ## Development The action's logic lives in `scripts/` so it can be tested outside of a live @@ -110,9 +114,11 @@ Run the checks with [shellcheck](https://www.shellcheck.net) and ```sh shellcheck scripts/*.sh bats test/ +PNPM_TEST_BINARY=/path/to/pnpm node --test test/refresh-lockfile.test.mjs ``` -CI runs both on every push and pull request. +CI runs shellcheck and bats on every push and pull request. The lockfile +refresh tests require a pnpm build that supports the loaded linker. ## Inputs diff --git a/action.yml b/action.yml index 906e570..970de06 100644 --- a/action.yml +++ b/action.yml @@ -31,7 +31,8 @@ inputs: default: 'latest' refresh-lockfile: description: >- - Delete pnpm-lock.yaml and node_modules before updating, so the whole + Delete pnpm-lock.yaml, node_modules, and the current installation lockfile + before updating, so the whole dependency graph — including transitive dependencies of unchanged packages — is freshly resolved instead of reused from the existing lockfile. Set to "false" to keep existing resolutions where possible. diff --git a/scripts/refresh-lockfile.mjs b/scripts/refresh-lockfile.mjs new file mode 100644 index 0000000..64db39e --- /dev/null +++ b/scripts/refresh-lockfile.mjs @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process' +import { rmSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' + +const config = JSON.parse(execFileSync('pnpm', ['config', 'list', '--json'], { encoding: 'utf8' })) +const setting = (camel, kebab) => config[camel] ?? config[kebab] +const linker = setting('nodeLinker', 'node-linker') +const loaded = (typeof linker === 'object' ? linker?.type : linker) === 'loaded' +const modules = resolvePath(setting('modulesDir', 'modules-dir') ?? (loaded ? '.pnpm' : 'node_modules')) +const virtualStore = setting('virtualStoreDir', 'virtual-store-dir') +const globalStore = loaded || setting('enableGlobalVirtualStore', 'enable-global-virtual-store') === true +const state = virtualStore && !globalStore ? resolvePath(virtualStore) : path.join(modules, '.pnpm') + +rmSync(path.join(state, 'lock.yaml'), { force: true }) +rmSync('pnpm-lock.yaml', { force: true }) +rmSync('node_modules', { recursive: true, force: true }) + +function resolvePath(value) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error('The installation directory must be a nonempty path') + } + return path.resolve(value.startsWith('~/') ? path.join(homedir(), value.slice(2)) : value) +} diff --git a/scripts/update.sh b/scripts/update.sh index c940ff7..162a5d9 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -54,9 +54,7 @@ if [ "$NODE" != "false" ]; then fi if [ "$REFRESH_LOCKFILE" = "true" ]; then - # Remove node_modules too so pnpm cannot reuse the hidden lockfile in - # node_modules/.pnpm as the missing wanted lockfile and skip resolution. - rm -rf node_modules pnpm-lock.yaml + node "$(dirname -- "${BASH_SOURCE[0]}")/refresh-lockfile.mjs" fi if [ "$UPDATE_DEPS" = "false" ]; then diff --git a/test/refresh-lockfile.test.mjs b/test/refresh-lockfile.test.mjs new file mode 100644 index 0000000..baeb762 --- /dev/null +++ b/test/refresh-lockfile.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { execFile, execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import http from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const execute = promisify(execFile) +const script = fileURLToPath(new URL('../scripts/update.sh', import.meta.url)) +const binary = process.env.PNPM_TEST_BINARY ?? 'pnpm' + +for (const linker of ['isolated', 'loaded']) { + test(`refresh-lockfile resolves newer matching versions with ${linker}`, async () => { + const root = await mkdtemp(path.join(tmpdir(), 'update-refresh-')) + let registry + try { + const workspace = path.join(root, 'workspace') + await mkdir(workspace) + const artifacts = {} + for (const version of ['1.0.0', '1.1.0']) { + const packageRoot = path.join(root, version, 'package') + await mkdir(packageRoot, { recursive: true }) + await writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: 'refresh-fixture', version })) + const archive = path.join(root, `${version}.tgz`) + execFileSync('tar', ['-czf', archive, '-C', path.dirname(packageRoot), 'package']) + artifacts[version] = await readFile(archive) + } + let published = ['1.0.0'] + registry = http.createServer((request, response) => { + const version = request.url.match(/^\/(1\.[01]\.0)\.tgz$/)?.[1] + if (version) { + response.end(artifacts[version]) + return + } + if (request.url !== '/refresh-fixture') { + response.writeHead(404).end() + return + } + const origin = `http://127.0.0.1:${registry.address().port}` + response.setHeader('Content-Type', 'application/json') + response.setHeader('Cache-Control', 'no-store') + response.end(JSON.stringify({ + name: 'refresh-fixture', + 'dist-tags': { latest: published.at(-1) }, + time: Object.fromEntries(published.map(version => [version, '2020-01-01T00:00:00.000Z'])), + versions: Object.fromEntries(published.map(version => [version, { + name: 'refresh-fixture', version, + dist: { tarball: `${origin}/${version}.tgz`, integrity: `sha512-${createHash('sha512').update(artifacts[version]).digest('base64')}` }, + }])), + })) + }) + await new Promise(resolve => registry.listen(0, '127.0.0.1', resolve)) + await writeFile(path.join(workspace, 'package.json'), JSON.stringify({ name: 'workspace', dependencies: { 'refresh-fixture': '^1.0.0' } })) + await writeFile(path.join(workspace, '.npmrc'), `registry=http://127.0.0.1:${registry.address().port}\n`) + const cache = path.join(root, 'cache') + await writeFile(path.join(workspace, 'pnpm-workspace.yaml'), [ + `nodeLinker: ${linker === 'loaded' ? '{ type: loaded }' : 'isolated'}`, + `storeDir: ${path.join(root, 'store')}`, `cacheDir: ${cache}`, + 'minimumReleaseAge: 0', 'ignoreScripts: true', + ].join('\n') + '\n') + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(npm_config_|pnpm_config_)/i.test(key))) + const bin = path.join(root, 'bin') + await mkdir(bin) + const resolvedBinary = binary === 'pnpm' ? execFileSync('which', ['pnpm'], { encoding: 'utf8' }).trim() : path.resolve(binary) + await writeFile(path.join(bin, 'pnpm'), `#!/usr/bin/env bash\nexec '${resolvedBinary.replaceAll("'", "'\\''")}' "$@"\n`, { mode: 0o755 }) + env.PATH = `${bin}${path.delimiter}${env.PATH}` + const options = { cwd: workspace, env, timeout: 60000 } + await execute('pnpm', ['install'], options) + const oldLockfile = await readFile(path.join(workspace, 'pnpm-lock.yaml'), 'utf8') + assert.match(oldLockfile, /refresh-fixture@1\.0\.0/) + published = ['1.0.0', '1.1.0'] + await rm(cache, { recursive: true, force: true }) + await execute('bash', [script], { ...options, env: { ...env, + UPDATE_PNPM: 'false', NODE: 'false', UPDATE_DEPS: 'false', REFRESH_LOCKFILE: 'true', + INCLUDE_GITHUB_ACTIONS: 'false', CHANGESETS: 'false', EXCLUDE: '', + } }) + const newLockfile = await readFile(path.join(workspace, 'pnpm-lock.yaml'), 'utf8') + assert.match(newLockfile, /refresh-fixture@1\.1\.0/) + assert.doesNotMatch(newLockfile, /refresh-fixture@1\.0\.0/) + } finally { + if (registry) await new Promise(resolve => registry.close(resolve)) + await rm(root, { recursive: true, force: true }) + } + }) +} diff --git a/test/stubs/pnpm b/test/stubs/pnpm index 5e62e26..82b90ee 100755 --- a/test/stubs/pnpm +++ b/test/stubs/pnpm @@ -5,6 +5,15 @@ # makes. Behavior is tuned via env vars: # STUB_SUPPORTS_CHANGESET "1" (default) => `update --help` lists --changeset # STUB_PNPM_VERSION version printed by `pnpm --version` (default 11.5.0) +if [ "$1" = "config" ] && [ "$2" = "list" ]; then + if [ -n "${STUB_CONFIG:-}" ]; then + printf '%s\n' "$STUB_CONFIG" + else + echo '{}' + fi + exit 0 +fi + prefix='' if [ -n "${PNPM_CONFIG_MINIMUM_RELEASE_AGE:-}" ]; then prefix="PNPM_CONFIG_MINIMUM_RELEASE_AGE=$PNPM_CONFIG_MINIMUM_RELEASE_AGE " diff --git a/test/update.bats b/test/update.bats index 62d91cc..a60c8d5 100644 --- a/test/update.bats +++ b/test/update.bats @@ -162,3 +162,38 @@ teardown() { grep -Fqx 'self-update next-12' "$PNPM_LOG" ! grep -Fq 'PNPM_CONFIG_MINIMUM_RELEASE_AGE' "$PNPM_LOG" } + +@test "loaded refresh removes installation state and preserves unrelated .pnpm files" { + export REFRESH_LOCKFILE=true UPDATE_DEPS=false STUB_CONFIG='{"nodeLinker":{"type":"loaded"}}' + mkdir -p .pnpm/.pnpm + touch .pnpm/.pnpm/lock.yaml .pnpm/keep + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -e .pnpm/.pnpm/lock.yaml ] + [ -e .pnpm/keep ] + grep -Fqx 'install' "$PNPM_LOG" +} + +@test "refresh respects explicitly configured installation directories" { + export REFRESH_LOCKFILE=true UPDATE_DEPS=false + export STUB_CONFIG='{"nodeLinker":{"type":"loaded"},"modulesDir":"custom modules","virtualStoreDir":"unrelated-store"}' + mkdir -p 'custom modules/.pnpm' .pnpm/.pnpm unrelated-store + touch 'custom modules/.pnpm/lock.yaml' .pnpm/.pnpm/lock.yaml unrelated-store/lock.yaml + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -e 'custom modules/.pnpm/lock.yaml' ] + [ -e .pnpm/.pnpm/lock.yaml ] + [ -e unrelated-store/lock.yaml ] +} + +@test "isolated refresh removes custom virtual store current lockfile only" { + export REFRESH_LOCKFILE=true UPDATE_DEPS=false + export STUB_CONFIG='{"virtualStoreDir":"custom store"}' + mkdir -p 'custom store' .pnpm + touch 'custom store/lock.yaml' 'custom store/keep' .pnpm/keep + run bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -e 'custom store/lock.yaml' ] + [ -e 'custom store/keep' ] + [ -e .pnpm/keep ] +}