| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | """Workspace status script used for build stamping.""" |
| 3 | |
| 4 | # Treat this script as shell code, but with Python syntax. We want to remain as |
| 5 | # simple as possible, and absolutely never use any non-standard Python library. |
| 6 | # This script should be able to run on any 'modern' Linux distribution with |
| 7 | # Python 3.8 or newer. |
| 8 | |
| 9 | # The following versioning concepts apply: |
| 10 | # 1. Version numbers follow the Semantic Versioning 2.0 spec. |
| 11 | # 2. Git tags in the form `<product>-vX.Y.Z` will be used as a basis for |
| 12 | # versioning a build. If the currently built release is exactly the same as |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 13 | # such a tag, it will be versioned at vX.Y.Z. Otherwise, a devNNN prerelease |
| 14 | # identifier will be appended to signify the amount of commits since the |
| 15 | # release. |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 16 | # 3. Product git tags are only made up of a major/minor/patch version. |
| 17 | # Prerelease and build tags are assigned by the build system and this |
| 18 | # script, Git tags have no influence on them. |
| 19 | # 4. 'Products' are release numbering trains within the Monogon monorepo. This |
| 20 | # means there is no such thing as a 'version' for the monorepo by itself, |
| 21 | # only within the context of some product. |
| 22 | |
| 23 | from dataclasses import dataclass |
| 24 | from datetime import datetime, timezone |
| 25 | import os |
| 26 | import re |
| 27 | import subprocess |
| 28 | import time |
| 29 | |
| 30 | from typing import Optional |
| 31 | |
| 32 | |
| 33 | # Variables to output. These will be printed to stdout at the end of the script |
| 34 | # runtime, sorted by key. |
| 35 | variables: dict[str, str] = {} |
| 36 | |
| 37 | # Git build tree status: clean or dirty. |
| 38 | git_tree_state: str = "clean" |
| 39 | if subprocess.call(["git", "status", "--porcelain"], stdout=subprocess.PIPE) == 0: |
| 40 | git_tree_state = "dirty" |
| 41 | |
| 42 | # Git commit hash. |
| 43 | git_commit: str = ( |
| 44 | subprocess.check_output(["git", "rev-parse", "HEAD^{commit}"]).decode().strip() |
| 45 | ) |
| 46 | |
| 47 | # Git tags pointing at this commit. |
| 48 | git_tags_b: [bytes] = subprocess.check_output( |
| 49 | ["git", "tag", "--points-at", "HEAD"] |
| 50 | ).split(b"\n") |
| 51 | git_tags: [str] = [t.decode().strip() for t in git_tags_b if t.decode().strip() != ""] |
| 52 | |
| 53 | # Build timestamp, respecting SOURCE_DATE_EPOCH for reproducible builds. |
| 54 | build_timestamp = int(time.time()) |
| 55 | sde = os.environ.get("SOURCE_DATE_EPOCH") |
| 56 | if sde is not None: |
| 57 | build_timestamp = int(sde) |
| 58 | |
| 59 | # Image tag to use in rules_docker. Since USER might not be set on CI, we have |
| 60 | # to craft this ourselves. |
| 61 | user = os.environ.get("USER", "unknown") |
| 62 | image_tag = f"{user}-{build_timestamp}" |
| 63 | |
| 64 | variables["STABLE_MONOGON_gitCommit"] = git_commit |
| 65 | variables["STABLE_MONOGON_gitTreeState"] = git_tree_state |
| 66 | variables["IMAGE_TAG"] = image_tag |
| 67 | |
| 68 | # Per product. Each product has it's own semver-style version number, which is |
| 69 | # deduced from git tags. |
| 70 | # |
| 71 | # For example: metropolis v. 1.2.3 would be tagged 'metropolis-v1.2.3'. |
| 72 | @dataclass |
| 73 | class Version: |
| 74 | """Describes a semver version for a given product.""" |
| 75 | |
| 76 | product: str |
| 77 | version: str |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 78 | prerelease: [str] |
| 79 | |
| 80 | def __str__(self) -> str: |
| 81 | ver = self.version |
| 82 | if self.prerelease: |
| 83 | ver += "-" + ".".join(self.prerelease) |
| 84 | return ver |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 85 | |
| 86 | |
| 87 | def parse_tag(tag: str, product: str) -> Optional[Version]: |
| 88 | prefix = product + "-" |
| 89 | if not tag.startswith(prefix): |
| 90 | return None |
| 91 | version = tag[len(prefix) :] |
| 92 | # The first release of Metropolis was v0.1, which we extend to v0.1.0. |
| 93 | if product == "metropolis" and version == "v0.1": |
| 94 | version = "v0.1.0" |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 95 | # Only care about the limited major/minor/patch subset of semver from git |
| 96 | # tags. All prerelease identifies will be appended by this code. |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 97 | if not re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+$", version): |
| 98 | return None |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 99 | return Version(product, version, []) |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 100 | |
| 101 | |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 102 | for product in ["metropolis", "cloud"]: |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 103 | versions = [] |
| 104 | # Get exact versions from tags. |
| 105 | for tag in git_tags: |
| 106 | version = parse_tag(tag, product) |
| 107 | if version is None: |
| 108 | continue |
| 109 | versions.append(version) |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 110 | version = None |
| 111 | if len(versions) > 0: |
| 112 | # Find the highest version and use that. Lexicographic sort is good enough |
| 113 | # for the limited subset of semver we support. |
| 114 | versions.sort(reverse=True) |
| 115 | version = versions[0] |
| 116 | |
| 117 | if version is None: |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 118 | # No exact version found. Use latest tag for the given product and |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 119 | # append a 'devXXX' identifier based on number of commits since that |
| 120 | # tag. |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 121 | for tag in ( |
| 122 | subprocess.check_output( |
| 123 | ["git", "tag", "--sort=-refname", "--merged", "HEAD"] |
| 124 | ) |
| 125 | .decode() |
| 126 | .strip() |
| 127 | .split("\n") |
| 128 | ): |
| 129 | version = parse_tag(tag, product) |
| 130 | if version is None: |
| 131 | continue |
| 132 | # Found the latest tag for this product. Augment it with the |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 133 | # devXXX identifier and add it to our versions. |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 134 | count = ( |
| 135 | subprocess.check_output(["git", "rev-list", tag + "..HEAD", "--count"]) |
| 136 | .decode() |
| 137 | .strip() |
| 138 | ) |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 139 | version.prerelease.append(f"dev{count}") |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 140 | break |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 141 | |
| 142 | if version is None: |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 143 | # This product never had a release! Use v0.0.0 as a fallback. |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 144 | version = Version(product, "v0.0.0", []) |
| 145 | # ... and count the number of all commits ever to use as the devXXX |
| 146 | # prerelease identifier. |
| 147 | count = ( |
| 148 | subprocess.check_output(["git", "rev-list", "HEAD", "--count"]) |
| 149 | .decode() |
| 150 | .strip() |
| 151 | ) |
| 152 | version.prerelease.append(f"dev{count}") |
| 153 | |
| 154 | version.prerelease.append(f"g{git_commit[:8]}") |
| 155 | if git_tree_state == "dirty": |
| 156 | version.prerelease.append("dirty") |
| 157 | variables[f"STABLE_MONOGON_{product}_version"] = str(version) |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 158 | |
| 159 | |
| 160 | # Special treatment for Kubernetes, which uses these stamp values in its build |
| 161 | # system. We populate the Kubernetes version from whatever is in |
| 162 | # //third_party/go/repositories.bzl. |
| 163 | def parse_repositories_bzl(path: str) -> dict[str, str]: |
| 164 | """ |
| 165 | Shoddily parse a Gazelle-created repositories.bzl into a map of |
| 166 | name->version. |
| 167 | |
| 168 | This relies heavily on repositories.bzl being correctly formatted and |
| 169 | sorted. |
| 170 | |
| 171 | If this breaks, it's probably best to try to use the actual Python parser |
| 172 | to deal with this, eg. by creating a fake environment for the .bzl file to |
| 173 | be parsed. |
| 174 | """ |
| 175 | |
| 176 | # Main parser state: None where we don't expect a version line, set to some |
| 177 | # value otherwise. |
| 178 | name: Optional[str] = None |
| 179 | |
| 180 | res = {} |
| 181 | for line in open(path): |
| 182 | line = line.strip() |
| 183 | if line == "go_repository(": |
| 184 | name = None |
| 185 | continue |
| 186 | if line.startswith("name ="): |
| 187 | if name is not None: |
| 188 | raise Exception("parse error in repositories.bzl: repeated name?") |
| 189 | if line.count('"') != 2: |
| 190 | raise Exception( |
| 191 | "parse error in repositories.bzl: invalid name line: " + name |
| 192 | ) |
| 193 | name = line.split('"')[1] |
| 194 | continue |
| 195 | if line.startswith("version ="): |
| 196 | if name is None: |
| 197 | raise Exception("parse error in repositories.bzl: version before name") |
| 198 | if line.count('"') != 2: |
| 199 | raise Exception( |
| 200 | "parse error in repositories.bzl: invalid name line: " + name |
| 201 | ) |
| 202 | version = line.split('"')[1] |
| 203 | res[name] = version |
| 204 | name = None |
| 205 | return res |
| 206 | |
| 207 | |
| 208 | # Parse repositories.bzl. |
| 209 | go_versions = parse_repositories_bzl("third_party/go/repositories.bzl") |
| 210 | |
| 211 | # Find Kubernetes version. |
| 212 | kubernetes_version: str = go_versions.get("io_k8s_kubernetes") |
| 213 | if kubernetes_version is None: |
| 214 | raise Exception("could not figure out Kubernetes version") |
| 215 | kubernetes_version_parsed = re.match( |
| 216 | r"^v([0-9]+)\.([0-9]+)\.[0-9]+$", kubernetes_version |
| 217 | ) |
| 218 | if not kubernetes_version_parsed: |
| 219 | raise Exception("invalid Kubernetes version: " + kubernetes_version) |
| 220 | |
| 221 | # The Kubernetes build tree is considered clean iff the monorepo build tree is |
| 222 | # considered clean. |
| 223 | variables["KUBERNETES_gitTreeState"] = git_tree_state |
| 224 | variables["KUBERNETES_buildDate"] = datetime.fromtimestamp( |
| 225 | build_timestamp, timezone.utc |
| 226 | ).strftime("%Y-%m-%dT%H:%M:%SZ") |
| 227 | variables["STABLE_KUBERNETES_gitMajor"] = kubernetes_version_parsed[1] |
| 228 | variables["STABLE_KUBERNETES_gitMinor"] = kubernetes_version_parsed[2] |
| 229 | variables["STABLE_KUBERNETES_gitVersion"] = kubernetes_version + "+mngn" |
| 230 | |
| 231 | # Backwards compat with existing stamping data as expected by the monorepo codebase. |
| 232 | # TODO(q3k): remove this once we migrate away into the new versioning data format in metropolis. |
| 233 | variables["STABLE_METROPOLIS_gitCommit"] = variables["STABLE_MONOGON_gitCommit"] |
| 234 | variables["STABLE_METROPOLIS_gitTreeState"] = variables["STABLE_MONOGON_gitTreeState"] |
| 235 | # Skip the 'v.'. |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame^] | 236 | variables["STABLE_METROPOLIS_version"] = variables["STABLE_MONOGON_metropolis_version"][1:].split('-')[0] |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 237 | |
| 238 | # Emit variables to stdout for consumption by Bazel and targets. |
| 239 | for key in sorted(variables.keys()): |
| 240 | print("{} {}".format(key, variables[key])) |