diff --git a/.github/scripts/compute_release_bump.py b/.github/scripts/compute_release_bump.py new file mode 100644 index 00000000..dc9929c9 --- /dev/null +++ b/.github/scripts/compute_release_bump.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""Compute the next `plugin` product SemVer bump from conventional commits. + +Issue #642 (design: +docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md): a +release-PR workflow needs to know, deterministically, whether the commits +since the last release warrant a `minor` or `patch` bump to the `plugin` +product's version (`docs/versioning.md`'s product-scoped SemVer axis; see +that file's "Commit convention" section) -- and by exactly how much, never +guessed by a human and never cumulative across commits. + +This module splits into a pure core (parsing, classification, version +arithmetic, manifest rewriting, notes rendering -- all unit-testable with +plain Python data, zero real git calls) and a thin git/CLI wrapper +(`discover_last_tag`, `collect_commits`, `main`) that only ever talks to a +repository through an injectable `git_runner` callable, so even the +wrapper stays swappable in tests. + +Commit-scope convention (docs/versioning.md): only a commit whose header +scope is exactly `plugin` (e.g. `feat(plugin): ...`) counts toward this +product's version. A commit scoped to something else (`feat(skills): ...`, +`feat(cli): ...`, unscoped, or unparsed) never bumps `plugin`'s version, +even if its type would otherwise qualify. + +Severity rule (also docs/versioning.md): `feat` or any breaking-marked +commit (`!` immediately before the header colon, or a `BREAKING CHANGE:` +footer line) is a `minor` bump; `fix`/`refactor`/`perf` is a `patch` bump +(per that doc's own text: "refactor ... is a patch at most"). Everything +else (`docs`/`chore`/`test`/`build`/`ci`, non-breaking) is a no-op. +Severity is the *max* across all commits since the last release, never +summed or multiplied -- one `feat(plugin)` and five `feat(plugin)`s both +produce exactly one minor bump. + +Critical invariant: **no code path in this module ever returns, implies, +or computes a major-version bump.** `1.0.0` is reserved for a deliberate +human guarantee, per docs/versioning.md and SemVer #4 -- not something +automation may decide on a commit-count heuristic. `classify()`'s return +type has no `"major"` value, and `compute_next_version()` re-asserts this +invariant at runtime (see its loop) so a future edit that accidentally +introduces one fails loudly instead of silently shipping a major bump. Do +not relax this by adding a `"major"` branch anywhere in this file. + +Manifest rewriting (`write_bumped_manifests`) is a targeted regex +substitution, deliberately not a `json.dump`/`yaml.safe_dump` round-trip: +a round-trip would reformat `plugin.json` (key order, indentation, +trailing newline) and would destroy `apm.yml`'s leading comment block +(PyYAML's `safe_dump` does not preserve comments). It asserts the version +line matches exactly once per file before writing -- zero matches or more +than one both raise `RuntimeError` (fail loud, matching +`scan_apm_manifest_drift.py`'s own style) rather than silently no-op-ing +or guessing which line to bump. Each file is written via a temp file in +the same directory followed by `os.replace` (atomic; no partially-written +manifest possible, and no stray temp file survives a successful run). + +Usage:: + + # Compute-only (dry run); prints the result, writes nothing. + python3 .github/scripts/compute_release_bump.py + + # Apply the bump to plugin.json/apm.yml, write release notes, and + # record bump=/version= for a GitHub Actions step. + python3 .github/scripts/compute_release_bump.py --write \\ + --notes-out release-notes.md --github-output "$GITHUB_OUTPUT" + +Exit codes: + 0 Success -- a bump was computed (and applied, if --write) or no + plugin-scoped commit since the last release warranted one (both + are a normal, successful outcome, not an error). + 1 Failure -- a manifest is malformed or missing its version field, + a manifest's version line is missing or duplicated, + `current_version` is not a valid X.Y.Z string, or the underlying + `git` invocation failed. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import os +import pathlib +import re +import subprocess +import sys +import tempfile +from typing import Callable, Literal + +PLUGIN_SCOPE = "plugin" + +_HEADER_RE = re.compile( + r"^(feat|fix|docs|refactor|perf|test|chore|build|ci)" + r"(\(([a-z0-9_-]+)\))?(!)?:\s*(.+)$" +) +_BREAKING_FOOTER_RE = re.compile(r"^BREAKING CHANGE:", re.MULTILINE) +_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +_PLUGIN_VERSION_RE = re.compile(r'"version":\s*"\d+\.\d+\.\d+"') +# `[ \t]*`, deliberately not `\s*`: `\s` matches "\n", so `\s*` let a match +# starting at a valueless `version:` line run on into the *next* line and +# treat that line's key as the version value -- still exactly one match, so +# the "exactly one match" guard below passed and the substitution silently +# deleted the following key. Keeping the whitespace run line-local means a +# valueless `version:` yields zero matches and fails loud instead. +_APM_VERSION_RE = re.compile(r"^version:[ \t]*\S+$", re.MULTILINE) + +_LAST_TAG_GLOB = "gitapex--v*" +_FIELD_SEP = "\x1f" +_RECORD_SEP = "\x1e" + +Bump = Literal["minor", "patch"] + + +@dataclasses.dataclass(frozen=True) +class ParsedCommit: + """A conventional-commit header, parsed. `scope` is None when the + header carries no `(scope)` at all (e.g. a bare `feat: ...`).""" + + commit_type: str + scope: str | None + breaking_marker: bool + description: str + + +def parse_header(subject: str) -> ParsedCommit | None: + """Parse a commit subject as a conventional-commit header. Returns + None for anything that doesn't match -- merge-commit subjects ("Merge + pull request #1 from ..."), freeform text, or a type outside the fixed + feat/fix/docs/refactor/perf/test/chore/build/ci set.""" + match = _HEADER_RE.match(subject) + if match is None: + return None + commit_type, _scope_group, scope, breaking_marker, description = match.groups() + return ParsedCommit( + commit_type=commit_type, + scope=scope, + breaking_marker=breaking_marker is not None, + description=description, + ) + + +def is_breaking(subject: str, body: str) -> bool: + """True if `subject` carries a conventional-commit `!` breaking marker + immediately before the header colon, or `body` contains a + `BREAKING CHANGE:` footer line. A subject that doesn't parse as a + conventional-commit header at all can still be breaking via the body + footer alone.""" + parsed = parse_header(subject) + if parsed is not None and parsed.breaking_marker: + return True + return _BREAKING_FOOTER_RE.search(body) is not None + + +def classify(parsed: ParsedCommit | None, breaking: bool) -> Bump | None: + """Classify one already-parsed commit as a `plugin` SemVer bump + severity, or None if it doesn't count. + + Only `scope == "plugin"` counts (docs/versioning.md's commit-scope + convention) -- a wrong scope, no scope, or an unparsed subject all + return None regardless of type or breaking marker. + + Critical invariant: this function's return type is `Bump | None` + where `Bump = Literal["minor", "patch"]` -- there is no `"major"` + value anywhere in this logic, by construction. `1.0.0` is a + deliberate human decision (docs/versioning.md), never an automated + one. Do not add a `"major"` branch here. + """ + if parsed is None or parsed.scope != PLUGIN_SCOPE: + return None + if breaking or parsed.commit_type == "feat": + return "minor" + if parsed.commit_type in ("fix", "refactor", "perf"): + return "patch" + return None + + +def compute_next_version( + current_version: str, commits: list[dict] +) -> dict[str, str] | None: + """Compute the max-of-signals bump across every commit in `commits` + (each a {"sha", "subject", "body"} dict) and apply it once to + `current_version`. + + Severity is the max across all commits, never cumulative: one + `feat(plugin)` and five `feat(plugin)`s both yield exactly one minor + bump. Returns None if no commit classifies (a genuine no-op -- nothing + to release). Otherwise returns {"version": "", "bump": + "minor"|"patch"}; a minor bump resets patch to 0, a patch bump leaves + major/minor alone, and major is never incremented. + """ + severity: Bump | None = None + for commit in commits: + parsed = parse_header(commit["subject"]) + breaking = is_breaking(commit["subject"], commit["body"]) + bump = classify(parsed, breaking) + if bump is None: + continue + if bump not in ("minor", "patch"): + # Defense-in-depth for the no-major-bump invariant: classify() + # is typed to never return anything else, but a future edit + # that violates that typing must fail loudly here rather than + # silently falling through to a wrong severity below. + raise AssertionError( + f"classify() returned {bump!r} for commit " + f"{commit.get('sha', '?')!r}; only 'minor', 'patch', or " + "None are ever valid" + ) + if bump == "minor": + severity = "minor" + elif severity != "minor": + severity = "patch" + + if severity is None: + return None + + match = _SEMVER_RE.match(current_version) + if match is None: + raise ValueError( + f"current_version {current_version!r} is not a valid X.Y.Z SemVer string" + ) + major, minor, patch = (int(part) for part in match.groups()) + if severity == "minor": + return {"version": f"{major}.{minor + 1}.0", "bump": "minor"} + return {"version": f"{major}.{minor}.{patch + 1}", "bump": "patch"} + + +def discover_last_tag(git_runner: Callable[[list[str]], str]) -> str | None: + """Return the most recent `gitapex--v*` tag (by version sort), or None + if no such tag exists yet (the pre-release bootstrap case). + + `git_runner` is a callable taking a list of git args and returning + stdout text, so this never needs a real git repository in tests. A + None result is not special-cased by any caller: `compute_next_version` + handles an arbitrarily long "all of history" commit list the same way + it handles a short one, by construction (max-of-signals still yields + at most one bump). + """ + output = git_runner(["tag", "-l", _LAST_TAG_GLOB, "--sort=-v:refname"]) + for line in output.splitlines(): + line = line.strip() + if line: + return line + return None + + +def write_bumped_manifests( + plugin_path: pathlib.Path, apm_path: pathlib.Path, new_version: str +) -> None: + """Bump `plugin_path`'s JSON `"version": "X.Y.Z"` line and + `apm_path`'s top-level YAML `version: X.Y.Z` line to `new_version`, in + place, each via a same-directory temp file + `os.replace` (atomic; no + partially-written manifest possible). + + A targeted regex substitution, deliberately not a + `json.dump`/`yaml.safe_dump` round-trip: a round-trip would reformat + `plugin.json` and would destroy `apm.yml`'s leading comment block + (PyYAML's `safe_dump` does not preserve comments). Raises + `RuntimeError` if a file's version line doesn't appear exactly once -- + zero matches or more than one both fail loud rather than silently + no-op-ing or guessing which line to bump. `plugin_path` is processed + before `apm_path`; if `plugin_path` fails, `apm_path` is never read or + touched at all. + """ + _replace_manifest_version( + pathlib.Path(plugin_path), + _PLUGIN_VERSION_RE, + f'"version": "{new_version}"', + 'JSON "version": "X.Y.Z"', + ) + _replace_manifest_version( + pathlib.Path(apm_path), + _APM_VERSION_RE, + f"version: {new_version}", + "top-level YAML version: X.Y.Z", + ) + + +def _replace_manifest_version( + manifest_path: pathlib.Path, + pattern: re.Pattern[str], + replacement: str, + field_description: str, +) -> None: + text = manifest_path.read_text(encoding="utf-8") + matches = list(pattern.finditer(text)) + if len(matches) != 1: + raise RuntimeError( + f"{manifest_path}: expected exactly one {field_description} " + f"line, found {len(matches)} -- refusing to guess which one " + "to bump" + ) + match = matches[0] + new_text = text[: match.start()] + replacement + text[match.end() :] + _atomic_write(manifest_path, new_text) + + +def _atomic_write(path: pathlib.Path, text: str) -> None: + fd, tmp_name = tempfile.mkstemp( + dir=path.parent, prefix=f".{path.name}.", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(text) + os.replace(tmp_name, path) + except BaseException: + if os.path.exists(tmp_name): + os.remove(tmp_name) + raise + + +def render_notes(commits: list[dict]) -> str: + """Render Markdown release notes grouped by classified type: `### + Features` (feat), `### Fixes` (fix), `### Refactors` (refactor/perf) + -- one bullet per commit as `- ()` (7-char short + sha). A section is omitted entirely when it has no commits. A trailing + line always prints, counting every other commit (wrong scope, + unparsed, or a `docs`/`chore`/`build`/`ci`/`test` type) that didn't + land in one of the three sections -- 0 is a valid, printed count, not + an omitted line. + + Deliberately independent of classify()'s breaking-marker override: + these three sections describe conventional-commit *type* (what kind + of change it was), not SemVer severity, so a breaking-marked `docs`/ + `chore`/`build`/`ci`/`test` commit (which classify() would still score + as a minor bump) is not force-fit into "Refactors" -- it has no + matching section here and is counted in the trailing omitted line + instead. + """ + features: list[str] = [] + fixes: list[str] = [] + refactors: list[str] = [] + omitted = 0 + + for commit in commits: + parsed = parse_header(commit["subject"]) + bucket: list[str] | None = None + if parsed is not None and parsed.scope == PLUGIN_SCOPE: + if parsed.commit_type == "feat": + bucket = features + elif parsed.commit_type == "fix": + bucket = fixes + elif parsed.commit_type in ("refactor", "perf"): + bucket = refactors + if bucket is None: + omitted += 1 + continue + bucket.append(f"- {commit['subject']} ({commit['sha'][:7]})") + + sections: list[str] = [] + if features: + sections.append("### Features\n" + "\n".join(features)) + if fixes: + sections.append("### Fixes\n" + "\n".join(fixes)) + if refactors: + sections.append("### Refactors\n" + "\n".join(refactors)) + sections.append( + f"_{omitted} other commit(s) since the last release " + "(docs/chore/ci/test) omitted above._" + ) + return "\n\n".join(sections) + "\n" + + +def _default_git_runner(repo_root: pathlib.Path) -> Callable[[list[str]], str]: + """Build a real `git_runner` (see discover_last_tag/collect_commits) + bound to `repo_root`. Kept separate from the pure functions above so + every test can inject its own stub instead.""" + + def run(args: list[str]) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + return run + + +def collect_commits( + repo_root: pathlib.Path, git_runner: Callable[[list[str]], str] +) -> list[dict]: + """Thin git wrapper: discover the last release tag via + `discover_last_tag`, then return every non-merge commit since it (or + since the start of history, if no tag exists yet) as plain + {"sha", "subject", "body"} dicts -- the shape every pure function + above expects. + + Uses ASCII unit/record separators (0x1f/0x1e) in `git log --format`, + the same delimiter-safety reasoning this repo's own + `detect_touched_eval_skills.py` applies to NUL-delimited paths: a + commit subject or body can legitimately contain almost any printable + character, so a human-typed delimiter (space, comma, pipe) risks + misparsing a record, while these two control bytes practically never + appear in commit messages. `repo_root` is accepted for symmetry with + `_default_git_runner`/discoverability even though `git_runner` already + carries its own bound working directory; it is not otherwise used + here. + """ + last_tag = discover_last_tag(git_runner) + commit_range = f"{last_tag}..HEAD" if last_tag is not None else "HEAD" + log_format = f"%H{_FIELD_SEP}%s{_FIELD_SEP}%b{_RECORD_SEP}" + output = git_runner(["log", commit_range, "--no-merges", f"--format={log_format}"]) + commits: list[dict] = [] + for record in output.split(_RECORD_SEP): + record = record.strip("\n") + if not record: + continue + sha, subject, body = record.split(_FIELD_SEP, 2) + commits.append({"sha": sha, "subject": subject, "body": body}) + return commits + + +def _read_current_version(plugin_path: pathlib.Path) -> str: + data = json.loads(plugin_path.read_text(encoding="utf-8")) + if "version" not in data: + raise KeyError(f"{plugin_path}: missing required 'version' field") + return str(data["version"]) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compute (and optionally apply) the next `plugin` " + "SemVer bump from conventional commits since the last " + "gitapex--v* tag. Never computes a major bump -- see this " + "module's docstring." + ) + parser.add_argument("--repo-root", default=".", help="Repository root (default: cwd).") + parser.add_argument( + "--plugin-manifest", + default=".claude-plugin/plugin.json", + help="Path to plugin.json, relative to --repo-root (default: %(default)s).", + ) + parser.add_argument( + "--apm-manifest", + default="apm.yml", + help="Path to apm.yml, relative to --repo-root (default: %(default)s).", + ) + parser.add_argument( + "--write", + action="store_true", + help="Apply the computed bump to the manifests. Omitted: " + "compute-only (dry run) -- nothing on disk changes.", + ) + parser.add_argument( + "--notes-out", default=None, help="Write rendered Markdown release notes to this path." + ) + parser.add_argument( + "--github-output", + default=None, + help="Append bump=none|minor|patch and version=X.Y.Z KEY=value " + "lines to this GITHUB_OUTPUT-format file.", + ) + args = parser.parse_args(argv) + + repo_root = pathlib.Path(args.repo_root).resolve() + plugin_path = repo_root / args.plugin_manifest + apm_path = repo_root / args.apm_manifest + + try: + current_version = _read_current_version(plugin_path) + git_runner = _default_git_runner(repo_root) + commits = collect_commits(repo_root, git_runner) + result = compute_next_version(current_version, commits) + notes = render_notes(commits) + + if args.notes_out is not None: + pathlib.Path(args.notes_out).write_text(notes, encoding="utf-8") + + if result is not None and args.write: + write_bumped_manifests(plugin_path, apm_path, result["version"]) + + if args.github_output is not None: + bump = result["bump"] if result is not None else "none" + version = result["version"] if result is not None else current_version + with open(args.github_output, "a", encoding="utf-8") as handle: + handle.write(f"bump={bump}\n") + handle.write(f"version={version}\n") + except ( + KeyError, + ValueError, + RuntimeError, + subprocess.CalledProcessError, + OSError, + ) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + if result is None: + print("No plugin-scoped commit since the last release; nothing to bump.") + else: + print(f"Computed {result['bump']} bump: {current_version} -> {result['version']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/release_pr_publish.py b/.github/scripts/release_pr_publish.py new file mode 100644 index 00000000..e98f29ac --- /dev/null +++ b/.github/scripts/release_pr_publish.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +"""Publish a signed, bot-authored pull request for a `plugin` version bump. + +The release-PR workflow computes the next SemVer for `plugin` (see +`compute_release_bump.py`) and needs to land the bumped +`.claude-plugin/plugin.json` / `apm.yml` plus rendered release notes as a +reviewable PR -- merging that PR is the release act. A plain ``git push`` +from the runner's default ``GITHUB_TOKEN`` produces an unsigned commit, +which a ``required_signatures`` branch-protection rule rejects at merge +time. This script instead creates the commit server-side via the GraphQL +``createCommitOnBranch`` mutation (signed/Verified, authored by the GitHub +App identity behind the token), and upserts a PR for it. + +This is the same problem `sync_pr_publish.py` solves for the +agent-instructions sync, and this file adapts that script's +``apply_call``/``graphql_call`` retry machinery, its +``createCommitOnBranch`` mutation and ``_create_commit_on_branch`` helper, +and its delete-and-recreate-branch-on-drift-only-when-no-open-PR safety +rule (see below). It is deliberately a standalone copy rather than an +import: this repository's `.github/scripts/*.py` files never import each +other, so each script's failure mode stays local to itself. + +The fixed release branch (``chore/release-plugin-bump`` by default) is +deleted and recreated off the base branch whenever there is no open PR +currently targeting it: a reused branch can otherwise accumulate an +unsigned ancestor from a stale local-push run, which permanently violates +``required_signatures`` even after later commits are signed. Delete+create +is not a force-push, so a ``non_fast_forward`` ruleset on the branch is +still honored. When an open PR already exists, the branch is left alone +(deleting it risks closing that PR and losing its review history) and the +new commit is appended onto its tip instead -- safe once this script owns +the branch, since every commit it makes is already signed. + +Unlike `sync_pr_publish.py`, this script does not check whether the base +branch already carries the additions' bytes before acting: the caller +workflow only ever invokes this script after computing a real version bump +(``bump != "none"``), so `main` can never already contain the not-yet- +merged bumped version -- that drift check does not apply here. + +Usage:: + + python3 .github/scripts/release_pr_publish.py \\ + --base main --branch chore/release-plugin-bump \\ + --old-version 0.1.0 --new-version 0.2.0 --bump-kind minor \\ + --notes-file notes.md \\ + --plugin-manifest .claude-plugin/plugin.json --apm-manifest apm.yml + +Environment variables: + GH_TOKEN GitHub token with contents:write and pull-requests:write scope. + REPO Repository in ``owner/repo`` format. + +Exit codes: + 0 Success (including the no-op "already up to date" case). + 1 Missing env var, missing file, or API error. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.request +from collections.abc import Callable +from pathlib import Path +from typing import Any + +_API_ROOT = "https://api.github.com" +_GRAPHQL_URL = "https://api.github.com/graphql" +_API_VERSION = "2022-11-28" +_HTTP_TIMEOUT_SECONDS = 30 + +_DEFAULT_BRANCH = "chore/release-plugin-bump" + +_RELEASE_NOTES_START_MARKER = "" +_RELEASE_NOTES_END_MARKER = "" + +_CREATE_COMMIT_ON_BRANCH_MUTATION = """ +mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { oid } + } +} +""" + +_GRAPHQL_TRANSIENT_ERROR_MARKER = "something went wrong while executing your query" + + +def _default_opener(request: urllib.request.Request) -> Any: + # S310 justification: every caller in this module builds `request` from a + # fixed https://api.github.com URL plus trusted env-var-derived segments. + return urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) # noqa: S310 + + +def apply_call( + *, + method: str, + url: str, + payload: dict[str, Any] | None, + token: str, + opener: Callable[[urllib.request.Request], Any] = _default_opener, + sleeper: Callable[[float], None] | None = None, +) -> tuple[int, str]: + """Call the GitHub REST API, retrying transient (5xx/network) failures.""" + sleeper = sleeper if sleeper is not None else time.sleep + last_code = 0 + last_body = "" + + for attempt in range(1, 4): + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") if payload is not None else None + request = urllib.request.Request(url, data=data, method=method) # noqa: S310 -- fixed https://api.github.com endpoint + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/vnd.github+json") + request.add_header("X-GitHub-Api-Version", _API_VERSION) + if payload is not None: + request.add_header("Content-Type", "application/json") + + try: + with opener(request) as response: + last_code = int(response.status) + last_body = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as error: + last_code = int(error.code) + last_body = error.read().decode("utf-8", errors="replace") + except urllib.error.URLError as error: + last_code = 0 + last_body = str(error.reason) + + if 200 <= last_code < 300: + break + print(f"Attempt {attempt}: HTTP {_format_code(last_code)} for {method} {url}", file=sys.stderr) + if last_code != 0 and last_code < 500: + break + if attempt < 3: + sleeper(attempt * 5) + + return last_code, last_body + + +def _graphql_is_transient(code: int, body: dict[str, Any]) -> bool: + if code == 0 or code >= 500: + return True + errors = body.get("errors") + if isinstance(errors, list): + for err in errors: + message = err.get("message", "") if isinstance(err, dict) else "" + if isinstance(message, str) and _GRAPHQL_TRANSIENT_ERROR_MARKER in message.lower(): + return True + return False + + +def graphql_call( + *, + query: str, + variables: dict[str, Any], + token: str, + opener: Callable[[urllib.request.Request], Any] = _default_opener, + sleeper: Callable[[float], None] | None = None, +) -> tuple[int, dict[str, Any]]: + """Execute a GitHub GraphQL query/mutation, retrying transient failures.""" + sleeper = sleeper if sleeper is not None else time.sleep + payload = json.dumps({"query": query, "variables": variables}, separators=(",", ":")) + last_code = 0 + last_body: dict[str, Any] = {} + + for attempt in range(1, 4): + request = urllib.request.Request(_GRAPHQL_URL, data=payload.encode("utf-8"), method="POST") # noqa: S310 + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/vnd.github+json") + request.add_header("X-GitHub-Api-Version", _API_VERSION) + request.add_header("Content-Type", "application/json") + try: + with opener(request) as response: + code = int(response.status) + body_str = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as error: + code = int(error.code) + body_str = error.read().decode("utf-8", errors="replace") + except urllib.error.URLError: + code = 0 + body_str = "" + try: + parsed = json.loads(body_str) if body_str else {} + except json.JSONDecodeError: + parsed = {} + last_code = code + last_body = parsed if isinstance(parsed, dict) else {} + + if not _graphql_is_transient(last_code, last_body): + break + print(f"Attempt {attempt}: transient GraphQL response HTTP {_format_code(last_code)}", file=sys.stderr) + if attempt < 3: + sleeper(attempt * 5) + + return last_code, last_body + + +def _format_code(code: int) -> str: + return "000" if code == 0 else str(code) + + +def _get_ref_sha(*, repo: str, ref: str, token: str, apply_call: Callable[..., tuple[int, str]] = apply_call) -> str: + url = f"{_API_ROOT}/repos/{repo}/git/ref/{ref}" + code, body = apply_call(method="GET", url=url, payload=None, token=token) + if not (200 <= code < 300): + raise RuntimeError(f"Get ref {ref} failed: HTTP {code}: {body[:200]}") + data = json.loads(body) + sha = data.get("object", {}).get("sha") + if not isinstance(sha, str) or not sha: + raise RuntimeError(f"Get ref {ref} response missing object.sha: {body[:200]}") + return sha + + +def _get_branch_head_oid( + *, repo: str, branch: str, token: str, apply_call: Callable[..., tuple[int, str]] = apply_call +) -> str | None: + """Return the head commit oid of ``refs/heads/{branch}``, or ``None`` if absent.""" + url = f"{_API_ROOT}/repos/{repo}/git/ref/heads/{branch}" + code, body = apply_call(method="GET", url=url, payload=None, token=token) + if code == 404: + return None + if not (200 <= code < 300): + raise RuntimeError(f"Get branch ref {branch} failed: HTTP {code}: {body[:200]}") + data = json.loads(body) + sha = data.get("object", {}).get("sha") + if not isinstance(sha, str) or not sha: + raise RuntimeError(f"Get branch ref {branch} response missing object.sha: {body[:200]}") + return sha + + +def _create_branch_ref( + *, repo: str, branch: str, sha: str, token: str, apply_call: Callable[..., tuple[int, str]] = apply_call +) -> None: + url = f"{_API_ROOT}/repos/{repo}/git/refs" + code, resp = apply_call(method="POST", url=url, payload={"ref": f"refs/heads/{branch}", "sha": sha}, token=token) + if not (200 <= code < 300): + raise RuntimeError(f"Create branch ref {branch} failed: HTTP {code}: {resp[:200]}") + + +def _delete_branch( + *, repo: str, branch: str, token: str, apply_call: Callable[..., tuple[int, str]] = apply_call +) -> None: + """Delete a remote branch ref. A 404/422 (already gone) is treated as success.""" + url = f"{_API_ROOT}/repos/{repo}/git/refs/heads/{branch}" + code, resp = apply_call(method="DELETE", url=url, payload=None, token=token) + if (200 <= code < 300) or code in (404, 422): + return + raise RuntimeError(f"Delete branch {branch} failed: HTTP {code}: {resp[:200]}") + + +def _get_file_bytes( + *, repo: str, path: str, ref: str, token: str, apply_call: Callable[..., tuple[int, str]] = apply_call +) -> bytes | None: + """Return the decoded bytes of *path* at *ref*, or ``None`` when absent there.""" + url = f"{_API_ROOT}/repos/{repo}/contents/{path}?ref={ref}" + code, body = apply_call(method="GET", url=url, payload=None, token=token) + if code == 404: + return None + if not (200 <= code < 300): + raise RuntimeError(f"Get contents {path}@{ref} failed: HTTP {code}: {body[:200]}") + data = json.loads(body) + encoding = data.get("encoding") + content = data.get("content") + if encoding != "base64" or not isinstance(content, str): + raise RuntimeError(f"Get contents {path}@{ref}: unexpected encoding {encoding!r}") + return base64.b64decode(content) + + +def _ref_drifts( + *, + repo: str, + ref: str, + additions: list[tuple[str, bytes]], + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> bool: + """Return True when *ref* does not already carry every addition's bytes.""" + return any( + _get_file_bytes(repo=repo, path=path, ref=ref, token=token, apply_call=apply_call) != content + for path, content in additions + ) + + +def _create_commit_on_branch( + *, + repo: str, + branch: str, + expected_head_oid: str, + headline: str, + body: str, + additions: list[dict[str, str]], + token: str, + graphql_call: Callable[..., tuple[int, dict[str, Any]]] = graphql_call, +) -> str: + """Create a signed commit on *branch* via GraphQL; return the new commit oid.""" + message: dict[str, str] = {"headline": headline} + if body: + message["body"] = body + variables = { + "input": { + "branch": {"repositoryNameWithOwner": repo, "branchName": branch}, + "message": message, + "expectedHeadOid": expected_head_oid, + "fileChanges": {"additions": additions}, + } + } + code, response = graphql_call(query=_CREATE_COMMIT_ON_BRANCH_MUTATION, variables=variables, token=token) + if not (200 <= code < 300): + raise RuntimeError(f"createCommitOnBranch HTTP {code}") + if "errors" in response: + raise RuntimeError(f"createCommitOnBranch errors: {response['errors']}") + try: + oid = response["data"]["createCommitOnBranch"]["commit"]["oid"] + except (KeyError, TypeError) as exc: + raise RuntimeError(f"createCommitOnBranch: unexpected response: {str(response)[:200]}") from exc + if not isinstance(oid, str) or not oid: + raise RuntimeError(f"createCommitOnBranch: missing commit oid: {str(response)[:200]}") + return oid + + +def _list_open_prs( + *, repo: str, head: str, token: str, apply_call: Callable[..., tuple[int, str]] = apply_call +) -> list[dict[str, Any]]: + owner = repo.split("/")[0] + url = f"{_API_ROOT}/repos/{repo}/pulls?head={owner}:{head}&state=open&per_page=1" + code, body = apply_call(method="GET", url=url, payload=None, token=token) + if not (200 <= code < 300): + raise RuntimeError(f"List PRs failed: HTTP {code}: {body[:200]}") + data = json.loads(body) + if not isinstance(data, list): + raise RuntimeError(f"Expected list from list PRs, got: {body[:200]}") + return data + + +def _create_pr( + *, + repo: str, + head: str, + base: str, + title: str, + body: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> int: + url = f"{_API_ROOT}/repos/{repo}/pulls" + code, resp = apply_call( + method="POST", url=url, payload={"title": title, "head": head, "base": base, "body": body}, token=token + ) + if not (200 <= code < 300): + raise RuntimeError(f"Create PR failed: HTTP {code}: {resp[:200]}") + return int(json.loads(resp)["number"]) + + +def _update_pr( + *, + repo: str, + number: int, + title: str, + body: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> None: + url = f"{_API_ROOT}/repos/{repo}/pulls/{number}" + code, resp = apply_call(method="PATCH", url=url, payload={"title": title, "body": body}, token=token) + if not (200 <= code < 300): + raise RuntimeError(f"Update PR failed: HTTP {code}: {resp[:200]}") + + +def _upsert_pr( + *, + repo: str, + head: str, + base: str, + title: str, + body: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> tuple[str, int]: + prs = _list_open_prs(repo=repo, head=head, token=token, apply_call=apply_call) + if prs: + number = int(prs[0]["number"]) + _update_pr(repo=repo, number=number, title=title, body=body, token=token, apply_call=apply_call) + return "updated", number + number = _create_pr(repo=repo, head=head, base=base, title=title, body=body, token=token, apply_call=apply_call) + return "created", number + + +def build_pr_body(old_version: str, new_version: str, bump_kind: str, notes_markdown: str) -> str: + """Return the Markdown body for a release-bump PR. + + The returned text wraps *notes_markdown* verbatim (byte-for-byte, no + re-formatting) between two literal marker lines, + ```` and ````, + each on its own line. `release_tag_publish.py` extracts exactly this + span from the merged PR's body later to build the GitHub Release, so + the marker text is load-bearing and must never change. + """ + lines = [ + f"Bumps `plugin` from `{old_version}` to `{new_version}` ({bump_kind}).", + "", + _RELEASE_NOTES_START_MARKER, + notes_markdown, + _RELEASE_NOTES_END_MARKER, + "", + "Merging this PR is the release act: on merge, " + "`.github/workflows/release-tag.yml` creates the `gitapex--vX.Y.Z` tag " + "and a GitHub Release using the notes above.", + "", + "Do not edit `.claude-plugin/plugin.json`/`apm.yml` by hand in this PR; " + "the next scheduled run will overwrite manual edits.", + ] + return "\n".join(lines) + "\n" + + +def publish_release_pr( + *, + repo: str, + additions: list[tuple[str, bytes]], + base: str, + branch: str, + title: str, + body: str, + commit_subject: str, + commit_body: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, + graphql_call: Callable[..., tuple[int, dict[str, Any]]] = graphql_call, +) -> str: + """Publish *additions* to *branch* and upsert a PR into *base*. + + Returns ``"up-to-date"`` when *additions* is empty (the caller workflow + only ever invokes this script after computing a real version bump, so + this is a defensive no-op rather than an expected outcome), or + ``":"`` (*verb* is ``created`` or ``updated``, + matching the PR-upsert outcome) otherwise. + + Unlike `sync_pr_publish.py`'s `publish_files_pr`, this does not check + whether *base* already carries every addition's bytes first: the caller + workflow gates on a real, not-yet-merged version bump before invoking + this script at all, so *base* can never already be up to date. + + *branch* is deleted and recreated off *base* with a single signed + commit whenever there is no open PR currently targeting it -- see the + module docstring for why a stale branch is unsafe to reuse as-is. When + an open PR already exists, the branch is left alone and the new commit + is appended onto its current tip instead, so the PR (and its review + history/comments) survives across runs. Once this script owns the + branch, every commit on it is already signed, so an append can never + reintroduce the unsigned-ancestor problem the recreate path guards + against. + """ + if not additions: + return "up-to-date" + + has_open_pr = bool(_list_open_prs(repo=repo, head=branch, token=token, apply_call=apply_call)) + if not has_open_pr: + _delete_branch(repo=repo, branch=branch, token=token, apply_call=apply_call) + + api_additions = [ + {"path": path, "contents": base64.b64encode(content).decode("ascii")} for path, content in additions + ] + head_oid = _get_branch_head_oid(repo=repo, branch=branch, token=token, apply_call=apply_call) + if head_oid is None: + head_oid = _get_ref_sha(repo=repo, ref=f"heads/{base}", token=token, apply_call=apply_call) + _create_branch_ref(repo=repo, branch=branch, sha=head_oid, token=token, apply_call=apply_call) + _create_commit_on_branch( + repo=repo, + branch=branch, + expected_head_oid=head_oid, + headline=commit_subject, + body=commit_body, + additions=api_additions, + token=token, + graphql_call=graphql_call, + ) + elif _ref_drifts(repo=repo, ref=branch, additions=additions, token=token, apply_call=apply_call): + _create_commit_on_branch( + repo=repo, + branch=branch, + expected_head_oid=head_oid, + headline=commit_subject, + body=commit_body, + additions=api_additions, + token=token, + graphql_call=graphql_call, + ) + + verb, number = _upsert_pr( + repo=repo, head=branch, base=base, title=title, body=body, token=token, apply_call=apply_call + ) + return f"{verb}:{number}" + + +def _collect_additions(repo_root: Path, paths: list[str]) -> list[tuple[str, bytes]]: + """Read each of *paths* (repo-root-relative) off disk under *repo_root*. + + The returned tuples keep the repo-root-relative path (not the absolute + on-disk path) as the commit path, since that is what + `createCommitOnBranch`'s `fileChanges.additions[].path` expects. + """ + additions: list[tuple[str, bytes]] = [] + for path in paths: + p = repo_root / path + if not p.is_file(): + raise RuntimeError(f"manifest path is not a readable file: {p}") + additions.append((path, p.read_bytes())) + return additions + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Publish a signed PR for a plugin version bump.") + parser.add_argument("--repo-root", default=".", dest="repo_root", help="Repository root (default: cwd)") + parser.add_argument("--base", default="main", help="Base branch to merge into") + parser.add_argument("--branch", default=_DEFAULT_BRANCH, help="Head branch name (recreated when no open PR)") + parser.add_argument("--old-version", required=True, dest="old_version", help="Version before the bump") + parser.add_argument("--new-version", required=True, dest="new_version", help="Version after the bump") + parser.add_argument("--bump-kind", required=True, dest="bump_kind", help="'minor' or 'patch'") + parser.add_argument( + "--notes-file", required=True, dest="notes_file", help="Path to the rendered release-notes Markdown" + ) + parser.add_argument( + "--plugin-manifest", + default=".claude-plugin/plugin.json", + dest="plugin_manifest", + help="Repo-root-relative path to the bumped plugin manifest", + ) + parser.add_argument( + "--apm-manifest", + default="apm.yml", + dest="apm_manifest", + help="Repo-root-relative path to the bumped apm manifest", + ) + args = parser.parse_args(argv) + + token = os.environ.get("GH_TOKEN", "") + if not token: + print("Error: GH_TOKEN environment variable is required", file=sys.stderr) + return 1 + repo = os.environ.get("REPO", "") + if not repo: + print("Error: REPO environment variable is required", file=sys.stderr) + return 1 + notes_path = Path(args.notes_file) + if not notes_path.exists(): + print(f"Error: notes file not found: {args.notes_file}", file=sys.stderr) + return 1 + + try: + repo_root = Path(args.repo_root) + additions = _collect_additions(repo_root, [args.plugin_manifest, args.apm_manifest]) + body = build_pr_body( + args.old_version, args.new_version, args.bump_kind, notes_path.read_text(encoding="utf-8") + ) + title = f"chore(plugin): bump version to {args.new_version}" + result = publish_release_pr( + repo=repo, + additions=additions, + base=args.base, + branch=args.branch, + title=title, + body=body, + commit_subject=f"chore(plugin): bump version to {args.new_version}", + commit_body="", + token=token, + ) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"release-pr-publish: {result}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_tag_publish.py b/.github/scripts/release_tag_publish.py new file mode 100644 index 00000000..6ef4cb23 --- /dev/null +++ b/.github/scripts/release_tag_publish.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Tag and publish a GitHub Release for a merged plugin-version-bump PR. + +The release workflow bumps ``version`` in ``.claude-plugin/plugin.json`` +through a normal reviewed PR. Once that PR merges to the base branch, this +script runs against the merge commit and, if that exact version has not been +published yet, creates an annotated git tag and a GitHub Release for it. + +Tag naming: ``gitapex--v{version}`` -- deliberately not ``plugin-v{version}``. +This repository's plugin-dependency consumers resolve a plugin's installable +versions from tags of the form ``--v``, so the tag must +be prefixed with this plugin's own name (``gitapex``), not the literal word +"plugin". + +Idempotency: "already published" means the tag ref *and* the GitHub Release +both exist. Only then is this script a no-op (exit 0). Publishing is three +API calls (tag object, tag ref, Release), so a run can die in between: the +tag alone is not proof the Release shipped. Treating it as proof made that +partial state unrecoverable -- every retry saw the tag, exited 0, and left +the Release permanently missing. Checking both instead means a retry +finishes whatever is left (creating the Release without re-creating the +tag), so re-running after a partial failure really is safe. + +Release notes come from the merged PR's body, extracted from between two +literal HTML-comment markers (```` / +````) so the PR author controls exactly what ships +in the release body, separate from the rest of the PR description. + +Scoped to this one workflow's needs (a single fixed-format tag/release per +run, driven off one plugin.json's version) rather than a general-purpose +release-publishing library. + +Usage:: + + python3 .github/scripts/release_tag_publish.py --sha + +Environment variables: + GH_TOKEN GitHub token with contents:write scope (tags) and + contents:write / administration scope for releases. + REPO Repository in ``owner/repo`` format (used when ``--repo`` is + not passed). + +Exit codes: + 0 Success, including the no-op "already published" case, and the + "not a release-PR merge" skip (no merged PR found for the commit, or + the merged PR's head branch is not the release-bump branch -- this + workflow triggers on every plugin.json-touching push to main, not + only release-PR merges, so an ordinary edit to the file must not + fail the run). + 1 Missing env var, missing/invalid manifest, missing release-notes + markers on a release-bump-branch PR, or a GitHub API error. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from collections.abc import Callable +from pathlib import Path +from typing import Any + +_API_ROOT = "https://api.github.com" +_API_VERSION = "2022-11-28" +_HTTP_TIMEOUT_SECONDS = 30 + +_RELEASE_NOTES_START = "" +_RELEASE_NOTES_END = "" +_RELEASE_NOTES_RE = re.compile(re.escape(_RELEASE_NOTES_START) + r"(.*?)" + re.escape(_RELEASE_NOTES_END), re.DOTALL) + +# Must match release_pr_publish.py's own `_DEFAULT_BRANCH` literal. Not +# imported -- this repo's `.github/scripts/*.py` files deliberately do not +# import each other (see release_pr_publish.py's own module docstring). +_RELEASE_BUMP_BRANCH = "chore/release-plugin-bump" + + +def _default_opener(request: urllib.request.Request) -> Any: + # S310 justification: every caller in this module builds `request` from a + # fixed https://api.github.com URL plus trusted env-var-derived segments. + return urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) # noqa: S310 + + +def apply_call( + *, + method: str, + url: str, + payload: dict[str, Any] | None, + token: str, + opener: Callable[[urllib.request.Request], Any] = _default_opener, + sleeper: Callable[[float], None] | None = None, +) -> tuple[int, str]: + """Call the GitHub REST API, retrying transient (5xx/network) failures.""" + sleeper = sleeper if sleeper is not None else time.sleep + last_code = 0 + last_body = "" + + for attempt in range(1, 4): + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") if payload is not None else None + request = urllib.request.Request(url, data=data, method=method) # noqa: S310 -- fixed https://api.github.com endpoint + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/vnd.github+json") + request.add_header("X-GitHub-Api-Version", _API_VERSION) + if payload is not None: + request.add_header("Content-Type", "application/json") + + try: + with opener(request) as response: + last_code = int(response.status) + last_body = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as error: + last_code = int(error.code) + last_body = error.read().decode("utf-8", errors="replace") + except urllib.error.URLError as error: + last_code = 0 + last_body = str(error.reason) + + if 200 <= last_code < 300: + break + print(f"Attempt {attempt}: HTTP {_format_code(last_code)} for {method} {url}", file=sys.stderr) + if last_code != 0 and last_code < 500: + break + if attempt < 3: + sleeper(attempt * 5) + + return last_code, last_body + + +def _format_code(code: int) -> str: + return "000" if code == 0 else str(code) + + +def tag_exists( + repo: str, + version: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> bool: + """Return True when ``gitapex--v{version}`` already exists as a tag ref.""" + url = f"{_API_ROOT}/repos/{repo}/git/ref/tags/gitapex--v{version}" + code, body = apply_call(method="GET", url=url, payload=None, token=token) + if code == 200: + return True + if code == 404: + return False + raise RuntimeError(f"Check tag gitapex--v{version} failed: HTTP {code}: {body[:200]}") + + +def release_exists( + repo: str, + version: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> bool: + """Return True when a GitHub Release for ``gitapex--v{version}`` exists. + + Checked separately from ``tag_exists`` because the tag ref is created + before the Release: between those two calls the tag exists while the + Release does not, and only this check can tell that partial state apart + from a fully published one. + """ + url = f"{_API_ROOT}/repos/{repo}/releases/tags/gitapex--v{version}" + code, body = apply_call(method="GET", url=url, payload=None, token=token) + if code == 200: + return True + if code == 404: + return False + raise RuntimeError(f"Check release gitapex--v{version} failed: HTTP {code}: {body[:200]}") + + +def find_merged_pr_for_commit( + repo: str, + sha: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> dict[str, Any] | None: + """Return the first merged PR associated with ``sha``, or None if none is.""" + url = f"{_API_ROOT}/repos/{repo}/commits/{sha}/pulls" + code, body = apply_call(method="GET", url=url, payload=None, token=token) + if not (200 <= code < 300): + raise RuntimeError(f"List PRs for commit {sha} failed: HTTP {code}: {body[:200]}") + data = json.loads(body) + if not isinstance(data, list): + raise RuntimeError(f"Expected list from commit-pulls for {sha}, got: {body[:200]}") + for pr in data: + if isinstance(pr, dict) and pr.get("merged_at"): + return pr + return None + + +def extract_release_notes(pr_body: str) -> str: + """Extract the text strictly between the release-notes marker lines. + + Raises ``RuntimeError`` when either marker is missing -- never returns an + empty string or fabricates placeholder notes. + """ + body = pr_body or "" + if _RELEASE_NOTES_START not in body: + raise RuntimeError(f"PR body is missing the {_RELEASE_NOTES_START!r} marker") + if _RELEASE_NOTES_END not in body: + raise RuntimeError(f"PR body is missing the {_RELEASE_NOTES_END!r} marker") + match = _RELEASE_NOTES_RE.search(body) + if match is None: + raise RuntimeError("PR body release-notes markers are out of order or malformed") + return match.group(1).strip() + + +def publish_tag_and_release( + repo: str, + version: str, + sha: str, + notes: str, + token: str, + apply_call: Callable[..., tuple[int, str]] = apply_call, + skip_tag: bool = False, +) -> None: + """Create an annotated tag object, point ``refs/tags/gitapex--v{version}`` + at it, then publish a GitHub Release for that tag. + + ``skip_tag`` skips both tag steps, for the recovery case where a previous + run already created the tag ref but died before the Release: re-POSTing + the ref would just fail with "Reference already exists" and strand the + Release again. + + Raises ``RuntimeError`` (with status/body) on the first non-2xx response + and does not continue past the failed step. + """ + tag_name = f"gitapex--v{version}" + + if not skip_tag: + code, body = apply_call( + method="POST", + url=f"{_API_ROOT}/repos/{repo}/git/tags", + payload={"tag": tag_name, "message": f"gitapex v{version}", "object": sha, "type": "commit"}, + token=token, + ) + if not (200 <= code < 300): + raise RuntimeError(f"Create tag object {tag_name} failed: HTTP {code}: {body[:200]}") + tag_sha = json.loads(body).get("sha") + if not isinstance(tag_sha, str) or not tag_sha: + raise RuntimeError(f"Create tag object {tag_name} response missing sha: {body[:200]}") + + code, body = apply_call( + method="POST", + url=f"{_API_ROOT}/repos/{repo}/git/refs", + payload={"ref": f"refs/tags/{tag_name}", "sha": tag_sha}, + token=token, + ) + if not (200 <= code < 300): + raise RuntimeError(f"Create tag ref {tag_name} failed: HTTP {code}: {body[:200]}") + + code, body = apply_call( + method="POST", + url=f"{_API_ROOT}/repos/{repo}/releases", + payload={"tag_name": tag_name, "name": f"gitapex v{version}", "body": notes}, + token=token, + ) + if not (200 <= code < 300): + raise RuntimeError(f"Create release {tag_name} failed: HTTP {code}: {body[:200]}") + + +def _read_version(manifest_path: Path) -> str: + """Read ``version`` out of the plugin manifest at ``manifest_path``. + + Reads the working tree directly (no ``git show``) -- this script runs + after checkout at the commit being tagged, so the file on disk already + reflects that commit's content. + """ + try: + raw = manifest_path.read_text(encoding="utf-8") + except OSError as exc: + raise RuntimeError(f"Could not read plugin manifest {manifest_path}: {exc}") from exc + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Plugin manifest {manifest_path} is not valid JSON: {exc}") from exc + version = data.get("version") if isinstance(data, dict) else None + if not isinstance(version, str) or not version: + raise RuntimeError(f"Plugin manifest {manifest_path} has no non-empty 'version' field") + return version + + +def main( + argv: list[str] | None = None, + apply_call: Callable[..., tuple[int, str]] = apply_call, +) -> int: + parser = argparse.ArgumentParser( + description="Tag and publish a GitHub Release for a merged plugin-version-bump PR." + ) + parser.add_argument("--repo-root", default=".", dest="repo_root", help="Working tree root (default: cwd)") + parser.add_argument( + "--plugin-manifest", + default=".claude-plugin/plugin.json", + dest="plugin_manifest", + help="Path to the plugin manifest, relative to --repo-root", + ) + parser.add_argument("--sha", required=True, help="Commit SHA to tag") + parser.add_argument("--repo", default=None, help="Repository in owner/repo format (default: REPO env var)") + args = parser.parse_args(argv) + + token = os.environ.get("GH_TOKEN", "") + if not token: + print("Error: GH_TOKEN environment variable is required", file=sys.stderr) + return 1 + repo = args.repo or os.environ.get("REPO", "") + if not repo: + print("Error: --repo or REPO environment variable is required", file=sys.stderr) + return 1 + + manifest_path = Path(args.repo_root) / args.plugin_manifest + + try: + version = _read_version(manifest_path) + tag_name = f"gitapex--v{version}" + has_tag = tag_exists(repo, version, token, apply_call=apply_call) + has_release = release_exists(repo, version, token, apply_call=apply_call) + if has_tag and has_release: + print(f"release-tag-publish: {tag_name} already published (tag + release) -- no-op") + return 0 + pr = find_merged_pr_for_commit(repo, args.sha, token, apply_call=apply_call) + head_ref = (pr.get("head") or {}).get("ref") if pr is not None else None + if pr is None or head_ref != _RELEASE_BUMP_BRANCH: + # This workflow triggers on every push to main that touches + # plugin.json, not only release-PR merges -- an ordinary PR + # editing plugin.json (a metadata field, or the deliberate + # manual major-version bump docs/versioning.md prescribes) is + # expected to reach this point with no release-PR-shaped merge + # behind it. That is not an error: skip quietly rather than + # raising, so this workflow does not go red on a legitimate, + # non-release change to the same file. + print( + f"release-tag-publish: commit {args.sha} was not merged from " + f"'{_RELEASE_BUMP_BRANCH}' (found: {head_ref!r}) -- not a release-PR " + "merge, skipping" + ) + return 0 + notes = extract_release_notes(pr.get("body") or "") + publish_tag_and_release( + repo, version, args.sha, notes, token, apply_call=apply_call, skip_tag=has_tag + ) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + print(f"release-tag-publish: published {tag_name}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 00000000..6ea0fce5 --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,99 @@ +name: Release PR + +on: + # Deliberately NOT push-triggered. Merging this workflow's own bump PR + # pushes to main, which would re-trigger a push-triggered version of this + # workflow before .github/workflows/release-tag.yml (also push-triggered, + # on .claude-plugin/plugin.json changes) has created the new tag -- + # producing a phantom second bump computed against a stale baseline. A + # scheduled cadence avoids this race entirely (mirrors + # sync-agent-instructions.yml's own cron "0 6 * * *"; this workflow uses + # "0 5 * * *", one hour earlier, to avoid contending for the same runner + # minute). + schedule: + - cron: "0 5 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + release-pr: + runs-on: ubuntu-latest + timeout-minutes: 10 + # The publish step mints its own short-lived GitHub App token (write + # scope), so the job's default GITHUB_TOKEN never needs more than read + # access to check out the repository. + permissions: + contents: read + environment: + name: release-bot + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Full tag history: the compute script's `git tag`/`git log` calls + # need it to derive the current version and bump kind. + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + + - name: Capture current version + id: old-version + run: | + set -euo pipefail + old=$(python3 -c "import json;print(json.load(open('.claude-plugin/plugin.json'))['version'])") + echo "old_version=${old}" >> "$GITHUB_OUTPUT" + + - name: Compute release bump + id: compute + run: | + set -euo pipefail + uv run --frozen python3 .github/scripts/compute_release_bump.py \ + --write \ + --notes-out /tmp/release-notes.md \ + --github-output "$GITHUB_OUTPUT" + + # A `git push` from the runner's default GITHUB_TOKEN produces an + # unsigned commit, which this repository's required_signatures + # branch-protection rule rejects at merge time (the original failure + # sync-agent-instructions.yml hit). Mint a short-lived GitHub App + # installation token instead; .github/scripts/release_pr_publish.py + # uses it to create the commit server-side, which GitHub signs and + # shows as Verified. See CONTRIBUTING.md -> "Signed-commit release bot + # App" for how RELEASE_BOT_APP_ID / RELEASE_BOT_APP_PRIVATE_KEY are + # issued. + - name: Mint GitHub App token + id: app-token + if: steps.compute.outputs.bump != 'none' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }} + + - name: Publish release PR + if: steps.compute.outputs.bump != 'none' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + uv run --frozen python3 .github/scripts/release_pr_publish.py \ + --base "${GITHUB_REF_NAME}" \ + --old-version "${{ steps.old-version.outputs.old_version }}" \ + --new-version "${{ steps.compute.outputs.version }}" \ + --bump-kind "${{ steps.compute.outputs.bump }}" \ + --notes-file /tmp/release-notes.md diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 00000000..6b2482cb --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,73 @@ +name: Release tag + +on: + push: + branches: [main] + paths: + - '.claude-plugin/plugin.json' + +permissions: + contents: read + +# Two plugin.json-touching pushes landing close together must not race: both +# runs would otherwise see "no tag yet" concurrently and could both attempt +# to create it, with one losing to a 422 and stranding a tag object with no +# ref. Serializing (not cancelling -- each push may carry a different +# version to tag) removes that window. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + release-tag: + runs-on: ubuntu-latest + timeout-minutes: 10 + # The publisher step mints its own short-lived GitHub App installation + # token (write scope), so the job's default GITHUB_TOKEN never needs + # more than read access to check out the repository. + permissions: + contents: read + environment: + name: release-bot + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 # need tag visibility for the script's tag-existence check + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: true + + # Uses the SAME App/secrets as release-pr.yml (not sync-bot's): both + # workflows mint credentials for the release bot identity, whereas + # sync-agent-instructions.yml mints a separate sync-bot App token. + # + # This is a deliberately conservative choice: whether + # required_signatures / tag-scoped rulesets actually cover tag-ref or + # GitHub-Release creation (as opposed to commits) is unverified in + # this repository (no ruleset config is committed here to check), so + # the App-token path is used regardless as the safer default. See + # CONTRIBUTING.md's "Signed-commit release bot App" section for how + # RELEASE_BOT_APP_ID / RELEASE_BOT_APP_PRIVATE_KEY are issued. + - name: Mint GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }} + + - name: Publish release tag + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + uv run --frozen python3 .github/scripts/release_tag_publish.py \ + --sha "${{ github.sha }}" \ + --repo "${{ github.repository }}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30357a2b..2972907e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,41 @@ The workflow's job runs under the `sync-bot` environment, so these secrets are only exposed to that job and can carry their own approval gates independent of other workflows in this repository. +## Signed-commit release bot App + +The "Release PR" and "Release tag" workflows +(`.github/workflows/release-pr.yml`, `.github/workflows/release-tag.yml`) +propose a `.claude-plugin/plugin.json`/`apm.yml` version-bump pull request and, +on merge, create the `gitapex--vX.Y.Z` tag and a GitHub Release. Both need a +write identity distinct from the default `GITHUB_TOKEN`, for the same +`required_signatures` branch-protection reason as the sync-bot App documented +above. This is a **separate, dedicated** GitHub App — not an extension of the +sync-bot App above — so each automation's write capability stays scoped to its +own blast radius: a compromise or bug in one cannot use the other's +credentials. + +To enable this: + +1. Create a GitHub App (repo or org-owned), suggested name + `gitapex-release-bot`, with: + - Repository permissions: **Contents: Read and write**, **Pull requests: + Read and write**. + - No webhook, no other permissions needed. +2. Install the App on this repository. +3. Generate a private key for the App and note its App ID. +4. In this repository's settings, create an **Environment** named + `release-bot` (optionally with required reviewers or other protection + rules). +5. Add two secrets scoped to the `release-bot` environment: + - `RELEASE_BOT_APP_ID` — the App ID. + - `RELEASE_BOT_APP_PRIVATE_KEY` — the App's private key (PEM contents). + +Both workflows' jobs run under the `release-bot` environment, so these secrets +are only exposed to those jobs. **Verification:** trigger `release-pr.yml` +once via `workflow_dispatch`, confirm the resulting bump PR's commit shows as +Verified, merge it, and confirm `release-tag.yml` creates a Verified-tagged +`gitapex--vX.Y.Z` and a GitHub Release carrying the PR's release-notes text. + ## ranking-the-open-queue weekly digest API key The "Weekly ranking-the-open-queue digest" diff --git a/docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md b/docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md new file mode 100644 index 00000000..144af7ef --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md @@ -0,0 +1,277 @@ +# Branch plan: versioning-release-mechanism (issue #642) + +Produced by `planning-a-branch-from-an-issue`, executed by +`executing-a-branch-plan`. Design source: issue #642's own Acceptance +Criteria Map, re-verified against `.github/workflows/sync-agent-instructions.yml`, +`.github/scripts/sync_pr_publish.py`, `.github/scripts/scan_apm_manifest_drift.py`, +and `git tag -l`/`git log --merges` (confirmed: zero tags exist today; this +repo's merge history uses real merge commits, not squash) before this plan +was written. + +## Acceptance Criteria Map (re-verified) + +| Criterion | Interpretation | Planned ops | Proof method | Residual risk | +|---|---|---|---|---| +| Release-PR flow: automation proposes a version-bump + release-notes PR; merging it is the release act | A scheduled workflow computes the next `plugin` SemVer from commits since the last `gitapex--v*` tag and opens a signed PR with the bump + notes; no direct push to `main` bumps the version | `.github/scripts/compute_release_bump.py` (pure core + git/file I/O wrappers) + `.github/scripts/release_pr_publish.py` (signed-commit PR publisher, adapted from `sync_pr_publish.py`'s pattern) + `.github/workflows/release-pr.yml` | `uv run pytest tests/test_compute_release_bump.py tests/test_release_pr_publish.py -v`; `actionlint .github/workflows/release-pr.yml` | Bump cadence (daily schedule) is a soft choice, not fixed by the issue | +| A new, dedicated GitHub App signs any commit/tag the automation authors | `release-bot` Environment, `RELEASE_BOT_APP_ID`/`RELEASE_BOT_APP_PRIVATE_KEY` secrets, used via `actions/create-github-app-token` + `createCommitOnBranch`, mirroring but separate from the sync-bot App | `CONTRIBUTING.md` new "Signed-commit release bot App" section (App creation is an external, human-only step -- documented, not performed by this plan) | Manual doc review: section mirrors the existing sync-bot section's concreteness | App does not exist yet at PR-open time; the workflows reference secrets that must be added afterward, same as `sync-agent-instructions.yml`'s own precedent | +| No retroactive tag for the untagged `0.1.0` history | Bootstrap case (no prior tag) is not special-cased -- the commit range is all of history, and max-of-signals severity still yields exactly one bump | `compute_release_bump.py`'s tag-discovery returning `None` triggers no special-case branch; `docs/versioning.md` bootstrap note | `tests/test_compute_release_bump.py`'s bootstrap-case test; manual doc review | none identified | +| Merging a release PR reliably bumps `plugin.json`'s `version`, fixing the current update freeze | `compute_release_bump.py`'s primary write target is `plugin.json`'s `version` field; `apm.yml` is updated in the same commit | Targeted regex substitution (not a full JSON/YAML round-trip, to avoid reformatting `plugin.json` or destroying `apm.yml`'s comment block), asserting exactly one match before writing | Test asserting both files change in one `createCommitOnBranch` call; `scan_apm_manifest_drift.py` still passes | none identified | +| Tag format corrected to `gitapex--vX.Y.Z` | `docs/versioning.md`'s tag-format cell and both workflows' literal tag string use `gitapex--v{version}` | `docs/versioning.md` edit; `release_tag_publish.py`'s tag-creation call | `grep -rn 'plugin-v' --include=*.md`; manual review | none identified | +| `docs/versioning.md`'s `cli`/`compose` rows corrected to reflect the future-fork plan | Rows reworded from "reserved... no version file yet" to out-of-scope-for-gitapex, future-separate-repo framing | `docs/versioning.md` edit | Manual doc review | none identified | + +Two ACM rows collapse into one `docs/versioning.md` task below (file-contention +rule -- both write the same file). + +## Task list (7 tasks, 2 waves) + +File-ownership map: no two tasks below write the same file. Interface- +dependency edges: Task 6 (workflow) reads Task 1+Task 2's actual CLI +contract; Task 7 (workflow) reads Task 3's actual CLI contract. All other +cross-task data contracts (file paths, flag names, `GITHUB_OUTPUT` keys, the +`` markers, secret/env names) are fixed by +this document, so wave-1 tasks need not read each other's code. + +### Wave 1 (5 tasks, no edges among them -- `isolation: 'worktree'`) + +#### Task 1 -- bump-computation script + +Satisfies ACM rows 1, 3, 4. + +Files: +- `.github/scripts/compute_release_bump.py` (new) +- `tests/test_compute_release_bump.py` (new) + +Steps: +1. `parse_header(subject) -> ParsedCommit | None`: regex + `^(feat|fix|docs|refactor|perf|test|chore|build|ci)(\(([a-z0-9_-]+)\))?(!)?:\s*(.+)$`. + Non-matching subjects (merges, freeform) return `None`. +2. `is_breaking(subject, body) -> bool`: `!` before the header colon, or a + `BREAKING CHANGE:` footer line in `body`. +3. `classify(parsed, breaking) -> "minor" | "patch" | None`: only + `scope == "plugin"` counts. `feat` or breaking -> `"minor"`. `fix` / + `refactor` / `perf` -> `"patch"`. Everything else (including wrong scope + or unparsed) -> `None`. **No branch anywhere in this module may return + or imply a major-version bump** -- there is no `"major"` value in this + function's return type, by construction. +4. `compute_next_version(current_version, commits) -> BumpResult | None`: + max-of-signals across all commits (one `feat` and five `feat`s both + produce exactly one minor bump, never cumulative). `None` when no commit + classifies. +5. `discover_last_tag(git_runner) -> str | None`: `git tag -l 'gitapex--v*' + --sort=-v:refname`, first line or `None`. A `None` result is not a + special case in step 4 -- the caller just passes the full history as the + commit list. +6. `write_bumped_manifests(plugin_path, apm_path, new_version)`: regex + substitution of just the `"version": "X.Y.Z"` line in `plugin.json` and + the top-level `version: X.Y.Z` line in `apm.yml`; assert exactly one + match per file before writing (raise `RuntimeError` otherwise, matching + `scan_apm_manifest_drift.py`'s fail-loud style); write via temp-file in + the same directory + `os.replace`. +7. `render_notes(commits) -> str`: Markdown grouped by classified type + (`### Features` / `### Fixes` / `### Refactors`), one bullet per commit + (`- ()`), plus a trailing line counting excluded + (unclassified) commits. +8. CLI (`argparse`): `--repo-root` (default cwd), `--plugin-manifest` + (default `.claude-plugin/plugin.json`), `--apm-manifest` (default + `apm.yml`), `--write` (apply; omitted = compute-only), `--notes-out PATH`, + `--github-output PATH` (appends `bump=none|minor|patch` and + `version=X.Y.Z` in `GITHUB_OUTPUT` format). Git calls (`git tag -l`, + `git log ..HEAD --no-merges --format=...`) go through an injectable + `git_runner` callable so tests never need a real repo. +9. Tests: `parse_header`/`is_breaking`/`classify` unit cases (including a + `feat(skills)`-scoped commit, confirming it does NOT count); a + max-of-signals case (one `feat(plugin)` + one `fix(plugin)` -> exactly + one minor bump, not two); a bootstrap case (`discover_last_tag` returns + `None`, commit list spans "all history," still yields exactly one bump); + an explicit adversarial case asserting **no possible input sequence + produces a major-version bump** (e.g. many breaking-marked `feat(plugin)` + commits still yield `"minor"`, never `"major"`); `write_bumped_manifests` + raising when a manifest's version line is missing or duplicated (not + silently no-op or double-writing); atomic-write behavior (temp file + never left behind on success). + +#### Task 2 -- release-PR publisher script + +Satisfies ACM row 1. + +Files: +- `.github/scripts/release_pr_publish.py` (new, self-contained -- no import + from `sync_pr_publish.py` or `compute_release_bump.py`, matching this + repo's own no-cross-import convention across all 23 existing + `.github/scripts/*.py` files) +- `tests/test_release_pr_publish.py` (new) + +Steps: +1. Adapt `sync_pr_publish.py`'s `apply_call`/`graphql_call`/ + `_create_commit_on_branch`/`_upsert_pr` machinery (same retry-on-5xx, + same `createCommitOnBranch` GraphQL mutation, same + delete-and-recreate-branch-on-drift-when-no-open-PR safety rule) for a + fixed branch `chore/release-plugin-bump`. +2. `build_pr_body(old_version, new_version, bump_kind, notes_markdown) -> + str`: version-bump summary + the notes wrapped verbatim in + `` / `` markers + + a trailer stating "Merging this PR is the release act" and warning + against hand-editing the manifests (next scheduled run overwrites). +3. CLI: `--repo-root`, `--old-version`, `--new-version`, `--bump-kind`, + `--notes-file`, `--plugin-manifest`, `--apm-manifest` (the two changed + files to commit), reusing `GH_TOKEN`/`REPO` env vars for parity with + `sync_pr_publish.py`. +4. Tests: reuse `sync_pr_publish.py`'s own test doubles/pattern for + `apply_call`/`graphql_call` (fake opener returning canned responses); + assert `build_pr_body`'s marker span round-trips exactly (what goes in + between the markers is what a regex extraction later gets back); + assert both manifest paths are included in the single commit's + `fileChanges.additions`, never as two commits. + +#### Task 3 -- tag-and-release publisher script + +Satisfies ACM row 5. + +Files: +- `.github/scripts/release_tag_publish.py` (new, self-contained) +- `tests/test_release_tag_publish.py` (new) + +Steps: +1. `tag_exists(repo, version, token) -> bool`: `GET + /repos/{repo}/git/ref/tags/gitapex--v{version}`; 200 -> True, 404 -> + False, anything else -> raise. +2. `find_merged_pr_for_commit(repo, sha, token) -> dict | None`: `GET + /repos/{repo}/commits/{sha}/pulls`, first merged result or `None`. +3. `extract_release_notes(pr_body) -> str`: regex extraction of the span + between `` and ``; raise if markers are absent (fail loud, not an empty-notes + Release). +4. `publish_tag_and_release(repo, version, sha, notes, token)`: `POST + .../git/tags` (annotated tag object) + `POST .../git/refs` + (`refs/tags/gitapex--v{version}`) at `sha`, then `POST .../releases` + with `tag_name=gitapex--v{version}`, `name="gitapex v{version}"`, + `body=notes`. +5. `main`: read `version` from `.claude-plugin/plugin.json` at the + checked-out `HEAD`; if `tag_exists`, print no-op and exit 0; else find + the merged PR for `HEAD`'s sha (raise if none found -- a + `plugin.json`-touching push to `main` outside the release-PR flow is an + unexpected state, not silently ignored), extract notes, publish. +6. Tests: tag-exists short-circuit; notes-extraction success and + missing-marker failure; the "no merged PR found" raise path; a fake + `apply_call` double confirming the tag ref and Release POST bodies carry + the extracted notes and the correct `gitapex--v{version}` name (not + `plugin-v{version}`). + +#### Task 4 -- `docs/versioning.md` edits + +Satisfies ACM rows 3, 5, 6. + +Files: +- `docs/versioning.md` (edit) + +Steps: +1. Product table: `plugin` row's Tag format cell `` `plugin-vX.Y.Z` `` -> + `` `gitapex--vX.Y.Z` ``. `cli`/`compose` rows' Tag format -> `N/A -- + out of scope for this repo`; Status -> `Out of scope for gitapex -- + moves to a future, separate forked repository when built`. +2. Prose below the table ("Only the **plugin** row is real today...."): + replace with prose stating gitapex stays plugin/skills-only; a future + CLI or other non-plugin product is built in a separate forked + repository, not here. +3. New "Release bootstrap" note: no retroactive tag was created for the + untagged `0.1.0` history (created 2026-07-21, never tagged); the first + tag/release is whatever version is computed next, on a fresh commit; + changelog/notes coverage begins there. +4. Replace "No automation yet" with an "Automation" section naming + `compute_release_bump.py`, `release-pr.yml`, `release-tag.yml`, and + stating the bump rule as user-facing policy: `feat(plugin)`/breaking -> + minor, `fix`/`refactor`/`perf(plugin)` -> patch, everything else -> no + bump, **major is never bumped automatically** -- `1.0.0` is always a + deliberate manual edit (cites SemVer §4, already referenced earlier in + the doc). + +Proof method: `grep -rn 'plugin-v' --include=*.md .` finds no remaining +tag-format reference; manual read. + +#### Task 5 -- `CONTRIBUTING.md` edit + +Satisfies ACM row 2. + +Files: +- `CONTRIBUTING.md` (edit) + +Steps: +1. Add a new "Signed-commit release bot App" section immediately after the + existing "Signed-commit bot App" section, same structure: what the + `release-pr.yml`/`release-tag.yml` workflows need it for; App creation + steps (repo/org-owned, suggested name `gitapex-release-bot`, Contents + read/write + Pull requests read/write permissions, no webhook); install + on repo; generate private key, note App ID; create Environment + `release-bot`; add `RELEASE_BOT_APP_ID` / `RELEASE_BOT_APP_PRIVATE_KEY` + secrets scoped to it; verification steps (trigger `release-pr.yml` via + `workflow_dispatch`, confirm the bump PR's commit shows Verified, merge + it, confirm `release-tag.yml` creates a Verified tag + Release). +2. State explicitly this is a **separate** App from `sync-bot`, not an + extension of it, and why (scoped blast radius per automation). + +Proof method: manual review against the existing sync-bot section's own +structure/concreteness. + +### Wave 2 (2 tasks, no edge between them, each edged on its wave-1 +script(s) -- `isolation: 'worktree'`) + +#### Task 6 -- release-PR workflow + +Satisfies ACM row 1 (interface edge on Task 1 + Task 2's actual CLI +contracts). + +Files: +- `.github/workflows/release-pr.yml` (new) + +Steps: +1. Trigger: `schedule: "0 5 * * *"` + `workflow_dispatch`. **Deliberately + not push-triggered** -- merging the bump PR pushes to `main`, which + would re-trigger a push-triggered `release-pr.yml` before + `release-tag.yml` (also push-triggered) creates the new tag, producing a + phantom second bump against a stale baseline. A scheduled cadence + avoids this race entirely (mirrors `sync-agent-instructions.yml`'s + `cron: "0 6 * * *"`, offset by an hour to avoid runner-minute + contention). +2. Steps: harden-runner (egress-policy audit); checkout + (`persist-credentials: false`, `fetch-depth: 0` -- need tag history); + install `uv`; run `compute_release_bump.py --write --notes-out + notes.md --github-output "$GITHUB_OUTPUT"`; `if: + steps.compute.outputs.bump != 'none'` mint the `release-bot` App token + (`actions/create-github-app-token`, `app-id: secrets.RELEASE_BOT_APP_ID`, + `private-key: secrets.RELEASE_BOT_APP_PRIVATE_KEY`); run + `release_pr_publish.py` with the computed version/notes. +3. `permissions: contents: read` at workflow and job level (write only via + the minted token); `environment: release-bot`. + +Proof method: `actionlint .github/workflows/release-pr.yml` clean. + +#### Task 7 -- release-tag workflow + +Satisfies ACM row 5 (interface edge on Task 3's actual CLI contract). + +Files: +- `.github/workflows/release-tag.yml` (new) + +Steps: +1. Trigger: `push: branches: [main], paths: + ['.claude-plugin/plugin.json']`. +2. Steps: harden-runner; checkout (`fetch-depth: 0` for tag visibility); + mint the `release-bot` App token (same secrets/environment as Task 6 -- + flagged open question, documented in the PR: whether + `required_signatures` covers tag/Release creation is unverifiable from + anything in this repo; the App-token path is used regardless as the + strictly safer default); run `release_tag_publish.py`. + +Proof method: `actionlint .github/workflows/release-tag.yml` clean. + +## Verification (whole-branch, after both waves + review gate) + +- `uv run --frozen pytest tests/test_compute_release_bump.py + tests/test_release_pr_publish.py tests/test_release_tag_publish.py -v` +- `python3 .github/scripts/scan_apm_manifest_drift.py` still passes +- `actionlint .github/workflows/release-pr.yml + .github/workflows/release-tag.yml` +- `grep -rn 'plugin-v' --include=*.md .` -- no remaining old tag format +- Full `uv run pytest -q` (no regressions elsewhere) +- The opened PR's own CI (`lint.yml`/`test.yml`) green before marking ready + for review diff --git a/docs/versioning.md b/docs/versioning.md index e14cd06e..45f3fa79 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -10,15 +10,15 @@ CLI-only change actually shipped. | Product | Scope | Tag format | Version file | Status | |---|---|---|---|---| -| **plugin** | `skills/`, `.claude-plugin/plugin.json` | `plugin-vX.Y.Z` | `.claude-plugin/plugin.json` | Live now (`0.1.0`) | -| **cli** | The future gitapex single-binary CLI: SSOT config (`.gitapex/ssot.json`) plus a modular policy-engine-driven governance/gate-control layer for business-domain changes (embedded Rego via `regorus`, per issue #125's decided design), including git/GitHub middleware and SaaS-integration operations -- the originally-scoped approved read-only gh wrapper is one governed-operation instance within this, not the whole product (see `docs/superpowers/specs/2026-07-15-gitapex-cli-governance-design.md` and tracking issue #82). Currently Python tooling under `.github/scripts/`; **Rust, decided 2026-07-18** (see `docs/superpowers/specs/2026-07-16-business-domain-policy-engine-tradeoff.md`'s Rust-vs-Go decision brief), conditional on the `regorus` conformance fixture-suite (#125) passing -- revisit to Go only if that tripwire fires, while the switch is still a design edit, not a code rewrite | `cli-vX.Y.Z` | To be decided when the CLI product exists | Reserved — no version file yet | -| **compose** | Future deployment/dev topology (e.g. docker-compose) | `compose-vX.Y.Z` | To be decided when compose assets exist | Reserved — no version file yet | +| **plugin** | `skills/`, `.claude-plugin/plugin.json` | `gitapex--vX.Y.Z` | `.claude-plugin/plugin.json` | Live now (`0.1.0`) | +| **cli** | The future gitapex single-binary CLI: SSOT config (`.gitapex/ssot.json`) plus a modular policy-engine-driven governance/gate-control layer for business-domain changes (embedded Rego via `regorus`, per issue #125's decided design), including git/GitHub middleware and SaaS-integration operations -- the originally-scoped approved read-only gh wrapper is one governed-operation instance within this, not the whole product (see `docs/superpowers/specs/2026-07-15-gitapex-cli-governance-design.md` and tracking issue #82). Currently Python tooling under `.github/scripts/`; **Rust, decided 2026-07-18** (see `docs/superpowers/specs/2026-07-16-business-domain-policy-engine-tradeoff.md`'s Rust-vs-Go decision brief), conditional on the `regorus` conformance fixture-suite (#125) passing -- revisit to Go only if that tripwire fires, while the switch is still a design edit, not a code rewrite | `N/A — out of scope for this repo` | To be decided when the CLI product exists | Out of scope for gitapex — moves to a future, separate forked repository when built | +| **compose** | Future deployment/dev topology (e.g. docker-compose) | `N/A — out of scope for this repo` | To be decided when compose assets exist | Out of scope for gitapex — moves to a future, separate forked repository when built | -Only the **plugin** row is real today. **cli** and **compose** are named and -reserved so future work lands on an already-agreed axis instead of -re-litigating the model, but neither has a version file or automation until -its product exists. Additional axes (e.g. a server/UI split) are added the -same way, only when needed — not speculatively. +gitapex remains a skills/plugin-only repository going forward: a future +CLI or any other non-plugin product need is built in a separate, forked +repository rather than inside gitapex. Only the **plugin** row above is +real, and it is the only axis this repository's own release automation +(see [Automation](#automation) below) ever targets. All axes start in the `0.x` range (initial development). `1.0.0` is reserved for the first release a product is willing to guarantee as a @@ -47,10 +47,40 @@ Development is trunk-based: `main` is the trunk and is always releasable. Work lands through short-lived branches and reviewed PRs. There are no long-lived `develop`/`release` branches. -## No automation yet +## Automation -Unlike `tvna/clairvoyance`, this repository does not (yet) run -semantic-release, version-drift CI gates, or scheduled release workflows. -`plugin.json`'s `version` is bumped by hand. A future issue can introduce -that automation once the manual process becomes a bottleneck, or once the -`cli`/`compose` products exist and need it too. +Three pieces now implement release automation for the **plugin** product: + +- **`.github/scripts/compute_release_bump.py`** — computes the next + version and writes it into both `.claude-plugin/plugin.json` and + `apm.yml`. +- **`.github/workflows/release-pr.yml`** — a scheduled workflow that + proposes a version-bump + release-notes pull request. Merging that PR + is the release act. +- **`.github/workflows/release-tag.yml`** — on merge, creates the + `gitapex--vX.Y.Z` git tag and a GitHub Release. + +### Bump rule + +- A `feat(plugin)` commit, or any `plugin`-scoped commit marked breaking + (regardless of its type), bumps the **minor** version. +- A non-breaking `fix(plugin)`, `refactor(plugin)`, or `perf(plugin)` + commit bumps the **patch** version. +- A non-breaking `docs(plugin)`, `chore(plugin)`, `test(plugin)`, + `build(plugin)`, or `ci(plugin)` commit, and any commit scoped to + anything other than `plugin`, trigger **no bump**. + +**The major version is never bumped automatically.** Reaching `1.0.0` (or +any future major version) is always a deliberate, manual edit to +`.claude-plugin/plugin.json` — per the Policy section above, `1.0.0` is +reserved for the first release a product is willing to guarantee as a +stable surface ([SemVer §4](https://semver.org/#spec-item-4)). + +### Release bootstrap + +No retroactive tag was created for the untagged `0.1.0` history: the +plugin manifest has carried `version: "0.1.0"` since 2026-07-21, but no +tag or GitHub Release has ever existed in this repository. The first +tag/release this mechanism produces is whatever version it computes next, +on a fresh commit; changelog/release-notes coverage begins at that first +tagged release, and commits before it are out of changelog scope. diff --git a/tests/test_compute_release_bump.py b/tests/test_compute_release_bump.py new file mode 100644 index 00000000..17d08693 --- /dev/null +++ b/tests/test_compute_release_bump.py @@ -0,0 +1,674 @@ +"""Tests for the release version-bump computation script +(.github/scripts/compute_release_bump.py). + +Steps 1-4 and 6-7 (parse_header/is_breaking/classify, compute_next_version, +write_bumped_manifests, render_notes) are pure-function unit tests: plain +Python data in, zero real git calls. discover_last_tag is exercised via a +stub `git_runner` callable. main()/collect_commits get one end-to-end pass +against a real, throwaway git repository (matching this repo's existing +test_skill_description_diff.py convention) so the CLI wrapper is proven +against the real `git` service path, not only against mocks. +""" + +from __future__ import annotations + +import json +import subprocess + +import pytest + +import compute_release_bump as crb + +# --------------------------------------------------------------------- +# parse_header +# --------------------------------------------------------------------- + + +def test_parse_header_scoped_commit(): + parsed = crb.parse_header("feat(plugin): add release automation") + assert parsed == crb.ParsedCommit( + commit_type="feat", + scope="plugin", + breaking_marker=False, + description="add release automation", + ) + + +def test_parse_header_unscoped_commit(): + parsed = crb.parse_header("fix: correct typo") + assert parsed.commit_type == "fix" + assert parsed.scope is None + assert parsed.breaking_marker is False + + +def test_parse_header_breaking_marker(): + parsed = crb.parse_header("feat(plugin)!: rework config schema") + assert parsed.breaking_marker is True + assert parsed.scope == "plugin" + + +def test_parse_header_rejects_merge_commit_subject(): + assert crb.parse_header("Merge pull request #123 from foo/bar") is None + + +def test_parse_header_rejects_freeform_text(): + assert crb.parse_header("bumped some stuff") is None + + +def test_parse_header_rejects_unknown_type(): + assert crb.parse_header("oops(plugin): not a real type") is None + + +def test_parse_header_rejects_uppercase_scope(): + # Scope charclass is [a-z0-9_-] only -- uppercase must not match. + assert crb.parse_header("feat(PLUGIN): x") is None + + +def test_parse_header_every_declared_type_parses(): + for commit_type in ( + "feat", + "fix", + "docs", + "refactor", + "perf", + "test", + "chore", + "build", + "ci", + ): + parsed = crb.parse_header(f"{commit_type}(plugin): change") + assert parsed is not None + assert parsed.commit_type == commit_type + + +# --------------------------------------------------------------------- +# is_breaking +# --------------------------------------------------------------------- + + +def test_is_breaking_true_via_header_marker(): + assert crb.is_breaking("feat(plugin)!: drop old flag", "") is True + + +def test_is_breaking_true_via_body_footer(): + body = "Longer explanation.\n\nBREAKING CHANGE: old flag removed.\n" + assert crb.is_breaking("feat(plugin): drop old flag", body) is True + + +def test_is_breaking_false_when_neither_present(): + assert crb.is_breaking("feat(plugin): add flag", "Just a normal body.") is False + + +def test_is_breaking_body_footer_detected_even_for_unparsed_subject(): + body = "BREAKING CHANGE: everything changed\n" + assert crb.is_breaking("totally freeform subject", body) is True + + +# --------------------------------------------------------------------- +# classify +# --------------------------------------------------------------------- + + +def test_classify_feat_plugin_is_minor(): + parsed = crb.parse_header("feat(plugin): add x") + assert crb.classify(parsed, breaking=False) == "minor" + + +def test_classify_wrong_scope_is_none(): + # feat(skills): ... must NOT count toward the plugin's version. + parsed = crb.parse_header("feat(skills): add new skill") + assert crb.classify(parsed, breaking=False) is None + + +def test_classify_breaking_feat_plugin_is_minor_never_major(): + parsed = crb.parse_header("feat(plugin)!: breaking change") + breaking = crb.is_breaking("feat(plugin)!: breaking change", "") + result = crb.classify(parsed, breaking) + assert result == "minor" + assert result != "major" + + +@pytest.mark.parametrize("commit_type", ["fix", "refactor", "perf"]) +def test_classify_patch_types(commit_type): + parsed = crb.parse_header(f"{commit_type}(plugin): change") + assert crb.classify(parsed, breaking=False) == "patch" + + +@pytest.mark.parametrize("commit_type", ["docs", "chore", "test", "build", "ci"]) +def test_classify_non_bumping_types(commit_type): + parsed = crb.parse_header(f"{commit_type}(plugin): change") + assert crb.classify(parsed, breaking=False) is None + + +def test_classify_unparsed_subject_is_none(): + assert crb.classify(None, breaking=False) is None + assert crb.classify(None, breaking=True) is None + + +def test_classify_no_scope_is_none(): + parsed = crb.parse_header("feat: no scope at all") + assert crb.classify(parsed, breaking=False) is None + + +# --------------------------------------------------------------------- +# compute_next_version +# --------------------------------------------------------------------- + + +def _commit(sha: str, subject: str, body: str = "") -> dict: + return {"sha": sha, "subject": subject, "body": body} + + +def test_compute_next_version_minor_bump(): + commits = [_commit("a" * 40, "feat(plugin): add thing")] + assert crb.compute_next_version("0.1.0", commits) == { + "version": "0.2.0", + "bump": "minor", + } + + +def test_compute_next_version_patch_bump(): + commits = [_commit("a" * 40, "fix(plugin): correct bug")] + assert crb.compute_next_version("0.1.0", commits) == { + "version": "0.1.1", + "bump": "patch", + } + + +def test_compute_next_version_minor_bump_resets_patch(): + commits = [_commit("a" * 40, "feat(plugin): add thing")] + assert crb.compute_next_version("0.4.7", commits) == { + "version": "0.5.0", + "bump": "minor", + } + + +def test_compute_next_version_no_op_returns_none(): + commits = [ + _commit("a" * 40, "docs(plugin): update readme"), + _commit("b" * 40, "feat(skills): unrelated product"), + _commit("c" * 40, "totally freeform"), + ] + assert crb.compute_next_version("0.1.0", commits) is None + + +def test_compute_next_version_empty_commit_list_is_none(): + assert crb.compute_next_version("0.1.0", []) is None + + +def test_compute_next_version_invalid_current_version_raises(): + commits = [_commit("a" * 40, "feat(plugin): add thing")] + with pytest.raises(ValueError): + crb.compute_next_version("not-a-version", commits) + + +def test_compute_next_version_max_of_signals_not_cumulative(): + # One feat(plugin) + one fix(plugin) must yield exactly ONE minor + # bump -- not two bumps, not a cumulative/multiplied version jump. + commits = [ + _commit("a" * 40, "feat(plugin): add thing"), + _commit("b" * 40, "fix(plugin): correct bug"), + ] + result = crb.compute_next_version("0.1.0", commits) + assert result == {"version": "0.2.0", "bump": "minor"} + + +def test_compute_next_version_many_feats_still_one_bump(): + commits = [_commit(str(i) * 40, "feat(plugin): add thing") for i in range(5)] + result = crb.compute_next_version("0.1.0", commits) + assert result == {"version": "0.2.0", "bump": "minor"} + + +def test_compute_next_version_bootstrap_long_history(): + # discover_last_tag() returning None means the caller passes the full + # history; simulate ~20+ mixed commits and confirm a correct, single + # bump is still computed from current_version. + commits = [] + for i in range(8): + commits.append(_commit(f"{i:040x}", "docs(plugin): update docs")) + for i in range(8): + commits.append(_commit(f"{i:040x}", "chore(plugin): tidy")) + for i in range(5): + commits.append(_commit(f"{i:040x}", "fix(plugin): fix bug")) + commits.append(_commit("f" * 40, "feat(plugin): add capability")) + for i in range(3): + commits.append(_commit(f"{i:040x}", "feat(skills): unrelated")) + + assert len(commits) >= 20 + + def stub_git_runner(_args: list[str]) -> str: + return "" # no tags + + assert crb.discover_last_tag(stub_git_runner) is None + result = crb.compute_next_version("0.3.2", commits) + assert result == {"version": "0.4.0", "bump": "minor"} + + +def test_compute_next_version_adversarial_many_breaking_feats_never_major(): + # Explicit adversarial case: many breaking-marked feat(plugin)! + # commits must still yield exactly "minor", never "major", and the + # major component of current_version must be unchanged. + commits = [ + _commit(f"{i:040x}", "feat(plugin)!: breaking change") for i in range(200) + ] + result = crb.compute_next_version("2.9.9", commits) + assert result is not None + assert result["bump"] == "minor" + assert result["bump"] != "major" + new_major = int(result["version"].split(".")[0]) + assert new_major == 2 # unchanged from current_version's major + assert result["version"] == "2.10.0" + + +# --------------------------------------------------------------------- +# discover_last_tag +# --------------------------------------------------------------------- + + +def test_discover_last_tag_returns_first_line(): + def git_runner(_args: list[str]) -> str: + return "gitapex--v0.3.0\ngitapex--v0.2.0\ngitapex--v0.1.0\n" + + assert crb.discover_last_tag(git_runner) == "gitapex--v0.3.0" + + +def test_discover_last_tag_none_when_no_tags(): + def git_runner(_args: list[str]) -> str: + return "" + + assert crb.discover_last_tag(git_runner) is None + + +def test_discover_last_tag_calls_expected_git_args(): + seen = {} + + def git_runner(args: list[str]) -> str: + seen["args"] = args + return "" + + crb.discover_last_tag(git_runner) + assert seen["args"] == ["tag", "-l", "gitapex--v*", "--sort=-v:refname"] + + +# --------------------------------------------------------------------- +# write_bumped_manifests +# --------------------------------------------------------------------- + +_PLUGIN_JSON_FIXTURE = """{ + "name": "gitapex", + "description": "A distributable skills collection for gitapex.", + "version": "0.1.0", + "author": { + "name": "tvna" + }, + "homepage": "https://github.com/tvna/gitapex", + "repository": "https://github.com/tvna/gitapex", + "license": "MIT" +} +""" + +_APM_YML_FIXTURE = """# gitapex is normally an apm *provider* (see docs/repository-layout.md); this +# declares the two plugins its own skills already assume are present +# (docs/motivation.md), so `apm install` provisions them too. +# +# name/version are required by apm's manifest schema; they mirror +# .claude-plugin/plugin.json (the version source of truth). The drift gate in +# .github/scripts/scan_apm_manifest_drift.py keeps the two in lockstep. +name: gitapex +version: 0.1.0 +dependencies: + apm: + - obra/superpowers + - tvna/clairvoyance +""" + + +def test_write_bumped_manifests_success(tmp_path): + plugin_path = tmp_path / "plugin.json" + apm_path = tmp_path / "apm.yml" + plugin_path.write_text(_PLUGIN_JSON_FIXTURE) + apm_path.write_text(_APM_YML_FIXTURE) + + crb.write_bumped_manifests(plugin_path, apm_path, "0.2.0") + + new_plugin_text = plugin_path.read_text() + new_apm_text = apm_path.read_text() + + assert '"version": "0.2.0"' in new_plugin_text + assert '"version": "0.1.0"' not in new_plugin_text + assert "version: 0.2.0" in new_apm_text + assert "version: 0.1.0" not in new_apm_text + + # Everything else byte-for-byte preserved, including apm.yml's leading + # comment block and plugin.json's other fields/formatting. + expected_plugin = _PLUGIN_JSON_FIXTURE.replace( + '"version": "0.1.0"', '"version": "0.2.0"' + ) + expected_apm = _APM_YML_FIXTURE.replace("version: 0.1.0", "version: 0.2.0") + assert new_plugin_text == expected_plugin + assert new_apm_text == expected_apm + # apm.yml's comment block specifically survives (yaml.safe_dump would + # have destroyed it). + assert "# gitapex is normally an apm *provider*" in new_apm_text + assert "# .github/scripts/scan_apm_manifest_drift.py keeps the two" in new_apm_text + + +def test_write_bumped_manifests_no_leftover_temp_file(tmp_path): + plugin_path = tmp_path / "plugin.json" + apm_path = tmp_path / "apm.yml" + plugin_path.write_text(_PLUGIN_JSON_FIXTURE) + apm_path.write_text(_APM_YML_FIXTURE) + + crb.write_bumped_manifests(plugin_path, apm_path, "0.2.0") + + remaining = sorted(p.name for p in tmp_path.iterdir()) + assert remaining == ["apm.yml", "plugin.json"] + + +def test_write_bumped_manifests_missing_version_line_raises(tmp_path): + plugin_path = tmp_path / "plugin.json" + apm_path = tmp_path / "apm.yml" + broken_plugin = _PLUGIN_JSON_FIXTURE.replace('"version": "0.1.0",\n', "") + plugin_path.write_text(broken_plugin) + apm_path.write_text(_APM_YML_FIXTURE) + + with pytest.raises(RuntimeError): + crb.write_bumped_manifests(plugin_path, apm_path, "0.2.0") + + # Fail loud, not silent no-op: neither file was touched. + assert plugin_path.read_text() == broken_plugin + assert apm_path.read_text() == _APM_YML_FIXTURE + + +def test_write_bumped_manifests_duplicate_version_line_raises(tmp_path): + plugin_path = tmp_path / "plugin.json" + apm_path = tmp_path / "apm.yml" + duplicated_plugin = _PLUGIN_JSON_FIXTURE.replace( + '"version": "0.1.0",\n', + '"version": "0.1.0",\n "duplicateVersion": "0.9.9",\n', + 1, + ) + # Force a genuine second match of the exact version-line pattern. + duplicated_plugin = duplicated_plugin.replace( + '"duplicateVersion": "0.9.9"', '"version": "0.9.9"' + ) + plugin_path.write_text(duplicated_plugin) + apm_path.write_text(_APM_YML_FIXTURE) + + with pytest.raises(RuntimeError): + crb.write_bumped_manifests(plugin_path, apm_path, "0.2.0") + + # Raises rather than picking one of the two matches arbitrarily; file + # is untouched. + assert plugin_path.read_text() == duplicated_plugin + assert apm_path.read_text() == _APM_YML_FIXTURE + + +def test_write_bumped_manifests_cleans_up_temp_file_on_write_failure(tmp_path, monkeypatch): + plugin_path = tmp_path / "plugin.json" + apm_path = tmp_path / "apm.yml" + plugin_path.write_text(_PLUGIN_JSON_FIXTURE) + apm_path.write_text(_APM_YML_FIXTURE) + + def failing_replace(_src, _dst): + raise OSError("simulated os.replace failure") + + monkeypatch.setattr(crb.os, "replace", failing_replace) + + with pytest.raises(OSError, match="simulated os.replace failure"): + crb.write_bumped_manifests(plugin_path, apm_path, "0.2.0") + + # The failure must not leave a stray temp file behind in the directory. + remaining = sorted(p.name for p in tmp_path.iterdir()) + assert remaining == ["apm.yml", "plugin.json"] + + +def test_write_bumped_manifests_apm_missing_version_raises(tmp_path): + plugin_path = tmp_path / "plugin.json" + apm_path = tmp_path / "apm.yml" + plugin_path.write_text(_PLUGIN_JSON_FIXTURE) + broken_apm = _APM_YML_FIXTURE.replace("version: 0.1.0\n", "") + apm_path.write_text(broken_apm) + + with pytest.raises(RuntimeError): + crb.write_bumped_manifests(plugin_path, apm_path, "0.2.0") + + # plugin.json is processed first and DID get bumped before apm.yml's + # failure surfaced -- write_bumped_manifests only guarantees each + # individual file's write is atomic, not both-or-neither across files. + assert '"version": "0.2.0"' in plugin_path.read_text() + assert apm_path.read_text() == broken_apm + + +def test_write_bumped_manifests_apm_empty_version_value_raises(tmp_path): + # Regression: an apm.yml whose `version:` key carries no value must + # fail loud, NOT swallow the following line. The version-line pattern + # must not let its inter-token whitespace cross a newline: with `\s*` + # (which matches "\n") the match ran from `version:` through the *next* + # key, counted as exactly one match, and the substitution then wrote + # `version: 0.2.0` over both lines -- silently deleting `dependencies:` + # and leaving a structurally broken manifest that the "exactly one + # match" guard was supposed to make impossible. + plugin_path = tmp_path / "plugin.json" + apm_path = tmp_path / "apm.yml" + plugin_path.write_text(_PLUGIN_JSON_FIXTURE) + broken_apm = _APM_YML_FIXTURE.replace("version: 0.1.0\n", "version:\n") + apm_path.write_text(broken_apm) + + with pytest.raises(RuntimeError): + crb.write_bumped_manifests(plugin_path, apm_path, "0.2.0") + + # The manifest is untouched -- in particular the key that followed the + # valueless `version:` line is still there. + assert apm_path.read_text() == broken_apm + assert "dependencies:" in apm_path.read_text() + + +def test_apm_version_pattern_never_matches_across_a_newline(): + # Directly pin the property the bug violated: the pattern's whitespace + # run may not span a line break, so it can never consume a following + # key as if it were the version value. + swallowing = "name: gitapex\nversion:\ndependencies:\n apm:\n - a/b\n" + assert crb._APM_VERSION_RE.findall(swallowing) == [] + # ...while a normal one-line `version: X.Y.Z` still matches exactly once. + assert len(crb._APM_VERSION_RE.findall("name: x\nversion: 0.1.0\n")) == 1 + + +# --------------------------------------------------------------------- +# render_notes +# --------------------------------------------------------------------- + + +def test_render_notes_grouped_sections(): + commits = [ + _commit("1111111abc", "feat(plugin): add feature one"), + _commit("2222222abc", "fix(plugin): correct bug one"), + _commit("3333333abc", "refactor(plugin): tidy module"), + _commit("4444444abc", "perf(plugin): speed up loop"), + _commit("5555555abc", "docs(plugin): update readme"), + ] + notes = crb.render_notes(commits) + + assert "### Features" in notes + assert "- feat(plugin): add feature one (1111111)" in notes + assert "### Fixes" in notes + assert "- fix(plugin): correct bug one (2222222)" in notes + assert "### Refactors" in notes + assert "- refactor(plugin): tidy module (3333333)" in notes + assert "- perf(plugin): speed up loop (4444444)" in notes + assert "_1 other commit(s) since the last release" in notes + + +def test_render_notes_omits_empty_sections(): + commits = [_commit("1111111abc", "feat(plugin): only a feature")] + notes = crb.render_notes(commits) + assert "### Features" in notes + assert "### Fixes" not in notes + assert "### Refactors" not in notes + + +def test_render_notes_zero_omitted_case_still_prints_line(): + commits = [_commit("1111111abc", "feat(plugin): only a feature")] + notes = crb.render_notes(commits) + assert "_0 other commit(s) since the last release" in notes + + +def test_render_notes_empty_commit_list(): + notes = crb.render_notes([]) + assert notes.strip() == ( + "_0 other commit(s) since the last release " + "(docs/chore/ci/test) omitted above._" + ) + + +def test_render_notes_wrong_scope_is_omitted_not_bucketed(): + commits = [_commit("1111111abc", "feat(skills): unrelated product")] + notes = crb.render_notes(commits) + assert "### Features" not in notes + assert "_1 other commit(s)" in notes + + +# --------------------------------------------------------------------- +# collect_commits / main() -- real throwaway git repo, end to end +# --------------------------------------------------------------------- + + +def _git(repo, *args): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + + +@pytest.fixture +def git_repo(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q", "-b", "main") + _git(repo, "config", "user.email", "a@b.c") + _git(repo, "config", "user.name", "t") + (repo / ".claude-plugin").mkdir() + (repo / ".claude-plugin" / "plugin.json").write_text(_PLUGIN_JSON_FIXTURE) + (repo / "apm.yml").write_text(_APM_YML_FIXTURE) + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "chore(plugin): bootstrap fixture repo") + return repo + + +def _commit_in_repo(repo, message, filename="file.txt"): + (repo / filename).write_text(message) + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", message) + + +def test_collect_commits_bootstrap_no_tag(git_repo): + _commit_in_repo(git_repo, "feat(plugin): add capability", "a.txt") + _commit_in_repo(git_repo, "feat(skills): unrelated", "b.txt") + + git_runner = crb._default_git_runner(git_repo) + assert crb.discover_last_tag(git_runner) is None + + commits = crb.collect_commits(git_repo, git_runner) + subjects = [c["subject"] for c in commits] + assert "feat(plugin): add capability" in subjects + assert "feat(skills): unrelated" in subjects + assert "chore(plugin): bootstrap fixture repo" in subjects + for commit in commits: + assert len(commit["sha"]) == 40 + + +def test_collect_commits_since_last_tag(git_repo): + _git(git_repo, "tag", "gitapex--v0.1.0") + _commit_in_repo(git_repo, "fix(plugin): correct bug", "c.txt") + + git_runner = crb._default_git_runner(git_repo) + assert crb.discover_last_tag(git_runner) == "gitapex--v0.1.0" + + commits = crb.collect_commits(git_repo, git_runner) + subjects = [c["subject"] for c in commits] + assert subjects == ["fix(plugin): correct bug"] + + +def test_main_write_and_notes_and_github_output(git_repo, tmp_path): + _commit_in_repo(git_repo, "feat(plugin): add capability", "a.txt") + _commit_in_repo(git_repo, "feat(skills): unrelated", "b.txt") + notes_path = tmp_path / "notes.md" + gh_output_path = tmp_path / "gh_output.txt" + + rc = crb.main( + [ + "--repo-root", + str(git_repo), + "--write", + "--notes-out", + str(notes_path), + "--github-output", + str(gh_output_path), + ] + ) + assert rc == 0 + + plugin_data = json.loads((git_repo / ".claude-plugin" / "plugin.json").read_text()) + assert plugin_data["version"] == "0.2.0" + apm_text = (git_repo / "apm.yml").read_text() + assert "version: 0.2.0" in apm_text + + notes_text = notes_path.read_text() + assert "### Features" in notes_text + assert "add capability" in notes_text + assert "unrelated" not in notes_text # wrong scope, omitted + + gh_output_text = gh_output_path.read_text() + assert "bump=minor" in gh_output_text + assert "version=0.2.0" in gh_output_text + + +def test_main_dry_run_does_not_write_without_flag(git_repo): + _commit_in_repo(git_repo, "feat(plugin): add capability", "a.txt") + + rc = crb.main(["--repo-root", str(git_repo)]) + assert rc == 0 + + plugin_data = json.loads((git_repo / ".claude-plugin" / "plugin.json").read_text()) + assert plugin_data["version"] == "0.1.0" # unchanged: --write was omitted + + +def test_main_no_applicable_commits_reports_bump_none(git_repo, tmp_path): + _commit_in_repo(git_repo, "docs(plugin): update readme", "a.txt") + gh_output_path = tmp_path / "gh_output.txt" + + rc = crb.main( + ["--repo-root", str(git_repo), "--write", "--github-output", str(gh_output_path)] + ) + assert rc == 0 + + plugin_data = json.loads((git_repo / ".claude-plugin" / "plugin.json").read_text()) + assert plugin_data["version"] == "0.1.0" # nothing to bump + + gh_output_text = gh_output_path.read_text() + assert "bump=none" in gh_output_text + assert "version=0.1.0" in gh_output_text + + +def test_main_missing_version_field_returns_one(git_repo, capsys): + plugin_path = git_repo / ".claude-plugin" / "plugin.json" + data = json.loads(plugin_path.read_text()) + del data["version"] + plugin_path.write_text(json.dumps(data)) + _git(git_repo, "add", "-A") + _git(git_repo, "commit", "-q", "-m", "chore(plugin): drop version field") + + rc = crb.main(["--repo-root", str(git_repo)]) + assert rc == 1 + assert "error:" in capsys.readouterr().err + + +def test_main_invalid_current_version_returns_one(git_repo, capsys): + plugin_path = git_repo / ".claude-plugin" / "plugin.json" + data = json.loads(plugin_path.read_text()) + data["version"] = "not-a-version" + plugin_path.write_text(json.dumps(data)) + _git(git_repo, "add", "-A") + _git(git_repo, "commit", "-q", "-m", "chore(plugin): corrupt version") + _commit_in_repo(git_repo, "feat(plugin): add capability", "a.txt") + + rc = crb.main(["--repo-root", str(git_repo), "--write"]) + assert rc == 1 + assert "error:" in capsys.readouterr().err diff --git a/tests/test_release_pr_publish.py b/tests/test_release_pr_publish.py new file mode 100644 index 00000000..e790419c --- /dev/null +++ b/tests/test_release_pr_publish.py @@ -0,0 +1,1015 @@ +from __future__ import annotations + +import base64 +import json +import re +import urllib.error +import urllib.request + +import pytest +import release_pr_publish as rpp + + +class Response: + def __init__(self, status: int, body: str = "") -> None: + self.status = status + self.body = body.encode() + + def __enter__(self) -> Response: + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return self.body + + def close(self) -> None: + return None + + +def http_error(code: int, body: str = "") -> urllib.error.HTTPError: + # Response duck-types urlopen's context-manager response, not the stdlib + # IO[bytes] HTTPError expects for its `fp` argument; mypy can't see the + # structural match. + return urllib.error.HTTPError("https://example.test", code, "err", {}, Response(code, body)) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# apply_call +# --------------------------------------------------------------------------- + + +def test_apply_call_happy_path() -> None: + sleeps: list[float] = [] + + def opener(request: urllib.request.Request) -> Response: + assert request.headers["Authorization"] == "Bearer tok" + return Response(201, '{"ok":true}') + + code, body = rpp.apply_call( + method="POST", + url="https://api.github.com/x", + payload={"a": 1}, + token="tok", + opener=opener, + sleeper=sleeps.append, + ) + assert code == 201 + assert body == '{"ok":true}' + assert sleeps == [] + + +def test_apply_call_retries_5xx_then_succeeds() -> None: + responses: list[urllib.error.HTTPError | Response] = [http_error(503, "one"), Response(200, "ok")] + sleeps: list[float] = [] + + def opener(request: urllib.request.Request) -> Response: + response = responses.pop(0) + if isinstance(response, urllib.error.HTTPError): + raise response + return response + + code, body = rpp.apply_call( + method="GET", + url="https://api.github.com/x", + payload=None, + token="tok", + opener=opener, + sleeper=sleeps.append, + ) + assert code == 200 + assert body == "ok" + assert sleeps == [5] + + +def test_apply_call_breaks_on_4xx() -> None: + calls = 0 + + def opener(request: urllib.request.Request) -> Response: + nonlocal calls + calls += 1 + raise http_error(422, "bad") + + code, body = rpp.apply_call(method="GET", url="https://api.github.com/x", payload=None, token="tok", opener=opener) + assert code == 422 + assert body == "bad" + assert calls == 1 + + +def test_apply_call_network_failure_retries_three_times() -> None: + calls = 0 + sleeps: list[float] = [] + + def opener(request: urllib.request.Request) -> Response: + nonlocal calls + calls += 1 + raise urllib.error.URLError("boom") + + code, body = rpp.apply_call( + method="GET", + url="https://api.github.com/x", + payload=None, + token="tok", + opener=opener, + sleeper=sleeps.append, + ) + assert code == 0 + assert body == "boom" + assert calls == 3 + assert sleeps == [5, 10] + + +def test_apply_call_uses_default_opener(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def fake_urlopen(request: urllib.request.Request, timeout: float | None = None) -> Response: + captured["timeout"] = timeout + return Response(200, "ok") + + monkeypatch.setattr(rpp.urllib.request, "urlopen", fake_urlopen) + code, _body = rpp.apply_call(method="GET", url="https://api.github.com/x", payload=None, token="tok") + assert code == 200 + assert captured["timeout"] == rpp._HTTP_TIMEOUT_SECONDS + + +# --------------------------------------------------------------------------- +# graphql_call +# --------------------------------------------------------------------------- + + +def test_graphql_call_happy_path() -> None: + def opener(request: urllib.request.Request) -> Response: + return Response(200, '{"data":{"x":1}}') + + code, body = rpp.graphql_call(query="q", variables={}, token="tok", opener=opener) + assert code == 200 + assert body == {"data": {"x": 1}} + + +def test_graphql_call_retries_5xx_then_succeeds() -> None: + responses: list[urllib.error.HTTPError | Response] = [http_error(502, ""), Response(200, '{"data":{}}')] + sleeps: list[float] = [] + + def opener(request: urllib.request.Request) -> Response: + response = responses.pop(0) + if isinstance(response, urllib.error.HTTPError): + raise response + return response + + code, _body = rpp.graphql_call(query="q", variables={}, token="tok", opener=opener, sleeper=sleeps.append) + assert code == 200 + assert sleeps == [5] + + +def test_graphql_call_retries_transient_marker_then_succeeds() -> None: + transient_body = json.dumps({"errors": [{"message": "Something went wrong while executing your query."}]}) + responses = [Response(200, transient_body), Response(200, '{"data":{}}')] + sleeps: list[float] = [] + + def opener(request: urllib.request.Request) -> Response: + return responses.pop(0) + + code, body = rpp.graphql_call(query="q", variables={}, token="tok", opener=opener, sleeper=sleeps.append) + assert code == 200 + assert body == {"data": {}} + assert sleeps == [5] + + +def test_graphql_call_non_transient_error_no_retry() -> None: + calls = 0 + + def opener(request: urllib.request.Request) -> Response: + nonlocal calls + calls += 1 + return Response(200, json.dumps({"errors": [{"message": "Validation failed"}]})) + + code, body = rpp.graphql_call(query="q", variables={}, token="tok", opener=opener) + assert calls == 1 + assert code == 200 + assert "errors" in body + + +def test_graphql_call_network_failure_degrades_to_empty_body() -> None: + sleeps: list[float] = [] + + def opener(request: urllib.request.Request) -> Response: + raise urllib.error.URLError("boom") + + code, body = rpp.graphql_call(query="q", variables={}, token="tok", opener=opener, sleeper=sleeps.append) + assert code == 0 + assert body == {} + assert sleeps == [5, 10] + + +def test_graphql_call_invalid_json_body() -> None: + def opener(request: urllib.request.Request) -> Response: + return Response(200, "not json") + + code, body = rpp.graphql_call(query="q", variables={}, token="tok", opener=opener) + assert code == 200 + assert body == {} + + +def test_graphql_call_uses_default_opener(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_urlopen(request: urllib.request.Request, timeout: float | None = None) -> Response: + return Response(200, '{"data":{}}') + + monkeypatch.setattr(rpp.urllib.request, "urlopen", fake_urlopen) + code, _body = rpp.graphql_call(query="q", variables={}, token="tok") + assert code == 200 + + +# --------------------------------------------------------------------------- +# _format_code +# --------------------------------------------------------------------------- + + +def test_format_code() -> None: + assert rpp._format_code(0) == "000" + assert rpp._format_code(404) == "404" + + +# --------------------------------------------------------------------------- +# Low-level GitHub REST helpers (apply_call injected directly) +# --------------------------------------------------------------------------- + + +def _fake_apply_call(responses: dict[str, tuple[int, str]]): + calls: list[tuple[str, str]] = [] + + def fake(*, method: str, url: str, payload, token: str) -> tuple[int, str]: + calls.append((method, url)) + key = f"{method} {url}" + if key in responses: + return responses[key] + return responses[url] + + return fake, calls + + +def test_get_ref_sha_success() -> None: + fake, _ = _fake_apply_call( + {"https://api.github.com/repos/o/r/git/ref/heads/main": (200, json.dumps({"object": {"sha": "abc"}}))} + ) + sha = rpp._get_ref_sha(repo="o/r", ref="heads/main", token="tok", apply_call=fake) + assert sha == "abc" + + +def test_get_ref_sha_failure_raises() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/git/ref/heads/main": (404, "nope")}) + with pytest.raises(RuntimeError, match="Get ref"): + rpp._get_ref_sha(repo="o/r", ref="heads/main", token="tok", apply_call=fake) + + +def test_get_ref_sha_missing_sha_raises() -> None: + fake, _ = _fake_apply_call( + {"https://api.github.com/repos/o/r/git/ref/heads/main": (200, json.dumps({"object": {}}))} + ) + with pytest.raises(RuntimeError, match=r"missing object\.sha"): + rpp._get_ref_sha(repo="o/r", ref="heads/main", token="tok", apply_call=fake) + + +def test_get_branch_head_oid_absent() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/git/ref/heads/chore": (404, "")}) + assert rpp._get_branch_head_oid(repo="o/r", branch="chore", token="tok", apply_call=fake) is None + + +def test_get_branch_head_oid_failure_raises() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/git/ref/heads/chore": (500, "boom")}) + with pytest.raises(RuntimeError, match="Get branch ref"): + rpp._get_branch_head_oid(repo="o/r", branch="chore", token="tok", apply_call=fake) + + +def test_get_branch_head_oid_success() -> None: + fake, _ = _fake_apply_call( + {"https://api.github.com/repos/o/r/git/ref/heads/chore": (200, json.dumps({"object": {"sha": "xyz"}}))} + ) + assert rpp._get_branch_head_oid(repo="o/r", branch="chore", token="tok", apply_call=fake) == "xyz" + + +def test_get_branch_head_oid_missing_sha_raises() -> None: + fake, _ = _fake_apply_call( + {"https://api.github.com/repos/o/r/git/ref/heads/chore": (200, json.dumps({"object": {}}))} + ) + with pytest.raises(RuntimeError, match=r"missing object\.sha"): + rpp._get_branch_head_oid(repo="o/r", branch="chore", token="tok", apply_call=fake) + + +def test_create_branch_ref_success() -> None: + fake, calls = _fake_apply_call({"https://api.github.com/repos/o/r/git/refs": (201, "")}) + rpp._create_branch_ref(repo="o/r", branch="chore", sha="abc", token="tok", apply_call=fake) + assert calls == [("POST", "https://api.github.com/repos/o/r/git/refs")] + + +def test_create_branch_ref_failure_raises() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/git/refs": (422, "exists")}) + with pytest.raises(RuntimeError, match="Create branch ref"): + rpp._create_branch_ref(repo="o/r", branch="chore", sha="abc", token="tok", apply_call=fake) + + +def test_delete_branch_success() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/git/refs/heads/chore": (204, "")}) + rpp._delete_branch(repo="o/r", branch="chore", token="tok", apply_call=fake) + + +def test_delete_branch_already_gone_is_success() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/git/refs/heads/chore": (404, "")}) + rpp._delete_branch(repo="o/r", branch="chore", token="tok", apply_call=fake) + + +def test_delete_branch_failure_raises() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/git/refs/heads/chore": (500, "boom")}) + with pytest.raises(RuntimeError, match="Delete branch"): + rpp._delete_branch(repo="o/r", branch="chore", token="tok", apply_call=fake) + + +def test_get_file_bytes_absent() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/contents/plugin.json?ref=main": (404, "")}) + assert rpp._get_file_bytes(repo="o/r", path="plugin.json", ref="main", token="tok", apply_call=fake) is None + + +def test_get_file_bytes_failure_raises() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/contents/plugin.json?ref=main": (500, "boom")}) + with pytest.raises(RuntimeError, match="Get contents"): + rpp._get_file_bytes(repo="o/r", path="plugin.json", ref="main", token="tok", apply_call=fake) + + +def test_get_file_bytes_success() -> None: + content = base64.b64encode(b"hello").decode("ascii") + fake, _ = _fake_apply_call( + { + "https://api.github.com/repos/o/r/contents/plugin.json?ref=main": ( + 200, + json.dumps({"encoding": "base64", "content": content}), + ) + } + ) + assert rpp._get_file_bytes(repo="o/r", path="plugin.json", ref="main", token="tok", apply_call=fake) == b"hello" + + +def test_get_file_bytes_unexpected_encoding_raises() -> None: + fake, _ = _fake_apply_call( + { + "https://api.github.com/repos/o/r/contents/plugin.json?ref=main": ( + 200, + json.dumps({"encoding": "none", "content": None}), + ) + } + ) + with pytest.raises(RuntimeError, match="unexpected encoding"): + rpp._get_file_bytes(repo="o/r", path="plugin.json", ref="main", token="tok", apply_call=fake) + + +# --------------------------------------------------------------------------- +# _ref_drifts +# --------------------------------------------------------------------------- + + +def test_ref_drifts_true_when_content_differs() -> None: + encoded = base64.b64encode(b"old").decode("ascii") + fake, _ = _fake_apply_call( + { + "https://api.github.com/repos/o/r/contents/plugin.json?ref=main": ( + 200, + json.dumps({"encoding": "base64", "content": encoded}), + ) + } + ) + assert rpp._ref_drifts(repo="o/r", ref="main", additions=[("plugin.json", b"new")], token="tok", apply_call=fake) + + +def test_ref_drifts_false_when_content_matches() -> None: + encoded = base64.b64encode(b"same").decode("ascii") + fake, _ = _fake_apply_call( + { + "https://api.github.com/repos/o/r/contents/plugin.json?ref=main": ( + 200, + json.dumps({"encoding": "base64", "content": encoded}), + ) + } + ) + assert not rpp._ref_drifts( + repo="o/r", ref="main", additions=[("plugin.json", b"same")], token="tok", apply_call=fake + ) + + +# --------------------------------------------------------------------------- +# _create_commit_on_branch +# --------------------------------------------------------------------------- + + +def test_create_commit_on_branch_success() -> None: + def fake_graphql(*, query, variables, token): + assert variables["input"]["message"]["body"] == "body text" + return 200, {"data": {"createCommitOnBranch": {"commit": {"oid": "newoid"}}}} + + oid = rpp._create_commit_on_branch( + repo="o/r", + branch="chore", + expected_head_oid="base", + headline="subject", + body="body text", + additions=[{"path": "a", "contents": "x"}], + token="tok", + graphql_call=fake_graphql, + ) + assert oid == "newoid" + + +def test_create_commit_on_branch_no_body_omits_message_body() -> None: + def fake_graphql(*, query, variables, token): + assert "body" not in variables["input"]["message"] + return 200, {"data": {"createCommitOnBranch": {"commit": {"oid": "newoid"}}}} + + rpp._create_commit_on_branch( + repo="o/r", + branch="chore", + expected_head_oid="base", + headline="subject", + body="", + additions=[], + token="tok", + graphql_call=fake_graphql, + ) + + +def test_create_commit_on_branch_http_failure_raises() -> None: + def fake_graphql(*, query, variables, token): + return 500, {} + + with pytest.raises(RuntimeError, match="createCommitOnBranch HTTP"): + rpp._create_commit_on_branch( + repo="o/r", + branch="chore", + expected_head_oid="base", + headline="s", + body="", + additions=[], + token="tok", + graphql_call=fake_graphql, + ) + + +def test_create_commit_on_branch_errors_in_response_raises() -> None: + def fake_graphql(*, query, variables, token): + return 200, {"errors": [{"message": "bad"}]} + + with pytest.raises(RuntimeError, match="createCommitOnBranch errors"): + rpp._create_commit_on_branch( + repo="o/r", + branch="chore", + expected_head_oid="base", + headline="s", + body="", + additions=[], + token="tok", + graphql_call=fake_graphql, + ) + + +def test_create_commit_on_branch_unexpected_response_raises() -> None: + def fake_graphql(*, query, variables, token): + return 200, {"data": {}} + + with pytest.raises(RuntimeError, match="unexpected response"): + rpp._create_commit_on_branch( + repo="o/r", + branch="chore", + expected_head_oid="base", + headline="s", + body="", + additions=[], + token="tok", + graphql_call=fake_graphql, + ) + + +def test_create_commit_on_branch_missing_oid_raises() -> None: + def fake_graphql(*, query, variables, token): + return 200, {"data": {"createCommitOnBranch": {"commit": {"oid": ""}}}} + + with pytest.raises(RuntimeError, match="missing commit oid"): + rpp._create_commit_on_branch( + repo="o/r", + branch="chore", + expected_head_oid="base", + headline="s", + body="", + additions=[], + token="tok", + graphql_call=fake_graphql, + ) + + +# --------------------------------------------------------------------------- +# PR helpers +# --------------------------------------------------------------------------- + + +def test_list_open_prs_success() -> None: + fake, _ = _fake_apply_call( + { + "https://api.github.com/repos/o/r/pulls?head=o:chore&state=open&per_page=1": ( + 200, + json.dumps([{"number": 1}]), + ) + } + ) + prs = rpp._list_open_prs(repo="o/r", head="chore", token="tok", apply_call=fake) + assert prs == [{"number": 1}] + + +def test_list_open_prs_failure_raises() -> None: + fake, _ = _fake_apply_call( + {"https://api.github.com/repos/o/r/pulls?head=o:chore&state=open&per_page=1": (500, "boom")} + ) + with pytest.raises(RuntimeError, match="List PRs failed"): + rpp._list_open_prs(repo="o/r", head="chore", token="tok", apply_call=fake) + + +def test_list_open_prs_unexpected_shape_raises() -> None: + fake, _ = _fake_apply_call( + {"https://api.github.com/repos/o/r/pulls?head=o:chore&state=open&per_page=1": (200, json.dumps({"x": 1}))} + ) + with pytest.raises(RuntimeError, match="Expected list"): + rpp._list_open_prs(repo="o/r", head="chore", token="tok", apply_call=fake) + + +def test_create_pr_success() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/pulls": (201, json.dumps({"number": 7}))}) + number = rpp._create_pr(repo="o/r", head="chore", base="main", title="t", body="b", token="tok", apply_call=fake) + assert number == 7 + + +def test_create_pr_failure_raises() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/pulls": (422, "bad")}) + with pytest.raises(RuntimeError, match="Create PR failed"): + rpp._create_pr(repo="o/r", head="chore", base="main", title="t", body="b", token="tok", apply_call=fake) + + +def test_update_pr_success() -> None: + fake, calls = _fake_apply_call({"https://api.github.com/repos/o/r/pulls/7": (200, "")}) + rpp._update_pr(repo="o/r", number=7, title="t", body="b", token="tok", apply_call=fake) + assert calls == [("PATCH", "https://api.github.com/repos/o/r/pulls/7")] + + +def test_update_pr_failure_raises() -> None: + fake, _ = _fake_apply_call({"https://api.github.com/repos/o/r/pulls/7": (500, "boom")}) + with pytest.raises(RuntimeError, match="Update PR failed"): + rpp._update_pr(repo="o/r", number=7, title="t", body="b", token="tok", apply_call=fake) + + +def test_upsert_pr_creates_when_absent() -> None: + fake, _ = _fake_apply_call( + { + "https://api.github.com/repos/o/r/pulls?head=o:chore&state=open&per_page=1": (200, json.dumps([])), + "https://api.github.com/repos/o/r/pulls": (201, json.dumps({"number": 9})), + } + ) + verb, number = rpp._upsert_pr(repo="o/r", head="chore", base="main", title="t", body="b", token="tok", apply_call=fake) + assert (verb, number) == ("created", 9) + + +def test_upsert_pr_updates_when_present() -> None: + fake, _ = _fake_apply_call( + { + "https://api.github.com/repos/o/r/pulls?head=o:chore&state=open&per_page=1": ( + 200, + json.dumps([{"number": 5}]), + ), + "https://api.github.com/repos/o/r/pulls/5": (200, ""), + } + ) + verb, number = rpp._upsert_pr(repo="o/r", head="chore", base="main", title="t", body="b", token="tok", apply_call=fake) + assert (verb, number) == ("updated", 5) + + +# --------------------------------------------------------------------------- +# build_pr_body +# --------------------------------------------------------------------------- + +_NOTES_SPAN_RE = re.compile( + re.escape(rpp._RELEASE_NOTES_START_MARKER) + r"\n(.*?)\n" + re.escape(rpp._RELEASE_NOTES_END_MARKER), + re.DOTALL, +) + + +def _extract_notes_span(body: str) -> str: + match = _NOTES_SPAN_RE.search(body) + assert match is not None, "release-notes markers not found in PR body" + return match.group(1) + + +def test_build_pr_body_marker_span_round_trips_exactly() -> None: + notes = "### Features\n- add widget (abc1234)\n\n### Fixes\n- fix bug (def5678)\n" + body = rpp.build_pr_body("0.1.0", "0.2.0", "minor", notes) + assert _extract_notes_span(body) == notes + + +def test_build_pr_body_marker_span_round_trips_no_trailing_newline() -> None: + notes = "### Features\n- add widget (abc1234)" + body = rpp.build_pr_body("0.1.0", "0.2.0", "minor", notes) + assert _extract_notes_span(body) == notes + + +def test_build_pr_body_marker_span_round_trips_empty_notes() -> None: + notes = "" + body = rpp.build_pr_body("0.1.0", "0.2.0", "patch", notes) + assert _extract_notes_span(body) == notes + + +def test_build_pr_body_contains_markers_on_their_own_lines() -> None: + body = rpp.build_pr_body("0.1.0", "0.2.0", "minor", "notes here") + lines = body.splitlines() + assert rpp._RELEASE_NOTES_START_MARKER in lines + assert rpp._RELEASE_NOTES_END_MARKER in lines + + +def test_build_pr_body_contains_version_summary() -> None: + body = rpp.build_pr_body("1.2.3", "1.3.0", "minor", "notes") + assert "`1.2.3`" in body + assert "`1.3.0`" in body + assert "minor" in body + + +def test_build_pr_body_contains_release_act_trailer_and_warning() -> None: + body = rpp.build_pr_body("0.1.0", "0.2.0", "minor", "notes") + assert "release-tag.yml" in body + assert "gitapex--vX.Y.Z" in body + assert "GitHub Release" in body + assert ".claude-plugin/plugin.json" in body + assert "apm.yml" in body + assert "Do not edit" in body + + +# --------------------------------------------------------------------------- +# publish_release_pr +# --------------------------------------------------------------------------- + + +def test_publish_release_pr_empty_additions_is_up_to_date() -> None: + result = rpp.publish_release_pr( + repo="o/r", + additions=[], + base="main", + branch="chore/release-plugin-bump", + title="t", + body="b", + commit_subject="s", + commit_body="", + token="tok", + ) + assert result == "up-to-date" + + +def test_publish_release_pr_no_open_pr_no_existing_branch_creates() -> None: + responses = { + "https://api.github.com/repos/o/r/pulls?head=o:chore/release-plugin-bump&state=open&per_page=1": ( + 200, + json.dumps([]), + ), + "https://api.github.com/repos/o/r/git/refs/heads/chore/release-plugin-bump": (404, ""), + "https://api.github.com/repos/o/r/git/ref/heads/chore/release-plugin-bump": (404, ""), + "https://api.github.com/repos/o/r/git/ref/heads/main": (200, json.dumps({"object": {"sha": "basesha"}})), + "https://api.github.com/repos/o/r/git/refs": (201, ""), + "https://api.github.com/repos/o/r/pulls": (201, json.dumps({"number": 3})), + } + fake, calls = _fake_apply_call(responses) + captured_additions: list[dict[str, str]] = [] + + def fake_graphql(*, query, variables, token): + assert variables["input"]["expectedHeadOid"] == "basesha" + captured_additions.extend(variables["input"]["fileChanges"]["additions"]) + return 200, {"data": {"createCommitOnBranch": {"commit": {"oid": "newsha"}}}} + + result = rpp.publish_release_pr( + repo="o/r", + additions=[(".claude-plugin/plugin.json", b'{"version": "0.2.0"}'), ("apm.yml", b"version: 0.2.0")], + base="main", + branch="chore/release-plugin-bump", + title="chore(plugin): bump version to 0.2.0", + body="b", + commit_subject="chore(plugin): bump version to 0.2.0", + commit_body="", + token="tok", + apply_call=fake, + graphql_call=fake_graphql, + ) + + assert result == "created:3" + # Both manifest paths land in a single createCommitOnBranch call, never + # as two separate commits. + assert len(captured_additions) == 2 + assert {addition["path"] for addition in captured_additions} == {".claude-plugin/plugin.json", "apm.yml"} + assert ("DELETE", "https://api.github.com/repos/o/r/git/refs/heads/chore/release-plugin-bump") in calls + + +def test_publish_release_pr_no_open_pr_stale_branch_deletes_and_recreates() -> None: + # Coverage gap closed (flagged by the Step 8 adversarial review, not a + # bug -- the code path was already correct): unlike the "no existing + # branch" case above, here the branch DOES already exist (e.g. left + # over from a prior run whose PR was merged or closed without deleting + # it) but no open PR currently targets it. This is exactly the "stale, + # possibly-unsigned-ancestor branch" case the module docstring says + # must be deleted and recreated fresh off base, never appended to. + responses = { + "https://api.github.com/repos/o/r/pulls?head=o:chore/release-plugin-bump&state=open&per_page=1": ( + 200, + json.dumps([]), + ), + # The DELETE call gets a real 204 (something existed and was + # actually removed), not the "already gone" 404 the sibling test + # above simulates -- proving this path handles a genuine stale + # branch, not only a no-op delete against nothing. + "https://api.github.com/repos/o/r/git/refs/heads/chore/release-plugin-bump": (204, ""), + # After that real deletion, the branch is gone -- the subsequent + # existence check correctly sees 404, so the code recreates fresh + # off base rather than appending onto a branch that no longer + # exists. + "https://api.github.com/repos/o/r/git/ref/heads/chore/release-plugin-bump": (404, ""), + "https://api.github.com/repos/o/r/git/ref/heads/main": (200, json.dumps({"object": {"sha": "basesha"}})), + "https://api.github.com/repos/o/r/git/refs": (201, ""), + "https://api.github.com/repos/o/r/pulls": (201, json.dumps({"number": 7})), + } + fake, calls = _fake_apply_call(responses) + captured_additions: list[dict[str, str]] = [] + + def fake_graphql(*, query, variables, token): + assert variables["input"]["expectedHeadOid"] == "basesha" + captured_additions.extend(variables["input"]["fileChanges"]["additions"]) + return 200, {"data": {"createCommitOnBranch": {"commit": {"oid": "newsha"}}}} + + result = rpp.publish_release_pr( + repo="o/r", + additions=[(".claude-plugin/plugin.json", b'{"version": "0.2.0"}'), ("apm.yml", b"version: 0.2.0")], + base="main", + branch="chore/release-plugin-bump", + title="chore(plugin): bump version to 0.2.0", + body="b", + commit_subject="chore(plugin): bump version to 0.2.0", + commit_body="", + token="tok", + apply_call=fake, + graphql_call=fake_graphql, + ) + + assert result == "created:7" + assert len(captured_additions) == 2 + # The stale branch must actually be deleted before recreation, and the + # recreated branch's ref must come from base's sha, not the stale + # branch's own tip (proven above via expectedHeadOid == "basesha"). + assert ("DELETE", "https://api.github.com/repos/o/r/git/refs/heads/chore/release-plugin-bump") in calls + create_ref_calls = [c for c in calls if c == ("POST", "https://api.github.com/repos/o/r/git/refs")] + assert len(create_ref_calls) == 1 + + +def test_publish_release_pr_open_pr_updates_and_leaves_branch_alone() -> None: + responses = { + "https://api.github.com/repos/o/r/pulls?head=o:chore/release-plugin-bump&state=open&per_page=1": ( + 200, + json.dumps([{"number": 4}]), + ), + "https://api.github.com/repos/o/r/git/ref/heads/chore/release-plugin-bump": ( + 200, + json.dumps({"object": {"sha": "branchtip"}}), + ), + "https://api.github.com/repos/o/r/contents/.claude-plugin/plugin.json?ref=chore/release-plugin-bump": ( + 404, + "", + ), + "https://api.github.com/repos/o/r/pulls/4": (200, ""), + } + fake, calls = _fake_apply_call(responses) + captured_additions: list[dict[str, str]] = [] + + def fake_graphql(*, query, variables, token): + assert variables["input"]["expectedHeadOid"] == "branchtip" + captured_additions.extend(variables["input"]["fileChanges"]["additions"]) + return 200, {"data": {"createCommitOnBranch": {"commit": {"oid": "newsha"}}}} + + result = rpp.publish_release_pr( + repo="o/r", + additions=[(".claude-plugin/plugin.json", b'{"version": "0.2.0"}'), ("apm.yml", b"version: 0.2.0")], + base="main", + branch="chore/release-plugin-bump", + title="chore(plugin): bump version to 0.2.0", + body="b", + commit_subject="chore(plugin): bump version to 0.2.0", + commit_body="", + token="tok", + apply_call=fake, + graphql_call=fake_graphql, + ) + + assert result == "updated:4" + assert len(captured_additions) == 2 + assert {addition["path"] for addition in captured_additions} == {".claude-plugin/plugin.json", "apm.yml"} + assert ("DELETE", "https://api.github.com/repos/o/r/git/refs/heads/chore/release-plugin-bump") not in calls + + +def test_publish_release_pr_open_pr_branch_already_current_skips_commit() -> None: + plugin_encoded = base64.b64encode(b'{"version": "0.2.0"}').decode("ascii") + apm_encoded = base64.b64encode(b"version: 0.2.0").decode("ascii") + responses = { + "https://api.github.com/repos/o/r/pulls?head=o:chore/release-plugin-bump&state=open&per_page=1": ( + 200, + json.dumps([{"number": 5}]), + ), + "https://api.github.com/repos/o/r/git/ref/heads/chore/release-plugin-bump": ( + 200, + json.dumps({"object": {"sha": "branchtip"}}), + ), + "https://api.github.com/repos/o/r/contents/.claude-plugin/plugin.json?ref=chore/release-plugin-bump": ( + 200, + json.dumps({"encoding": "base64", "content": plugin_encoded}), + ), + "https://api.github.com/repos/o/r/contents/apm.yml?ref=chore/release-plugin-bump": ( + 200, + json.dumps({"encoding": "base64", "content": apm_encoded}), + ), + "https://api.github.com/repos/o/r/pulls/5": (200, ""), + } + fake, calls = _fake_apply_call(responses) + + def fake_graphql(*, query, variables, token): + raise AssertionError("no commit should be created when the branch already matches the desired content") + + result = rpp.publish_release_pr( + repo="o/r", + additions=[(".claude-plugin/plugin.json", b'{"version": "0.2.0"}'), ("apm.yml", b"version: 0.2.0")], + base="main", + branch="chore/release-plugin-bump", + title="chore(plugin): bump version to 0.2.0", + body="b", + commit_subject="chore(plugin): bump version to 0.2.0", + commit_body="", + token="tok", + apply_call=fake, + graphql_call=fake_graphql, + ) + + assert result == "updated:5" + assert ("DELETE", "https://api.github.com/repos/o/r/git/refs/heads/chore/release-plugin-bump") not in calls + + +# --------------------------------------------------------------------------- +# _collect_additions +# --------------------------------------------------------------------------- + + +def test_collect_additions_success(tmp_path) -> None: + plugin = tmp_path / ".claude-plugin" + plugin.mkdir() + (plugin / "plugin.json").write_text('{"version": "0.2.0"}') + (tmp_path / "apm.yml").write_text("version: 0.2.0\n") + + additions = rpp._collect_additions(tmp_path, [".claude-plugin/plugin.json", "apm.yml"]) + assert additions == [ + (".claude-plugin/plugin.json", b'{"version": "0.2.0"}'), + ("apm.yml", b"version: 0.2.0\n"), + ] + + +def test_collect_additions_missing_file_raises(tmp_path) -> None: + with pytest.raises(RuntimeError, match="not a readable file"): + rpp._collect_additions(tmp_path, ["missing.json"]) + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + + +def test_main_missing_gh_token(monkeypatch: pytest.MonkeyPatch, capsys) -> None: + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("REPO", raising=False) + rc = rpp.main( + ["--old-version", "0.1.0", "--new-version", "0.2.0", "--bump-kind", "minor", "--notes-file", "x"] + ) + assert rc == 1 + assert "GH_TOKEN" in capsys.readouterr().err + + +def test_main_missing_repo(monkeypatch: pytest.MonkeyPatch, capsys) -> None: + monkeypatch.setenv("GH_TOKEN", "tok") + monkeypatch.delenv("REPO", raising=False) + rc = rpp.main( + ["--old-version", "0.1.0", "--new-version", "0.2.0", "--bump-kind", "minor", "--notes-file", "x"] + ) + assert rc == 1 + assert "REPO" in capsys.readouterr().err + + +def test_main_missing_notes_file(monkeypatch: pytest.MonkeyPatch, capsys, tmp_path) -> None: + monkeypatch.setenv("GH_TOKEN", "tok") + monkeypatch.setenv("REPO", "o/r") + rc = rpp.main( + [ + "--old-version", + "0.1.0", + "--new-version", + "0.2.0", + "--bump-kind", + "minor", + "--notes-file", + str(tmp_path / "missing.md"), + ] + ) + assert rc == 1 + assert "notes file not found" in capsys.readouterr().err + + +def test_main_runtime_error_from_collect_additions(monkeypatch: pytest.MonkeyPatch, capsys, tmp_path) -> None: + monkeypatch.setenv("GH_TOKEN", "tok") + monkeypatch.setenv("REPO", "o/r") + notes_file = tmp_path / "notes.md" + notes_file.write_text("notes") + rc = rpp.main( + [ + "--repo-root", + str(tmp_path), + "--old-version", + "0.1.0", + "--new-version", + "0.2.0", + "--bump-kind", + "minor", + "--notes-file", + str(notes_file), + "--plugin-manifest", + "missing-plugin.json", + "--apm-manifest", + "missing-apm.yml", + ] + ) + assert rc == 1 + assert "Error:" in capsys.readouterr().err + + +def test_main_success_created(monkeypatch: pytest.MonkeyPatch, capsys, tmp_path) -> None: + # publish_release_pr's own `apply_call`/`graphql_call` parameters default + # to the module-level functions bound at *definition* time, so + # monkeypatching `rpp.apply_call`/`rpp.graphql_call` after import would + # not reach this call path. Faking at the `urllib.request.urlopen` layer + # instead (same technique as test_apply_call_uses_default_opener / + # test_graphql_call_uses_default_opener above) exercises main()'s real + # wiring end to end without a real network call. + monkeypatch.setenv("GH_TOKEN", "tok") + monkeypatch.setenv("REPO", "o/r") + + plugin_dir = tmp_path / ".claude-plugin" + plugin_dir.mkdir() + (plugin_dir / "plugin.json").write_text('{"version": "0.2.0"}') + (tmp_path / "apm.yml").write_text("version: 0.2.0\n") + notes_file = tmp_path / "notes.md" + notes_file.write_text("### Features\n- add widget (abc1234)\n") + + rest_responses: dict[tuple[str, str], tuple[int, str]] = { + ( + "GET", + "https://api.github.com/repos/o/r/pulls?head=o:chore/release-plugin-bump&state=open&per_page=1", + ): (200, "[]"), + ("DELETE", "https://api.github.com/repos/o/r/git/refs/heads/chore/release-plugin-bump"): (404, ""), + ("GET", "https://api.github.com/repos/o/r/git/ref/heads/chore/release-plugin-bump"): (404, ""), + ("GET", "https://api.github.com/repos/o/r/git/ref/heads/main"): ( + 200, + json.dumps({"object": {"sha": "basesha"}}), + ), + ("POST", "https://api.github.com/repos/o/r/git/refs"): (201, ""), + ("POST", "https://api.github.com/repos/o/r/pulls"): (201, json.dumps({"number": 11})), + } + graphql_responses = [(200, json.dumps({"data": {"createCommitOnBranch": {"commit": {"oid": "newsha"}}}}))] + graphql_call_count = {"n": 0} + + def fake_urlopen(request: urllib.request.Request, timeout: float | None = None) -> Response: + method = request.get_method() + url = request.full_url + if url == rpp._GRAPHQL_URL: + code, body = graphql_responses[graphql_call_count["n"]] + graphql_call_count["n"] += 1 + else: + code, body = rest_responses[(method, url)] + if 200 <= code < 300: + return Response(code, body) + raise http_error(code, body) + + monkeypatch.setattr(rpp.urllib.request, "urlopen", fake_urlopen) + + rc = rpp.main( + [ + "--repo-root", + str(tmp_path), + "--old-version", + "0.1.0", + "--new-version", + "0.2.0", + "--bump-kind", + "minor", + "--notes-file", + str(notes_file), + ] + ) + assert rc == 0 + assert "created:11" in capsys.readouterr().err + assert graphql_call_count["n"] == 1 diff --git a/tests/test_release_tag_publish.py b/tests/test_release_tag_publish.py new file mode 100644 index 00000000..96ac13b0 --- /dev/null +++ b/tests/test_release_tag_publish.py @@ -0,0 +1,607 @@ +from __future__ import annotations + +import json +import urllib.error + +import pytest +import release_tag_publish as rtp + + +class Response: + """Test double for the urlopen response `apply_call` expects: a + context-managed, status+read()-bearing object. Shared across every + `apply_call` test below (previously each test defined its own + near-identical inline class) and doubles as the `fp` argument to a + constructed `HTTPError` via `http_error()`, matching + test_release_pr_publish.py's equivalent test double for the sibling + script.""" + + def __init__(self, status: int, body: str = "") -> None: + self.status = status + self._body = body.encode() + + def __enter__(self) -> Response: + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return self._body + + +def http_error(code: int, body: str = "") -> urllib.error.HTTPError: + return urllib.error.HTTPError("https://example.test", code, "err", {}, Response(code, body)) # type: ignore[arg-type] + + +class _FakeApplyCall: + """Records every call and returns a canned ``(code, body)`` response + keyed by ``"METHOD url"`` (or bare ``url`` as a fallback), mirroring + test_sync_pr_publish.py's ``_fake_apply_call`` test-double pattern. + + Raises ``AssertionError`` on any call with no matching response, so an + end-to-end test can prove a code path never reaches an unexpected API + call (e.g. the no-op path never reaching a publish call) just by using + a responses dict that only covers the calls it expects. + """ + + def __init__(self, responses: dict[str, tuple[int, str]]) -> None: + self._responses = responses + self.calls: list[tuple[str, str, dict | None]] = [] + + def __call__(self, *, method: str, url: str, payload: dict | None, token: str) -> tuple[int, str]: + self.calls.append((method, url, payload)) + key = f"{method} {url}" + if key in self._responses: + return self._responses[key] + if url in self._responses: + return self._responses[url] + raise AssertionError(f"unexpected call: {method} {url}") + + +def _write_manifest(tmp_path, version: str, rel_path: str = ".claude-plugin/plugin.json"): + manifest = tmp_path / rel_path + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text(json.dumps({"version": version}), encoding="utf-8") + return manifest + + +# --------------------------------------------------------------------------- +# apply_call +# --------------------------------------------------------------------------- + + +def test_apply_call_happy_path() -> None: + sleeps: list[float] = [] + + def opener(request): + assert request.headers["Authorization"] == "Bearer tok" + return Response(201, '{"ok":true}') + + code, body = rtp.apply_call( + method="POST", url="https://api.github.com/x", payload={"a": 1}, token="tok", opener=opener, sleeper=sleeps.append + ) + assert code == 201 + assert body == '{"ok":true}' + assert sleeps == [] + + +def test_apply_call_breaks_on_4xx() -> None: + calls = 0 + + def opener(request): + nonlocal calls + calls += 1 + raise http_error(422, "bad") + + code, body = rtp.apply_call(method="GET", url="https://api.github.com/x", payload=None, token="tok", opener=opener) + assert code == 422 + assert body == "bad" + assert calls == 1 + + +def test_apply_call_retries_5xx_then_succeeds() -> None: + responses: list[urllib.error.HTTPError | Response] = [http_error(503, "one"), Response(200, "ok")] + sleeps: list[float] = [] + + def opener(request): + response = responses.pop(0) + if isinstance(response, urllib.error.HTTPError): + raise response + return response + + code, body = rtp.apply_call( + method="GET", url="https://api.github.com/x", payload=None, token="tok", opener=opener, sleeper=sleeps.append + ) + assert code == 200 + assert body == "ok" + assert sleeps == [5] + + +def test_apply_call_network_failure_retries_three_times() -> None: + calls = 0 + sleeps: list[float] = [] + + def opener(request): + nonlocal calls + calls += 1 + raise urllib.error.URLError("boom") + + code, body = rtp.apply_call( + method="GET", url="https://api.github.com/x", payload=None, token="tok", opener=opener, sleeper=sleeps.append + ) + assert code == 0 + assert body == "boom" + assert calls == 3 + assert sleeps == [5, 10] + + +def test_apply_call_uses_default_opener(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def fake_urlopen(request, timeout=None): + captured["timeout"] = timeout + return Response(200, "ok") + + monkeypatch.setattr(rtp.urllib.request, "urlopen", fake_urlopen) + code, _body = rtp.apply_call(method="GET", url="https://api.github.com/x", payload=None, token="tok") + assert code == 200 + assert captured["timeout"] == rtp._HTTP_TIMEOUT_SECONDS + + +def test_format_code() -> None: + assert rtp._format_code(0) == "000" + assert rtp._format_code(404) == "404" + + +# --------------------------------------------------------------------------- +# tag_exists +# --------------------------------------------------------------------------- + + +def test_tag_exists_true_on_200() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (200, "{}")}) + assert rtp.tag_exists("o/r", "1.2.3", "tok", apply_call=fake) is True + + +def test_tag_exists_false_on_404() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (404, "not found")}) + assert rtp.tag_exists("o/r", "1.2.3", "tok", apply_call=fake) is False + + +def test_tag_exists_raises_on_500() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (500, "boom")}) + with pytest.raises(RuntimeError, match="500"): + rtp.tag_exists("o/r", "1.2.3", "tok", apply_call=fake) + + +# --------------------------------------------------------------------------- +# release_exists +# --------------------------------------------------------------------------- + + +def test_release_exists_true_on_200() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (200, "{}")}) + assert rtp.release_exists("o/r", "1.2.3", "tok", apply_call=fake) is True + + +def test_release_exists_false_on_404() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (404, "not found")}) + assert rtp.release_exists("o/r", "1.2.3", "tok", apply_call=fake) is False + + +def test_release_exists_raises_on_500() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (500, "boom")}) + with pytest.raises(RuntimeError, match="500"): + rtp.release_exists("o/r", "1.2.3", "tok", apply_call=fake) + + +# --------------------------------------------------------------------------- +# extract_release_notes +# --------------------------------------------------------------------------- + + +def test_extract_release_notes_success() -> None: + body = "intro text\n\nline one\nline two\n\noutro text" + assert rtp.extract_release_notes(body) == "line one\nline two" + + +def test_extract_release_notes_missing_start_marker_raises() -> None: + body = "no start marker here\n\n" + with pytest.raises(RuntimeError, match="release-notes:start"): + rtp.extract_release_notes(body) + + +def test_extract_release_notes_missing_end_marker_raises() -> None: + body = "\nno end marker here\n" + with pytest.raises(RuntimeError, match="release-notes:end"): + rtp.extract_release_notes(body) + + +def test_extract_release_notes_missing_both_markers_raises() -> None: + with pytest.raises(RuntimeError, match="release-notes:start"): + rtp.extract_release_notes("plain body, no markers at all") + + +def test_extract_release_notes_empty_body_raises() -> None: + with pytest.raises(RuntimeError, match="release-notes:start"): + rtp.extract_release_notes("") + + +def test_extract_release_notes_markers_out_of_order_raises() -> None: + # Both marker strings are present (so the presence checks pass), but the + # end marker appears before the start marker -- the regex search must + # still fail closed rather than matching across the wrong span. + body = "middle" + with pytest.raises(RuntimeError, match="out of order"): + rtp.extract_release_notes(body) + + +# --------------------------------------------------------------------------- +# find_merged_pr_for_commit +# --------------------------------------------------------------------------- + + +def test_find_merged_pr_for_commit_found() -> None: + prs = [{"number": 1, "merged_at": None}, {"number": 2, "merged_at": "2026-08-01T00:00:00Z", "body": "x"}] + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, json.dumps(prs))}) + pr = rtp.find_merged_pr_for_commit("o/r", "deadbeef", "tok", apply_call=fake) + assert pr == prs[1] + + +def test_find_merged_pr_for_commit_not_found_empty_list() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, "[]")}) + assert rtp.find_merged_pr_for_commit("o/r", "deadbeef", "tok", apply_call=fake) is None + + +def test_find_merged_pr_for_commit_only_open_prs_returns_none() -> None: + prs = [{"number": 1, "merged_at": None}, {"number": 2, "merged_at": None}] + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, json.dumps(prs))}) + assert rtp.find_merged_pr_for_commit("o/r", "deadbeef", "tok", apply_call=fake) is None + + +def test_find_merged_pr_for_commit_failure_raises() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (500, "boom")}) + with pytest.raises(RuntimeError, match="500"): + rtp.find_merged_pr_for_commit("o/r", "deadbeef", "tok", apply_call=fake) + + +def test_find_merged_pr_for_commit_unexpected_shape_raises() -> None: + fake = _FakeApplyCall({"GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, json.dumps({"x": 1}))}) + with pytest.raises(RuntimeError, match="Expected list"): + rtp.find_merged_pr_for_commit("o/r", "deadbeef", "tok", apply_call=fake) + + +# --------------------------------------------------------------------------- +# publish_tag_and_release +# --------------------------------------------------------------------------- + + +def test_publish_tag_and_release_success_posts_expected_bodies() -> None: + fake = _FakeApplyCall( + { + "POST https://api.github.com/repos/o/r/git/tags": (201, json.dumps({"sha": "tagsha123"})), + "POST https://api.github.com/repos/o/r/git/refs": (201, "{}"), + "POST https://api.github.com/repos/o/r/releases": (201, "{}"), + } + ) + rtp.publish_tag_and_release("o/r", "1.2.3", "deadbeef", "notes text", "tok", apply_call=fake) + + tag_call = next(c for c in fake.calls if c[1].endswith("/git/tags")) + assert tag_call[2] == {"tag": "gitapex--v1.2.3", "message": "gitapex v1.2.3", "object": "deadbeef", "type": "commit"} + + ref_call = next(c for c in fake.calls if c[1].endswith("/git/refs")) + assert ref_call[2] == {"ref": "refs/tags/gitapex--v1.2.3", "sha": "tagsha123"} + + release_call = next(c for c in fake.calls if c[1].endswith("/releases")) + assert release_call[2] == {"tag_name": "gitapex--v1.2.3", "name": "gitapex v1.2.3", "body": "notes text"} + + +def test_publish_tag_and_release_tag_creation_failure_raises_and_stops() -> None: + fake = _FakeApplyCall({"POST https://api.github.com/repos/o/r/git/tags": (422, "bad")}) + with pytest.raises(RuntimeError, match="422"): + rtp.publish_tag_and_release("o/r", "1.2.3", "deadbeef", "notes", "tok", apply_call=fake) + assert len(fake.calls) == 1 + + +def test_publish_tag_and_release_missing_tag_sha_raises() -> None: + fake = _FakeApplyCall({"POST https://api.github.com/repos/o/r/git/tags": (201, json.dumps({}))}) + with pytest.raises(RuntimeError, match="missing sha"): + rtp.publish_tag_and_release("o/r", "1.2.3", "deadbeef", "notes", "tok", apply_call=fake) + + +def test_publish_tag_and_release_ref_creation_failure_stops_before_release() -> None: + fake = _FakeApplyCall( + { + "POST https://api.github.com/repos/o/r/git/tags": (201, json.dumps({"sha": "tagsha123"})), + "POST https://api.github.com/repos/o/r/git/refs": (422, "exists"), + } + ) + with pytest.raises(RuntimeError, match="422"): + rtp.publish_tag_and_release("o/r", "1.2.3", "deadbeef", "notes", "tok", apply_call=fake) + assert len(fake.calls) == 2 + + +def test_publish_tag_and_release_release_creation_failure_raises() -> None: + fake = _FakeApplyCall( + { + "POST https://api.github.com/repos/o/r/git/tags": (201, json.dumps({"sha": "tagsha123"})), + "POST https://api.github.com/repos/o/r/git/refs": (201, "{}"), + "POST https://api.github.com/repos/o/r/releases": (500, "boom"), + } + ) + with pytest.raises(RuntimeError, match="500"): + rtp.publish_tag_and_release("o/r", "1.2.3", "deadbeef", "notes", "tok", apply_call=fake) + + +# --------------------------------------------------------------------------- +# _read_version +# --------------------------------------------------------------------------- + + +def test_read_version_success(tmp_path) -> None: + manifest = _write_manifest(tmp_path, "2.0.0") + assert rtp._read_version(manifest) == "2.0.0" + + +def test_read_version_missing_file_raises(tmp_path) -> None: + with pytest.raises(RuntimeError, match="Could not read"): + rtp._read_version(tmp_path / "missing.json") + + +def test_read_version_invalid_json_raises(tmp_path) -> None: + manifest = tmp_path / "plugin.json" + manifest.write_text("{not json", encoding="utf-8") + with pytest.raises(RuntimeError, match="not valid JSON"): + rtp._read_version(manifest) + + +def test_read_version_missing_field_raises(tmp_path) -> None: + manifest = tmp_path / "plugin.json" + manifest.write_text(json.dumps({"name": "x"}), encoding="utf-8") + with pytest.raises(RuntimeError, match="version"): + rtp._read_version(manifest) + + +def test_read_version_non_string_field_raises(tmp_path) -> None: + manifest = tmp_path / "plugin.json" + manifest.write_text(json.dumps({"version": 123}), encoding="utf-8") + with pytest.raises(RuntimeError, match="version"): + rtp._read_version(manifest) + + +# --------------------------------------------------------------------------- +# main -- argument/env validation +# --------------------------------------------------------------------------- + + +def test_main_missing_gh_token(monkeypatch: pytest.MonkeyPatch, capsys) -> None: + monkeypatch.delenv("GH_TOKEN", raising=False) + rc = rtp.main(["--sha", "deadbeef", "--repo", "o/r"]) + assert rc == 1 + assert "GH_TOKEN" in capsys.readouterr().err + + +def test_main_missing_repo(monkeypatch: pytest.MonkeyPatch, capsys) -> None: + monkeypatch.setenv("GH_TOKEN", "tok") + monkeypatch.delenv("REPO", raising=False) + rc = rtp.main(["--sha", "deadbeef"]) + assert rc == 1 + assert "REPO" in capsys.readouterr().err + + +def test_main_repo_env_var_fallback(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + monkeypatch.setenv("REPO", "o/r") + fake = _FakeApplyCall( + { + "GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (200, "{}"), + "GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (200, "{}"), + } + ) + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef"], apply_call=fake) + assert rc == 0 + + +def test_main_manifest_error_surfaces_as_error(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: + monkeypatch.setenv("GH_TOKEN", "tok") + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"]) + assert rc == 1 + assert "Error:" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# main -- end-to-end paths +# --------------------------------------------------------------------------- + + +def test_main_no_op_when_tag_and_release_already_exist( + monkeypatch: pytest.MonkeyPatch, tmp_path, capsys +) -> None: + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + # Only the two existence GETs are legal calls here -- _FakeApplyCall + # raises AssertionError on anything else, so this proves the no-op path + # never reaches find_merged_pr_for_commit or publish_tag_and_release. + fake = _FakeApplyCall( + { + "GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (200, "{}"), + "GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (200, "{}"), + } + ) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 0 + assert "already published" in capsys.readouterr().out + assert fake.calls == [ + ("GET", "https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3", None), + ("GET", "https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3", None), + ] + + +def test_main_completes_missing_release_when_tag_already_exists( + monkeypatch: pytest.MonkeyPatch, tmp_path, capsys +) -> None: + # Regression: a run that created the tag ref and then failed on the + # Release POST used to be unrecoverable. The tag alone was treated as + # "already published", so every retry exited 0 (green) while the + # GitHub Release stayed permanently missing. A retry must instead + # finish the job -- create the Release, and NOT re-create the tag. + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + pr_body = "\nNotable change.\n" + prs = [ + { + "number": 9, + "merged_at": "2026-08-01T00:00:00Z", + "body": pr_body, + "head": {"ref": "chore/release-plugin-bump"}, + } + ] + fake = _FakeApplyCall( + { + "GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (200, "{}"), + "GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, json.dumps(prs)), + "POST https://api.github.com/repos/o/r/releases": (201, "{}"), + } + ) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 0 + posts = [c for c in fake.calls if c[0] == "POST"] + # Exactly one POST, and it is the Release -- the already-existing tag + # object/ref must not be created a second time. + assert len(posts) == 1 + assert posts[0][1] == "https://api.github.com/repos/o/r/releases" + assert posts[0][2]["body"] == "Notable change." + assert "published gitapex--v1.2.3" in capsys.readouterr().err + + +def test_main_publishes_tag_and_release_when_absent(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + pr_body = ( + "## Summary\nSome PR description text.\n\n" + "\n" + "Notable change one.\nNotable change two.\n" + "\n\nRefs #642" + ) + prs = [ + { + "number": 9, + "merged_at": "2026-08-01T00:00:00Z", + "body": pr_body, + "head": {"ref": "chore/release-plugin-bump"}, + } + ] + fake = _FakeApplyCall( + { + "GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, json.dumps(prs)), + "POST https://api.github.com/repos/o/r/git/tags": (201, json.dumps({"sha": "tagsha123"})), + "POST https://api.github.com/repos/o/r/git/refs": (201, "{}"), + "POST https://api.github.com/repos/o/r/releases": (201, "{}"), + } + ) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 0 + tag_call = next(c for c in fake.calls if c[1].endswith("/git/tags")) + # Regression guard: the tag must use the "gitapex--v{version}" prefix, + # never the earlier "plugin-v{version}" convention. + assert tag_call[2]["tag"] == "gitapex--v1.2.3" + release_call = next(c for c in fake.calls if c[1].endswith("/releases")) + assert release_call[2]["body"] == "Notable change one.\nNotable change two." + assert "published gitapex--v1.2.3" in capsys.readouterr().err + + +def test_main_skips_quietly_when_no_merged_pr_found(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: + # A plugin.json-touching push to main with no merged PR behind the + # commit at all (e.g. a direct push, or the commits/pulls lookup racing + # ahead of GitHub's own indexing) is treated the same as "not a + # release-PR merge" -- skip quietly, do not fail the workflow. + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + fake = _FakeApplyCall( + { + "GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, "[]"), + } + ) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 0 + assert "not a release-PR merge, skipping" in capsys.readouterr().out + assert not [c for c in fake.calls if c[0] == "POST"] + + +def test_main_skips_quietly_when_merged_pr_is_not_the_release_bump_branch( + monkeypatch: pytest.MonkeyPatch, tmp_path, capsys +) -> None: + # Regression: release-tag.yml triggers on EVERY push to main that + # touches plugin.json, not only release-PR merges -- an ordinary PR + # editing plugin.json (a metadata field, or the deliberate manual + # major-version bump docs/versioning.md prescribes) merges from some + # other branch and must not make this workflow fail. + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + prs = [ + { + "number": 12, + "merged_at": "2026-08-01T00:00:00Z", + "body": "Bump the license field.", + "head": {"ref": "chore/tidy-plugin-metadata"}, + } + ] + fake = _FakeApplyCall( + { + "GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, json.dumps(prs)), + } + ) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 0 + out = capsys.readouterr().out + assert "not a release-PR merge, skipping" in out + assert "chore/tidy-plugin-metadata" in out + assert not [c for c in fake.calls if c[0] == "POST"] + + +def test_main_missing_release_notes_markers_raises(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: + # Unlike the two skip cases above, a merged PR that IS from the + # release-bump branch but somehow lacks the release-notes markers is a + # genuine bug (release_pr_publish.py always writes them) and must still + # fail loudly, not skip. + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + prs = [ + { + "number": 9, + "merged_at": "2026-08-01T00:00:00Z", + "body": "no markers here", + "head": {"ref": "chore/release-plugin-bump"}, + } + ] + fake = _FakeApplyCall( + { + "GET https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/releases/tags/gitapex--v1.2.3": (404, ""), + "GET https://api.github.com/repos/o/r/commits/deadbeef/pulls": (200, json.dumps(prs)), + } + ) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 1 + assert "release-notes:start" in capsys.readouterr().err