blob: 8339cd5461e11b78f75e287ebf62d22086c3a46f [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
13# such a tag, it will be versioned at vX.Y.Z. Otherwise, a -devNNN suffix
14# will be appended to signify the amount of commits since the release.
15# 3. Product git tags are only made up of a major/minor/patch version.
16# Prerelease and build tags are assigned by the build system and this
17# script, Git tags have no influence on them.
18# 4. 'Products' are release numbering trains within the Monogon monorepo. This
19# means there is no such thing as a 'version' for the monorepo by itself,
20# only within the context of some product.
21
22from dataclasses import dataclass
23from datetime import datetime, timezone
24import os
25import re
26import subprocess
27import time
28
29from typing import Optional
30
31
32# Variables to output. These will be printed to stdout at the end of the script
33# runtime, sorted by key.
34variables: dict[str, str] = {}
35
36# Git build tree status: clean or dirty.
37git_tree_state: str = "clean"
38if subprocess.call(["git", "status", "--porcelain"], stdout=subprocess.PIPE) == 0:
39 git_tree_state = "dirty"
40
41# Git commit hash.
42git_commit: str = (
43 subprocess.check_output(["git", "rev-parse", "HEAD^{commit}"]).decode().strip()
44)
45
46# Git tags pointing at this commit.
47git_tags_b: [bytes] = subprocess.check_output(
48 ["git", "tag", "--points-at", "HEAD"]
49).split(b"\n")
50git_tags: [str] = [t.decode().strip() for t in git_tags_b if t.decode().strip() != ""]
51
52# Build timestamp, respecting SOURCE_DATE_EPOCH for reproducible builds.
53build_timestamp = int(time.time())
54sde = os.environ.get("SOURCE_DATE_EPOCH")
55if sde is not None:
56 build_timestamp = int(sde)
57
58# Image tag to use in rules_docker. Since USER might not be set on CI, we have
59# to craft this ourselves.
60user = os.environ.get("USER", "unknown")
61image_tag = f"{user}-{build_timestamp}"
62
63variables["STABLE_MONOGON_gitCommit"] = git_commit
64variables["STABLE_MONOGON_gitTreeState"] = git_tree_state
65variables["IMAGE_TAG"] = image_tag
66
67# Per product. Each product has it's own semver-style version number, which is
68# deduced from git tags.
69#
70# For example: metropolis v. 1.2.3 would be tagged 'metropolis-v1.2.3'.
71@dataclass
72class Version:
73 """Describes a semver version for a given product."""
74
75 product: str
76 version: str
77
78
79def parse_tag(tag: str, product: str) -> Optional[Version]:
80 prefix = product + "-"
81 if not tag.startswith(prefix):
82 return None
83 version = tag[len(prefix) :]
84 # The first release of Metropolis was v0.1, which we extend to v0.1.0.
85 if product == "metropolis" and version == "v0.1":
86 version = "v0.1.0"
87 # Only care about proper semver tags. Or at least proper enough (this
88 # will still accept v01.01.01 which it probably shouldn't).
89 if not re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+$", version):
90 return None
91 return Version(product, version)
92
93
94for product in ["metropolis"]:
95 versions = []
96 # Get exact versions from tags.
97 for tag in git_tags:
98 version = parse_tag(tag, product)
99 if version is None:
100 continue
101 versions.append(version)
102 if len(versions) == 0:
103 # No exact version found. Use latest tag for the given product and
104 # append a '-devXXX' tag based on number of commits since that tag.
105 for tag in (
106 subprocess.check_output(
107 ["git", "tag", "--sort=-refname", "--merged", "HEAD"]
108 )
109 .decode()
110 .strip()
111 .split("\n")
112 ):
113 version = parse_tag(tag, product)
114 if version is None:
115 continue
116 # Found the latest tag for this product. Augment it with the
117 # -devXXX suffix and add it to our versions.
118 count = (
119 subprocess.check_output(["git", "rev-list", tag + "..HEAD", "--count"])
120 .decode()
121 .strip()
122 )
123 version.version += f"-dev{count}"
124 versions.append(version)
125 break
126 if len(versions) == 0:
127 # This product never had a release! Use v0.0.0 as a fallback.
128 versions.append(Version(product, "v0.0.0"))
129 # Find the highest version and use that. Lexicographic sort is good enough
130 # for the limited subset of semver we support.
131 versions.sort(reverse=True)
132 version = versions[0]
133 variables[f"STABLE_MONOGON_{product}_gitVersion"] = version.version
134
135
136# Special treatment for Kubernetes, which uses these stamp values in its build
137# system. We populate the Kubernetes version from whatever is in
138# //third_party/go/repositories.bzl.
139def parse_repositories_bzl(path: str) -> dict[str, str]:
140 """
141 Shoddily parse a Gazelle-created repositories.bzl into a map of
142 name->version.
143
144 This relies heavily on repositories.bzl being correctly formatted and
145 sorted.
146
147 If this breaks, it's probably best to try to use the actual Python parser
148 to deal with this, eg. by creating a fake environment for the .bzl file to
149 be parsed.
150 """
151
152 # Main parser state: None where we don't expect a version line, set to some
153 # value otherwise.
154 name: Optional[str] = None
155
156 res = {}
157 for line in open(path):
158 line = line.strip()
159 if line == "go_repository(":
160 name = None
161 continue
162 if line.startswith("name ="):
163 if name is not None:
164 raise Exception("parse error in repositories.bzl: repeated name?")
165 if line.count('"') != 2:
166 raise Exception(
167 "parse error in repositories.bzl: invalid name line: " + name
168 )
169 name = line.split('"')[1]
170 continue
171 if line.startswith("version ="):
172 if name is None:
173 raise Exception("parse error in repositories.bzl: version before name")
174 if line.count('"') != 2:
175 raise Exception(
176 "parse error in repositories.bzl: invalid name line: " + name
177 )
178 version = line.split('"')[1]
179 res[name] = version
180 name = None
181 return res
182
183
184# Parse repositories.bzl.
185go_versions = parse_repositories_bzl("third_party/go/repositories.bzl")
186
187# Find Kubernetes version.
188kubernetes_version: str = go_versions.get("io_k8s_kubernetes")
189if kubernetes_version is None:
190 raise Exception("could not figure out Kubernetes version")
191kubernetes_version_parsed = re.match(
192 r"^v([0-9]+)\.([0-9]+)\.[0-9]+$", kubernetes_version
193)
194if not kubernetes_version_parsed:
195 raise Exception("invalid Kubernetes version: " + kubernetes_version)
196
197# The Kubernetes build tree is considered clean iff the monorepo build tree is
198# considered clean.
199variables["KUBERNETES_gitTreeState"] = git_tree_state
200variables["KUBERNETES_buildDate"] = datetime.fromtimestamp(
201 build_timestamp, timezone.utc
202).strftime("%Y-%m-%dT%H:%M:%SZ")
203variables["STABLE_KUBERNETES_gitMajor"] = kubernetes_version_parsed[1]
204variables["STABLE_KUBERNETES_gitMinor"] = kubernetes_version_parsed[2]
205variables["STABLE_KUBERNETES_gitVersion"] = kubernetes_version + "+mngn"
206
207# Backwards compat with existing stamping data as expected by the monorepo codebase.
208# TODO(q3k): remove this once we migrate away into the new versioning data format in metropolis.
209variables["STABLE_METROPOLIS_gitCommit"] = variables["STABLE_MONOGON_gitCommit"]
210variables["STABLE_METROPOLIS_gitTreeState"] = variables["STABLE_MONOGON_gitTreeState"]
211# Skip the 'v.'.
212variables["STABLE_METROPOLIS_version"] = variables["STABLE_MONOGON_metropolis_gitVersion"][1:]
213
214# Emit variables to stdout for consumption by Bazel and targets.
215for key in sorted(variables.keys()):
216 print("{} {}".format(key, variables[key]))