blob: af6207972d4a8059988da58fc6d0083af7f0c1fc [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"
Serge Bazanskic5b52f12024-02-13 14:45:19 +010039git_status = subprocess.check_output(["git", "status", "--porcelain"])
40if git_status.decode().strip() != "":
Serge Bazanskia6a03922023-11-13 19:57:48 +010041 git_tree_state = "dirty"
42
43# Git commit hash.
44git_commit: str = (
45 subprocess.check_output(["git", "rev-parse", "HEAD^{commit}"]).decode().strip()
46)
47
48# Git tags pointing at this commit.
49git_tags_b: [bytes] = subprocess.check_output(
50 ["git", "tag", "--points-at", "HEAD"]
51).split(b"\n")
52git_tags: [str] = [t.decode().strip() for t in git_tags_b if t.decode().strip() != ""]
53
54# Build timestamp, respecting SOURCE_DATE_EPOCH for reproducible builds.
55build_timestamp = int(time.time())
56sde = os.environ.get("SOURCE_DATE_EPOCH")
57if sde is not None:
58 build_timestamp = int(sde)
59
Serge Bazanskia6a03922023-11-13 19:57:48 +010060variables["STABLE_MONOGON_gitCommit"] = git_commit
61variables["STABLE_MONOGON_gitTreeState"] = git_tree_state
Serge Bazanskia6a03922023-11-13 19:57:48 +010062
63# Per product. Each product has it's own semver-style version number, which is
64# deduced from git tags.
65#
66# For example: metropolis v. 1.2.3 would be tagged 'metropolis-v1.2.3'.
67@dataclass
68class Version:
69 """Describes a semver version for a given product."""
70
71 product: str
72 version: str
Serge Bazanskif42e3642023-11-28 16:25:25 +010073 prerelease: [str]
74
75 def __str__(self) -> str:
76 ver = self.version
77 if self.prerelease:
78 ver += "-" + ".".join(self.prerelease)
79 return ver
Serge Bazanskia6a03922023-11-13 19:57:48 +010080
81
82def parse_tag(tag: str, product: str) -> Optional[Version]:
83 prefix = product + "-"
84 if not tag.startswith(prefix):
85 return None
86 version = tag[len(prefix) :]
87 # The first release of Metropolis was v0.1, which we extend to v0.1.0.
88 if product == "metropolis" and version == "v0.1":
89 version = "v0.1.0"
Serge Bazanskif42e3642023-11-28 16:25:25 +010090 # Only care about the limited major/minor/patch subset of semver from git
91 # tags. All prerelease identifies will be appended by this code.
Serge Bazanskia6a03922023-11-13 19:57:48 +010092 if not re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+$", version):
93 return None
Serge Bazanskif42e3642023-11-28 16:25:25 +010094 return Version(product, version, [])
Serge Bazanskia6a03922023-11-13 19:57:48 +010095
96
Serge Bazanskif42e3642023-11-28 16:25:25 +010097for product in ["metropolis", "cloud"]:
Serge Bazanskia6a03922023-11-13 19:57:48 +010098 versions = []
99 # Get exact versions from tags.
100 for tag in git_tags:
101 version = parse_tag(tag, product)
102 if version is None:
103 continue
104 versions.append(version)
Serge Bazanskif42e3642023-11-28 16:25:25 +0100105 version = None
106 if len(versions) > 0:
107 # Find the highest version and use that. Lexicographic sort is good enough
108 # for the limited subset of semver we support.
109 versions.sort(reverse=True)
110 version = versions[0]
111
112 if version is None:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100113 # No exact version found. Use latest tag for the given product and
Serge Bazanskif42e3642023-11-28 16:25:25 +0100114 # append a 'devXXX' identifier based on number of commits since that
115 # tag.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100116 for tag in (
117 subprocess.check_output(
118 ["git", "tag", "--sort=-refname", "--merged", "HEAD"]
119 )
120 .decode()
121 .strip()
122 .split("\n")
123 ):
124 version = parse_tag(tag, product)
125 if version is None:
126 continue
127 # Found the latest tag for this product. Augment it with the
Serge Bazanskif42e3642023-11-28 16:25:25 +0100128 # devXXX identifier and add it to our versions.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100129 count = (
130 subprocess.check_output(["git", "rev-list", tag + "..HEAD", "--count"])
131 .decode()
132 .strip()
133 )
Serge Bazanskif42e3642023-11-28 16:25:25 +0100134 version.prerelease.append(f"dev{count}")
Serge Bazanskia6a03922023-11-13 19:57:48 +0100135 break
Serge Bazanskif42e3642023-11-28 16:25:25 +0100136
137 if version is None:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100138 # This product never had a release! Use v0.0.0 as a fallback.
Serge Bazanskif42e3642023-11-28 16:25:25 +0100139 version = Version(product, "v0.0.0", [])
140 # ... and count the number of all commits ever to use as the devXXX
141 # prerelease identifier.
142 count = (
143 subprocess.check_output(["git", "rev-list", "HEAD", "--count"])
144 .decode()
145 .strip()
146 )
147 version.prerelease.append(f"dev{count}")
148
149 version.prerelease.append(f"g{git_commit[:8]}")
150 if git_tree_state == "dirty":
151 version.prerelease.append("dirty")
152 variables[f"STABLE_MONOGON_{product}_version"] = str(version)
Serge Bazanskia6a03922023-11-13 19:57:48 +0100153
154
155# Special treatment for Kubernetes, which uses these stamp values in its build
156# system. We populate the Kubernetes version from whatever is in
157# //third_party/go/repositories.bzl.
158def parse_repositories_bzl(path: str) -> dict[str, str]:
159 """
160 Shoddily parse a Gazelle-created repositories.bzl into a map of
161 name->version.
162
163 This relies heavily on repositories.bzl being correctly formatted and
164 sorted.
165
166 If this breaks, it's probably best to try to use the actual Python parser
167 to deal with this, eg. by creating a fake environment for the .bzl file to
168 be parsed.
169 """
170
171 # Main parser state: None where we don't expect a version line, set to some
172 # value otherwise.
173 name: Optional[str] = None
174
175 res = {}
176 for line in open(path):
177 line = line.strip()
178 if line == "go_repository(":
179 name = None
180 continue
181 if line.startswith("name ="):
182 if name is not None:
183 raise Exception("parse error in repositories.bzl: repeated name?")
184 if line.count('"') != 2:
185 raise Exception(
186 "parse error in repositories.bzl: invalid name line: " + name
187 )
188 name = line.split('"')[1]
189 continue
190 if line.startswith("version ="):
191 if name is None:
192 raise Exception("parse error in repositories.bzl: version before name")
193 if line.count('"') != 2:
194 raise Exception(
195 "parse error in repositories.bzl: invalid name line: " + name
196 )
197 version = line.split('"')[1]
198 res[name] = version
199 name = None
200 return res
201
202
203# Parse repositories.bzl.
204go_versions = parse_repositories_bzl("third_party/go/repositories.bzl")
205
206# Find Kubernetes version.
207kubernetes_version: str = go_versions.get("io_k8s_kubernetes")
208if kubernetes_version is None:
209 raise Exception("could not figure out Kubernetes version")
210kubernetes_version_parsed = re.match(
211 r"^v([0-9]+)\.([0-9]+)\.[0-9]+$", kubernetes_version
212)
213if not kubernetes_version_parsed:
214 raise Exception("invalid Kubernetes version: " + kubernetes_version)
215
216# The Kubernetes build tree is considered clean iff the monorepo build tree is
217# considered clean.
218variables["KUBERNETES_gitTreeState"] = git_tree_state
219variables["KUBERNETES_buildDate"] = datetime.fromtimestamp(
220 build_timestamp, timezone.utc
221).strftime("%Y-%m-%dT%H:%M:%SZ")
222variables["STABLE_KUBERNETES_gitMajor"] = kubernetes_version_parsed[1]
223variables["STABLE_KUBERNETES_gitMinor"] = kubernetes_version_parsed[2]
224variables["STABLE_KUBERNETES_gitVersion"] = kubernetes_version + "+mngn"
225
Serge Bazanskia6a03922023-11-13 19:57:48 +0100226# Emit variables to stdout for consumption by Bazel and targets.
227for key in sorted(variables.keys()):
228 print("{} {}".format(key, variables[key]))