Repository navigation
repo-ops: merge-guard, an advisory warning when a command merges a PR… #34
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
| name: version-bump | |
| # Patch-bump every plugin whose files changed in a push to main, then tag it. | |
| # | |
| # A plugin's version is the only signal Claude Code has that an installed copy is stale — | |
| # the install cache is keyed by version — so a merge that ships a skill change without a | |
| # bump never reaches anyone who already installed the plugin. Doing this on merge rather | |
| # than in each PR means bot-authored PRs (dependabot, auto-dev) get it for free. | |
| # | |
| # Unlike validate.yml this needs a write token, so it must never run on a fork's PR ref: | |
| # `push` to main only, and everything it executes comes from main post-merge. | |
| on: | |
| push: | |
| branches: [main] | |
| permissions: | |
| contents: write | |
| # Serialize: two merges landing together would otherwise race to push bump commits. | |
| concurrency: | |
| group: version-bump | |
| cancel-in-progress: false | |
| jobs: | |
| bump: | |
| # Closes the loop: the bump commit itself touches plugins/*/.claude-plugin/plugin.json | |
| # and would otherwise bump forever. A GITHUB_TOKEN push does not trigger workflows, so | |
| # that alone would do it — this keeps it closed if the token is ever swapped for a PAT. | |
| # | |
| # It doubles as the manual escape hatch (documented in the README's Versioning | |
| # section): put [skip bump] in a merge or squash commit message to land a change under | |
| # plugins/** without publishing a new version. Deliberately matched loosely so a human | |
| # can reach for it, which does mean any message carrying that marker skips. | |
| if: ${{ !contains(github.event.head_commit.message, '[skip bump]') }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # Full history: the bump script diffs against the pre-push sha and reads each | |
| # manifest as it stood there. | |
| fetch-depth: 0 | |
| - name: Resolve the pre-push commit | |
| id: base | |
| env: | |
| BEFORE: ${{ github.event.before }} | |
| run: | | |
| # `before` is all-zeros on a branch's first push and stale after a force-push. | |
| # First parent of HEAD is the previous tip of main in both cases. | |
| # | |
| # Ancestry, not existence: a force-push leaves the old commit reachable through | |
| # some other ref, so it still resolves. Diffing HEAD against a commit on an | |
| # abandoned history reports files this push never touched, and would bump and | |
| # tag plugins that did not change. | |
| if [ -n "$BEFORE" ] && git merge-base --is-ancestor "$BEFORE" HEAD 2>/dev/null; then | |
| echo "ref=$BEFORE" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "ref=HEAD~1" >> "$GITHUB_OUTPUT" | |
| echo "note: ${BEFORE:-<empty>} is not reachable, falling back to HEAD~1" | |
| fi | |
| - name: Bump the plugins this push touched | |
| id: bump | |
| env: | |
| BASE: ${{ steps.base.outputs.ref }} | |
| run: | | |
| # The script refuses to bump a plugin whose manifest and marketplace entry | |
| # already disagree, and reparses both files after editing, so validate.yml's | |
| # agreement invariant holds by construction on the commit this pushes. | |
| # | |
| # Tags go to a file rather than an output: an empty run and a one-blank-line | |
| # run are the same string once a heredoc output is trimmed, and those two mean | |
| # opposite things here. | |
| python3 scripts/bump-version.py --changed-since "$BASE" > "$RUNNER_TEMP/tags.txt" | |
| if [ -s "$RUNNER_TEMP/tags.txt" ]; then | |
| echo "bumped=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Commit, push, and tag | |
| if: ${{ steps.bump.outputs.bumped == 'true' }} | |
| run: | | |
| set -euo pipefail | |
| # One tag per line, read into an array — splitting an unquoted "$TAGS" on IFS | |
| # would be at the mercy of the shell's newline handling. Read rather than | |
| # mapfile so this block runs the same under any bash back to 3.2. | |
| TAGS=() | |
| while IFS= read -r line; do TAGS+=("$line"); done < "$RUNNER_TEMP/tags.txt" | |
| # Fail loudly on a re-run: the checkout predates the bump, so the script would | |
| # recompute versions that main already carries. A clashing tag is the cheapest | |
| # place to catch that, before a duplicate bump commit exists. | |
| git fetch --tags --quiet | |
| for tag in "${TAGS[@]}"; do | |
| if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then | |
| echo "error: tag $tag already exists — main already has this bump" >&2 | |
| exit 1 | |
| fi | |
| done | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| # maintainerd-core--v0.1.1 -> "maintainerd-core 0.1.1", joined with commas. | |
| SUMMARY=$(printf '%s\n' "${TAGS[@]}" | sed 's/--v/ /' | paste -sd, - | sed 's/,/, /g') | |
| # Stage the manifests by name — the script touches nothing else, and `-A` would | |
| # sweep in anything a future step happens to leave in the tree. plugins/*/plugin.json | |
| # is the Agent Plugins v1.0.0 manifest bump-version.py also edits where present. | |
| git add .claude-plugin/marketplace.json plugins/*/.claude-plugin/plugin.json plugins/*/plugin.json | |
| # No staged change means every version here was bumped by hand in the PR. That | |
| # still wants a tag, so fall through to tagging rather than treating it as a | |
| # no-op — but there is nothing to commit or push. | |
| if git diff --cached --quiet; then | |
| echo "versions already bumped in the merge — tagging only" | |
| else | |
| git commit -m "chore(version): bump ${SUMMARY} [skip bump]" | |
| # Push the branch before creating tags: a rebase moves the commit, and a tag | |
| # made beforehand would be left pointing at an object no longer on main. | |
| pushed= | |
| for attempt in 1 2 3; do | |
| if git push origin HEAD:main; then pushed=1; break; fi | |
| echo "push rejected (attempt $attempt) — rebasing onto main" | |
| git pull --rebase origin main | |
| done | |
| [ -n "$pushed" ] || { echo "error: could not push the bump to main" >&2; exit 1; } | |
| fi | |
| for tag in "${TAGS[@]}"; do | |
| git tag -a "$tag" -m "${tag%%--v*} ${tag##*--v}" | |
| done | |
| git push origin "${TAGS[@]}" |