Skip to content

Version-bump gate: enforce plugin-version bump when hooks/skills/commands change #690

@michael-wojcik

Description

@michael-wojcik

Problem

Plugin version (pact-plugin/.claude-plugin/plugin.json's version field) is the marketplace cache key. Installations are keyed on version string — two distinct merges into the same version label become indistinguishable to users who already have that version cached. Hook/skill/command/agent changes that ship without a version bump are silently buried in the cache for every existing installation until the next bump.

Live instance: PR #683 (commit 1f20c886, "Decouple secretary self-completion carve-out from spawn name to agent type") merged the RESERVED_NAMES relax into main but did not bump plugin.json from 4.1.4. Users running the cached 4.1.4 (populated at the prior #664 bump) ran the pre-#682 dispatch_gate.py for an unbounded interval — until #689's republish to 4.1.5 invalidated the cache. The session that merged #682 itself caught this: bootstrap ritual called Agent(name="secretary", ...) and the cached gate refused with reserved-token set despite source on disk explicitly relaxing the set.

The pinned memory "Tag + GitHub release after every plugin-version bump" documents the post-merge release discipline, but there is no enforcement that the bump itself happened pre-merge.

Why this is uniquely dangerous

A typical bug missed in PR review fails for one PR. Patch-version reuse fails for N future PRs that all merge into the same unbumped version label — every fix from those PRs is invisible to existing installations until someone notices and bumps. The asymmetry between cost (trivial 4-file edit) and silent-bury risk (unbounded latency to user-visible) is what makes this gate-worthy rather than checklist-worthy.

Affected change types

A bump is required when any of these change in a PR (because users running the old cache won't pick the change up):

  • pact-plugin/hooks/**/*.py — hook code (load-bearing, runs at tool-use boundaries)
  • pact-plugin/skills/** — skill bodies that the orchestrator/teammates read at runtime
  • pact-plugin/commands/** — slash-command bodies
  • pact-plugin/agents/** — agent persona files
  • pact-plugin/protocols/** — protocol references resolved via Read(...) from agent bodies
  • pact-plugin/templates/** — output templates
  • pact-plugin/.claude-plugin/plugin.json's non-version fields (e.g., commands[], agents[], skills list)

The exclusion list (changes that do NOT require a bump):

  • pact-plugin/tests/** — CI-only, never reaches user installs via cache
  • docs/** — gitignored per project policy; never installed
  • Top-level README.md (when the change is editorial / unrelated to versioned content)
  • .github/** — repo automation, not packaged

Proposed approaches (sketch — pick or combine)

Option A — PreToolUse hook on gh pr merge (defense-in-depth)

A pre-merge hook (run at gh pr merge time, similar pattern to merge_guard_pre.py) that checks the PR's diff for changes to the affected paths. If any are present AND plugin.json's version matches main's version, refuse the merge with a directive to bump first.

# pact-plugin/hooks/version_bump_guard_pre.py (sketch)
AFFECTED_PATHS = (
    "pact-plugin/hooks/",
    "pact-plugin/skills/",
    "pact-plugin/commands/",
    "pact-plugin/agents/",
    "pact-plugin/protocols/",
    "pact-plugin/templates/",
)

def requires_bump(diff_paths: list[str], plugin_json_version_pre: str, plugin_json_version_post: str) -> bool:
    touches_packaged = any(p.startswith(AFFECTED_PATHS) for p in diff_paths)
    if not touches_packaged:
        return False  # docs / tests / .github only — no bump needed
    return plugin_json_version_pre == plugin_json_version_post  # bump missing

Pro: ironclad, gate-enforced.
Con: another merge_guard-family hook; pairs with the existing #665 regex bug and #677 field-mismatch bug as a third thing to maintain.

Option B — GitHub Actions check on PR

A CI check that runs on every PR with paths: filter for the affected directories, comparing plugin.json's version against main's.

# .github/workflows/version-bump-gate.yml (sketch)
on:
  pull_request:
    paths:
      - 'pact-plugin/hooks/**'
      - 'pact-plugin/skills/**'
      - 'pact-plugin/commands/**'
      - 'pact-plugin/agents/**'
      - 'pact-plugin/protocols/**'
      - 'pact-plugin/templates/**'
      - 'pact-plugin/.claude-plugin/plugin.json'
jobs:
  require-bump:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Compare plugin.json version against main
        run: |
          MAIN_VER=$(git show origin/main:pact-plugin/.claude-plugin/plugin.json | jq -r .version)
          PR_VER=$(jq -r .version pact-plugin/.claude-plugin/plugin.json)
          if [ "$MAIN_VER" = "$PR_VER" ]; then
            echo "::error::PR touches packaged paths but plugin.json version is unchanged ($MAIN_VER). Bump version (4-file dance) before merge."
            exit 1
          fi

Pro: standard GitHub Actions discipline; no hook surface to maintain; visible on PR page.
Con: bypassable via admin merge or by stripping the check; relies on branch protection to enforce.

Option C — Pre-commit hook in the repo

A .pre-commit-config.yaml entry or a pre-commit git hook that fires on git commit when staged changes touch the affected paths.

Pro: catches the issue at commit-time, before the PR exists.
Con: pre-commit hooks aren't enforced for contributors who don't install the hook locally; weakest of the three options.

Recommendation

Start with Option B (GitHub Actions). Lowest maintenance cost, visible on every PR, and the failure mode (admin bypass) is explicit and accountable rather than silent. Layer Option A in later if Option B's bypassability becomes a real problem in practice.

Adjacent considerations

4-file dance enforcement

A bump touches 4 files (pact-plugin/.claude-plugin/plugin.json, .claude-plugin/marketplace.json, README.md, pact-plugin/README.md). If only 1 or 2 are bumped, the cache invalidates but the docs lie. The version-bump gate could check all four are in sync on merge:

# Cross-file consistency check (Option B addendum)
PLUGIN_VER=$(jq -r .version pact-plugin/.claude-plugin/plugin.json)
MARKET_VER=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json)
ROOT_README_VER=$(grep -oE '4\.[0-9]+\.[0-9]+' README.md | head -1)
PLUGIN_README_VER=$(grep -oE '> \*\*Version\*\*: \K[0-9.]+' pact-plugin/README.md)

if [ "$PLUGIN_VER" != "$MARKET_VER" ] || [ "$PLUGIN_VER" != "$ROOT_README_VER" ] || [ "$PLUGIN_VER" != "$PLUGIN_README_VER" ]; then
  echo "::error::Version mismatch across canonical 4-file set. Bump all four to the same version."
  exit 1
fi

Tag + release verification

Pinned memory says "Tag + GitHub release after every plugin-version bump". A second CI step on main could detect a fresh version commit and post a comment if the matching v{N} tag is missing > 24h after merge. Out of scope for this issue but worth tracking as a follow-up to a follow-up.

Calibration corpus reference

This is the second failure of plugin-version discipline in the post-v4 era:

  1. PR Dispatch-protocol hardening: rename Task→Agent + dispatch_gate + task_lifecycle_gate + bootstrap_gate F24/F25 (#662) #663 — surfaced the merge_guard_pre._GH_PR_NUMBER_RE greedy-quantifier bug (merge_guard_pre _GH_PR_NUMBER_RE matches wrong digit on heredoc bodies and stderr redirects #665), which is a separate issue but lives in the same merge_guard-family neighborhood as Option A above.
  2. PR Decouple secretary self-completion carve-out from spawn name to agent type (#682) #683 / v4.1.5: republish to invalidate marketplace cache for #682 #689 (this issue) — patch-version reuse without bump.

If a third instance appears, the case for Option A (hook-enforced) over Option B (CI-enforced) gets stronger — same-user admin bypass becomes empirically common rather than theoretical.

Acceptance criteria

  • A merged PR that touches pact-plugin/hooks/** or pact-plugin/skills/** or pact-plugin/commands/** etc. without a version bump is rejected before merge.
  • The 4-file dance consistency is checked: all four files report the same version on main.
  • Documentation updated: pinned memory in CLAUDE.md extended to reference the gate (rather than relying on memory alone).
  • Test: a deliberately-malformed PR (touches a hook, leaves version untouched) demonstrably fails the gate.

Out of scope

  • Automatic version-bump suggestion (semver heuristic) — the developer should still consciously choose patch / minor / major; the gate just enforces "some bump happened".
  • Tag/release automation — pinned memory's post-merge discipline stays manual for now.
  • Marketplace-side cache invalidation independent of version (would require Anthropic-side changes; not actionable here).

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions