Skip to content

feat(plugin): version-bump release-PR mechanism + versioning.md corrections - #644

Open
tvna wants to merge 12 commits into
mainfrom
claude/versioning-release-mechanism-k1bggu
Open

feat(plugin): version-bump release-PR mechanism + versioning.md corrections#644
tvna wants to merge 12 commits into
mainfrom
claude/versioning-release-mechanism-k1bggu

Conversation

@tvna

@tvna tvna commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Implements issue #642: a release-PR mechanism for the plugin product. A
scheduled workflow proposes a version-bump + release-notes PR from
Conventional-Commit-shaped history; merging that PR is the release act, and
a second workflow then tags (gitapex--vX.Y.Z) and publishes a GitHub
Release. Corrects docs/versioning.md's tag format and its cli/compose
framing to match the repository owner's decision to fork non-plugin work
into a separate repository rather than build it here.

Facts

  • .claude-plugin/plugin.json's version field is the cache key Claude
    Code uses for /plugin update (code.claude.com/docs/en/plugins-reference.md,
    "Version management"). It has been static at 0.1.0 since 2026-07-21
    while skills/ received 156 commits through 2026-07-31 -- none of that
    content has reached /plugin update consumers.
  • Claude Code's cross-plugin dependency version-constraint resolution
    requires tags named {plugin-name}--v{version}
    (code.claude.com/docs/en/plugin-dependencies.md) -- gitapex--vX.Y.Z
    for this plugin, not docs/versioning.md's previously-documented
    plugin-vX.Y.Z.
  • No tag or GitHub Release has ever existed in this repository (git tag -l
    returns none), confirmed at issue-drafting time, again while writing this
    branch's task-decomposition plan, and again during the adversarial review.
  • This repository's required_signatures branch protection rejects
    GITHUB_TOKEN-authored commits; the existing sync-agent-instructions.yml
    • sync_pr_publish.py pair (documented in CONTRIBUTING.md) is the
      verified, working precedent for a GitHub-App-signed commit via
      createCommitOnBranch, adapted here for a separate, dedicated App.

Assumptions

  • The exact bump algorithm (max-of-signals per commit type, scoped to
    feat(plugin)/fix(plugin)/etc., never a major bump) is this PR's own
    design choice, not literally specified by the issue -- grounded in
    docs/versioning.md's existing text about refactor being "a patch at
    most" and 1.0.0 requiring a deliberate human guarantee. Verified by
    29,552 generated adversarial cases in the Step 8 review: no input makes
    compute_release_bump.py produce a major-version bump.
  • release-tag.yml uses the same App token as release-pr.yml for
    tag/Release creation. Whether required_signatures (or a separate tag
    ruleset) actually covers tag-ref/Release creation is unverifiable from
    anything committed in this repository (GitHub-UI-only setting) --
    speculation, not fact; the App-token path is the safer default regardless
    of the answer.
  • Release-PR cadence (daily schedule) is a soft choice the issue's own ACM
    leaves open.

