feat: auto-fetch node versions in module extension (#3919)
More aggressive alternative to #3916: We even do the fetching if facts
are not available.
## PR Checklist
Please check if your PR fulfills the following requirements:
- [x] Tests for the changes have been added (for bug fixes / features)
- [x] Docs have been added / updated (for bug fixes / features)
## PR Type
What kind of change does this PR introduce?
<!-- Please check the one that applies to this PR using "x". -->
- [ ] Bugfix
- [x] Feature (please, look at the "Scope of the project" section in the
README.md file)
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Documentation content changes
- [ ] Other... Please describe:
## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or
link to a relevant issue. -->
Node versions / digests are hardcoded and shipped with rules_nodejs.
## What is the new behavior?
rules_nodejs retrieves required Node digests as needed and stores them
in facts or in generatedRepoSpecs in the bzlmod lockfile.
## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No
<!-- If this PR contains a breaking change, please describe the impact
and migration path for existing applications below. -->
## Other information
- #3916 (fact only alternative)
- [Example lockfile with bazel
8.1.0](https://gist.github.com/gzm0/777a23ce5201d2c70a3b18bde4b83d4a)
- [Example lockfile with bazel
9.1.1](https://gist.github.com/gzm0/eeb1e54658e78259a33f763376a79004)
diff --git a/e2e/nodejs_host/BUILD.bazel b/e2e/nodejs_host/BUILD.bazel
index 1871163..9a54ae2 100644
--- a/e2e/nodejs_host/BUILD.bazel
+++ b/e2e/nodejs_host/BUILD.bazel
@@ -23,6 +23,7 @@
"node16",
"node16_nvmrc",
"node17_custom",
+ "node26_auto_fetch",
]
]
@@ -66,5 +67,7 @@
("node16_nvmrc", "npx"),
("node17_custom", "npm"),
("node17_custom", "npx"),
+ ("node26_auto_fetch", "npm"),
+ ("node26_auto_fetch", "npx"),
]
]
diff --git a/e2e/nodejs_host/MODULE.bazel b/e2e/nodejs_host/MODULE.bazel
index 8c2b4ff..ff12b2f 100644
--- a/e2e/nodejs_host/MODULE.bazel
+++ b/e2e/nodejs_host/MODULE.bazel
@@ -35,6 +35,10 @@
node_urls = ["https://nodejs.org/dist/v17.0.1/{filename}"],
node_version = "17.0.1.custom",
)
+node.toolchain(
+ name = "node26_auto_fetch",
+ node_version = "26.3.0",
+)
# FIXME(6.0): a repo rule with name=foo should create a repo named @foo, not @foo_toolchains
use_repo(
@@ -60,6 +64,13 @@
"node17_custom_linux_arm64",
"node17_custom_toolchains",
"node17_custom_windows_amd64",
+ "node26_auto_fetch",
+ "node26_auto_fetch_darwin_amd64",
+ "node26_auto_fetch_darwin_arm64",
+ "node26_auto_fetch_linux_amd64",
+ "node26_auto_fetch_linux_arm64",
+ "node26_auto_fetch_toolchains",
+ "node26_auto_fetch_windows_amd64",
"nodejs",
"nodejs_darwin_amd64",
"nodejs_darwin_arm64",
diff --git a/nodejs/BUILD.bazel b/nodejs/BUILD.bazel
index 622b1f7..202be5a 100644
--- a/nodejs/BUILD.bazel
+++ b/nodejs/BUILD.bazel
@@ -9,7 +9,13 @@
bzl_library(
name = "extensions",
srcs = ["extensions.bzl"],
- deps = [":repositories"],
+ deps = [
+ ":repositories",
+ "//nodejs/private:fetch_node_repositories",
+ "//nodejs/private:node_versions",
+ "//nodejs/private:version_from_attr",
+ "@bazel_features//:features",
+ ],
)
bzl_library(
@@ -29,6 +35,7 @@
"//nodejs/private:nodejs_repo_host_os_alias",
"//nodejs/private:nodejs_toolchains_repo",
"//nodejs/private:os_name",
+ "//nodejs/private:version_from_attr",
],
)
diff --git a/nodejs/extensions.bzl b/nodejs/extensions.bzl
index 3b4b53f..720b056 100644
--- a/nodejs/extensions.bzl
+++ b/nodejs/extensions.bzl
@@ -9,6 +9,10 @@
```
"""
+load("@bazel_features//:features.bzl", "bazel_features")
+load("//nodejs/private:fetch_node_repositories.bzl", "fetch_node_repositories")
+load("//nodejs/private:node_versions.bzl", "NODE_VERSIONS")
+load("//nodejs/private:version_from_attr.bzl", "version_from_attr")
load(
":repositories.bzl",
"DEFAULT_NODE_REPOSITORY",
@@ -52,17 +56,50 @@
else:
registrations[toolchain.name] = toolchain
+ supports_facts = hasattr(module_ctx, "facts")
+ new_repository_facts = {}
+
for k, v in registrations.items():
+ node_version = version_from_attr(module_ctx, v)
+ node_repositories = v.node_repositories or NODE_VERSIONS.get(node_version, {})
+
+ if not node_repositories:
+ node_repositories = (
+ new_repository_facts.get(node_version) or
+ (module_ctx.facts.get(node_version) if supports_facts else None) or
+ # TODO: Add support for node_urls?
+ fetch_node_repositories(module_ctx, node_version)
+ )
+
+ new_repository_facts[node_version] = node_repositories
+
nodejs_register_toolchains(
name = k,
- node_version = v.node_version,
- node_version_from_nvmrc = v.node_version_from_nvmrc,
+ node_version = node_version,
node_urls = v.node_urls,
- node_repositories = v.node_repositories,
+ node_repositories = node_repositories,
include_headers = v.include_headers,
register = False,
)
+ if not hasattr(module_ctx, "extension_metadata"):
+ return # buildifier: disable=return-value (allow no value)
+
+ if not bazel_features.external_deps.extension_metadata_has_reproducible:
+ return module_ctx.extension_metadata()
+
+ if not supports_facts:
+ return module_ctx.extension_metadata(
+ # if we have new_repository_facts is non-empty, we called
+ # fetch_node_repositories making this invocation non-reproducible.
+ reproducible = not new_repository_facts,
+ )
+
+ return module_ctx.extension_metadata(
+ reproducible = True,
+ facts = new_repository_facts,
+ )
+
_ATTRS = {
"name": attr.string(
doc = "Base name for generated repositories",
diff --git a/nodejs/private/BUILD.bazel b/nodejs/private/BUILD.bazel
index 7388dc4..9c92ac7 100644
--- a/nodejs/private/BUILD.bazel
+++ b/nodejs/private/BUILD.bazel
@@ -41,3 +41,13 @@
srcs = ["user_build_settings.bzl"],
deps = ["//nodejs/private/providers:user_build_settings"],
)
+
+bzl_library(
+ name = "version_from_attr",
+ srcs = ["version_from_attr.bzl"],
+)
+
+bzl_library(
+ name = "fetch_node_repositories",
+ srcs = ["fetch_node_repositories.bzl"],
+)
diff --git a/nodejs/private/fetch_node_repositories.bzl b/nodejs/private/fetch_node_repositories.bzl
new file mode 100644
index 0000000..ab0627a
--- /dev/null
+++ b/nodejs/private/fetch_node_repositories.bzl
@@ -0,0 +1,58 @@
+"""Implementation of node SHASUM fetching for facts."""
+
+_REPOSITORY_TYPES = {
+ "darwin-arm64.tar.gz": "darwin_arm64",
+ "darwin-x64.tar.gz": "darwin_amd64",
+ "linux-x64.tar.xz": "linux_amd64",
+ "linux-arm64.tar.xz": "linux_arm64",
+ "linux-s390x.tar.xz": "linux_s390x",
+ "win-x64.zip": "windows_amd64",
+ "win-arm64.zip": "windows_arm64",
+ "linux-ppc64le.tar.xz": "linux_ppc64le",
+}
+
+def fetch_node_repositories(module_ctx, version):
+ """Fetches node repositories for the given node version.
+
+ Port of scripts/update-nodejs-versions.js
+
+ Args:
+ module_ctx: Module context
+ version: The node version to fetch repositories for.
+
+ Returns:
+ A dictionary in the shape of node_repositories.
+ """
+
+ shasums_filename = "{version}-SHASUMS256.txt".format(version = version)
+ url = "https://nodejs.org/dist/v{version}/SHASUMS256.txt".format(version = version)
+
+ result = module_ctx.download(url = url, output = shasums_filename)
+ if not result.success:
+ fail("Failed to fetch node shasums:", url, result, sep = "\n")
+
+ shasums = module_ctx.read(shasums_filename)
+
+ result = {}
+
+ for line in shasums.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+
+ parts = line.split(" ")
+ if len(parts) != 2:
+ fail("{url} contains unexpected line:\n{line}".format(
+ url = url,
+ line = line,
+ ))
+
+ sha, filename = parts
+ type = _REPOSITORY_TYPES.get(filename.removeprefix("node-v%s-" % version))
+ if not type:
+ continue
+
+ strip_prefix = filename.removesuffix(".tar.gz").removesuffix(".tar.xz").removesuffix(".zip")
+ result[version + "-" + type] = (filename, strip_prefix, sha)
+
+ return result
diff --git a/nodejs/private/version_from_attr.bzl b/nodejs/private/version_from_attr.bzl
new file mode 100644
index 0000000..6d7fa20
--- /dev/null
+++ b/nodejs/private/version_from_attr.bzl
@@ -0,0 +1,28 @@
+"""Helper to get node version from user config."""
+
+def _verify_version_is_valid(version):
+ major, minor, patch = (version.split(".") + [None, None, None])[:3]
+ if not major.isdigit() or not minor.isdigit() or not patch.isdigit():
+ fail("Invalid node version: %s" % version)
+
+def version_from_attr(ctx, attr):
+ """Extract the node version from attr.
+
+ Verifies if the extracted version is valid.
+
+ Args:
+ ctx: repository or module context
+ attr: A struct with fields node_version and node_version_from_nvmrc
+
+ Returns:
+ The node version.
+ """
+
+ node_version = attr.node_version
+
+ if attr.node_version_from_nvmrc:
+ node_version = str(ctx.read(attr.node_version_from_nvmrc)).strip()
+
+ _verify_version_is_valid(node_version)
+
+ return node_version
diff --git a/nodejs/repositories.bzl b/nodejs/repositories.bzl
index 3a278cf..d5f7ab2 100644
--- a/nodejs/repositories.bzl
+++ b/nodejs/repositories.bzl
@@ -3,6 +3,7 @@
load("//nodejs/private:node_versions.bzl", "NODE_VERSIONS")
load("//nodejs/private:nodejs_repo_host_os_alias.bzl", "nodejs_repo_host_os_alias")
load("//nodejs/private:nodejs_toolchains_repo.bzl", "PLATFORMS", "nodejs_toolchains_repo")
+load("//nodejs/private:version_from_attr.bzl", "version_from_attr")
# Default base name for node toolchain repositories
# created by the module extension
@@ -66,12 +67,7 @@
# @nodejs_PLATFORM where PLATFORM is one of BUILT_IN_NODE_PLATFORMS
host_os = repository_ctx.attr.platform or repository_ctx.name.split("nodejs_", 1)[1]
- node_version = repository_ctx.attr.node_version
-
- if repository_ctx.attr.node_version_from_nvmrc:
- node_version = str(repository_ctx.read(repository_ctx.attr.node_version_from_nvmrc)).strip()
-
- _verify_version_is_valid(node_version)
+ node_version = version_from_attr(repository_ctx, repository_ctx.attr)
node_repositories = repository_ctx.attr.node_repositories
@@ -302,11 +298,6 @@
return path[len("bin/"):]
-def _verify_version_is_valid(version):
- major, minor, patch = (version.split(".") + [None, None, None])[:3]
- if not major.isdigit() or not minor.isdigit() or not patch.isdigit():
- fail("Invalid node version: %s" % version)
-
def _nodejs_repositories_impl(repository_ctx):
reproducible = _download_node(repository_ctx)
_prepare_node(repository_ctx)