diff --git a/.gitignore b/.gitignore index 3aa69d9e..7121abd4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,10 @@ replay_pid* *.DS_Store scripts/policy-description-count/ + +# Generated policy release report (local artifact, not committed) +policy-release-status.csv + +# Python caches +__pycache__/ +*.pyc diff --git a/scripts/policy-release/README.md b/scripts/policy-release/README.md new file mode 100644 index 00000000..e0ebd804 --- /dev/null +++ b/scripts/policy-release/README.md @@ -0,0 +1,140 @@ +# Policy Release Tooling + +Two scripts that work together to (1) report which policies need releasing and +(2) release them in dependency order via the GitHub **Release Policy** workflow. + +| Script | Role | +|---|---| +| `policy_release_status.py` | **Read-only.** Fetches upstream tags, inspects each policy, and writes `policy-release-status.csv`. | +| `release_policies.py` | **Dispatch-only.** Reads that CSV and triggers the release workflow, wave by wave. Never edits code. | + +Run `policy_release_status.py` first; `release_policies.py` consumes its CSV. + +--- + +## 1. Generate the status report + +```bash +python3 scripts/policy-release/policy_release_status.py +``` + +- Requires Python 3.10+, git, and an `upstream` remote (`git remote -v` to check). +- Fetches tags from `upstream`, then writes `policy-release-status.csv` at the repo root. +- Prints a per-policy summary grouped by dependency wave. + +The generated CSV is **git-ignored** — it is a local working artifact. Upload it +to Google Sheets (File → Import → Upload) to slice/filter further. + +### CSV columns + +| Column | Meaning | +|---|---| +| `policy_name` | Policy name (= folder name) | +| `policy_type` | `go` (has `go.mod`) / `python` (has `pyproject.toml`) / `unknown` | +| `latest_released_version` | Latest `policies//vX.Y.Z` tag on upstream (no `v` prefix) | +| `yaml_version` | `version:` from `policy-definition.yaml` (no `v` prefix) | +| `yaml_version_bumped` | `yes` if `yaml_version` is strictly **ahead** of the latest release | +| `version_files_consistent` | python: `yes` if `pyproject.toml` version == yaml; go: `n/a` | +| `needs_release` | `yes` if own commits exist **or** a dependency is being released | +| `release_reason` | `own-changes` / `dependency-update` / `both` / `none` | +| `release_ready` | `yes` when own commits landed **and** yaml is bumped ahead (python also requires pyproject == yaml) | +| `release_wave` | Topological wave — release ascending (`0` first) | +| `depends_on` | **Go only.** Intra-repo deps as `name@pinnedVersion` | +| `dependents` | **Go only.** Policies that depend on this one | +| `dep_pin_stale` | **Go only.** `yes` if a pinned dep version ≠ that dep's yaml version (go.mod bump needed) | +| `num_changes` / `changes` | Count and subjects of unreleased commits | + +> **Policy types.** Go policies (`go.mod`) can depend on other policies in this +> repo, so they carry the dependency columns and wave ordering. Python policies +> (`pyproject.toml`) have no inter-policy dependencies — their dependency columns +> are empty and they always land in wave 0. Python policies have a second version +> file (`pyproject.toml`) that must match `policy-definition.yaml`; this is what +> `version_files_consistent` tracks. + +> **Versions are stored without the `v` prefix** so they pass straight to the +> release workflow (which rejects `v`). + +### Interpreting the result + +- **`release_ready = yes`** → ready to release now. +- **`release_reason = dependency-update`, `release_ready = no`** → a dependency is + releasing; bump this policy's go.mod pin + yaml version + commit, then it becomes ready. +- **`⚠ ANOMALY: yaml version is BEHIND latest release`** → the yaml `version:` is + lower than an already-released tag. Fix the yaml before releasing. +- **`⚠ ANOMALY: pyproject.toml version != yaml version`** (python only) → bump both + version files to the same value before releasing; the workflow rejects a mismatch. + +--- + +## 2. Release the policies + +Dry-run first (this is the **default** — it dispatches nothing): + +```bash +python3 scripts/policy-release/release_policies.py +``` + +It prints the exact `gh workflow run` commands, grouped by wave, and lists any +policies held back for safety. + +Then actually dispatch: + +```bash +python3 scripts/policy-release/release_policies.py --execute +``` + +- Requires the [`gh`](https://cli.github.com/) CLI, authenticated with permission + to dispatch workflows on the target repo. +- Selects rows where `release_ready == yes`, groups them by `release_wave`, and for + each wave (ascending) dispatches every policy **concurrently (async)**. +- By **default it watches** each run to completion and reports pass/fail, with a + barrier between waves. If a wave fails, dependent waves are **not** started. + +### Options + +| Flag | Default | Effect | +|---|---|---| +| `--execute` | off (dry-run) | Actually dispatch the workflows | +| `--no-report` | report on | Fire-and-forget: dispatch without watching/reporting status | +| `--repo ` | `wso2/gateway-controllers` | Repo to dispatch against | +| `--ref ` | `main` | Git ref the workflow runs on | +| `--csv ` | `/policy-release-status.csv` | Status CSV to read | +| `--only ` | all ready | Limit to specific policy names | + +Examples: + +```bash +# Dispatch everything ready, don't wait for results +python3 scripts/policy-release/release_policies.py --execute --no-report + +# Release just two policies +python3 scripts/policy-release/release_policies.py --execute --only cors,semantic-cache +``` + +### Safety model (dispatch-only) + +The release script **never edits go.mod or commits** anything. It only triggers +the workflow, which itself re-validates version consistency, tag uniqueness, and +runs tests before tagging. + +If a release-ready policy still has a **stale dependency pin** (`dep_pin_stale = yes`), +it is **held back** with a warning: a human must first bump its go.mod pin to the +new dependency version and commit that. This keeps dependent policies from being +released against an outdated embedded dependency. + +--- + +## Dependency ordering + +Some policies embed others via go.mod (e.g. `basic-ratelimit`, `mcp-ratelimit`, +`token-based-ratelimit`, `llm-cost-based-ratelimit` all require `advanced-ratelimit`; +`mcp-auth` requires `jwt-auth`). Because a dependent compiles the dependency's code +into its own binary, when a dependency is released the dependents should be +re-released to propagate the change — this is why `needs_release` accounts for +dependency updates and why releases run in waves. + +## Notes + +- `policy-release-status.csv` is git-ignored; only these scripts and this README are committed. +- Merge commits are excluded from the `changes` column. +- Only **direct** go.mod requires are treated as policy dependencies. diff --git a/scripts/policy-release/policy_release_status.py b/scripts/policy-release/policy_release_status.py new file mode 100644 index 00000000..00e65443 --- /dev/null +++ b/scripts/policy-release/policy_release_status.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +""" +Generate a CSV report of policy release status, including inter-policy +dependency ordering. + +Run from any directory inside the repository: + python3 scripts/policy-release/policy_release_status.py + +Output: policy-release-status.csv at the repo root. + +The CSV is consumed by release_policies.py to drive wave-ordered releases. +Versions in the CSV are stored WITHOUT the leading 'v' so they can be passed +straight to the Release Policy workflow (which rejects the 'v' prefix). +""" + +import csv +import re +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +POLICIES_DIR = REPO_ROOT / "policies" +OUTPUT_CSV = REPO_ROOT / "policy-release-status.csv" +UPSTREAM_REMOTE = "upstream" + +# Matches an intra-repo policy dependency in a go.mod require line, e.g. +# github.com/wso2/gateway-controllers/policies/advanced-ratelimit v1.1.0 +# github.com/wso2/gateway-controllers/policies/foo/v2 v2.0.1 (major >= 2) +DEP_RE = re.compile( + r"github\.com/wso2/gateway-controllers/policies/([^/\s]+)(?:/v\d+)?\s+(v\d\S*)" +) + +CSV_FIELDS = [ + "policy_name", + "policy_type", # go | python | unknown + "latest_released_version", + "yaml_version", + "yaml_version_bumped", + "version_files_consistent", # python: pyproject == yaml; go: n/a + "needs_release", + "release_reason", # own-changes | dependency-update | both | none + "release_ready", + "release_wave", # topological wave; release ascending (0 first) + "depends_on", # go only: intra-repo deps: name@pinnedVersion; ... + "dependents", # go only: policies that depend on this one + "dep_pin_stale", # go only: yes if a pinned dep version != dep's yaml version + "num_changes", + "changes", +] + + +def run(cmd, cwd=None, check=False): + """Run a command. On non-zero exit, print stderr; raise if check=True. + + Without check, callers still see a warning instead of silently getting "" + (which downstream code would misread as "no tags"/"no commits"). + """ + result = subprocess.run( + cmd, cwd=str(cwd or REPO_ROOT), capture_output=True, text=True + ) + if result.returncode != 0: + msg = result.stderr.strip() or f"exit code {result.returncode}" + if check: + raise RuntimeError(f"command failed: {' '.join(cmd)}\n {msg}") + print(f" ⚠ command failed: {' '.join(cmd)}\n {msg}") + return result.stdout.strip() + + +def strip_v(version_str): + """'v1.2.3' -> '1.2.3'. Leaves non-version sentinels untouched.""" + if version_str and version_str.startswith("v"): + return version_str[1:] + return version_str + + +def semver_key(version_str): + """Return a sortable tuple from a vX.Y.Z string.""" + v = strip_v(version_str) + key = [] + for p in re.split(r"[.\-]", v): + try: + key.append((0, int(p))) + except ValueError: + key.append((1, p)) + return key + + +def fetch_upstream_tags(): + print(f"Fetching tags from remote '{UPSTREAM_REMOTE}'...", flush=True) + # Hard failure: proceeding on stale local tags would silently produce a + # wrong report (e.g. policies flagged as needing release when they don't). + out = run(["git", "fetch", UPSTREAM_REMOTE, "--tags", "--force"], check=True) + if out: + print(out) + + +def get_all_policy_tags(): + """Return dict: policy_name -> sorted list of version strings (latest last).""" + raw = run(["git", "tag", "--list", "policies/*"]) + tags: dict[str, list[str]] = {} + for line in raw.splitlines(): + m = re.match(r"^policies/([^/]+)/(.+)$", line.strip()) + if m: + name, version = m.group(1), m.group(2) + tags.setdefault(name, []).append(version) + for name in tags: + tags[name].sort(key=semver_key) + return tags + + +def read_yaml_name_version(yaml_path: Path): + """Extract name and version from policy-definition.yaml without external deps.""" + name = version = None + with open(yaml_path) as f: + for line in f: + if name is None: + m = re.match(r"^name:\s*(\S+)", line) + if m: + name = m.group(1) + if version is None: + m = re.match(r"^version:\s*(\S+)", line) + if m: + version = m.group(1) + if name and version: + break + return name, version + + +def read_gomod_deps(policy_dir: Path): + """Return dict: dep_policy_name -> pinned_version (with 'v') from go.mod. + + Only DIRECT requires are considered (indirect deps are transitive and do + not embed this policy's code path). The module line is skipped. + """ + gomod = policy_dir / "go.mod" + deps: dict[str, str] = {} + if not gomod.exists(): + return deps + with open(gomod) as f: + for line in f: + stripped = line.strip() + if stripped.startswith("module "): + continue + if "// indirect" in line: + continue + m = DEP_RE.search(line) + if m: + deps[m.group(1)] = m.group(2) + return deps + + +def detect_policy_type(policy_dir: Path) -> str: + """Mirror release-policy.yml: go.mod -> go, pyproject.toml -> python.""" + if (policy_dir / "go.mod").exists(): + return "go" + if (policy_dir / "pyproject.toml").exists(): + return "python" + return "unknown" + + +def read_pyproject_version(policy_dir: Path): + """Return the [project] version from pyproject.toml, or None.""" + pyproject = policy_dir / "pyproject.toml" + if not pyproject.exists(): + return None + with open(pyproject) as f: + for line in f: + m = re.match(r'^\s*version\s*=\s*["\']([^"\']+)["\']', line) + if m: + return m.group(1) + return None + + +def commits_since_tag(policy_name: str, latest_tag_version: str | None) -> list[str]: + """Return list of commit subjects since the given tag on the policy's path.""" + policy_path = f"policies/{policy_name}/" + if latest_tag_version: + ref = f"policies/{policy_name}/{latest_tag_version}" + log_range = f"{ref}..HEAD" + else: + log_range = "HEAD" + + raw = run( + ["git", "log", "--oneline", "--no-merges", log_range, "--", policy_path] + ) + if not raw: + return [] + subjects = [] + for line in raw.splitlines(): + parts = line.strip().split(" ", 1) + subjects.append(parts[1] if len(parts) == 2 else line.strip()) + return subjects + + +def compute_waves(records): + """Assign each policy a topological wave based on intra-repo depends_on. + + wave = 0 for policies with no intra-repo deps; otherwise + wave = 1 + max(wave of its in-repo deps). Cycles (not expected) fall back + to wave 0 to avoid infinite recursion. + """ + wave_cache: dict[str, int] = {} + + def wave_of(name, stack): + if name in wave_cache: + return wave_cache[name] + if name in stack: # cycle guard + return 0 + rec = records.get(name) + if not rec or not rec["deps"]: + wave_cache[name] = 0 + return 0 + stack.add(name) + dep_waves = [ + wave_of(dep, stack) for dep in rec["deps"] if dep in records + ] + stack.discard(name) + wave_cache[name] = (1 + max(dep_waves)) if dep_waves else 0 + return wave_cache[name] + + for name in records: + wave_of(name, set()) + return wave_cache + + +def propagate_needs_release(records): + """A policy needs release if it has own commits OR any in-repo dep it + depends on needs release. Resolved to a fixpoint (safe for any depth).""" + changed = True + while changed: + changed = False + for rec in records.values(): + if rec["needs_release"]: + continue + if any( + records[dep]["needs_release"] + for dep in rec["deps"] + if dep in records + ): + rec["needs_release"] = True + changed = True + + +def main(): + fetch_upstream_tags() + all_tags = get_all_policy_tags() + + print("") + # ---- Pass 1: gather per-policy facts ---- + records: dict[str, dict] = {} + policy_dirs = sorted( + [d for d in POLICIES_DIR.iterdir() if d.is_dir()], + key=lambda d: d.name, + ) + + for policy_dir in policy_dirs: + yaml_path = policy_dir / "policy-definition.yaml" + if not yaml_path.exists(): + print(f" SKIP {policy_dir.name}: no policy-definition.yaml") + continue + + yaml_name, yaml_version = read_yaml_name_version(yaml_path) + policy_name = yaml_name or policy_dir.name + + policy_type = detect_policy_type(policy_dir) + + # Python policies carry a second version source (pyproject.toml) that the + # release workflow also validates. Consistency is n/a for go policies. + if policy_type == "python": + pyproject_version = read_pyproject_version(policy_dir) + version_files_consistent = ( + pyproject_version is not None + and yaml_version is not None + and strip_v(pyproject_version) == strip_v(yaml_version) + ) + else: + pyproject_version = None + version_files_consistent = None # -> "n/a" + + tag_versions = all_tags.get(policy_name, []) + latest_released_raw = tag_versions[-1] if tag_versions else None # with 'v' + + changes = commits_since_tag(policy_name, latest_released_raw) + own_needs = len(changes) > 0 + # A real bump means the yaml version is strictly AHEAD of the latest + # released tag. Equal = not bumped; behind = anomaly (handled below). + if latest_released_raw and yaml_version: + cmp = semver_key(yaml_version) > semver_key(latest_released_raw) + yaml_version_bumped = cmp + yaml_version_behind = semver_key(yaml_version) < semver_key( + latest_released_raw + ) + else: + # No prior release: any yaml version counts as a bump. + yaml_version_bumped = bool(yaml_version) + yaml_version_behind = False + + records[policy_name] = { + "policy_name": policy_name, + "policy_type": policy_type, + "pyproject_version": pyproject_version, + "version_files_consistent": version_files_consistent, + "latest_released_raw": latest_released_raw, + "yaml_version": yaml_version, + "yaml_version_bumped": yaml_version_bumped, + "yaml_version_behind": yaml_version_behind, + "own_needs": own_needs, + "needs_release": own_needs, # augmented by propagation below + # Dependency graph is go-only; python policies have no go.mod deps. + "deps": read_gomod_deps(policy_dir) if policy_type == "go" else {}, + "changes": changes, + } + + # ---- Pass 2: dependency graph derivations ---- + # reverse edges + dependents: dict[str, list[str]] = {name: [] for name in records} + for name, rec in records.items(): + for dep in rec["deps"]: + if dep in dependents: + dependents[dep].append(name) + + waves = compute_waves(records) + propagate_needs_release(records) + + # ---- Pass 3: assemble rows ---- + rows = [] + for name in sorted(records): + rec = records[name] + dep_needs = any( + records[dep]["own_needs"] for dep in rec["deps"] if dep in records + ) or any( + records[dep]["needs_release"] for dep in rec["deps"] if dep in records + ) + + if rec["own_needs"] and dep_needs: + reason = "both" + elif rec["own_needs"]: + reason = "own-changes" + elif dep_needs: + reason = "dependency-update" + else: + reason = "none" + + # release_ready: only when the human step is done — own commits landed + # (which includes the go.mod pin bump + yaml bump) AND yaml is bumped. + # For python, the workflow also requires pyproject == yaml, so gate on it. + release_ready = rec["own_needs"] and rec["yaml_version_bumped"] + if rec["policy_type"] == "python" and not rec["version_files_consistent"]: + release_ready = False + + # dep_pin_stale: a pinned dep version differs from that dep's yaml version + dep_pin_stale = False + for dep, pinned in rec["deps"].items(): + if dep in records and records[dep]["yaml_version"]: + if strip_v(pinned) != strip_v(records[dep]["yaml_version"]): + dep_pin_stale = True + break + + depends_on_str = "; ".join( + f"{dep}@{strip_v(ver)}" for dep, ver in sorted(rec["deps"].items()) + ) + dependents_str = "; ".join(sorted(dependents[name])) + + if rec["version_files_consistent"] is None: + vfc = "n/a" + else: + vfc = "yes" if rec["version_files_consistent"] else "no" + + rows.append( + { + "policy_name": name, + "policy_type": rec["policy_type"], + "latest_released_version": strip_v(rec["latest_released_raw"]) + or "(none)", + "yaml_version": strip_v(rec["yaml_version"]) or "(missing)", + "yaml_version_bumped": "yes" if rec["yaml_version_bumped"] else "no", + "version_files_consistent": vfc, + "needs_release": "yes" if rec["needs_release"] else "no", + "release_reason": reason, + "release_ready": "yes" if release_ready else "no", + "release_wave": waves.get(name, 0), + "depends_on": depends_on_str, + "dependents": dependents_str, + "dep_pin_stale": "yes" if dep_pin_stale else "no", + "num_changes": len(rec["changes"]), + "changes": "; ".join(rec["changes"]), + } + ) + + with open(OUTPUT_CSV, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=CSV_FIELDS) + writer.writeheader() + writer.writerows(rows) + + # ---- Console summary ---- + for row in rows: + rec = records[row["policy_name"]] + if rec["yaml_version_behind"]: + status = "⚠ ANOMALY: yaml version is BEHIND latest release" + elif row["policy_type"] == "python" and row["version_files_consistent"] == "no": + status = "⚠ ANOMALY: pyproject.toml version != yaml version" + elif row["release_ready"] == "yes": + status = "READY TO TAG" + elif row["needs_release"] == "yes": + status = f"needs release ({row['release_reason']})" + else: + status = "up-to-date" + dep_note = f" deps=[{row['depends_on']}]" if row["depends_on"] else "" + print( + f" wave{row['release_wave']} {row['policy_type']:<6} " + f"{row['policy_name']:<42} " + f"released={row['latest_released_version']:<9} " + f"yaml={row['yaml_version']:<9} chg={row['num_changes']:<2} " + f"[{status}]{dep_note}" + ) + + go_count = sum(1 for r in rows if r["policy_type"] == "go") + py_count = sum(1 for r in rows if r["policy_type"] == "python") + unknown = sum(1 for r in rows if r["policy_type"] == "unknown") + needs = sum(1 for r in rows if r["needs_release"] == "yes") + ready = sum(1 for r in rows if r["release_ready"] == "yes") + dep_only = sum(1 for r in rows if r["release_reason"] == "dependency-update") + stale = sum(1 for r in rows if r["dep_pin_stale"] == "yes") + + print(f"\nWrote: {OUTPUT_CSV}") + print(f"Total policies : {len(rows)} " + f"(go={go_count}, python={py_count}" + + (f", unknown={unknown}" if unknown else "") + ")") + print(f"Needs release : {needs}") + print(f" Ready to tag : {ready} (own commits + yaml bumped)") + print(f" Dependency-update only : {dep_only} (needs go.mod pin + yaml bump first)") + print(f"Policies with stale pins : {stale}") + + +if __name__ == "__main__": + main() diff --git a/scripts/policy-release/release_policies.py b/scripts/policy-release/release_policies.py new file mode 100644 index 00000000..e7ae5167 --- /dev/null +++ b/scripts/policy-release/release_policies.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +""" +Release policies by dispatching the "Release Policy" GitHub workflow, ordered +by dependency wave, based on policy-release-status.csv. + +Reads the CSV produced by policy_release_status.py, selects rows where +release_ready == yes, groups them by release_wave, and for each wave (ascending) +dispatches every policy's release workflow concurrently (async), optionally +watching each run to completion before moving to the next wave. + +SAFETY (option A): this script is dispatch-only. It never edits go.mod or +commits. If a release-ready policy still has a stale dependency pin +(dep_pin_stale == yes), it is SKIPPED with a warning — a human must commit the +go.mod pin bump first. If a wave has failures, dependent waves are not started. + +Usage: + # Dry run (default) — prints the gh commands, dispatches nothing: + python3 scripts/policy-release/release_policies.py + + # Actually dispatch the releases: + python3 scripts/policy-release/release_policies.py --execute + + # Dispatch without waiting for/reporting run status: + python3 scripts/policy-release/release_policies.py --execute --no-report + +Requires: gh CLI authenticated with workflow dispatch permission on the repo. +""" + +import argparse +import csv +import re +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +DEFAULT_CSV = REPO_ROOT / "policy-release-status.csv" +DEFAULT_REPO = "wso2/gateway-controllers" +DEFAULT_REF = "main" +WORKFLOW_FILE = "release-policy.yml" + + +def gh(args, capture=True): + """Run a gh command; return (returncode, stdout, stderr).""" + result = subprocess.run( + ["gh", *args], capture_output=capture, text=True + ) + return ( + result.returncode, + (result.stdout or "").strip(), + (result.stderr or "").strip(), + ) + + +# Matches the run URL gh prints on dispatch (gh >= 2.87.0), e.g. +# https://github.com/wso2/gateway-controllers/actions/runs/1234567890 +RUN_URL_RE = re.compile(r"/actions/runs/(\d+)") + + +def load_ready_rows(csv_path: Path, only: set[str] | None): + rows = [] + with open(csv_path, newline="") as f: + for row in csv.DictReader(f): + if row.get("release_ready") != "yes": + continue + if only and row["policy_name"] not in only: + continue + rows.append(row) + return rows + + +def latest_run_id(repo, ref): + """Return the databaseId of the most recent Release Policy run, or None.""" + rc, out, _ = gh( + [ + "run", "list", + "--repo", repo, + "--workflow", WORKFLOW_FILE, + "--branch", ref, + "-L", "1", + "--json", "databaseId", + "-q", ".[0].databaseId", + ] + ) + if rc != 0 or not out: + return None + return out.strip() + + +def dispatch(policy, version, repo, ref): + """Dispatch one workflow run; return the created run id (best effort).""" + before = latest_run_id(repo, ref) + rc, out, err = gh( + [ + "workflow", "run", WORKFLOW_FILE, + "--repo", repo, + "--ref", ref, + "-f", f"policy={policy}", + "-f", f"version={version}", + ] + ) + if rc != 0: + print(f" ✗ dispatch failed for {policy}: {err or out}") + return None + + # Preferred (gh >= 2.87.0): gh prints the created run URL — parse the id + # directly. This is exact, with no risk of picking up a concurrent run. + m = RUN_URL_RE.search(f"{out}\n{err}") + if m: + return m.group(1) + + # Fallback for older gh: poll until a new run id appears. Racy if another + # Release Policy run starts on the same ref during this window. + for _ in range(20): + time.sleep(1.5) + rid = latest_run_id(repo, ref) + if rid and rid != before: + return rid + print(f" ⚠ dispatched {policy} but could not resolve run id") + return None + + +def watch(run_id, repo): + """Block until the run finishes; return (run_id, conclusion). + + `gh run watch --exit-status` exits non-zero both for a failed run and for + transient CLI/network errors, so on non-zero we query the authoritative + conclusion. Only an actual failing conclusion is reported as "failure"; + an unreadable conclusion is "unknown" (still treated as non-success, but + distinguishable from a real workflow failure). + """ + rc, _, _ = gh( + ["run", "watch", run_id, "--repo", repo, "--exit-status", "--interval", "10"], + capture=False, + ) + if rc == 0: + return run_id, "success" + + rc2, out, _ = gh( + ["run", "view", run_id, "--repo", repo, "--json", "conclusion", + "-q", ".conclusion"] + ) + if rc2 == 0 and out: + # e.g. failure, cancelled, timed_out, success (if watch hiccupped) + return run_id, out.strip() + return run_id, "unknown" + + +def run_url(run_id, repo): + return f"https://github.com/{repo}/actions/runs/{run_id}" + + +def main(): + ap = argparse.ArgumentParser(description="Wave-ordered policy release dispatcher.") + ap.add_argument("--csv", default=str(DEFAULT_CSV), help="Path to release-status CSV") + ap.add_argument("--repo", default=DEFAULT_REPO, help="owner/repo to dispatch against") + ap.add_argument("--ref", default=DEFAULT_REF, help="git ref the workflow runs on") + ap.add_argument("--execute", action="store_true", + help="Actually dispatch (default is dry-run)") + ap.add_argument("--no-report", dest="report", action="store_false", + help="Do not watch/report run status (report is on by default)") + ap.add_argument("--only", default="", + help="Comma-separated policy names to limit to") + args = ap.parse_args() + + csv_path = Path(args.csv) + if not csv_path.exists(): + sys.exit(f"CSV not found: {csv_path}\nRun policy_release_status.py first.") + + only = {s.strip() for s in args.only.split(",") if s.strip()} or None + ready = load_ready_rows(csv_path, only) + if not ready: + print("No release-ready policies found (release_ready == yes). Nothing to do.") + return + + # Option-A safety: hold back policies whose dependency pin is still stale. + releasable, held = [], [] + for row in ready: + (held if row.get("dep_pin_stale") == "yes" else releasable).append(row) + + if held: + print("⚠ Held back (stale dependency pin — commit go.mod bump first):") + for row in held: + print(f" {row['policy_name']} depends_on=[{row['depends_on']}]") + print("") + + if not releasable: + print("Nothing releasable after safety checks.") + return + + # Group by wave. + waves: dict[int, list[dict]] = {} + for row in releasable: + waves.setdefault(int(row["release_wave"]), []).append(row) + + mode = "EXECUTE" if args.execute else "DRY-RUN" + print(f"=== Policy release plan [{mode}] repo={args.repo} ref={args.ref} ===") + for wave in sorted(waves): + names = ", ".join(r["policy_name"] for r in waves[wave]) + print(f" wave {wave}: {names}") + print("") + + overall = [] + for wave in sorted(waves): + batch = waves[wave] + print(f"--- Wave {wave} ({len(batch)} polic{'y' if len(batch)==1 else 'ies'}) ---") + + if not args.execute: + for row in batch: + print( + f" [dry-run] gh workflow run {WORKFLOW_FILE} " + f"--repo {args.repo} --ref {args.ref} " + f"-f policy={row['policy_name']} -f version={row['yaml_version']}" + ) + continue + + # Dispatch all in the wave (async on GitHub's side). + dispatched = [] + for row in batch: + print(f" → dispatching {row['policy_name']} v{row['yaml_version']}") + rid = dispatch(row["policy_name"], row["yaml_version"], args.repo, args.ref) + dispatched.append((row, rid)) + + if not args.report: + for row, rid in dispatched: + loc = run_url(rid, args.repo) if rid else "(run id unresolved)" + print(f" dispatched {row['policy_name']}: {loc}") + overall.append((row["policy_name"], row["yaml_version"], "dispatched", rid)) + continue + + # Watch concurrently, barrier at end of wave. + watchable = [(row, rid) for row, rid in dispatched if rid] + results = {} + if watchable: + print(f" watching {len(watchable)} run(s)...") + with ThreadPoolExecutor(max_workers=len(watchable)) as ex: + futures = { + ex.submit(watch, rid, args.repo): row for row, rid in watchable + } + for fut in futures: + row = futures[fut] + rid, conclusion = fut.result() + results[row["policy_name"]] = (conclusion, rid) + + wave_failed = False + for row, rid in dispatched: + if not rid: + conclusion, rid_show = "unknown", None + else: + conclusion, rid_show = results.get(row["policy_name"], ("unknown", rid)) + mark = "✓" if conclusion == "success" else "✗" + print(f" {mark} {row['policy_name']}: {conclusion} {run_url(rid_show, args.repo) if rid_show else ''}") + overall.append((row["policy_name"], row["yaml_version"], conclusion, rid_show)) + if conclusion != "success": + wave_failed = True + + if wave_failed and wave != max(waves): + print( + f"\n✗ Wave {wave} had failures; stopping before dependent waves " + f"to avoid releasing against a missing/failed dependency." + ) + break + + # Final summary. + if overall: + print("\n=== Summary ===") + for name, ver, status, rid in overall: + print(f" {name} v{ver}: {status}" + + (f" {run_url(rid, args.repo)}" if rid else "")) + + +if __name__ == "__main__": + main()