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