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