From e3d8f620ed2cd1dbfdbd572792d37057530b9be2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:05:14 +0000 Subject: [PATCH 01/12] docs(plugin): add branch plan for versioning/release mechanism Task decomposition (7 tasks, 2 waves) for issue #642's Acceptance Criteria Map, produced by planning-a-branch-from-an-issue and executed by executing-a-branch-plan per this repository's own process. Refs #642 --- ...2026-08-01-versioning-release-mechanism.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md 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 From 992b14ac82bcfbb9039b6b73bee5113534f4bbcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:10:08 +0000 Subject: [PATCH 02/12] docs(plugin): correct tag format, cli/compose scope, and automation status in versioning.md Refs #642 --- docs/versioning.md | 57 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/docs/versioning.md b/docs/versioning.md index e14cd06e..4d3bfed8 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,39 @@ 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 commit marked breaking, bumps the + **minor** version. +- A `fix(plugin)`, `refactor(plugin)`, or `perf(plugin)` commit bumps the + **patch** version. +- `docs`, `chore`, `test`, `build`, and `ci` commits, and commits 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. From e5b91ad2ebc57577a6fb9d8609b18fe913b48e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:09:39 +0000 Subject: [PATCH 03/12] docs(plugin): document the release-bot GitHub App setup Refs #642 --- CONTRIBUTING.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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" From 69113047eea02289c8a13d3bb98d4fc4a7d521d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:15:03 +0000 Subject: [PATCH 04/12] feat(plugin): add release tag/GitHub-Release publisher script Adds .github/scripts/release_tag_publish.py: given a merge commit on main that bumped .claude-plugin/plugin.json's version, tags it gitapex--v{version} (the corrected plugin-dependency tag-resolution convention, not plugin-v{version}) and publishes a GitHub Release using the release notes extracted from between markers in the merged PR's body. A tag that already exists is treated as an idempotent no-op; a plugin.json-touching commit with no merged PR behind it fails loudly via RuntimeError rather than silently skipping. Refs #642 --- .github/scripts/release_tag_publish.py | 296 +++++++++++++++ tests/test_release_tag_publish.py | 496 +++++++++++++++++++++++++ 2 files changed, 792 insertions(+) create mode 100644 .github/scripts/release_tag_publish.py create mode 100644 tests/test_release_tag_publish.py diff --git a/.github/scripts/release_tag_publish.py b/.github/scripts/release_tag_publish.py new file mode 100644 index 00000000..a7e036db --- /dev/null +++ b/.github/scripts/release_tag_publish.py @@ -0,0 +1,296 @@ +#!/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: the tag's existence is the single source of truth for "already +published". If ``gitapex--v{version}`` already exists, this script is a +no-op (exit 0) -- safe to re-run after a partial failure or a workflow retry. + +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 tagged" case. + 1 Missing env var, missing/invalid manifest, no merged PR found for the + commit, missing release-notes markers, 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) + + +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 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, +) -> None: + """Create an annotated tag object, point ``refs/tags/gitapex--v{version}`` + at it, then publish a GitHub Release for that tag. + + 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}" + + 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}" + if tag_exists(repo, version, token, apply_call=apply_call): + print(f"release-tag-publish: {tag_name} already exists (refs/tags/{tag_name}) -- no-op") + return 0 + pr = find_merged_pr_for_commit(repo, args.sha, token, apply_call=apply_call) + if pr is None: + raise RuntimeError( + f"No merged PR found for commit {args.sha} in {repo}: a plugin.json version " + "bump reached the base branch outside the release-PR flow -- refusing to " + "silently no-op" + ) + notes = extract_release_notes(pr.get("body") or "") + publish_tag_and_release(repo, version, args.sha, notes, token, apply_call=apply_call) + 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/tests/test_release_tag_publish.py b/tests/test_release_tag_publish.py new file mode 100644 index 00000000..8272bb44 --- /dev/null +++ b/tests/test_release_tag_publish.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +import json + +import pytest +import release_tag_publish as rtp + + +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] = [] + + class Response: + def __init__(self, status: int, body: str) -> None: + self.status = status + self._body = body.encode() + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def read(self) -> bytes: + return self._body + + 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: + import urllib.error + + calls = 0 + + class Response: + def __init__(self, status, body): + self.status = status + self._body = body.encode() + + def read(self): + return self._body + + def opener(request): + nonlocal calls + calls += 1 + raise urllib.error.HTTPError("https://example.test", 422, "err", {}, Response(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: + import urllib.error + + class Response: + def __init__(self, status, body=""): + self.status = status + self._body = body.encode() + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def read(self): + return self._body + + responses = [urllib.error.HTTPError("https://example.test", 503, "err", {}, Response(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: + import urllib.error + + 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] = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + status = 200 + + def read(self): + return b"ok" + + def fake_urlopen(request, timeout=None): + captured["timeout"] = timeout + return Response() + + 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) + + +# --------------------------------------------------------------------------- +# 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, "{}")}) + 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_already_exists(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: + _write_manifest(tmp_path, "1.2.3") + monkeypatch.setenv("GH_TOKEN", "tok") + # Only the tag-existence GET is a legal call 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, "{}")}) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 0 + assert "already exists" in capsys.readouterr().out + assert fake.calls == [("GET", "https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3", None)] + + +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}] + 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/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_raises_when_no_merged_pr_found(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: + _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/commits/deadbeef/pulls": (200, "[]"), + } + ) + + rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) + + assert rc == 1 + assert "No merged PR found" in capsys.readouterr().err + + +def test_main_missing_release_notes_markers_raises(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: + _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"}] + 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/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 From 439745534bd1a38a78e8fe864e65b5272da2a512 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:15:39 +0000 Subject: [PATCH 05/12] feat(plugin): add release-PR publisher script Adapts sync_pr_publish.py's signed-commit-via-GitHub-App-token pattern (apply_call/graphql_call retry machinery, createCommitOnBranch mutation, delete-and-recreate-branch-on-drift-when-no-open-PR safety rule) into a standalone script for the release-PR flow: publish_release_pr commits a plugin.json/apm.yml version bump plus rendered release notes as a single signed commit and upserts a PR, with build_pr_body wrapping the notes verbatim between markers that release_tag_publish.py will later extract. Refs #642 --- .github/scripts/release_pr_publish.py | 578 ++++++++++++++++ tests/test_release_pr_publish.py | 956 ++++++++++++++++++++++++++ 2 files changed, 1534 insertions(+) create mode 100644 .github/scripts/release_pr_publish.py create mode 100644 tests/test_release_pr_publish.py 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/tests/test_release_pr_publish.py b/tests/test_release_pr_publish.py new file mode 100644 index 00000000..28a9638a --- /dev/null +++ b/tests/test_release_pr_publish.py @@ -0,0 +1,956 @@ +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_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 From e4d1519f9d61f610e1f009286aaa195c341321de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:19:54 +0000 Subject: [PATCH 06/12] feat(plugin): add release version-bump computation script Adds .github/scripts/compute_release_bump.py: a stdlib-only pure-core + thin-git-wrapper module that parses conventional-commit headers, classifies them against docs/versioning.md's plugin-scope convention, computes a max-of-signals minor/patch SemVer bump (never major -- 1.0.0 stays a deliberate human decision), rewrites plugin.json/apm.yml's version lines atomically, and renders grouped release notes. tests/test_compute_release_bump.py covers the pure functions with plain data, an injectable git_runner stub, and one end-to-end pass against a real throwaway git repo. Refs #642 --- .github/scripts/compute_release_bump.py | 490 ++++++++++++++++++ tests/test_compute_release_bump.py | 640 ++++++++++++++++++++++++ 2 files changed, 1130 insertions(+) create mode 100644 .github/scripts/compute_release_bump.py create mode 100644 tests/test_compute_release_bump.py diff --git a/.github/scripts/compute_release_bump.py b/.github/scripts/compute_release_bump.py new file mode 100644 index 00000000..73db7bf4 --- /dev/null +++ b/.github/scripts/compute_release_bump.py @@ -0,0 +1,490 @@ +#!/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+"') +_APM_VERSION_RE = re.compile(r"^version:\s*\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/tests/test_compute_release_bump.py b/tests/test_compute_release_bump.py new file mode 100644 index 00000000..b5c316f8 --- /dev/null +++ b/tests/test_compute_release_bump.py @@ -0,0 +1,640 @@ +"""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 + + +# --------------------------------------------------------------------- +# 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 From fd831d5cdec1fd56a33943d3d9ff70da2cdde37c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:24:33 +0000 Subject: [PATCH 07/12] feat(plugin): add release-PR workflow Refs #642 --- .github/workflows/release-pr.yml | 99 ++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/release-pr.yml 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 From b7dc611483d07eee80e6c40c6ed3a983049428a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:24:22 +0000 Subject: [PATCH 08/12] feat(plugin): add release-tag workflow Adds .github/workflows/release-tag.yml, triggered on pushes to main that touch .claude-plugin/plugin.json. Mirrors sync-agent-instructions.yml's job shape (harden-runner, checkout, mint App token, invoke a publisher script) and mints a GitHub App token via the same App/secrets as release-pr.yml, since a required_signatures branch-protection rule rejects unsigned GITHUB_TOKEN pushes/tags. Refs #642 --- .github/workflows/release-tag.yml | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/release-tag.yml diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 00000000..5a10fe3c --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,64 @@ +name: Release tag + +on: + push: + branches: [main] + paths: + - '.claude-plugin/plugin.json' + +permissions: + contents: read + +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 }}" From 95bff0eb5ecf4e4284b5d9c3394f89533096a0dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:32:24 +0000 Subject: [PATCH 09/12] refactor(plugin): simplify release-mechanism diff (aggregate review pass) Aggregate refactor/simplify pass (Step 8a) over the versioning-release- mechanism branch diff. Consolidated tests/test_release_tag_publish.py's four near-identical inline `Response` test-double classes (one hand-rolled per apply_call test, one missing __enter__/__exit__, one missing a default body) plus three redundant per-test `import urllib.error` statements into a single module-level `Response` class and `http_error()` helper, matching the pattern test_release_pr_publish.py already uses for the sibling script. Same assertions, same test behavior, same coverage (157/157 passing, 100% coverage on all three new scripts) -- only the fixture plumbing was de-duplicated. Net -26 lines. Reviewed but left in place, per this pass's explicit convention against introducing a cross-import between .github/scripts/*.py files: apply_call, _default_opener, _format_code, and the _API_ROOT/_API_VERSION/ _HTTP_TIMEOUT_SECONDS constants are genuinely identical between release_pr_publish.py and release_tag_publish.py, mirroring how sync_pr_publish.py's own retry machinery was independently adapted twice. No same-file simplification applies to that duplication without breaking the repo's no-cross-import convention for these scripts, so it stays as documented, intentional duplication. Refs #642 --- tests/test_release_tag_publish.py | 88 +++++++++++-------------------- 1 file changed, 31 insertions(+), 57 deletions(-) diff --git a/tests/test_release_tag_publish.py b/tests/test_release_tag_publish.py index 8272bb44..ccbd531d 100644 --- a/tests/test_release_tag_publish.py +++ b/tests/test_release_tag_publish.py @@ -1,11 +1,39 @@ 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 @@ -46,20 +74,6 @@ def _write_manifest(tmp_path, version: str, rel_path: str = ".claude-plugin/plug def test_apply_call_happy_path() -> None: sleeps: list[float] = [] - class Response: - def __init__(self, status: int, body: str) -> None: - self.status = status - self._body = body.encode() - - def __enter__(self): - return self - - def __exit__(self, *args): - return None - - def read(self) -> bytes: - return self._body - def opener(request): assert request.headers["Authorization"] == "Bearer tok" return Response(201, '{"ok":true}') @@ -73,22 +87,12 @@ def opener(request): def test_apply_call_breaks_on_4xx() -> None: - import urllib.error - calls = 0 - class Response: - def __init__(self, status, body): - self.status = status - self._body = body.encode() - - def read(self): - return self._body - def opener(request): nonlocal calls calls += 1 - raise urllib.error.HTTPError("https://example.test", 422, "err", {}, Response(422, "bad")) + 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 @@ -97,23 +101,7 @@ def opener(request): def test_apply_call_retries_5xx_then_succeeds() -> None: - import urllib.error - - class Response: - def __init__(self, status, body=""): - self.status = status - self._body = body.encode() - - def __enter__(self): - return self - - def __exit__(self, *args): - return None - - def read(self): - return self._body - - responses = [urllib.error.HTTPError("https://example.test", 503, "err", {}, Response(503, "one")), Response(200, "ok")] + responses: list[urllib.error.HTTPError | Response] = [http_error(503, "one"), Response(200, "ok")] sleeps: list[float] = [] def opener(request): @@ -131,8 +119,6 @@ def opener(request): def test_apply_call_network_failure_retries_three_times() -> None: - import urllib.error - calls = 0 sleeps: list[float] = [] @@ -153,21 +139,9 @@ def opener(request): def test_apply_call_uses_default_opener(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} - class Response: - def __enter__(self): - return self - - def __exit__(self, *args): - return None - - status = 200 - - def read(self): - return b"ok" - def fake_urlopen(request, timeout=None): captured["timeout"] = timeout - return Response() + 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") From 526761bf5ac7d1c979a0d38b895741c570962837 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:43:01 +0000 Subject: [PATCH 10/12] fix(plugin): stop apm.yml version regex swallowing the next line write_bumped_manifests() promises that a manifest whose version line does not appear exactly once fails loud rather than "silently no-op-ing or guessing which line to bump". For apm.yml that guard could be defeated. _APM_VERSION_RE was `^version:\s*\S+$` (re.MULTILINE). `\s` matches "\n", so against an apm.yml whose `version:` key carries no value the match started at `version:`, ran the `\s*` across the line break, and accepted the *next* line's key as the version value. That is still exactly one match, so the len(matches) != 1 guard passed, and the substitution wrote `version: 0.2.0` over both lines -- silently deleting the following key. Observed on a fixture mirroring the real apm.yml: name: gitapex name: gitapex version: -> version: 0.2.0 dependencies: apm: apm: - a/b - a/b `dependencies:` is gone and the manifest is structurally broken, with no error raised. The release-PR workflow reads apm.yml back off disk after --write and commits it, so this would have shipped the corrupted manifest into the release PR. Fix: use `[ \t]*` so the whitespace run stays line-local. A valueless `version:` now yields zero matches and raises RuntimeError as documented. The real .claude-plugin/plugin.json and apm.yml still bump correctly -- verified end-to-end against copies of both live files: exactly one line changes, all keys and apm.yml's leading comment block survive, no stray temp file remains. _PLUGIN_VERSION_RE keeps `\s*` deliberately: it requires a quoted X.Y.Z immediately after, and _read_current_version() json.loads() the file first, so in valid JSON that run can only span a key and its own value. Adds a regression test for the corrupting fixture plus a direct property test that the pattern never matches across a newline. Refs #642 --- .github/scripts/compute_release_bump.py | 8 +++++- tests/test_compute_release_bump.py | 34 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/scripts/compute_release_bump.py b/.github/scripts/compute_release_bump.py index 73db7bf4..dc9929c9 100644 --- a/.github/scripts/compute_release_bump.py +++ b/.github/scripts/compute_release_bump.py @@ -94,7 +94,13 @@ _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+"') -_APM_VERSION_RE = re.compile(r"^version:\s*\S+$", re.MULTILINE) +# `[ \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" diff --git a/tests/test_compute_release_bump.py b/tests/test_compute_release_bump.py index b5c316f8..17d08693 100644 --- a/tests/test_compute_release_bump.py +++ b/tests/test_compute_release_bump.py @@ -440,6 +440,40 @@ def test_write_bumped_manifests_apm_missing_version_raises(tmp_path): 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 # --------------------------------------------------------------------- From 893fb19532306193c231f1276db0a42650a6af94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:47:42 +0000 Subject: [PATCH 11/12] fix(plugin): make release retry finish a stranded GitHub Release release_tag_publish.py documented "the tag's existence is the single source of truth for 'already published' ... safe to re-run after a partial failure or a workflow retry". The code matched that rule, but the rule cannot support that promise. Publishing is three API calls: create tag object, create tag ref, create Release. Between call 2 and call 3 the tag exists and the Release does not. Any run that died in that window left state the script could not recover from -- the retry saw the tag, declared "already published", and exited 0. The workflow went green while the GitHub Release stayed permanently missing, and no later run would ever create it. Reproduced end-to-end against main() with a stateful API double: run 1: Release POST returns 500 -> exit 1, tag ref created run 2: API healthy, operator retry -> "gitapex--v0.2.0 already exists -- no-op", exit 0 -> releases: [] (never published, silently) Silent-green is the worst failure shape here: nothing signals that the release is incomplete. The most plausible trigger is not a rare outage but first-time App setup -- a Release POST rejected on permissions still happens after the tag ref is created, so the very first release strands itself and every retry reports success. Fix: "already published" now means tag AND Release both exist. Adds release_exists() (GET /repos/{repo}/releases/tags/{tag}) and makes main() no-op only when both are present. When the tag exists but the Release does not, the retry proceeds and publish_tag_and_release(skip_tag=True) creates just the Release -- re-POSTing the ref would only fail with "Reference already exists" and strand it again. Verified with the same repro: run 2 now issues exactly one POST (the Release), does not re-create the tag, and exits 0 with the Release present. Needs no new token scope -- reading releases is covered by the Contents permission the App already has per CONTRIBUTING.md. Existing main() tests gained the new existence GET in their response stubs; the no-op test now asserts both GETs and that nothing else is called. Adds unit tests for release_exists and a regression test for the tag-exists-but-release-missing recovery path. Refs #642 --- .github/scripts/release_tag_publish.py | 90 ++++++++++++++++++-------- tests/test_release_tag_publish.py | 84 ++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 32 deletions(-) diff --git a/.github/scripts/release_tag_publish.py b/.github/scripts/release_tag_publish.py index a7e036db..1708da2c 100644 --- a/.github/scripts/release_tag_publish.py +++ b/.github/scripts/release_tag_publish.py @@ -12,9 +12,14 @@ be prefixed with this plugin's own name (``gitapex``), not the literal word "plugin". -Idempotency: the tag's existence is the single source of truth for "already -published". If ``gitapex--v{version}`` already exists, this script is a -no-op (exit 0) -- safe to re-run after a partial failure or a workflow retry. +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 (```` / @@ -135,6 +140,28 @@ def tag_exists( 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, @@ -179,35 +206,42 @@ def publish_tag_and_release( 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}" - 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]}") + 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", @@ -272,8 +306,10 @@ def main( try: version = _read_version(manifest_path) tag_name = f"gitapex--v{version}" - if tag_exists(repo, version, token, apply_call=apply_call): - print(f"release-tag-publish: {tag_name} already exists (refs/tags/{tag_name}) -- no-op") + 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) if pr is None: @@ -283,7 +319,9 @@ def main( "silently no-op" ) notes = extract_release_notes(pr.get("body") or "") - publish_tag_and_release(repo, version, args.sha, notes, token, apply_call=apply_call) + 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 diff --git a/tests/test_release_tag_publish.py b/tests/test_release_tag_publish.py index ccbd531d..d8cc0ee1 100644 --- a/tests/test_release_tag_publish.py +++ b/tests/test_release_tag_publish.py @@ -175,6 +175,27 @@ def test_tag_exists_raises_on_500() -> None: 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 # --------------------------------------------------------------------------- @@ -373,7 +394,12 @@ def test_main_repo_env_var_fallback(monkeypatch: pytest.MonkeyPatch, tmp_path) - _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, "{}")}) + 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 @@ -390,19 +416,62 @@ def test_main_manifest_error_surfaces_as_error(monkeypatch: pytest.MonkeyPatch, # --------------------------------------------------------------------------- -def test_main_no_op_when_tag_already_exists(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: +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 tag-existence GET is a legal call here -- _FakeApplyCall + # 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, "{}")}) + 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 exists" in capsys.readouterr().out - assert fake.calls == [("GET", "https://api.github.com/repos/o/r/git/ref/tags/gitapex--v1.2.3", None)] + 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}] + 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: @@ -418,6 +487,7 @@ def test_main_publishes_tag_and_release_when_absent(monkeypatch: pytest.MonkeyPa 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, "{}"), @@ -443,6 +513,7 @@ def test_main_raises_when_no_merged_pr_found(monkeypatch: pytest.MonkeyPatch, tm 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, "[]"), } ) @@ -460,6 +531,7 @@ def test_main_missing_release_notes_markers_raises(monkeypatch: pytest.MonkeyPat 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)), } ) From 0882b3dfaebc88673ccfd8e6c85da586a890fcfb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 04:56:51 +0000 Subject: [PATCH 12/12] fix(plugin): narrow release-tag.yml to release-PR merges only The Step 8 adversarial review flagged that release-tag.yml triggers on every push to main touching plugin.json, not only release-PR merges -- an ordinary PR editing the file (a metadata field, or the deliberate manual major-version bump docs/versioning.md itself prescribes) would reach find_merged_pr_for_commit, fail to find release-notes markers, and go red on a legitimate, non-release change. release_tag_publish.py now checks the merged PR's head branch against the fixed release-bump branch name and skips quietly (exit 0) when it doesn't match, or when no merged PR is found at all -- only a PR from the release-bump branch missing its markers is still a genuine bug that fails loudly. Also: a concurrency block on release-tag.yml (mirroring release-pr.yml's own) serializes two plugin.json-touching pushes landing close together, closing a second flagged race window. A docs/versioning.md wording fix adds the missing "plugin-scoped"/"non-breaking" qualifiers the bump-rule bullets omitted (a breaking-marked docs(plugin) commit IS a minor bump, which the prose previously contradicted). A regression test closes the one coverage gap the review found in already-correct code: the no-open-PR + stale-existing-branch delete-and-recreate path in release_pr_publish.py. Refs #642 --- .github/scripts/release_tag_publish.py | 36 +++++++++--- .github/workflows/release-tag.yml | 9 +++ docs/versioning.md | 13 +++-- tests/test_release_pr_publish.py | 59 ++++++++++++++++++++ tests/test_release_tag_publish.py | 77 ++++++++++++++++++++++++-- 5 files changed, 174 insertions(+), 20 deletions(-) diff --git a/.github/scripts/release_tag_publish.py b/.github/scripts/release_tag_publish.py index 1708da2c..6ef4cb23 100644 --- a/.github/scripts/release_tag_publish.py +++ b/.github/scripts/release_tag_publish.py @@ -41,9 +41,14 @@ not passed). Exit codes: - 0 Success, including the no-op "already tagged" case. - 1 Missing env var, missing/invalid manifest, no merged PR found for the - commit, missing release-notes markers, or a GitHub API error. + 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 @@ -68,6 +73,11 @@ _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 @@ -312,12 +322,22 @@ def main( 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) - if pr is None: - raise RuntimeError( - f"No merged PR found for commit {args.sha} in {repo}: a plugin.json version " - "bump reached the base branch outside the release-PR flow -- refusing to " - "silently no-op" + 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 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 5a10fe3c..6b2482cb 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -9,6 +9,15 @@ on: 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 diff --git a/docs/versioning.md b/docs/versioning.md index 4d3bfed8..45f3fa79 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -62,12 +62,13 @@ Three pieces now implement release automation for the **plugin** product: ### Bump rule -- A `feat(plugin)` commit, or any commit marked breaking, bumps the - **minor** version. -- A `fix(plugin)`, `refactor(plugin)`, or `perf(plugin)` commit bumps the - **patch** version. -- `docs`, `chore`, `test`, `build`, and `ci` commits, and commits scoped - to anything other than `plugin`, trigger **no bump**. +- 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 diff --git a/tests/test_release_pr_publish.py b/tests/test_release_pr_publish.py index 28a9638a..e790419c 100644 --- a/tests/test_release_pr_publish.py +++ b/tests/test_release_pr_publish.py @@ -705,6 +705,65 @@ def fake_graphql(*, query, variables, token): 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": ( diff --git a/tests/test_release_tag_publish.py b/tests/test_release_tag_publish.py index d8cc0ee1..96ac13b0 100644 --- a/tests/test_release_tag_publish.py +++ b/tests/test_release_tag_publish.py @@ -452,7 +452,14 @@ def test_main_completes_missing_release_when_tag_already_exists( _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}] + 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, "{}"), @@ -483,7 +490,14 @@ def test_main_publishes_tag_and_release_when_absent(monkeypatch: pytest.MonkeyPa "Notable change one.\nNotable change two.\n" "\n\nRefs #642" ) - prs = [{"number": 9, "merged_at": "2026-08-01T00:00:00Z", "body": pr_body}] + 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, ""), @@ -507,7 +521,11 @@ def test_main_publishes_tag_and_release_when_absent(monkeypatch: pytest.MonkeyPa assert "published gitapex--v1.2.3" in capsys.readouterr().err -def test_main_raises_when_no_merged_pr_found(monkeypatch: pytest.MonkeyPatch, tmp_path, capsys) -> None: +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( @@ -520,14 +538,61 @@ def test_main_raises_when_no_merged_pr_found(monkeypatch: pytest.MonkeyPatch, tm rc = rtp.main(["--repo-root", str(tmp_path), "--sha", "deadbeef", "--repo", "o/r"], apply_call=fake) - assert rc == 1 - assert "No merged PR found" in capsys.readouterr().err + 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"}] + 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, ""),