feat(plugin): version-bump release-PR mechanism + versioning.md corrections - #644
Open
tvna wants to merge 12 commits into
Open
feat(plugin): version-bump release-PR mechanism + versioning.md corrections#644tvna wants to merge 12 commits into
tvna wants to merge 12 commits into
Conversation
…tatus in versioning.md 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
marked this pull request as ready for review
August 1, 2026 04:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements issue #642: a release-PR mechanism for the
pluginproduct. Ascheduled 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 GitHubRelease. Corrects
docs/versioning.md's tag format and itscli/composeframing 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'sversionfield is the cache key ClaudeCode uses for
/plugin update(code.claude.com/docs/en/plugins-reference.md,"Version management"). It has been static at
0.1.0since 2026-07-21while
skills/received 156 commits through 2026-07-31 -- none of thatcontent has reached
/plugin updateconsumers.requires tags named
{plugin-name}--v{version}(
code.claude.com/docs/en/plugin-dependencies.md) --gitapex--vX.Y.Zfor this plugin, not
docs/versioning.md's previously-documentedplugin-vX.Y.Z.git tag -lreturns none), confirmed at issue-drafting time, again while writing this
branch's task-decomposition plan, and again during the adversarial review.
required_signaturesbranch protection rejectsGITHUB_TOKEN-authored commits; the existingsync-agent-instructions.ymlsync_pr_publish.pypair (documented inCONTRIBUTING.md) is theverified, working precedent for a GitHub-App-signed commit via
createCommitOnBranch, adapted here for a separate, dedicated App.Assumptions
feat(plugin)/fix(plugin)/etc., never a major bump) is this PR's owndesign choice, not literally specified by the issue -- grounded in
docs/versioning.md's existing text aboutrefactorbeing "a patch atmost" and
1.0.0requiring a deliberate human guarantee. Verified by29,552 generated adversarial cases in the Step 8 review: no input makes
compute_release_bump.pyproduce a major-version bump.release-tag.ymluses the same App token asrelease-pr.ymlfortag/Release creation. Whether
required_signatures(or a separate tagruleset) 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.
leaves open.
Risk / blast radius
release-botGitHubApp/Environment, separate from the existing
sync-botApp -- acompromise or bug in one cannot use the other's credentials.
direct-to-
mainversion bump), so a bug in the bump computation produces,at worst, a wrong-looking PR a human declines to merge -- not a bad
release.
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.ymltriggers on every push tomaintouching.claude-plugin/plugin.json, not only release-PR merges (an ordinaryedit, or the deliberate manual major-version bump
docs/versioning.mditself prescribes, also touches that file). The Step 8 adversarial review
caught this;
release_tag_publish.pynow checks the merged PR's headbranch 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-containedand additive -- disabling or deleting either (or both) has no effect on any
existing workflow, script, or manifest. If the
release-botApp is createdand later needs to be revoked, removing its installation or the
RELEASE_BOT_APP_ID/RELEASE_BOT_APP_PRIVATE_KEYsecrets makes bothworkflows fail closed (no token to mint) rather than fail open.
Verification
Acceptance Criteria Map (from issue #642), all rows proven:
uv run pytest tests/test_compute_release_bump.py tests/test_release_pr_publish.py -v;actionlint .github/workflows/release-pr.ymlactionlintCI check running on this PR)CONTRIBUTING.md's new "Signed-commit release bot App" section0.1.0historytests/test_compute_release_bump.py's bootstrap-case test (no tag -> full history scanned, still exactly one bump)plugin.json'sversionscan_apm_manifest_drift.pystill passesversion: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 breakgitapex--vX.Y.Zgrep -rn 'plugin-v' --include=*.md .in living docs (docs/versioning.md,CONTRIBUTING.md) finds nothingdocs/superpowers/{plans,specs}/2026-07-12-*still sayplugin-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'scli/composerows correctedFull task-by-task breakdown:
docs/superpowers/plans/2026-08-01-versioning-release-mechanism.md.Step 8 (mandatory,
executing-a-branch-plan) results:test_release_tag_publish.pyinto one, matching its sibling test file's pattern. No behavior change; 157 targeted tests + full suite unchanged.write_bumped_manifests's apm.yml regex matched across a newline on a valuelessversion:key, silently deleting the following key instead of raising as documented (see Verification table above).release_existscheck -- 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 onrelease-tag.yml, and imprecise bump-rule prose indocs/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.ruff checkclean,scan_apm_manifest_drift.pyclean.Skill audit evidence
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'snever-bumps-major invariant (29,552 generated adversarial cases, invariant
held) and
write_bumped_manifests's atomic-write guard (18 adversarialfixtures; found and fixed one real defeat -- see Verification table). Full
findings summary in the Verification section above.
Checklist
docs/versioning.md,CONTRIBUTING.md).github/scripts/*.pydeterministic-script-shaped files added -- see## Skill audit evidenceabove (RAN)evals/*/split.mdeditskills/*/SKILL.mdStop-boundary/dispatch-branch changeExecution log
PlanApproved-- Branch plan approved in-session (Claude Code plan-modeapproval); 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"}95bff0e-- test-double consolidation, no behavior change.526761b(apm.yml regex fix) and893fb19(stranded-release recovery fix).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).Related Issue
Closes #642