Skip to content

Commit dbc4d49

Browse files
mtnbikencclaude
andcommitted
[hack] Add create-release-tag.py script
Adds a script to create consistent annotated release tags for WMCO. The commit SHA is resolved from the bundle image in the Red Hat Container Catalog (preferring the org.opencontainers.image.revision label, falling back to the short-SHA image tag), and the tag date is set to the operator image's push_date so the timestamp reflects the actual release date. A --commit override is available for releases not present in the catalog (e.g. backport-only releases), and --date allows overriding the publish date. The script prompts for confirmation before creating the tag. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 282a862 commit dbc4d49

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

hack/create-release-tag.py

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
#!/usr/bin/env python3
2+
"""
3+
create-release-tag.py — Create an annotated release tag for WMCO.
4+
5+
The commit SHA is resolved from the bundle image in the Red Hat Container
6+
Catalog, preferring the org.opencontainers.image.revision label (full SHA)
7+
and falling back to the short-SHA image tag. The tag date is set to the
8+
operator image's push_date so the timestamp reflects the actual release date.
9+
10+
Usage:
11+
python3 hack/create-release-tag.py <version>
12+
python3 hack/create-release-tag.py <version> --commit <SHA>
13+
python3 hack/create-release-tag.py <version> --date YYYY-MM-DD
14+
python3 hack/create-release-tag.py <version> --commit <SHA> --date YYYY-MM-DD
15+
16+
--commit is required when the version has no entry in the bundle catalog
17+
(e.g. backport releases that were never shipped as a container image).
18+
"""
19+
20+
import argparse
21+
import os
22+
import re
23+
import subprocess
24+
import sys
25+
26+
import requests
27+
28+
OPERATOR_CATALOG_API = (
29+
"https://catalog.redhat.com/api/containers/v1/repositories/"
30+
"registry/registry.access.redhat.com/repository/"
31+
"openshift4-wincw/windows-machine-config-rhel9-operator/images"
32+
)
33+
BUNDLE_CATALOG_API = (
34+
"https://catalog.redhat.com/api/containers/v1/repositories/"
35+
"registry/registry.access.redhat.com/repository/"
36+
"openshift4-wincw/windows-machine-config-operator-bundle/images"
37+
)
38+
39+
_VERSION_TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
40+
_HEX_RE = re.compile(r"^[0-9a-f]{7,40}$")
41+
42+
43+
# ---------------------------------------------------------------------------
44+
# Catalog helpers (shared pattern with verify-release.py)
45+
# ---------------------------------------------------------------------------
46+
47+
def _fetch_pages(api_url: str) -> list:
48+
"""Fetch all image records from a catalog API endpoint, handling pagination."""
49+
images, page = [], 0
50+
while True:
51+
resp = requests.get(api_url, params={"page_size": 100, "page": page,
52+
"sort_by": "creation_date[desc]"}, timeout=30)
53+
resp.raise_for_status()
54+
batch = resp.json().get("data", [])
55+
if not batch:
56+
break
57+
images.extend(batch)
58+
if len(batch) < 100:
59+
break
60+
page += 1
61+
return images
62+
63+
64+
def _version_from_tags(repos: list) -> str:
65+
"""Return the x.y.z version string from a repository's tag list, or ''."""
66+
for repo in repos:
67+
for tag in repo.get("tags", []):
68+
m = _VERSION_TAG_RE.match(tag.get("name", ""))
69+
if m:
70+
return m.group(1)
71+
return ""
72+
73+
74+
def _labels(img: dict) -> dict:
75+
return {lbl["name"]: lbl["value"]
76+
for lbl in img.get("parsed_data", {}).get("labels", [])}
77+
78+
79+
def fetch_bundle_info(version: str) -> tuple[str, str]:
80+
"""
81+
Return (commit, source_description) for the given version from the bundle catalog.
82+
Prefers the org.opencontainers.image.revision label (full SHA); falls back to
83+
the short hex SHA image tag. Returns ('', '') if the version is not found.
84+
"""
85+
for img in _fetch_pages(BUNDLE_CATALOG_API):
86+
repos = img.get("repositories", [])
87+
if _version_from_tags(repos) != version:
88+
continue
89+
all_tags = [t.get("name", "") for repo in repos for t in repo.get("tags", [])]
90+
commit = _labels(img).get("org.opencontainers.image.revision", "")
91+
if commit:
92+
return commit, "bundle image OCI label"
93+
sha_tags = [t for t in all_tags if _HEX_RE.match(t) and not _VERSION_TAG_RE.match(t)]
94+
if sha_tags:
95+
return sha_tags[0], "bundle image tag (short SHA)"
96+
return "", ""
97+
return "", ""
98+
99+
100+
def fetch_operator_push_date(version: str) -> str:
101+
"""
102+
Return the push_date (YYYY-MM-DD) for the given version from the operator catalog,
103+
or '' if not found.
104+
"""
105+
for img in _fetch_pages(OPERATOR_CATALOG_API):
106+
for repo in img.get("repositories", []):
107+
for tag in repo.get("tags", []):
108+
m = _VERSION_TAG_RE.match(tag.get("name", ""))
109+
if m and m.group(1) == version:
110+
push_date = repo.get("push_date", "")
111+
return push_date[:10] if push_date else ""
112+
return ""
113+
114+
115+
# ---------------------------------------------------------------------------
116+
# Git helpers
117+
# ---------------------------------------------------------------------------
118+
119+
def git(*args, env=None) -> str:
120+
"""Run a git command and return stdout, raising on failure."""
121+
result = subprocess.run(["git", *args], capture_output=True, text=True, env=env)
122+
if result.returncode != 0:
123+
raise RuntimeError(result.stderr.strip())
124+
return result.stdout.strip()
125+
126+
127+
def tag_exists(tag: str) -> bool:
128+
try:
129+
git("rev-parse", tag)
130+
return True
131+
except RuntimeError:
132+
return False
133+
134+
135+
def resolve_commit(ref: str) -> str:
136+
"""Return the full commit SHA for ref, or raise RuntimeError."""
137+
try:
138+
return git("rev-parse", f"{ref}^{{commit}}")
139+
except RuntimeError:
140+
raise RuntimeError(
141+
f"Commit '{ref}' not found in this repository.\n"
142+
"Ensure your local repo is up to date: git fetch origin"
143+
)
144+
145+
146+
# ---------------------------------------------------------------------------
147+
# Main
148+
# ---------------------------------------------------------------------------
149+
150+
def main():
151+
parser = argparse.ArgumentParser(
152+
description="Create an annotated WMCO release tag.",
153+
formatter_class=argparse.RawDescriptionHelpFormatter,
154+
epilog="""\
155+
Examples:
156+
python3 hack/create-release-tag.py 10.21.1
157+
python3 hack/create-release-tag.py 10.17.2 --commit abc1234 --date 2025-06-03
158+
""",
159+
)
160+
parser.add_argument("version", metavar="X.Y.Z",
161+
help="Release version without 'v' prefix")
162+
parser.add_argument("--commit", metavar="SHA",
163+
help="Override commit SHA (required if version is not in catalog)")
164+
parser.add_argument("--date", metavar="YYYY-MM-DD",
165+
help="Override published date")
166+
args = parser.parse_args()
167+
168+
# Validate inputs
169+
if not re.fullmatch(r"\d+\.\d+\.\d+", args.version):
170+
parser.error(f"version must be X.Y.Z, got: {args.version!r}")
171+
172+
if args.date and not re.fullmatch(r"\d{4}-\d{2}-\d{2}", args.date):
173+
parser.error(f"--date must be YYYY-MM-DD, got: {args.date!r}")
174+
175+
version = args.version
176+
tag = f"v{version}"
177+
message = f"Windows Machine Config Operator {tag}"
178+
179+
if tag_exists(tag):
180+
print(f"ERROR: Tag '{tag}' already exists. Delete it first if you intend to recreate it.",
181+
file=sys.stderr)
182+
sys.exit(1)
183+
184+
# Resolve commit
185+
need_catalog = not args.commit or not args.date
186+
if need_catalog:
187+
print(f"Fetching release details for {tag} from Red Hat Container Catalog...", flush=True)
188+
189+
if args.commit:
190+
commit_sha = args.commit
191+
commit_source = "provided manually"
192+
else:
193+
commit_sha, commit_source = fetch_bundle_info(version)
194+
if not commit_sha:
195+
print(f"\nERROR: Could not resolve commit SHA for {tag}.", file=sys.stderr)
196+
print(" The bundle image for this version may not be in the catalog.", file=sys.stderr)
197+
print(" Provide the commit manually: --commit <SHA>", file=sys.stderr)
198+
sys.exit(1)
199+
200+
if args.date:
201+
published_date = args.date
202+
date_source = "provided manually"
203+
else:
204+
published_date = fetch_operator_push_date(version)
205+
date_source = "operator image push date"
206+
if not published_date:
207+
print(f"\nERROR: Could not resolve published date for {tag}.", file=sys.stderr)
208+
print(" The operator image for this version may not be in the catalog.", file=sys.stderr)
209+
print(" Provide the date manually: --date YYYY-MM-DD", file=sys.stderr)
210+
sys.exit(1)
211+
212+
# Expand to full commit SHA and verify it exists locally
213+
try:
214+
full_commit = resolve_commit(commit_sha)
215+
except RuntimeError as exc:
216+
print(f"\nERROR: {exc}", file=sys.stderr)
217+
sys.exit(1)
218+
219+
# Use noon UTC on the published date for an unambiguous timestamp
220+
tag_date = f"{published_date}T12:00:00+0000"
221+
222+
# Confirm with user
223+
print()
224+
print("Tag details:")
225+
print()
226+
print(f" {'Tag:':<10} {tag}")
227+
print(f" {'Message:':<10} {message}")
228+
print(f" {'Commit:':<10} {full_commit} ({commit_source})")
229+
print(f" {'Date:':<10} {tag_date} ({date_source})")
230+
print()
231+
try:
232+
answer = input("Proceed? [y/N] ")
233+
except (KeyboardInterrupt, EOFError):
234+
print("\nAborted.")
235+
sys.exit(0)
236+
237+
if answer.strip().lower() != "y":
238+
print("Aborted.")
239+
sys.exit(0)
240+
241+
# Create the annotated tag with the catalog publish date
242+
env = {**os.environ, "GIT_COMMITTER_DATE": tag_date}
243+
try:
244+
git("tag", "-a", tag, full_commit, "-m", message, env=env)
245+
except RuntimeError as exc:
246+
print(f"\nERROR: Failed to create tag: {exc}", file=sys.stderr)
247+
sys.exit(1)
248+
249+
print()
250+
print(f"Tag '{tag}' created. To push:")
251+
print(f" git push origin {tag}")
252+
253+
254+
if __name__ == "__main__":
255+
main()

0 commit comments

Comments
 (0)