hugo-academic-mirror/scripts/release_modules.py

763 lines
30 KiB
Python
Raw Permalink Normal View History

2025-08-20 01:03:08 +01:00
#!/usr/bin/env python3
"""
Release management for HugoBlox modules.
Features:
- Detects changes per module since last path-prefixed tag (e.g., modules/blox-tailwind/v0.4.3)
- Determines next semantic version per module (conventional commits heuristics, default to patch)
- Updates special module metadata (blox-tailwind data/hugoblox.yaml version)
- Updates dependent modules' go.mod "require" versions when a dependency is released
- Tags changed modules with annotated tags and pushes tags
- Updates templates' go.mod to latest released module versions and bumps Hugo version in CI/Netlify config
2025-08-20 01:03:08 +01:00
Usage examples:
2025-08-20 01:27:19 +01:00
poetry run python scripts/release_modules.py --dry-run --log-level INFO
poetry run python scripts/release_modules.py --yes --no-propagate --log-level DEBUG
2025-08-20 01:03:08 +01:00
Notes:
- Tags must be created with the subdirectory path prefix per Go's submodule tagging rules.
- For modules with major version path suffix (e.g., /v5), versions must start with that major (e.g., v5.2.3).
- This script assumes you have a clean working tree before running with --yes.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
2025-09-21 16:17:58 +01:00
from datetime import datetime, timezone
2025-08-20 01:27:19 +01:00
import logging
2025-08-20 01:03:08 +01:00
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
import requests
import tomlkit
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import SingleQuotedScalarString
REPO_ROOT = Path(__file__).resolve().parents[1]
MODULES_DIR = REPO_ROOT / "modules"
STARTERS_DIR = REPO_ROOT / "templates"
2025-08-20 01:03:08 +01:00
EXCLUDED_MODULE_DIRS = {"blox-bootstrap"}
def run_cmd(args: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess:
2025-08-20 01:27:19 +01:00
workdir = str(cwd) if cwd else None
logging.debug("run: %s [cwd=%s]", " ".join(args), workdir or str(REPO_ROOT))
process = subprocess.run(args, cwd=workdir, text=True, capture_output=True)
if process.stdout:
logging.debug("stdout:\n%s", process.stdout.strip())
if process.stderr:
logging.debug("stderr:\n%s", process.stderr.strip())
2025-08-20 01:03:08 +01:00
if check and process.returncode != 0:
2025-08-20 01:27:19 +01:00
logging.error("command failed (%s): %s", process.returncode, " ".join(args))
2025-08-20 01:03:08 +01:00
raise RuntimeError(f"Command failed: {' '.join(args)}\nSTDOUT:\n{process.stdout}\nSTDERR:\n{process.stderr}")
return process
def get_latest_hugo_version() -> Optional[str]:
url = "https://api.github.com/repos/gohugoio/hugo/releases/latest"
try:
resp = requests.get(url, timeout=15)
if resp.status_code == 200:
tag = resp.json().get("tag_name", "").lstrip("v").strip()
return tag or None
except Exception:
pass
return None
def get_module_latest_commit_info(module: Module) -> Optional[Tuple[str, datetime]]:
"""Get the latest commit hash and timestamp for a module."""
try:
# Get latest commit hash for the module directory
res = run_cmd(["git", "log", "-1", "--format=%H %ct", "--", str(module.rel_dir)], cwd=REPO_ROOT)
if not res.stdout.strip():
return None
parts = res.stdout.strip().split()
if len(parts) != 2:
return None
commit_hash = parts[0]
2025-09-21 16:17:58 +01:00
# Use UTC timestamp as required by Go module versioning
timestamp = datetime.fromtimestamp(int(parts[1]), tz=timezone.utc)
return commit_hash, timestamp
except Exception as e:
logging.warning("Failed to get commit info for %s: %s", module.name, e)
return None
def format_pseudo_version(commit_hash: str, timestamp: datetime, base_version: str = "v0.0.0") -> str:
"""Format a commit hash and timestamp as a Go pseudo-version."""
# Format: v0.0.0-20060102150405-abcdefabcdef
2025-09-21 16:17:58 +01:00
# Ensure timestamp is in UTC as required by Go module versioning
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
time_str = timestamp.strftime("%Y%m%d%H%M%S")
short_hash = commit_hash[:12]
return f"{base_version}-{time_str}-{short_hash}"
def update_go_mod_require_pseudo_version(go_mod_text: str, dep_module_path: str, pseudo_version: str) -> str:
"""Replace exact require line with pseudo-version format."""
# This handles both versioned requires (v1.2.3) and existing pseudo-versions
pattern_line = rf"(?m)^(\s*{re.escape(dep_module_path)}\s+)(?:v\d+[^\s]*|v\d+\.\d+\.\d+-\d+-[a-f0-9]+)$"
replacement_line = rf"\g<1>{pseudo_version}"
return re.sub(pattern_line, replacement_line, go_mod_text)
2025-08-20 01:03:08 +01:00
@dataclass
class Module:
name: str # e.g., blox-tailwind
rel_dir: Path # e.g., modules/blox-tailwind
module_path: str # go module path e.g., github.com/HugoBlox/hugo-blox-builder/modules/blox-tailwind
major: int # 0,1,2,... (derived from module_path suffix /vN if any)
requires: Set[str] = field(default_factory=set) # module paths this module requires
dependents: Set[str] = field(default_factory=set) # reverse edge, filled later
def tag_prefix(self) -> str:
# tags must be like: modules/blox-tailwind/vX.Y.Z
return f"{self.rel_dir.as_posix()}/v"
def current_version_tag(self) -> Optional[str]:
# Find last tag for this module, prefixed by module rel path
# Sorted by semantic version descending
# For modules with major >= 2, the version series must start with v{major}.
pattern = f"{self.rel_dir.as_posix()}/v{self.major if self.major >= 2 else ''}*"
# Clean potential double 'v' for major 0/1
pattern = pattern.replace("v*v", "v*")
res = run_cmd(["git", "tag", "--list", pattern, "--sort=-v:refname"], cwd=REPO_ROOT)
tags = [t.strip() for t in res.stdout.splitlines() if t.strip()]
return tags[0] if tags else None
def last_version(self) -> Optional[str]:
tag = self.current_version_tag()
if not tag:
return None
m = re.search(r"/v(\d+\.\d+\.\d+)$", tag)
return m.group(1) if m else None
def _extract_requires_from_go_mod(content: str) -> Set[str]:
requires: Set[str] = set()
# Single-line require
for m in re.findall(r"(?m)^\s*require\s+([^\s]+)\s+v[0-9][^\s]*$", content):
requires.add(m.strip())
# Block require
for block in re.findall(r"(?ms)^require\s*\(\s*(.*?)\s*\)", content):
for line in block.splitlines():
m = re.search(r"^\s*([^\s]+)\s+v[0-9][^\s]*$", line)
if m:
requires.add(m.group(1).strip())
return requires
def discover_modules() -> Dict[str, Module]:
modules: Dict[str, Module] = {}
for go_mod in MODULES_DIR.glob("*/go.mod"):
rel_dir = go_mod.parent.relative_to(REPO_ROOT)
if rel_dir.name in EXCLUDED_MODULE_DIRS:
continue
content = go_mod.read_text(encoding="utf-8")
m = re.search(r"^module\s+(.+)$", content, re.MULTILINE)
if not m:
continue
module_path = m.group(1).strip()
name = rel_dir.name
major = 0
major_suffix = re.search(r"/v(\d+)$", module_path)
if major_suffix:
major = int(major_suffix.group(1))
else:
# default major version is 0 or 1. We keep 0 to preserve existing series.
# We don't force elevation to 1 automatically.
major = 0
requires: Set[str] = _extract_requires_from_go_mod(content)
modules[module_path] = Module(
name=name,
rel_dir=rel_dir,
module_path=module_path,
major=major,
requires=requires,
)
2025-08-20 01:27:19 +01:00
logging.debug("discovered module: %s (dir=%s, major=%s, requires=%s)", module_path, rel_dir, major, sorted(list(requires)))
2025-08-20 01:03:08 +01:00
# Fill dependents
for mod in modules.values():
for req in list(mod.requires):
if req in modules:
modules[req].dependents.add(mod.module_path)
2025-08-20 01:27:19 +01:00
logging.info("discovered %d modules", len(modules))
2025-08-20 01:03:08 +01:00
return modules
def get_commits_since_tag(module: Module, tag: Optional[str]) -> List[str]:
if not tag:
# All history counts as changes for initial release
res = run_cmd(["git", "log", "--pretty=%s", "--", str(module.rel_dir)], cwd=REPO_ROOT)
else:
res = run_cmd(["git", "log", f"{tag}..HEAD", "--pretty=%s", "--", str(module.rel_dir)], cwd=REPO_ROOT)
msgs = [l.strip() for l in res.stdout.splitlines() if l.strip()]
2025-08-20 01:27:19 +01:00
logging.debug("%s commits since %s: %d", module.name, tag or "<none>", len(msgs))
2025-08-20 01:03:08 +01:00
return msgs
def has_changes_since_tag(module: Module, tag: Optional[str]) -> bool:
if not tag:
return True
res = run_cmd(["git", "diff", "--name-only", tag, "--", str(module.rel_dir)], cwd=REPO_ROOT)
2025-08-20 01:27:19 +01:00
changed = any(l.strip() for l in res.stdout.splitlines())
logging.debug("changed since tag? %s: %s", module.name, changed)
return changed
2025-08-20 01:03:08 +01:00
def determine_bump_from_commits(commits: List[str]) -> str:
# conventional commits heuristic
# returns one of: "major" | "minor" | "patch"
bump = "patch"
for msg in commits:
lower = msg.lower()
if "breaking change" in lower or re.search(r"^\w+(\(.+\))?!:", lower):
return "major"
if lower.startswith("feat:") or lower.startswith("feat("):
bump = "minor"
return bump
def bump_semver(version: Optional[str], bump: str, enforced_major: Optional[int]) -> str:
"""
Calculate next version. For modules with v2+ path (enforced_major >= 2), do NOT auto-bump
the major number here; a true major requires a new module path (/vN+1). We clamp the major to
enforced_major and treat a requested "major" bump as "minor".
For modules without a vN suffix, v0->v1 is allowed, but v1->v2 is NOT (would require /v2 path),
so a requested "major" bump at v1 is treated as "minor".
"""
if version is None:
# initial release default
if enforced_major and enforced_major >= 2:
return f"{enforced_major}.0.0"
return "0.1.0"
major, minor, patch = [int(x) for x in version.split(".")]
if enforced_major and enforced_major >= 2:
# clamp major and downgrade a requested major bump to minor
major = enforced_major
effective_bump = "minor" if bump == "major" else bump
else:
# No suffix: v0->v1 is OK, but v1->v2 requires path change we don't automate here
if bump == "major" and major >= 1:
effective_bump = "minor"
else:
effective_bump = bump
if effective_bump == "major":
major += 1
minor = 0
patch = 0
elif effective_bump == "minor":
minor += 1
patch = 0
else:
patch += 1
return f"{major}.{minor}.{patch}"
def write_blox_tailwind_theme_version(module: Module, version: str) -> List[Path]:
updated: List[Path] = []
# Update modules/blox-tailwind/data/hugoblox.yaml version: "X.Y.Z"
if module.name != "blox-tailwind":
return updated
data_file = module.rel_dir / "data" / "hugoblox.yaml"
if not data_file.exists():
return updated
yaml = YAML()
yaml.preserve_quotes = True
with data_file.open("r", encoding="utf-8") as f:
data = yaml.load(f) or {}
data["version"] = SingleQuotedScalarString(version)
with data_file.open("w", encoding="utf-8") as f:
yaml.dump(data, f)
updated.append(data_file)
2025-08-20 01:27:19 +01:00
logging.info("bumped theme version in %s to %s", data_file, version)
2025-08-20 01:03:08 +01:00
return updated
def update_citation_release_info(module: Module, version: str) -> List[Path]:
"""Update the public citation metadata to the latest blox-tailwind release."""
if module.name != "blox-tailwind":
return []
citation = REPO_ROOT / "CITATION.cff"
if not citation.exists():
return []
original = citation.read_text(encoding="utf-8")
release_date = datetime.now(timezone.utc).date().isoformat()
updated = original
version_pattern = r'(?m)^(version:\s*)"[^"]+"'
if re.search(version_pattern, updated):
updated = re.sub(version_pattern, rf'\g<1>"{version}"', updated)
else:
updated = f'{updated.rstrip()}\nversion: "{version}"\n'
date_pattern = r'(?m)^(date-released:\s*)(.+)$'
if re.search(date_pattern, updated):
updated = re.sub(date_pattern, rf"\g<1>{release_date}", updated)
else:
updated = f"{updated.rstrip()}\ndate-released: {release_date}\n"
if updated != original:
citation.write_text(updated, encoding="utf-8")
logging.info("updated CITATION.cff -> version %s, date %s", version, release_date)
return [citation]
return []
2025-08-20 01:03:08 +01:00
def update_go_mod_require_version(go_mod_text: str, dep_module_path: str, new_version: str) -> str:
# Replace exact require line if present
# Handles both single-line require and block form
pattern_line = rf"(?m)^(\s*{re.escape(dep_module_path)}\s+)v\d+[^\s]*$"
replacement_line = rf"\g<1>v{new_version}"
def replace_in_block(text: str) -> str:
# single-line replacement will naturally work for block content too
return re.sub(pattern_line, replacement_line, text)
# First try block section-specific replace
new_text = replace_in_block(go_mod_text)
return new_text
2025-08-20 02:37:50 +01:00
def update_module_requirements_to_latest(
module: Module,
modules: Dict[str, Module],
known_latest_versions: Dict[str, str],
) -> Optional[Path]:
"""
Ensure module's go.mod requires the latest tag for all internal dependencies.
Uses known_latest_versions (from earlier releases in this run) or the dependency's last tag.
Returns the go.mod path if changed.
"""
go_mod_file = module.rel_dir / "go.mod"
if not go_mod_file.exists() or not module.requires:
return None
original = go_mod_file.read_text(encoding="utf-8")
updated = original
for dep_path in sorted(module.requires):
if dep_path not in modules:
continue # external dep
version = known_latest_versions.get(dep_path) or modules[dep_path].last_version()
if not version:
continue
updated = update_go_mod_require_version(updated, dep_path, version)
if updated != original:
go_mod_file.write_text(updated, encoding="utf-8")
logging.info("%s: synced go.mod internal deps to latest tags", module.name)
return go_mod_file
return None
2025-08-20 01:03:08 +01:00
def stage_and_maybe_commit(paths: List[Path], message: str, commit: bool, push: bool) -> None:
if not paths:
return
2025-08-20 01:27:19 +01:00
str_paths: List[str] = []
for p in paths:
if p.is_absolute():
try:
rel = p.relative_to(REPO_ROOT)
str_paths.append(str(rel))
except Exception:
# Fallback to OS relpath if not within repo root
rel = os.path.relpath(str(p), str(REPO_ROOT))
str_paths.append(rel)
else:
# Use as-is relative to repo root since git cwd is REPO_ROOT
str_paths.append(str(p))
logging.info("git add: %s", ", ".join(str_paths))
2025-08-20 01:03:08 +01:00
run_cmd(["git", "add", *str_paths], cwd=REPO_ROOT)
if commit:
2025-08-20 01:27:19 +01:00
logging.info("git commit -m '%s'", message)
2025-08-20 01:03:08 +01:00
run_cmd(["git", "commit", "-m", message], cwd=REPO_ROOT)
if push:
2025-08-20 01:27:19 +01:00
logging.info("git push origin main")
2025-08-20 01:03:08 +01:00
run_cmd(["git", "push", "origin", "main"], cwd=REPO_ROOT)
def create_and_push_tag(module: Module, version: str, yes: bool) -> None:
tag = f"{module.rel_dir.as_posix()}/v{version}"
annotate_msg = f"{module.name}: v{version}"
if yes:
2025-08-20 01:27:19 +01:00
logging.info("create tag %s", tag)
2025-08-20 01:03:08 +01:00
run_cmd(["git", "tag", "-a", tag, "-m", annotate_msg], cwd=REPO_ROOT)
2025-08-20 01:27:19 +01:00
logging.info("push tag %s", tag)
2025-08-20 01:03:08 +01:00
run_cmd(["git", "push", "origin", tag], cwd=REPO_ROOT)
def topological_order(modules: Dict[str, Module]) -> List[Module]:
# Kahn's algorithm
in_degree: Dict[str, int] = {mp: 0 for mp in modules}
for m in modules.values():
for req in m.requires:
if req in modules:
in_degree[m.module_path] += 1
queue: List[str] = [mp for mp, deg in in_degree.items() if deg == 0]
ordered: List[Module] = []
while queue:
mp = queue.pop(0)
ordered.append(modules[mp])
for dep in modules[mp].dependents:
in_degree[dep] -= 1
if in_degree[dep] == 0:
queue.append(dep)
# If cycle (shouldn't happen), just return arbitrary order
if len(ordered) != len(modules):
return list(modules.values())
2025-08-20 01:27:19 +01:00
logging.info("release order: %s", ", ".join(m.module_path for m in ordered))
2025-08-20 01:03:08 +01:00
return ordered
def update_dependents_for_release(
modules: Dict[str, Module],
released_module: Module,
released_version: str,
commit: bool,
push: bool,
) -> List[Module]:
"""
For each dependent module, update go.mod to require the released_module at released_version.
Returns the list of modules that were touched (and thus should be considered changed for this run).
"""
touched: List[Module] = []
for dependent_path in sorted(released_module.dependents):
dep_mod = modules.get(dependent_path)
if not dep_mod:
continue
go_mod_file = dep_mod.rel_dir / "go.mod"
if not go_mod_file.exists():
continue
original = go_mod_file.read_text(encoding="utf-8")
updated = update_go_mod_require_version(original, released_module.module_path, released_version)
if updated != original:
go_mod_file.write_text(updated, encoding="utf-8")
stage_and_maybe_commit([go_mod_file], f"chore({dep_mod.name}): require {released_module.name} v{released_version}", commit, push)
2025-08-20 01:27:19 +01:00
logging.info("updated dependent %s go.mod -> %s %s", dep_mod.name, released_module.name, released_version)
2025-08-20 01:03:08 +01:00
touched.append(dep_mod)
return touched
def update_starters(modules: Dict[str, Module], commit: bool, push: bool) -> None:
updated_paths: List[Path] = []
latest_hugo = get_latest_hugo_version()
yaml = YAML()
yaml.preserve_quotes = True
for starter_dir in sorted([p for p in STARTERS_DIR.glob("*") if p.is_dir()]):
# skip non-starters explicitly (none yet) and respect ignore list
go_mod = starter_dir / "go.mod"
if go_mod.exists():
text = go_mod.read_text(encoding="utf-8")
new_text = text
# For any internal module referenced, replace with its latest tag version
referenced = _extract_requires_from_go_mod(text)
for module_path in referenced:
if module_path in modules:
latest = modules[module_path].last_version()
if latest:
new_text = update_go_mod_require_version(new_text, module_path, latest)
if new_text != text:
go_mod.write_text(new_text, encoding="utf-8")
updated_paths.append(go_mod)
2025-08-20 01:27:19 +01:00
logging.info("starter %s: updated go.mod module versions", starter_dir.name)
2025-08-20 01:03:08 +01:00
# bump hugo version in hugoblox.yaml
hb_yaml = starter_dir / "hugoblox.yaml"
if latest_hugo and hb_yaml.exists():
with hb_yaml.open("r", encoding="utf-8") as f:
data = yaml.load(f) or {}
if "build" not in data:
data["build"] = {}
if data["build"].get("hugo_version") != latest_hugo:
data["build"]["hugo_version"] = SingleQuotedScalarString(latest_hugo)
with hb_yaml.open("w", encoding="utf-8") as f:
yaml.dump(data, f)
updated_paths.append(hb_yaml)
2025-08-20 01:27:19 +01:00
logging.info("starter %s: hugoblox.yaml hugo_version -> %s", starter_dir.name, latest_hugo)
2025-08-20 01:03:08 +01:00
# bump netlify.toml HUGO_VERSION
netlify = starter_dir / "netlify.toml"
if latest_hugo and netlify.exists():
parsed = tomlkit.parse(netlify.read_text(encoding="utf-8"))
if "build" in parsed and "environment" in parsed["build"]:
env = parsed["build"]["environment"]
if env.get("HUGO_VERSION") != latest_hugo:
env["HUGO_VERSION"] = latest_hugo
netlify.write_text(tomlkit.dumps(parsed), encoding="utf-8")
updated_paths.append(netlify)
2025-08-20 01:27:19 +01:00
logging.info("starter %s: netlify.toml HUGO_VERSION -> %s", starter_dir.name, latest_hugo)
2025-08-20 01:03:08 +01:00
# bump deploy.yml env WC_HUGO_VERSION
workflow = starter_dir / ".github" / "workflows" / "deploy.yml"
2025-08-20 01:03:08 +01:00
if latest_hugo and workflow.exists():
with workflow.open("r", encoding="utf-8") as f:
wf = yaml.load(f) or {}
if "env" in wf and wf["env"].get("WC_HUGO_VERSION") != latest_hugo:
wf["env"]["WC_HUGO_VERSION"] = latest_hugo
with workflow.open("w", encoding="utf-8") as f:
yaml.dump(wf, f)
updated_paths.append(workflow)
logging.info("starter %s: deploy.yml WC_HUGO_VERSION -> %s", starter_dir.name, latest_hugo)
2025-08-20 01:03:08 +01:00
if updated_paths:
stage_and_maybe_commit(updated_paths, "chore(templates): bump modules and Hugo", commit, push)
2025-08-20 01:03:08 +01:00
def update_starters_to_commits(modules: Dict[str, Module], commit: bool, push: bool) -> None:
"""Update starters to use latest commit hashes of modules instead of tagged versions."""
updated_paths: List[Path] = []
latest_hugo = get_latest_hugo_version()
yaml = YAML()
yaml.preserve_quotes = True
# Get commit info for all modules first
module_commit_info: Dict[str, Tuple[str, str]] = {} # module_path -> (pseudo_version, short_description)
for module in modules.values():
commit_info = get_module_latest_commit_info(module)
if commit_info:
commit_hash, timestamp = commit_info
pseudo_version = format_pseudo_version(commit_hash, timestamp)
short_desc = f"{commit_hash[:7]} ({timestamp.strftime('%Y-%m-%d %H:%M:%S')})"
module_commit_info[module.module_path] = (pseudo_version, short_desc)
logging.info("module %s -> %s (%s)", module.name, pseudo_version, short_desc)
for starter_dir in sorted([p for p in STARTERS_DIR.glob("*") if p.is_dir()]):
# Update go.mod with commit-based pseudo-versions
go_mod = starter_dir / "go.mod"
if go_mod.exists():
text = go_mod.read_text(encoding="utf-8")
new_text = text
referenced = _extract_requires_from_go_mod(text)
for module_path in referenced:
if module_path in modules and module_path in module_commit_info:
pseudo_version, short_desc = module_commit_info[module_path]
new_text = update_go_mod_require_pseudo_version(new_text, module_path, pseudo_version)
logging.info("starter %s: %s -> %s", starter_dir.name, modules[module_path].name, short_desc)
if new_text != text:
go_mod.write_text(new_text, encoding="utf-8")
updated_paths.append(go_mod)
logging.info("starter %s: updated go.mod to use commit versions", starter_dir.name)
# bump hugo version in hugoblox.yaml (same as regular update_starters)
hb_yaml = starter_dir / "hugoblox.yaml"
if latest_hugo and hb_yaml.exists():
with hb_yaml.open("r", encoding="utf-8") as f:
data = yaml.load(f) or {}
if "build" not in data:
data["build"] = {}
if data["build"].get("hugo_version") != latest_hugo:
data["build"]["hugo_version"] = SingleQuotedScalarString(latest_hugo)
with hb_yaml.open("w", encoding="utf-8") as f:
yaml.dump(data, f)
updated_paths.append(hb_yaml)
logging.info("starter %s: hugoblox.yaml hugo_version -> %s", starter_dir.name, latest_hugo)
# bump netlify.toml HUGO_VERSION (same as regular update_starters)
netlify = starter_dir / "netlify.toml"
if latest_hugo and netlify.exists():
parsed = tomlkit.parse(netlify.read_text(encoding="utf-8"))
if "build" in parsed and "environment" in parsed["build"]:
env = parsed["build"]["environment"]
if env.get("HUGO_VERSION") != latest_hugo:
env["HUGO_VERSION"] = latest_hugo
netlify.write_text(tomlkit.dumps(parsed), encoding="utf-8")
updated_paths.append(netlify)
logging.info("starter %s: netlify.toml HUGO_VERSION -> %s", starter_dir.name, latest_hugo)
# bump deploy.yml env WC_HUGO_VERSION (same as regular update_starters)
workflow = starter_dir / ".github" / "workflows" / "deploy.yml"
if latest_hugo and workflow.exists():
with workflow.open("r", encoding="utf-8") as f:
wf = yaml.load(f) or {}
if "env" in wf and wf["env"].get("WC_HUGO_VERSION") != latest_hugo:
wf["env"]["WC_HUGO_VERSION"] = latest_hugo
with workflow.open("w", encoding="utf-8") as f:
yaml.dump(wf, f)
updated_paths.append(workflow)
logging.info("starter %s: deploy.yml WC_HUGO_VERSION -> %s", starter_dir.name, latest_hugo)
if updated_paths:
stage_and_maybe_commit(updated_paths, "chore(templates): update modules to latest commits", commit, push)
logging.info("Updated %d template files to use latest module commits", len(updated_paths))
2025-08-20 01:03:08 +01:00
def ensure_clean_worktree() -> None:
res = run_cmd(["git", "status", "--porcelain"], cwd=REPO_ROOT)
dirty = [l for l in res.stdout.splitlines() if l.strip()]
if dirty:
raise RuntimeError("Working tree is dirty. Commit or stash changes before running with --yes. Use --dry-run to preview.")
2025-08-20 01:27:19 +01:00
logging.info("working tree is clean")
2025-08-20 01:03:08 +01:00
def main() -> None:
parser = argparse.ArgumentParser(description="Release HugoBlox modules.")
parser.add_argument("--yes", action="store_true", help="Execute changes (commits, tags, pushes). Without this, it's a dry run.")
parser.add_argument("--dry-run", action="store_true", help="Alias for not passing --yes; forces no-op execution.")
parser.add_argument("--propagate", action="store_true", default=True, help="Propagate dependency updates to dependents (default true).")
parser.add_argument("--no-propagate", dest="propagate", action="store_false", help="Do not bump dependents purely for dependency updates.")
parser.add_argument("--allow-major", action="store_true", help="Allow major version bumps when inferred from commits. By default majors are downgraded to minor to avoid Go module path changes.")
parser.add_argument("--print-plan", action="store_true", help="Print the planned releases as JSON.")
parser.add_argument("--update-starters-to-commits", action="store_true", help="Update starters to use latest module commits instead of releasing new tags. This is a standalone operation that doesn't create releases.")
2025-08-20 01:27:19 +01:00
parser.add_argument("--log-level", default=os.getenv("LOG_LEVEL", "INFO"), help="Logging level: DEBUG, INFO, WARNING, ERROR")
2025-08-20 01:03:08 +01:00
args = parser.parse_args()
2025-08-20 01:27:19 +01:00
# Configure logging
level = getattr(logging, str(args.log_level).upper(), logging.INFO)
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
2025-08-20 01:03:08 +01:00
yes = bool(args.yes) and not bool(args.dry_run)
commit = yes
push = yes
modules = discover_modules()
order = topological_order(modules)
# Handle the standalone --update-starters-to-commits option
if args.update_starters_to_commits:
if yes:
ensure_clean_worktree()
logging.info("Updating templates to use latest module commits...")
update_starters_to_commits(modules, commit=yes, push=yes)
logging.info("Template update to commits complete")
return
2025-08-20 01:03:08 +01:00
if yes:
ensure_clean_worktree()
# Detect changes and planned bumps
planned: Dict[str, Dict[str, str]] = {}
will_release: Set[str] = set()
changed_since_tag: Dict[str, bool] = {}
for m in order:
tag = m.current_version_tag()
changed = has_changes_since_tag(m, tag)
changed_since_tag[m.module_path] = changed
if not changed:
continue
commits = get_commits_since_tag(m, tag)
bump = determine_bump_from_commits(commits)
effective_bump = "minor" if (bump == "major" and not args.allow_major) else bump
next_version = bump_semver(m.last_version(), effective_bump, m.major if m.major >= 2 else None)
planned[m.module_path] = {
"name": m.name,
"path": m.rel_dir.as_posix(),
"last": m.last_version() or "<none>",
"next": next_version,
"bump": bump,
"effective_bump": effective_bump,
}
will_release.add(m.module_path)
2025-08-20 01:27:19 +01:00
logging.info("plan: %s -> %s (bump=%s, effective=%s)", m.name, next_version, bump, effective_bump)
2025-08-20 01:03:08 +01:00
# If propagate is enabled, any dependent of a releasing module is considered for release later (due to go.mod update)
if args.propagate:
frontier = list(will_release)
while frontier:
mp = frontier.pop()
for dep in modules[mp].dependents:
if dep not in will_release:
# For dependents that weren't changed otherwise, default to patch bump
base_version = modules[dep].last_version()
next_version = bump_semver(base_version, "patch", modules[dep].major if modules[dep].major >= 2 else None)
planned[dep] = planned.get(dep, {
"name": modules[dep].name,
"path": modules[dep].rel_dir.as_posix(),
"last": base_version or "<none>",
"next": next_version,
"bump": "patch",
})
will_release.add(dep)
frontier.append(dep)
if args.print_plan or not yes:
print(json.dumps({
"planned_releases": planned,
"order": [m.module_path for m in order],
}, indent=2))
if not yes:
return
2025-08-20 02:37:50 +01:00
# 1) Release in dependency order: sync module go.mod deps, update metadata, commit, tag, push, then update dependents' go.mod
2025-08-20 01:03:08 +01:00
latest_versions: Dict[str, str] = {}
for m in order:
if m.module_path not in will_release:
continue
next_version = planned[m.module_path]["next"]
touched_files: List[Path] = []
2025-08-20 02:37:50 +01:00
# Sync internal deps in this module's go.mod to latest known tags before tagging
go_mod_synced = update_module_requirements_to_latest(m, modules, latest_versions)
if go_mod_synced:
touched_files.append(go_mod_synced)
# Special-case blox-tailwind theme version bump and citation metadata
2025-08-20 01:03:08 +01:00
touched_files.extend(write_blox_tailwind_theme_version(m, next_version))
touched_files.extend(update_citation_release_info(m, next_version))
2025-08-20 01:03:08 +01:00
# Stage and commit any pre-release file changes
if touched_files:
stage_and_maybe_commit(touched_files, f"chore({m.name}): prepare v{next_version}", commit=True, push=True)
# Tag and push
create_and_push_tag(m, next_version, yes=True)
latest_versions[m.module_path] = next_version
2025-08-20 01:27:19 +01:00
logging.info("released %s v%s", m.name, next_version)
2025-08-20 01:03:08 +01:00
# Update dependents to require this new version (causes their go.mod to change)
touched_dependents = update_dependents_for_release(modules, m, next_version, commit=True, push=True)
# Note: those dependents will be tagged in their respective iteration when reached in order
# 2) After all module tags are created and dependents were updated, ensure every planned module has been tagged
# It's possible a dependent with no other changes still needs a tag due to go.mod update above
for m in order:
if m.module_path not in will_release:
continue
# If it wasn't directly tagged earlier (it was), this loop is just a safeguard
next_version = planned[m.module_path]["next"]
# Verify tag exists; if not, create it
tag = f"{m.rel_dir.as_posix()}/v{next_version}"
res = run_cmd(["git", "tag", "--list", tag], cwd=REPO_ROOT)
if not res.stdout.strip():
create_and_push_tag(m, next_version, yes=True)
# 3) Update Templates
2025-08-20 01:03:08 +01:00
update_starters(modules, commit=True, push=True)
2025-08-20 01:27:19 +01:00
logging.info("release complete")
2025-08-20 01:03:08 +01:00
if __name__ == "__main__":
try:
main()
except Exception as e:
2025-08-20 01:27:19 +01:00
logging.exception("release failed: %s", e)
2025-08-20 01:03:08 +01:00
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)