blob: 8339cd5461e11b78f75e287ebf62d22086c3a46f [file] [log] [blame]
#!/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]))