| 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" |
| Serge Bazanski | c5b52f1 | 2024-02-13 14:45:19 +0100 | [diff] [blame] | 39 | git_status = subprocess.check_output(["git", "status", "--porcelain"]) |
| 40 | if git_status.decode().strip() != "": |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 41 | git_tree_state = "dirty" |
| 42 | |
| 43 | # Git commit hash. |
| 44 | git_commit: str = ( |
| 45 | subprocess.check_output(["git", "rev-parse", "HEAD^{commit}"]).decode().strip() |
| 46 | ) |
| 47 | |
| 48 | # Git tags pointing at this commit. |
| 49 | git_tags_b: [bytes] = subprocess.check_output( |
| 50 | ["git", "tag", "--points-at", "HEAD"] |
| 51 | ).split(b"\n") |
| 52 | git_tags: [str] = [t.decode().strip() for t in git_tags_b if t.decode().strip() != ""] |
| 53 | |
| 54 | # Build timestamp, respecting SOURCE_DATE_EPOCH for reproducible builds. |
| 55 | build_timestamp = int(time.time()) |
| 56 | sde = os.environ.get("SOURCE_DATE_EPOCH") |
| 57 | if sde is not None: |
| 58 | build_timestamp = int(sde) |
| 59 | |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 60 | variables["STABLE_MONOGON_gitCommit"] = git_commit |
| 61 | variables["STABLE_MONOGON_gitTreeState"] = git_tree_state |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 62 | |
| 63 | # Per product. Each product has it's own semver-style version number, which is |
| 64 | # deduced from git tags. |
| 65 | # |
| 66 | # For example: metropolis v. 1.2.3 would be tagged 'metropolis-v1.2.3'. |
| 67 | @dataclass |
| 68 | class Version: |
| 69 | """Describes a semver version for a given product.""" |
| 70 | |
| 71 | product: str |
| 72 | version: str |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 73 | prerelease: [str] |
| 74 | |
| 75 | def __str__(self) -> str: |
| 76 | ver = self.version |
| 77 | if self.prerelease: |
| 78 | ver += "-" + ".".join(self.prerelease) |
| 79 | return ver |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 80 | |
| 81 | |
| 82 | def parse_tag(tag: str, product: str) -> Optional[Version]: |
| 83 | prefix = product + "-" |
| 84 | if not tag.startswith(prefix): |
| 85 | return None |
| 86 | version = tag[len(prefix) :] |
| 87 | # The first release of Metropolis was v0.1, which we extend to v0.1.0. |
| 88 | if product == "metropolis" and version == "v0.1": |
| 89 | version = "v0.1.0" |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 90 | # Only care about the limited major/minor/patch subset of semver from git |
| 91 | # tags. All prerelease identifies will be appended by this code. |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 92 | if not re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+$", version): |
| 93 | return None |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 94 | return Version(product, version, []) |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 95 | |
| 96 | |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 97 | for product in ["metropolis", "cloud"]: |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 98 | versions = [] |
| 99 | # Get exact versions from tags. |
| 100 | for tag in git_tags: |
| 101 | version = parse_tag(tag, product) |
| 102 | if version is None: |
| 103 | continue |
| 104 | versions.append(version) |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 105 | version = None |
| 106 | if len(versions) > 0: |
| 107 | # Find the highest version and use that. Lexicographic sort is good enough |
| 108 | # for the limited subset of semver we support. |
| 109 | versions.sort(reverse=True) |
| 110 | version = versions[0] |
| 111 | |
| 112 | if version is None: |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 113 | # No exact version found. Use latest tag for the given product and |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 114 | # append a 'devXXX' identifier based on number of commits since that |
| 115 | # tag. |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 116 | for tag in ( |
| 117 | subprocess.check_output( |
| 118 | ["git", "tag", "--sort=-refname", "--merged", "HEAD"] |
| 119 | ) |
| 120 | .decode() |
| 121 | .strip() |
| 122 | .split("\n") |
| 123 | ): |
| 124 | version = parse_tag(tag, product) |
| 125 | if version is None: |
| 126 | continue |
| 127 | # Found the latest tag for this product. Augment it with the |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 128 | # devXXX identifier and add it to our versions. |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 129 | count = ( |
| 130 | subprocess.check_output(["git", "rev-list", tag + "..HEAD", "--count"]) |
| 131 | .decode() |
| 132 | .strip() |
| 133 | ) |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 134 | version.prerelease.append(f"dev{count}") |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 135 | break |
| Serge Bazanski | f42e364 | 2023-11-28 16:25:25 +0100 | [diff] [blame] | 136 | |
| 137 | if version is None: |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 138 | # 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] | 139 | version = Version(product, "v0.0.0", []) |
| 140 | # ... and count the number of all commits ever to use as the devXXX |
| 141 | # prerelease identifier. |
| 142 | count = ( |
| 143 | subprocess.check_output(["git", "rev-list", "HEAD", "--count"]) |
| 144 | .decode() |
| 145 | .strip() |
| 146 | ) |
| 147 | version.prerelease.append(f"dev{count}") |
| 148 | |
| 149 | version.prerelease.append(f"g{git_commit[:8]}") |
| 150 | if git_tree_state == "dirty": |
| 151 | version.prerelease.append("dirty") |
| 152 | variables[f"STABLE_MONOGON_{product}_version"] = str(version) |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 153 | |
| 154 | |
| 155 | # Special treatment for Kubernetes, which uses these stamp values in its build |
| 156 | # system. We populate the Kubernetes version from whatever is in |
| 157 | # //third_party/go/repositories.bzl. |
| 158 | def parse_repositories_bzl(path: str) -> dict[str, str]: |
| 159 | """ |
| 160 | Shoddily parse a Gazelle-created repositories.bzl into a map of |
| 161 | name->version. |
| 162 | |
| 163 | This relies heavily on repositories.bzl being correctly formatted and |
| 164 | sorted. |
| 165 | |
| 166 | If this breaks, it's probably best to try to use the actual Python parser |
| 167 | to deal with this, eg. by creating a fake environment for the .bzl file to |
| 168 | be parsed. |
| 169 | """ |
| 170 | |
| 171 | # Main parser state: None where we don't expect a version line, set to some |
| 172 | # value otherwise. |
| 173 | name: Optional[str] = None |
| 174 | |
| 175 | res = {} |
| 176 | for line in open(path): |
| 177 | line = line.strip() |
| 178 | if line == "go_repository(": |
| 179 | name = None |
| 180 | continue |
| 181 | if line.startswith("name ="): |
| 182 | if name is not None: |
| 183 | raise Exception("parse error in repositories.bzl: repeated name?") |
| 184 | if line.count('"') != 2: |
| 185 | raise Exception( |
| 186 | "parse error in repositories.bzl: invalid name line: " + name |
| 187 | ) |
| 188 | name = line.split('"')[1] |
| 189 | continue |
| 190 | if line.startswith("version ="): |
| 191 | if name is None: |
| 192 | raise Exception("parse error in repositories.bzl: version before name") |
| 193 | if line.count('"') != 2: |
| 194 | raise Exception( |
| 195 | "parse error in repositories.bzl: invalid name line: " + name |
| 196 | ) |
| 197 | version = line.split('"')[1] |
| 198 | res[name] = version |
| 199 | name = None |
| 200 | return res |
| 201 | |
| 202 | |
| 203 | # Parse repositories.bzl. |
| 204 | go_versions = parse_repositories_bzl("third_party/go/repositories.bzl") |
| 205 | |
| 206 | # Find Kubernetes version. |
| 207 | kubernetes_version: str = go_versions.get("io_k8s_kubernetes") |
| 208 | if kubernetes_version is None: |
| 209 | raise Exception("could not figure out Kubernetes version") |
| 210 | kubernetes_version_parsed = re.match( |
| 211 | r"^v([0-9]+)\.([0-9]+)\.[0-9]+$", kubernetes_version |
| 212 | ) |
| 213 | if not kubernetes_version_parsed: |
| 214 | raise Exception("invalid Kubernetes version: " + kubernetes_version) |
| 215 | |
| 216 | # The Kubernetes build tree is considered clean iff the monorepo build tree is |
| 217 | # considered clean. |
| 218 | variables["KUBERNETES_gitTreeState"] = git_tree_state |
| 219 | variables["KUBERNETES_buildDate"] = datetime.fromtimestamp( |
| 220 | build_timestamp, timezone.utc |
| 221 | ).strftime("%Y-%m-%dT%H:%M:%SZ") |
| 222 | variables["STABLE_KUBERNETES_gitMajor"] = kubernetes_version_parsed[1] |
| 223 | variables["STABLE_KUBERNETES_gitMinor"] = kubernetes_version_parsed[2] |
| 224 | variables["STABLE_KUBERNETES_gitVersion"] = kubernetes_version + "+mngn" |
| 225 | |
| Serge Bazanski | a6a0392 | 2023-11-13 19:57:48 +0100 | [diff] [blame] | 226 | # Emit variables to stdout for consumption by Bazel and targets. |
| 227 | for key in sorted(variables.keys()): |
| 228 | print("{} {}".format(key, variables[key])) |