blob: 9eb551fa2aa0b3f4b3e482b323f1f8899c26614c [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(
Jan Schärdb3866a2024-04-08 17:33:45 +020050 ["git", "tag", "--sort=-version:refname", "--points-at", "HEAD"]
Serge Bazanskia6a03922023-11-13 19:57:48 +010051).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"]:
Jan Schärdb3866a2024-04-08 17:33:45 +020098 # Get exact version from tags.
99 version = None
Serge Bazanskia6a03922023-11-13 19:57:48 +0100100 for tag in git_tags:
101 version = parse_tag(tag, product)
Jan Schärdb3866a2024-04-08 17:33:45 +0200102 if version is not None:
103 break
Serge Bazanskif42e3642023-11-28 16:25:25 +0100104
105 if version is None:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100106 # No exact version found. Use latest tag for the given product and
Serge Bazanskif42e3642023-11-28 16:25:25 +0100107 # append a 'devXXX' identifier based on number of commits since that
108 # tag.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100109 for tag in (
110 subprocess.check_output(
Jan Schärdb3866a2024-04-08 17:33:45 +0200111 ["git", "tag", "--sort=-version:refname", "--merged", "HEAD"]
Serge Bazanskia6a03922023-11-13 19:57:48 +0100112 )
113 .decode()
114 .strip()
115 .split("\n")
116 ):
117 version = parse_tag(tag, product)
118 if version is None:
119 continue
120 # Found the latest tag for this product. Augment it with the
Serge Bazanskif42e3642023-11-28 16:25:25 +0100121 # devXXX identifier and add it to our versions.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100122 count = (
123 subprocess.check_output(["git", "rev-list", tag + "..HEAD", "--count"])
124 .decode()
125 .strip()
126 )
Serge Bazanskif42e3642023-11-28 16:25:25 +0100127 version.prerelease.append(f"dev{count}")
Serge Bazanskia6a03922023-11-13 19:57:48 +0100128 break
Serge Bazanskif42e3642023-11-28 16:25:25 +0100129
130 if version is None:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100131 # This product never had a release! Use v0.0.0 as a fallback.
Serge Bazanskif42e3642023-11-28 16:25:25 +0100132 version = Version(product, "v0.0.0", [])
133 # ... and count the number of all commits ever to use as the devXXX
134 # prerelease identifier.
135 count = (
136 subprocess.check_output(["git", "rev-list", "HEAD", "--count"])
137 .decode()
138 .strip()
139 )
140 version.prerelease.append(f"dev{count}")
141
142 version.prerelease.append(f"g{git_commit[:8]}")
143 if git_tree_state == "dirty":
144 version.prerelease.append("dirty")
145 variables[f"STABLE_MONOGON_{product}_version"] = str(version)
Serge Bazanskia6a03922023-11-13 19:57:48 +0100146
147
148# Special treatment for Kubernetes, which uses these stamp values in its build
149# system. We populate the Kubernetes version from whatever is in
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200150# //go.mod.
151def parse_go_mod(path: str) -> dict[str, str]:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100152 """
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200153 Shoddily parse a go.mod into a map of name->version.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100154
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200155 This relies heavily on go.mod being correctly formatted and
Serge Bazanskia6a03922023-11-13 19:57:48 +0100156 sorted.
157
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200158 If this breaks, it's probably best to try to port this to Go
159 and parse it using golang.org/x/mod/modfile, shell out to
160 "go mod edit -json", or similar.
Serge Bazanskia6a03922023-11-13 19:57:48 +0100161 """
162
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200163 # Just a copied together regex to find the url followed by a semver.
164 NAME_VERSION_REGEX = r"([-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*) v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)"
Serge Bazanskia6a03922023-11-13 19:57:48 +0100165
166 res = {}
167 for line in open(path):
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200168 matches = re.findall(NAME_VERSION_REGEX, line)
169 if not matches:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100170 continue
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200171
172 [name, version] = matches[0][0].strip().split(" ")
173
174 # If we already saw a package, skip it.
175 if name in res:
Serge Bazanskia6a03922023-11-13 19:57:48 +0100176 continue
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200177
178 res[name] = version
179
Serge Bazanskia6a03922023-11-13 19:57:48 +0100180 return res
181
182
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200183# Parse go.mod.
184go_versions = parse_go_mod("go.mod")
Serge Bazanskia6a03922023-11-13 19:57:48 +0100185
186# Find Kubernetes version.
Tim Windelschmidte5e90a82024-07-17 23:46:22 +0200187kubernetes_version: str = go_versions.get("k8s.io/kubernetes")
Serge Bazanskia6a03922023-11-13 19:57:48 +0100188if kubernetes_version is None:
189 raise Exception("could not figure out Kubernetes version")
190kubernetes_version_parsed = re.match(
191 r"^v([0-9]+)\.([0-9]+)\.[0-9]+$", kubernetes_version
192)
193if not kubernetes_version_parsed:
194 raise Exception("invalid Kubernetes version: " + kubernetes_version)
195
196# The Kubernetes build tree is considered clean iff the monorepo build tree is
197# considered clean.
198variables["KUBERNETES_gitTreeState"] = git_tree_state
199variables["KUBERNETES_buildDate"] = datetime.fromtimestamp(
200 build_timestamp, timezone.utc
201).strftime("%Y-%m-%dT%H:%M:%SZ")
202variables["STABLE_KUBERNETES_gitMajor"] = kubernetes_version_parsed[1]
203variables["STABLE_KUBERNETES_gitMinor"] = kubernetes_version_parsed[2]
204variables["STABLE_KUBERNETES_gitVersion"] = kubernetes_version + "+mngn"
205
Serge Bazanskia6a03922023-11-13 19:57:48 +0100206# Emit variables to stdout for consumption by Bazel and targets.
207for key in sorted(variables.keys()):
208 print("{} {}".format(key, variables[key]))