Skip to content

Commit 0b29d7f

Browse files
committed
feat(npm): use pnpm publish for npm_package
1 parent 5a47903 commit 0b29d7f

6 files changed

Lines changed: 165 additions & 17 deletions

File tree

npm/private/npm_package.bzl

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,19 @@ def npm_package(
140140
include_runfiles = False,
141141
hardlink = "auto",
142142
publishable = False,
143+
pnpm_binary = "@pnpm//:pnpm",
143144
verbose = False,
144145
**kwargs):
145146
"""A macro that packages sources into a directory (a tree artifact) and provides an `NpmPackageInfo`.
146147
147148
This target can be used as the `src` attribute to `npm_link_package`.
148149
149150
With `publishable = True` the macro also produces a target `[name].publish`, that can be run to publish to an npm registry.
150-
Under the hood, this target runs `npm publish`. You can pass arguments to npm by escaping them from Bazel using a double-hyphen,
151-
for example: `bazel run //path/to:my_package.publish -- --tag=next`
151+
Under the hood, this target runs `pnpm publish`. You can pass arguments to pnpm by escaping them from Bazel using a double-hyphen,
152+
for example: `bazel run //path/to:my_package.publish -- --tag=next`.
153+
154+
pnpm publishing uses the configured `pnpm_binary` and can resolve pnpm workspace settings such as `catalog` entries from
155+
`pnpm-workspace.yaml`. Workspace protocol dependencies follow pnpm's own resolution requirements.
152156
153157
Files and directories can be arranged as needed in the output directory using
154158
the `root_paths`, `include_srcs_patterns`, `exclude_srcs_patterns` and `replace_prefixes` attributes.
@@ -210,7 +214,7 @@ def npm_package(
210214
211215
srcs: Files and/or directories or targets that provide `DirectoryPathInfo` to copy into the output directory.
212216
213-
args: Arguments that are passed down to `<name>.publish` target and `npm publish` command.
217+
args: Arguments that are passed down to `<name>.publish` target and the publish command.
214218
215219
data: Runtime / linktime npm dependencies of this npm package.
216220
@@ -409,6 +413,9 @@ def npm_package(
409413
410414
publishable: When True, enable generation of `{name}.publish` target
411415
416+
pnpm_binary: Label of the pnpm binary used for publishing. This is typically `@pnpm//:pnpm`
417+
from the `pnpm` extension in `@aspect_rules_js//npm:extensions.bzl`.
418+
412419
verbose: If true, prints out verbose logs to stdout
413420
414421
**kwargs: Additional attributes such as `tags` and `visibility`
@@ -437,10 +444,11 @@ def npm_package(
437444
name = "{}.publish".format(name),
438445
entry_point = Label("@aspect_rules_js//npm/private:npm_publish_mjs"),
439446
fixed_args = [
447+
"$(rootpath {})".format(pnpm_binary),
440448
"./$(rootpath :{})".format(name),
441449
],
442-
data = [name],
443-
# required to make npm to be available in PATH
450+
data = [name, pnpm_binary],
451+
# pnpm <11 may delegate registry operations to npm. This can be removed when rules_js drops pnpm <=10.
444452
include_npm = True,
445453
args = args,
446454
tags = kwargs.get("tags", []) + ["manual"],

npm/private/npm_publish.mjs

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,90 @@
11
import { spawnSync } from 'node:child_process'
2+
import {
3+
copyFileSync,
4+
existsSync,
5+
mkdtempSync,
6+
readFileSync,
7+
rmSync,
8+
} from 'node:fs'
9+
import { tmpdir } from 'node:os'
10+
import path from 'node:path'
211

3-
const restArgs = process.argv.slice(2)
12+
const [toolPath, packageDir, ...restArgs] = process.argv.slice(2)
413

5-
const spawn = spawnSync('npm', ['publish', ...restArgs], {
14+
if (!toolPath || !packageDir) {
15+
console.error(
16+
'Expected publish tool path and package directory arguments.',
17+
)
18+
process.exit(1)
19+
}
20+
21+
const dependencyFields = [
22+
'dependencies',
23+
'devDependencies',
24+
'optionalDependencies',
25+
'peerDependencies',
26+
]
27+
28+
const packagePath = path.resolve(packageDir)
29+
const packageJsonPath = path.join(packagePath, 'package.json')
30+
const packageManifest = existsSync(packageJsonPath)
31+
? JSON.parse(readFileSync(packageJsonPath, 'utf8'))
32+
: {}
33+
const hasCatalogDependency = dependencyFields.some(field =>
34+
Object.values(packageManifest[field] || {}).some(
35+
version => typeof version === 'string' && version.startsWith('catalog:'),
36+
),
37+
)
38+
39+
let cleanupCwd
40+
let spawnOptions = {
641
stdio: 'inherit',
7-
})
42+
}
43+
44+
if (hasCatalogDependency && process.env.BUILD_WORKSPACE_DIRECTORY) {
45+
cleanupCwd = mkdtempSync(path.join(tmpdir(), 'rules-js-pnpm-publish-'))
46+
47+
// Give pnpm only the workspace-level files it needs to resolve catalogs.
48+
for (const filename of ['pnpm-workspace.yaml', '.npmrc']) {
49+
const source = path.join(process.env.BUILD_WORKSPACE_DIRECTORY, filename)
50+
if (existsSync(source)) {
51+
copyFileSync(source, path.join(cleanupCwd, filename))
52+
}
53+
}
54+
55+
spawnOptions = {
56+
cwd: cleanupCwd,
57+
env: {
58+
...process.env,
59+
// The pnpm binary is itself a js_binary launcher; this keeps it runnable after
60+
// changing cwd away from the Bazel output tree.
61+
BAZEL_BINDIR: process.env.BAZEL_BINDIR || '.',
62+
},
63+
stdio: 'inherit',
64+
}
65+
}
66+
67+
const spawn = spawnSync(
68+
path.resolve(toolPath),
69+
['publish', packagePath, '--no-git-checks', ...restArgs],
70+
spawnOptions,
71+
)
72+
73+
if (cleanupCwd) {
74+
rmSync(cleanupCwd, {
75+
force: true,
76+
recursive: true,
77+
})
78+
}
79+
80+
if (spawn.error) {
81+
console.error(spawn.error.message)
82+
process.exit(1)
83+
}
84+
85+
if (spawn.signal) {
86+
console.error(`pnpm publish exited with signal ${spawn.signal}`)
87+
process.exit(1)
88+
}
889

990
process.exit(spawn.status)

npm/private/test/npm_package_publish/BUILD.bazel

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,28 @@ npm_package(
2020
publishable = True,
2121
)
2222

23+
npm_package(
24+
name = "pkg_pnpm",
25+
srcs = [
26+
"pnpm_catalog/index.js",
27+
"pnpm_catalog/package.json",
28+
],
29+
publishable = True,
30+
root_paths = [package_name() + "/pnpm_catalog"],
31+
)
32+
2333
sh_test(
2434
name = "test",
2535
srcs = ["test.sh"],
2636
args = [
2737
"$(locations :pkg_a.publish)",
2838
"$(locations :pkg_b.publish)",
39+
"$(locations :pkg_pnpm.publish)",
2940
],
3041
data = [
3142
":pkg_a.publish",
3243
":pkg_b.publish",
44+
":pkg_pnpm.publish",
3345
],
3446
tags = [
3547
"no-remote-exec",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const value = 1
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "@mycorp/pkg-to-publish-pnpm",
3+
"version": "1.0.0",
4+
"dependencies": {
5+
"typescript": "catalog:"
6+
}
7+
}

npm/private/test/npm_package_publish/test.sh

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,66 @@
22

33
readonly PUBLISH_A="$1"
44
readonly PUBLISH_B="$2"
5+
readonly PUBLISH_PNPM="$3"
56

6-
# assert that it prints package name from package.json to stderr,
7-
# to ensure package directory is properly passed and npm can read it.
8-
$PUBLISH_A 2>pub_a.log
7+
# Assert that pnpm reads package.json from the package directory.
8+
$PUBLISH_A >pub_a.log 2>&1
99

10-
cat pub_a.log | grep 'npm notice package: @mycorp/pkg-to-publish@'
10+
cat pub_a.log | grep 'ERR_PNPM_PACKAGE_VERSION_NOT_FOUND'
1111

1212
# shellcheck disable=SC2181
1313
if [ $? != 0 ]; then
14-
echo "FAIL: expected 'npm notice package: @mycorp/pkg-to-publish@' error, GOT: $(cat pub_a.log)"
14+
echo "FAIL: expected 'ERR_PNPM_PACKAGE_VERSION_NOT_FOUND' error, GOT: $(cat pub_a.log)"
1515
exit 1
1616
fi
1717

1818
# asserting that npm_package has no package.json in it's srcs and we fail correctly.
19-
# npm publish requires a package.json in the root of the package directory.
20-
$PUBLISH_B 2>pub_b.log
19+
# pnpm publish requires a package.json in the root of the package directory.
20+
$PUBLISH_B >pub_b.log 2>&1
2121

22-
cat pub_b.log | grep 'npm error enoent Could not read package.json:'
22+
cat pub_b.log | grep 'ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND'
2323

2424
# shellcheck disable=SC2181
2525
if [ $? != 0 ]; then
26-
echo "FAIL: expected 'npm error enoent Could not read package.json:' error, GOT: $(cat pub_b.log)"
26+
echo "FAIL: expected 'ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND' error, GOT: $(cat pub_b.log)"
27+
exit 1
28+
fi
29+
30+
readonly TMP_WORKSPACE="$(mktemp -d)"
31+
trap 'rm -rf "${TMP_WORKSPACE}"' EXIT
32+
33+
cat >"${TMP_WORKSPACE}/pnpm-workspace.yaml" <<'EOF'
34+
packages:
35+
- packages/*
36+
catalog:
37+
typescript: 5.9.3
38+
EOF
39+
40+
# Ensure pnpm publish can read workspace-level catalog settings when publishing
41+
# a generated package directory.
42+
BUILD_WORKSPACE_DIRECTORY="${TMP_WORKSPACE}" \
43+
"$PUBLISH_PNPM" --dry-run --json >pub_pnpm.log 2>pub_pnpm.err
44+
45+
# shellcheck disable=SC2181
46+
if [ $? != 0 ]; then
47+
echo "FAIL: expected pnpm publish dry-run to pass, GOT stdout: $(cat pub_pnpm.log), stderr: $(cat pub_pnpm.err)"
48+
exit 1
49+
fi
50+
51+
cat pub_pnpm.log | grep '"name": "@mycorp/pkg-to-publish-pnpm"'
52+
53+
# shellcheck disable=SC2181
54+
if [ $? != 0 ]; then
55+
echo "FAIL: expected pnpm publish dry-run output to include package name, GOT: $(cat pub_pnpm.log)"
56+
exit 1
57+
fi
58+
59+
# The source package.json is 132 bytes. pnpm rewrites catalog: to the concrete
60+
# version before packing, producing the smaller manifest below.
61+
cat pub_pnpm.log | grep '"size": 116'
62+
63+
# shellcheck disable=SC2181
64+
if [ $? != 0 ]; then
65+
echo "FAIL: expected pnpm publish to resolve catalog: in package.json, GOT: $(cat pub_pnpm.log)"
2766
exit 1
2867
fi

0 commit comments

Comments
 (0)