Risk / blast radius

  • New write capability is scoped to a new, dedicated release-bot GitHub
    App/Environment, separate from the existing sync-bot App -- a
    compromise or bug in one cannot use the other's credentials.
  • The release-PR flow requires a human merge before anything ships (no
    direct-to-main version bump), so a bug in the bump computation produces,
    at worst, a wrong-looking PR a human declines to merge -- not a bad
    release.
  • The GitHub App itself does not exist yet at PR-open time (external,
    human-only creation step, documented in CONTRIBUTING.md's new section)
    -- both new workflows reference secrets that must be added afterward,
    same precedent as sync-agent-instructions.yml's own App.
  • release-tag.yml triggers on every push to main touching
    .claude-plugin/plugin.json, not only release-PR merges (an ordinary
    edit, or the deliberate manual major-version bump docs/versioning.md
    itself prescribes, also touches that file). The Step 8 adversarial review
    caught this; release_tag_publish.py now checks the merged PR's head
    branch and skips quietly when it isn't the release-bump branch, rather
    than failing the workflow on a legitimate non-release change.

Rollback

Both new workflows (release-pr.yml, release-tag.yml) are self-contained
and additive -- disabling or deleting either (or both) has no effect on any
existing workflow, script, or manifest. If the release-bot App is created
and later needs to be revoked, removing its installation or the
RELEASE_BOT_APP_ID/RELEASE_BOT_APP_PRIVATE_KEY secrets makes both
workflows fail closed (no token to mint) rather than fail open.

Verification

Acceptance Criteria Map (from issue #642), all rows proven:

Criterion Proof method Result
Release-PR flow: automation proposes a version-bump + release-notes PR; merging it is the release act uv run pytest tests/test_compute_release_bump.py tests/test_release_pr_publish.py -v; actionlint .github/workflows/release-pr.yml Done -- 119 tests pass across the two files; workflow validated (YAML + manual structural comparison locally; real actionlint CI check running on this PR)
A new, dedicated GitHub App signs any commit/tag the automation authors Manual doc review of CONTRIBUTING.md's new "Signed-commit release bot App" section Done
No retroactive tag for the untagged 0.1.0 history tests/test_compute_release_bump.py's bootstrap-case test (no tag -> full history scanned, still exactly one bump) Done
Merging a release PR reliably bumps plugin.json's version Test asserting both manifests change in one commit; scan_apm_manifest_drift.py still passes Done -- also fixed a real bug the adversarial review found: an apm.yml with a valueless version: line was silently corrupted (the next key deleted) rather than raising; regex narrowed from \s* to [ \t]* so it can no longer match across a line break
Tag format corrected to gitapex--vX.Y.Z grep -rn 'plugin-v' --include=*.md . in living docs (docs/versioning.md, CONTRIBUTING.md) finds nothing Done. Two pre-existing dated historical records under docs/superpowers/{plans,specs}/2026-07-12-* still say plugin-vX.Y.Z -- left untouched as point-in-time snapshots, per this repo's own "do not edit dated design-spec records to keep them current" convention (docs/agent-product-scope.md)
docs/versioning.md's cli/compose rows corrected Manual doc review Done

Full task-by-task breakdown: docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md.

Step 8 (mandatory, executing-a-branch-plan) results:

  • Refactor/simplify pass: consolidated 4 near-duplicate inline test-double classes in test_release_tag_publish.py into one, matching its sibling test file's pattern. No behavior change; 157 targeted tests + full suite unchanged.
  • Independent adversarial review (fresh subagent, Opus): 2 CONFIRMED bugs found and fixed --
    1. write_bumped_manifests's apm.yml regex matched across a newline on a valueless version: key, silently deleting the following key instead of raising as documented (see Verification table above).
    2. A GitHub Release POST failing after the tag ref was already created left an unrecoverable "stranded" release: every retry saw the tag, treated it as "already published", and exited 0 while the Release stayed permanently missing. Fixed with a separate release_exists check -- a retry now finishes the Release without re-creating the tag.
      Plus one HIGH VALUE finding (release-tag.yml over-triggering, see Risk section above) and two lower-priority items (a missing concurrency: block on release-tag.yml, and imprecise bump-rule prose in docs/versioning.md), all fixed directly. One coverage gap in already-correct code (the no-open-PR + stale-branch delete-and-recreate path) closed with a new regression test.
  • Full suite after all fixes: 1812 passed, 99% overall coverage, ruff check clean, scan_apm_manifest_drift.py clean.

Skill audit evidence

  • checker-script-adversarial-review: RAN

Independent adversarial review completed against the full accumulated diff
(fresh subagent, not the same one that wrote the code), including two
mandatory defeat-cases against compute_release_bump.py's
never-bumps-major invariant (29,552 generated adversarial cases, invariant
held) and write_bumped_manifests's atomic-write guard (18 adversarial
fixtures; found and fixed one real defeat -- see Verification table). Full
findings summary in the Verification section above.

Checklist

  • Tests pass locally (1812 passed, 99% coverage)
  • Docs updated if behavior changed (docs/versioning.md, CONTRIBUTING.md)
  • Issue number cited in every commit
  • .github/scripts/*.py deterministic-script-shaped files added -- see ## Skill audit evidence above (RAN)
  • N/A -- no evals/*/split.md edit
  • N/A -- no skills/*/SKILL.md Stop-boundary/dispatch-branch change

Execution log

  • PlanApproved -- Branch plan approved in-session (Claude Code plan-mode
    approval); task decomposition at
    docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md.
  • TaskCompleted{task: 4, commit: 992b14a} -- docs/versioning.md edits.
  • TaskCompleted{task: 5, commit: e5b91ad} -- CONTRIBUTING.md release-bot App section.
  • TaskCompleted{task: 3, commit: 6911304} -- release_tag_publish.py + tests.
  • TaskCompleted{task: 2, commit: 4397455} -- release_pr_publish.py + tests.
  • TaskCompleted{task: 1, commit: e4d1519} -- compute_release_bump.py + tests. Wave 1 complete.
  • TaskCompleted{task: 6, commit: fd831d5} -- release-pr.yml workflow.
  • TaskCompleted{task: 7, commit: b7dc611} -- release-tag.yml workflow. Wave 2 complete.
  • StageDeviated{action: none, note: "workflow-file hard-flag per threat-model reference applied as direct orchestrator review, not a paused escalation -- both files were explicitly planned Task 6/7 content, reviewed line-by-line for secrets/network-destination/privilege-scope before merge"}
  • Step 8a refactor pass, commit 95bff0e -- test-double consolidation, no behavior change.
  • Step 8b adversarial review, commits 526761b (apm.yml regex fix) and 893fb19 (stranded-release recovery fix).
  • Direct orchestrator fix, commit 0882b3d -- release-tag.yml branch-scoping, concurrency block, docs prose precision, missing-coverage regression test (all responding to Step 8b's flagged-but-unconfirmed items).
  • Remote branch confirmed to match local HEAD before marking ready for review.

Related Issue

Closes #642

claude added 12 commits August 1, 2026 04:05
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
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
<!-- release-notes:start/end --> 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
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 <!-- release-notes:start/end --> markers that
release_tag_publish.py will later extract.

Refs #642
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
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
…ass)

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
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
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
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
@tvna
tvna marked this pull request as ready for review August 1, 2026 04:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(plugin): version-bump release-PR mechanism + versioning.md corrections

2 participants