npm.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. # Copyright Materialize, Inc. and contributors. All rights reserved.
  2. #
  3. # Use of this software is governed by the Business Source License
  4. # included in the LICENSE file at the root of this repository.
  5. #
  6. # As of the Change Date specified in that file, in accordance with
  7. # the Business Source License, use of this software will be governed
  8. # by the Apache License, Version 2.0.
  9. import argparse
  10. import json
  11. import logging
  12. import os
  13. import shutil
  14. import urllib.parse
  15. from dataclasses import dataclass
  16. from pathlib import Path
  17. from typing import cast
  18. import requests
  19. from semver.version import VersionInfo
  20. from materialize import MZ_ROOT, cargo, spawn
  21. logging.basicConfig(
  22. format="%(asctime)s [%(levelname)s] %(message)s",
  23. level=os.environ.get("MZ_DEV_LOG", "INFO").upper(),
  24. )
  25. logger = logging.getLogger(__name__)
  26. PUBLISH_CRATES = ["mz-sql-lexer-wasm", "mz-sql-pretty-wasm"]
  27. @dataclass(frozen=True)
  28. class NpmPackageVersion:
  29. rust: VersionInfo
  30. node: str
  31. is_development: bool
  32. def generate_version(
  33. crate_version: VersionInfo, build_identifier: int | None
  34. ) -> NpmPackageVersion:
  35. node_version = str(crate_version)
  36. is_development = False
  37. if crate_version.prerelease:
  38. if build_identifier is None:
  39. raise ValueError(
  40. "a build identifier must be provided for prerelease builds"
  41. )
  42. node_version = str(
  43. crate_version.replace(
  44. prerelease=f"{crate_version.prerelease}.{build_identifier}"
  45. )
  46. )
  47. is_development = True
  48. else:
  49. buildkite_tag = os.environ.get("BUILDKITE_TAG")
  50. # For self-managed branch the buildkite tag is not set, but we are still not in a prerelease version
  51. if buildkite_tag:
  52. # buildkite_tag starts with a 'v' and node_version does not.
  53. assert (
  54. buildkite_tag == f"v{node_version}"
  55. ), f"Buildkite tag ({buildkite_tag}) does not match environmentd version ({crate_version})"
  56. return NpmPackageVersion(
  57. rust=crate_version, node=node_version, is_development=is_development
  58. )
  59. def build_package(version: NpmPackageVersion, crate_path: Path) -> Path:
  60. spawn.runv(["bin/wasm-build", str(crate_path)])
  61. package_path = crate_path / "pkg"
  62. shutil.copyfile(str(MZ_ROOT / "LICENSE"), str(package_path / "LICENSE"))
  63. with open(package_path / "package.json", "r+") as package_file:
  64. package = json.load(package_file)
  65. # Since all packages are scoped to the MaterializeInc org, names don't need prefixes
  66. package["name"] = package["name"].replace("/mz-", "/")
  67. # Remove any -wasm suffixes.
  68. package["name"] = package["name"].removesuffix("-wasm")
  69. package["version"] = version.node
  70. package["license"] = "SEE LICENSE IN 'LICENSE'"
  71. package["repository"] = "github:MaterializeInc/materialize"
  72. package_file.seek(0)
  73. json.dump(package, package_file, indent=2)
  74. return package_path
  75. def release_package(version: NpmPackageVersion, package_path: Path) -> None:
  76. with open(package_path / "package.json") as package_file:
  77. package = json.load(package_file)
  78. name = package["name"]
  79. if version_exists_in_npm(name, version):
  80. logger.warning("%s %s already released, skipping.", name, version.node)
  81. return
  82. else:
  83. dist_tag: str | None = "dev" if version.is_development else "latest"
  84. branch_tag: str | None = None
  85. if dist_tag == "latest":
  86. branch_tag = f"latest-{version.rust.major}.{version.rust.minor}"
  87. latest_published = get_latest_version(name)
  88. if latest_published and latest_published > version.node:
  89. logger.info(
  90. "Latest version of %s on npm (%s) is newer than %s. Skipping tag.",
  91. name,
  92. latest_published,
  93. version.node,
  94. )
  95. dist_tag = None
  96. logger.info("Releasing %s %s", name, version.node)
  97. set_npm_credentials(package_path)
  98. # If we do not specify a dist tag, this automatically is tagged as
  99. # `latest`. So, force a dist tag for release builds that are lower than
  100. # the stable version. This usually happens when we cut a hotfix release
  101. # for the in-production version after a release has been cut for the
  102. # next version.
  103. spawn.runv(
  104. [
  105. "npm",
  106. "publish",
  107. "--access",
  108. "public",
  109. "--tag",
  110. cast(str, dist_tag or branch_tag),
  111. ],
  112. cwd=package_path,
  113. )
  114. # If we didn't tag the release with the branch tag, add it now.
  115. if dist_tag == "latest" and branch_tag:
  116. spawn.runv(
  117. ["npm", "dist-tag", "add", f"{name}@{version.node}", branch_tag],
  118. cwd=package_path,
  119. )
  120. def build_all(
  121. workspace: cargo.Workspace, version: NpmPackageVersion, *, do_release: bool = True
  122. ) -> None:
  123. for crate_name in PUBLISH_CRATES:
  124. crate_path = workspace.all_crates[crate_name].path
  125. logger.info("Building %s @ %s", crate_path, version.node)
  126. package_path = build_package(version, crate_path)
  127. logger.info("Built %s", crate_path)
  128. if do_release:
  129. release_package(version, package_path)
  130. logger.info("Released %s", package_path)
  131. else:
  132. logger.info("Skipping release for %s", package_path)
  133. def _query_npm_version(name: str, version: str) -> requests.Response:
  134. """Queries NPM for a specific version of the package."""
  135. quoted = urllib.parse.quote(name)
  136. return requests.get(f"https://registry.npmjs.org/{quoted}/{version}")
  137. def get_latest_version(name: str) -> VersionInfo | None:
  138. res = _query_npm_version(name, "latest")
  139. if res.status_code == 404:
  140. # This is a new package
  141. return None
  142. res.raise_for_status()
  143. data = res.json()
  144. version = data["version"]
  145. return VersionInfo.parse(version)
  146. def version_exists_in_npm(name: str, version: NpmPackageVersion) -> bool:
  147. res = _query_npm_version(name, version.node)
  148. if res.status_code == 404:
  149. # This is a new package
  150. return False
  151. res.raise_for_status()
  152. return True
  153. def set_npm_credentials(package_path: Path) -> None:
  154. (package_path / ".npmrc").write_text(
  155. "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n"
  156. )
  157. def parse_args() -> argparse.Namespace:
  158. parser = argparse.ArgumentParser(
  159. "npm.py", description="build and publish NPM packages"
  160. )
  161. parser.add_argument(
  162. "-v,--verbose",
  163. action="store_true",
  164. dest="verbose",
  165. help="Enable verbose logging",
  166. )
  167. parser.add_argument(
  168. "--release",
  169. action=argparse.BooleanOptionalAction,
  170. dest="do_release",
  171. default=True,
  172. help="Whether or not the built package should be released",
  173. )
  174. parser.add_argument(
  175. "--build-id",
  176. type=int,
  177. help="An optional build identifier. Used in pre-release version numbers",
  178. )
  179. return parser.parse_args()
  180. if __name__ == "__main__":
  181. args = parse_args()
  182. if args.verbose:
  183. logger.setLevel(logging.DEBUG)
  184. build_id = args.build_id
  185. if os.environ.get("BUILDKITE_BUILD_NUMBER") is not None:
  186. if build_id is not None:
  187. logger.warning(
  188. "Build ID specified via both envvar and CLI arg. Using CLI value"
  189. )
  190. else:
  191. build_id = int(os.environ["BUILDKITE_BUILD_NUMBER"])
  192. if args.do_release and "NPM_TOKEN" not in os.environ:
  193. raise ValueError("'NPM_TOKEN' must be set")
  194. root_workspace = cargo.Workspace(MZ_ROOT)
  195. wasm_workspace = cargo.Workspace(MZ_ROOT / "misc" / "wasm")
  196. crate_version = VersionInfo.parse(
  197. root_workspace.crates["mz-environmentd"].version_string
  198. )
  199. version = generate_version(crate_version, build_id)
  200. build_all(wasm_workspace, version, do_release=args.do_release)