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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions scripts/refresh-lockfile.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 1 addition & 3 deletions scripts/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions test/refresh-lockfile.test.mjs
Original file line number Diff line number Diff line change
@@ -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 })
}
})
}
9 changes: 9 additions & 0 deletions test/stubs/pnpm
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
35 changes: 35 additions & 0 deletions test/update.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
}
Loading