| #!/usr/bin/env python3 |
| """Workspace status script used for build stamping.""" |
| |
| # Treat this script as shell code, but with Python syntax. We want to remain as |
| # simple as possible, and absolutely never use any non-standard Python library. |
| # This script should be able to run on any 'modern' Linux distribution with |
| # Python 3.8 or newer. |
| |
| # The following versioning concepts apply: |
| # 1. Version numbers follow the Semantic Versioning 2.0 spec. |
| # 2. Git tags in the form `<product>-vX.Y.Z` will be used as a basis for |
| # versioning a build. If the currently built release is exactly the same as |
| # such a tag, it will be versioned at vX.Y.Z. Otherwise, a -devNNN suffix |
| # will be appended to signify the amount of commits since the release. |
| # 3. Product git tags are only made up of a major/minor/patch version. |
| # Prerelease and build tags are assigned by the build system and this |
| # script, Git tags have no influence on them. |
| # 4. 'Products' are release numbering trains within the Monogon monorepo. This |
| # means there is no such thing as a 'version' for the monorepo by itself, |
| # only within the context of some product. |
| |
| from dataclasses import dataclass |
| from datetime import datetime, timezone |
| import os |
| import re |
| import subprocess |
| import time |
| |
| from typing import Optional |
| |
| |
| # Variables to output. These will be printed to stdout at the end of the script |
| # runtime, sorted by key. |
| variables: dict[str, str] = {} |
| |
| # Git build tree status: clean or dirty. |
| git_tree_state: str = "clean" |
| if subprocess.call(["git", "status", "--porcelain"], stdout=subprocess.PIPE) == 0: |
| git_tree_state = "dirty" |
| |
| # Git commit hash. |
| git_commit: str = ( |
| subprocess.check_output(["git", "rev-parse", "HEAD^{commit}"]).decode().strip() |
| ) |
| |
| # Git tags pointing at this commit. |
| git_tags_b: [bytes] = subprocess.check_output( |
| ["git", "tag", "--points-at", "HEAD"] |
| ).split(b"\n") |
| git_tags: [str] = [t.decode().strip() for t in git_tags_b if t.decode().strip() != ""] |
| |
| # Build timestamp, respecting SOURCE_DATE_EPOCH for reproducible builds. |
| build_timestamp = int(time.time()) |
| sde = os.environ.get("SOURCE_DATE_EPOCH") |
| if sde is not None: |
| build_timestamp = int(sde) |
| |
| # Image tag to use in rules_docker. Since USER might not be set on CI, we have |
| # to craft this ourselves. |
| user = os.environ.get("USER", "unknown") |
| image_tag = f"{user}-{build_timestamp}" |
| |
| variables["STABLE_MONOGON_gitCommit"] = git_commit |
| variables["STABLE_MONOGON_gitTreeState"] = git_tree_state |
| variables["IMAGE_TAG"] = image_tag |
| |
| # Per product. Each product has it's own semver-style version number, which is |
| # deduced from git tags. |
| # |
| # For example: metropolis v. 1.2.3 would be tagged 'metropolis-v1.2.3'. |
| @dataclass |
| class Version: |
| """Describes a semver version for a given product.""" |
| |
| product: str |
| version: str |
| |
| |
| def parse_tag(tag: str, product: str) -> Optional[Version]: |
| prefix = product + "-" |
| if not tag.startswith(prefix): |
| return None |
| version = tag[len(prefix) :] |
| # The first release of Metropolis was v0.1, which we extend to v0.1.0. |
| if product == "metropolis" and version == "v0.1": |
| version = "v0.1.0" |
| # Only care about proper semver tags. Or at least proper enough (this |
| # will still accept v01.01.01 which it probably shouldn't). |
| if not re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+$", version): |
| return None |
| return Version(product, version) |
| |
| |
| for product in ["metropolis"]: |
| versions = [] |
| # Get exact versions from tags. |
| for tag in git_tags: |
| version = parse_tag(tag, product) |
| if version is None: |
| continue |
| versions.append(version) |
| if len(versions) == 0: |
| # No exact version found. Use latest tag for the given product and |
| # append a '-devXXX' tag based on number of commits since that tag. |
| for tag in ( |
| subprocess.check_output( |
| ["git", "tag", "--sort=-refname", "--merged", "HEAD"] |
| ) |
| .decode() |
| .strip() |
| .split("\n") |
| ): |
| version = parse_tag(tag, product) |
| if version is None: |
| continue |
| # Found the latest tag for this product. Augment it with the |
| # -devXXX suffix and add it to our versions. |
| count = ( |
| subprocess.check_output(["git", "rev-list", tag + "..HEAD", "--count"]) |
| .decode() |
| .strip() |
| ) |
| version.version += f"-dev{count}" |
| versions.append(version) |
| break |
| if len(versions) == 0: |
| # This product never had a release! Use v0.0.0 as a fallback. |
| versions.append(Version(product, "v0.0.0")) |
| # Find the highest version and use that. Lexicographic sort is good enough |
| # for the limited subset of semver we support. |
| versions.sort(reverse=True) |
| version = versions[0] |
| variables[f"STABLE_MONOGON_{product}_gitVersion"] = version.version |
| |
| |
| # Special treatment for Kubernetes, which uses these stamp values in its build |
| # system. We populate the Kubernetes version from whatever is in |
| # //third_party/go/repositories.bzl. |
| def parse_repositories_bzl(path: str) -> dict[str, str]: |
| """ |
| Shoddily parse a Gazelle-created repositories.bzl into a map of |
| name->version. |
| |
| This relies heavily on repositories.bzl being correctly formatted and |
| sorted. |
| |
| If this breaks, it's probably best to try to use the actual Python parser |
| to deal with this, eg. by creating a fake environment for the .bzl file to |
| be parsed. |
| """ |
| |
| # Main parser state: None where we don't expect a version line, set to some |
| # value otherwise. |
| name: Optional[str] = None |
| |
| res = {} |
| for line in open(path): |
| line = line.strip() |
| if line == "go_repository(": |
| name = None |
| continue |
| if line.startswith("name ="): |
| if name is not None: |
| raise Exception("parse error in repositories.bzl: repeated name?") |
| if line.count('"') != 2: |
| raise Exception( |
| "parse error in repositories.bzl: invalid name line: " + name |
| ) |
| name = line.split('"')[1] |
| continue |
| if line.startswith("version ="): |
| if name is None: |
| raise Exception("parse error in repositories.bzl: version before name") |
| if line.count('"') != 2: |
| raise Exception( |
| "parse error in repositories.bzl: invalid name line: " + name |
| ) |
| version = line.split('"')[1] |
| res[name] = version |
| name = None |
| return res |
| |
| |
| # Parse repositories.bzl. |
| go_versions = parse_repositories_bzl("third_party/go/repositories.bzl") |
| |
| # Find Kubernetes version. |
| kubernetes_version: str = go_versions.get("io_k8s_kubernetes") |
| if kubernetes_version is None: |
| raise Exception("could not figure out Kubernetes version") |
| kubernetes_version_parsed = re.match( |
| r"^v([0-9]+)\.([0-9]+)\.[0-9]+$", kubernetes_version |
| ) |
| if not kubernetes_version_parsed: |
| raise Exception("invalid Kubernetes version: " + kubernetes_version) |
| |
| # The Kubernetes build tree is considered clean iff the monorepo build tree is |
| # considered clean. |
| variables["KUBERNETES_gitTreeState"] = git_tree_state |
| variables["KUBERNETES_buildDate"] = datetime.fromtimestamp( |
| build_timestamp, timezone.utc |
| ).strftime("%Y-%m-%dT%H:%M:%SZ") |
| variables["STABLE_KUBERNETES_gitMajor"] = kubernetes_version_parsed[1] |
| variables["STABLE_KUBERNETES_gitMinor"] = kubernetes_version_parsed[2] |
| variables["STABLE_KUBERNETES_gitVersion"] = kubernetes_version + "+mngn" |
| |
| # Backwards compat with existing stamping data as expected by the monorepo codebase. |
| # TODO(q3k): remove this once we migrate away into the new versioning data format in metropolis. |
| variables["STABLE_METROPOLIS_gitCommit"] = variables["STABLE_MONOGON_gitCommit"] |
| variables["STABLE_METROPOLIS_gitTreeState"] = variables["STABLE_MONOGON_gitTreeState"] |
| # Skip the 'v.'. |
| variables["STABLE_METROPOLIS_version"] = variables["STABLE_MONOGON_metropolis_gitVersion"][1:] |
| |
| # Emit variables to stdout for consumption by Bazel and targets. |
| for key in sorted(variables.keys()): |
| print("{} {}".format(key, variables[key])) |