diff --git a/.github/workflows/release-modules.yml b/.github/workflows/release-modules.yml new file mode 100644 index 00000000..a4748e05 --- /dev/null +++ b/.github/workflows/release-modules.yml @@ -0,0 +1,67 @@ +name: Release HugoBlox Modules + +on: + workflow_dispatch: + inputs: + dry_run: + description: "Run without committing/tagging" + type: boolean + default: true + propagate: + description: "Propagate dependency bumps to dependents" + type: boolean + default: true + +permissions: + contents: write + +concurrency: + group: release-modules + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.8.3 + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: | + poetry --version + poetry install --no-interaction --no-ansi + + - name: Configure Git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Show plan (dry run) + if: ${{ inputs.dry_run }} + run: | + poetry run python scripts/release_modules.py --print-plan + + - name: Release (commit, tag, push) + if: ${{ !inputs.dry_run }} + run: | + if [ "${{ inputs.propagate }}" = "true" ]; then + poetry run python scripts/release_modules.py --yes --propagate + else + poetry run python scripts/release_modules.py --yes --no-propagate + fi + + diff --git a/scripts/release_modules.py b/scripts/release_modules.py new file mode 100644 index 00000000..fcf47705 --- /dev/null +++ b/scripts/release_modules.py @@ -0,0 +1,519 @@ +#!/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 starter templates' go.mod to latest released module versions and bumps Hugo version in CI/Netlify config + +Usage examples: + poetry run python scripts/release_modules.py --dry-run + poetry run python scripts/release_modules.py --yes --no-propagate + +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 +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 / "starters" +EXCLUDED_MODULE_DIRS = {"blox-bootstrap"} + + +def run_cmd(args: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess: + process = subprocess.run(args, cwd=str(cwd) if cwd else None, text=True, capture_output=True) + if check and process.returncode != 0: + 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 + + +@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, + ) + + # Fill dependents + for mod in modules.values(): + for req in list(mod.requires): + if req in modules: + modules[req].dependents.add(mod.module_path) + 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()] + 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) + return any(l.strip() for l in res.stdout.splitlines()) + + +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) + return updated + + +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 + + +def stage_and_maybe_commit(paths: List[Path], message: str, commit: bool, push: bool) -> None: + if not paths: + return + str_paths = [str(p.relative_to(REPO_ROOT)) for p in paths] + run_cmd(["git", "add", *str_paths], cwd=REPO_ROOT) + if commit: + run_cmd(["git", "commit", "-m", message], cwd=REPO_ROOT) + if push: + 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: + run_cmd(["git", "tag", "-a", tag, "-m", annotate_msg], cwd=REPO_ROOT) + 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()) + 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) + 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) + + # 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) + + # 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) + + # bump publish.yaml env WC_HUGO_VERSION + workflow = starter_dir / ".github" / "workflows" / "publish.yaml" + 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) + + if updated_paths: + stage_and_maybe_commit(updated_paths, "chore(starters): bump modules and Hugo", commit, push) + + +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.") + + +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.") + args = parser.parse_args() + + yes = bool(args.yes) and not bool(args.dry_run) + commit = yes + push = yes + + modules = discover_modules() + order = topological_order(modules) + + 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 "", + "next": next_version, + "bump": bump, + "effective_bump": effective_bump, + } + will_release.add(m.module_path) + + # 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 "", + "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 + + # 1) Release in dependency order: update metadata, commit, tag, push, then update dependents' go.mod + 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] = [] + # Special-case blox-tailwind theme version bump + touched_files.extend(write_blox_tailwind_theme_version(m, next_version)) + + # 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 + + # 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 Starters (excluding starters-bootstrap) + update_starters(modules, commit=True, push=True) + + print("Release complete.") + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +