blob: 457c16458081e24b9b051df758576c0191ef8c71 [file] [log] [blame]
Serge Bazanskia6a03922023-11-13 19:57:48 +01001#!/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 Bazanskif42e3642023-11-28 16:25:25 +010013# 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 Bazanskia6a03922023-11-13 19:57:48 +010016# 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
23from dataclasses import dataclass
24from datetime import datetime, timezone
25import os
26import re
27import subprocess
28import time
29
30from 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.
35variables: dict[str, str] = {}
36
37# Git build tree status: clean or dirty.
38git_tree_state: str = "clean"
39if subprocess.call(["git", "status", "--porcelain"], stdout=subprocess.PIPE) == 0:
40 git_tree_state = "dirty"
41
42# Git commit hash.
43git_commit: str = (
44 subprocess.check_output(["git", "rev-parse", "HEAD^{commit}"]).decode().strip()
45)
46
47# Git tags pointing at this commit.
48git_tags_b: [bytes] = subprocess.check_output(
49 ["git", "tag", "--points-at", "HEAD"]
50).split(b"\n")
51git_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.
54build_timestamp = int(time.time())
55sde = os.environ.get("SOURCE_DATE_EPOCH")
56if 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.
61user = os.environ.get("USER", "unknown")
62image_tag = f"{user}-{build_timestamp}"
63
64variables["STABLE_MONOGON_gitCommit"] = git_commit
65variables["STABLE_MONOGON_gitTreeState"] = git_tree_state
66variables["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
73class Version:
74 """Describes a semver version for a given product."""
75
76 product: str
77 version: str
Serge Bazanskif42e3642023-11-28 16:25:25 +010078 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 Bazanskia6a03922023-11-13 19:57:48 +010085
86
87def 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 Bazanskif42e3642023-11-28 16:25:25 +010095 # 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 Bazanskia6a03922023-11-13 19:57:48 +010097 if not re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+$", version):
98 return None
Serge Bazanskif42e3642023-11-28 16:25:25 +010099 return Version(product, version, [])
Serge Bazanskia6a03922023-11-13 19:57:48 +0100100
101
Serge Bazanskif42e3642023-11-28 16:25:25 +0100102for product in ["metropolis", "cloud"]:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100103 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 Bazanskif42e3642023-11-28 16:25:25 +0100110 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 Bazanskia6a03922023-11-13 19:57:48 +0100118 # No exact version found. Use latest tag for the given product and
Serge Bazanskif42e3642023-11-28 16:25:25 +0100119 # append a 'devXXX' identifier based on number of commits since that
120 # tag.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100121 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 Bazanskif42e3642023-11-28 16:25:25 +0100133 # devXXX identifier and add it to our versions.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100134 count = (
135 subprocess.check_output(["git", "rev-list", tag + "..HEAD", "--count"])
136 .decode()
137 .strip()
138 )
Serge Bazanskif42e3642023-11-28 16:25:25 +0100139 version.prerelease.append(f"dev{count}")
Serge Bazanskia6a03922023-11-13 19:57:48 +0100140 break
Serge Bazanskif42e3642023-11-28 16:25:25 +0100141
142 if version is None:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100143 # This product never had a release! Use v0.0.0 as a fallback.
Serge Bazanskif42e3642023-11-28 16:25:25 +0100144 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 Bazanskia6a03922023-11-13 19:57:48 +0100158
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.
163def 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.
209go_versions = parse_repositories_bzl("third_party/go/repositories.bzl")
210
211# Find Kubernetes version.
212kubernetes_version: str = go_versions.get("io_k8s_kubernetes")
213if kubernetes_version is None:
214 raise Exception("could not figure out Kubernetes version")
215kubernetes_version_parsed = re.match(
216 r"^v([0-9]+)\.([0-9]+)\.[0-9]+$", kubernetes_version
217)
218if 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.
223variables["KUBERNETES_gitTreeState"] = git_tree_state
224variables["KUBERNETES_buildDate"] = datetime.fromtimestamp(
225 build_timestamp, timezone.utc
226).strftime("%Y-%m-%dT%H:%M:%SZ")
227variables["STABLE_KUBERNETES_gitMajor"] = kubernetes_version_parsed[1]
228variables["STABLE_KUBERNETES_gitMinor"] = kubernetes_version_parsed[2]
229variables["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.
233variables["STABLE_METROPOLIS_gitCommit"] = variables["STABLE_MONOGON_gitCommit"]
234variables["STABLE_METROPOLIS_gitTreeState"] = variables["STABLE_MONOGON_gitTreeState"]
235# Skip the 'v.'.
Serge Bazanskif42e3642023-11-28 16:25:25 +0100236variables["STABLE_METROPOLIS_version"] = variables["STABLE_MONOGON_metropolis_version"][1:].split('-')[0]
Serge Bazanskia6a03922023-11-13 19:57:48 +0100237
238# Emit variables to stdout for consumption by Bazel and targets.
239for key in sorted(variables.keys()):
240 print("{} {}".format(key, variables[key]))