From 36d54b6d948fdb356d48cecdd8d0c484fefcb0b1 Mon Sep 17 00:00:00 2001 From: James Prial Date: Mon, 26 Jan 2026 02:23:41 -0500 Subject: [PATCH 1/2] feat(ci): add auto semantic versioning workflows Three workflows chain via repository_dispatch: 1. detect-plugin-changes - detects changes, analyzes bump type 2. bump-plugin-versions - bumps versions, commits 3. create-release-tags - creates git tags Bump type determined from PR labels or conventional commits. Requires VERSION_PAT secret for workflow chaining. Co-Authored-By: Claude Opus 4.5 --- .github/CLAUDE.md | 53 ++++++ .github/workflows/bump-plugin-versions.yml | 199 ++++++++++++++++++++ .github/workflows/create-release-tags.yml | 118 ++++++++++++ .github/workflows/detect-plugin-changes.yml | 180 ++++++++++++++++++ 4 files changed, 550 insertions(+) create mode 100644 .github/workflows/bump-plugin-versions.yml create mode 100644 .github/workflows/create-release-tags.yml create mode 100644 .github/workflows/detect-plugin-changes.yml diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 3849ada..a339b3a 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -9,6 +9,9 @@ GitHub Actions workflows for the plugin marketplace. | `validate-versions.yml` | PR/push when marketplace.json or plugin.json changes | Blocks PRs if versions mismatch | | `test.yml` | PR to main, push to main/staging | Runs pytest for all plugins | | `source-branch-check.yml` | PR to main | Enforces staging → main flow | +| `detect-plugin-changes.yml` | Push to main (plugin paths) | Detects changes, dispatches version bump | +| `bump-plugin-versions.yml` | repository_dispatch: bump-versions | Bumps versions, commits, dispatches tagging | +| `create-release-tags.yml` | repository_dispatch: create-tags | Creates and pushes git tags | ## Version Validation @@ -16,6 +19,56 @@ The `validate-versions.yml` workflow uses a matrix strategy to check each plugin - **Local plugins**: Compares marketplace.json version to `{source}/.claude-plugin/plugin.json` - **External plugins**: Shallow clones the URL and compares versions +## Auto Semantic Versioning + +Three workflows chain together via `repository_dispatch` to automatically version plugins: + +``` +Push to main (plugin changes) + │ + ▼ +┌─────────────────────────┐ +│ detect-plugin-changes │ Detects which plugins changed +│ Analyzes PR labels or │ Determines bump type (major/minor/patch) +│ commit messages │ +└───────────┬─────────────┘ + │ repository_dispatch: bump-versions + ▼ +┌─────────────────────────┐ +│ bump-plugin-versions │ Calculates new versions +│ Updates plugin.json & │ Creates chore commit +│ marketplace.json │ +└───────────┬─────────────┘ + │ repository_dispatch: create-tags + ▼ +┌─────────────────────────┐ +│ create-release-tags │ Creates git tags (plugin@vX.Y.Z) +│ Pushes tags to origin │ +└─────────────────────────┘ +``` + +### Bump Type Detection + +Priority order: +1. **PR labels**: `major`/`breaking` → major, `feat`/`feature`/`minor` → minor, `fix`/`bugfix`/`patch` → patch +2. **Commit message**: `BREAKING CHANGE` → major, `feat:` → minor +3. **Default**: patch + +### Prerequisites + +Requires `VERSION_PAT` repository secret with: +- Contents: Read and write +- Pull requests: Read +- Workflows: Read and write + +### Manual Bumps + +If you manually update a `plugin.json` version before merging, the workflow detects the mismatch and syncs `marketplace.json` without auto-incrementing. + +### External Plugins + +External plugins (like `golang-workflow`) are skipped - they're versioned in their own repositories. + ## Adding New Workflows 1. Create `.github/workflows/.yml` diff --git a/.github/workflows/bump-plugin-versions.yml b/.github/workflows/bump-plugin-versions.yml new file mode 100644 index 0000000..570a5bd --- /dev/null +++ b/.github/workflows/bump-plugin-versions.yml @@ -0,0 +1,199 @@ +# Workflow 2 of 3: Auto Semantic Versioning +# Receives plugin list and bump type, calculates new versions, updates files, commits, dispatches to create-release-tags.yml +# +# Triggered by: repository_dispatch event "bump-versions" from detect-plugin-changes.yml +# Payload: { plugins: ["plugin1", "plugin2"], bump_type: "patch|minor|major", trigger_sha: "..." } +# +# Flow: Receive dispatch → bump versions → update marketplace.json → commit → dispatch create-tags event + +name: Bump Plugin Versions + +on: + repository_dispatch: + types: [bump-versions] + +concurrency: + group: bump-versions + cancel-in-progress: false + +permissions: + contents: write + +jobs: + bump: + name: Bump Versions & Commit + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.VERSION_PAT }} + ref: main # Ensure we're on latest main + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Bump versions + id: versions + run: | + PLUGINS='${{ toJson(github.event.client_payload.plugins) }}' + BUMP_TYPE="${{ github.event.client_payload.bump_type }}" + + echo "Plugins to bump: $PLUGINS" + echo "Bump type: $BUMP_TYPE" + + # Function to calculate new version + bump_version() { + local current=$1 + local bump_type=$2 + + # Handle missing or null version + if [ -z "$current" ] || [ "$current" = "null" ]; then + echo "0.1.0" + return + fi + + # Parse semver (major.minor.patch) + IFS='.' read -r major minor patch <<< "$current" + + # Default to 0 if parsing fails + major=${major:-0} + minor=${minor:-0} + patch=${patch:-0} + + case "$bump_type" in + major) + echo "$((major + 1)).0.0" + ;; + minor) + echo "${major}.$((minor + 1)).0" + ;; + patch|*) + echo "${major}.${minor}.$((patch + 1))" + ;; + esac + } + + # Initialize version map + VERSION_MAP="{}" + BUMPED_PLUGINS=() + + # Process each plugin + for plugin in $(echo "$PLUGINS" | jq -r '.[]'); do + PLUGIN_JSON="${plugin}/.claude-plugin/plugin.json" + + if [ ! -f "$PLUGIN_JSON" ]; then + echo "::warning::plugin.json not found for $plugin, skipping" + continue + fi + + # Get current version + CURRENT_VERSION=$(jq -r '.version // "null"' "$PLUGIN_JSON") + echo "Plugin: $plugin, Current version: $CURRENT_VERSION" + + # Check marketplace.json for manual bump detection + MARKETPLACE_VERSION=$(jq -r --arg name "$plugin" '.plugins[] | select(.name == $name) | .version // "null"' .claude-plugin/marketplace.json) + + if [ "$CURRENT_VERSION" != "$MARKETPLACE_VERSION" ] && [ "$CURRENT_VERSION" != "null" ] && [ "$MARKETPLACE_VERSION" != "null" ]; then + echo "::notice::Manual bump detected for $plugin (plugin.json: $CURRENT_VERSION, marketplace: $MARKETPLACE_VERSION)" + echo "Syncing marketplace.json to $CURRENT_VERSION without auto-bump" + NEW_VERSION="$CURRENT_VERSION" + else + # Calculate new version + NEW_VERSION=$(bump_version "$CURRENT_VERSION" "$BUMP_TYPE") + echo "Bumping $plugin: $CURRENT_VERSION -> $NEW_VERSION" + + # Update plugin.json + jq --arg v "$NEW_VERSION" '.version = $v' "$PLUGIN_JSON" > "${PLUGIN_JSON}.tmp" + mv "${PLUGIN_JSON}.tmp" "$PLUGIN_JSON" + fi + + # Add to version map + VERSION_MAP=$(echo "$VERSION_MAP" | jq --arg p "$plugin" --arg v "$NEW_VERSION" '. + {($p): $v}') + BUMPED_PLUGINS+=("$plugin") + done + + echo "Version map: $VERSION_MAP" + echo "version_map=$VERSION_MAP" >> $GITHUB_OUTPUT + + # Store bumped plugins list for commit message + PLUGINS_LIST=$(IFS=', '; echo "${BUMPED_PLUGINS[*]}") + echo "plugins_list=$PLUGINS_LIST" >> $GITHUB_OUTPUT + + - name: Update marketplace.json + run: | + VERSION_MAP='${{ steps.versions.outputs.version_map }}' + + echo "Updating marketplace.json with new versions..." + + for plugin in $(echo "$VERSION_MAP" | jq -r 'keys[]'); do + NEW_VERSION=$(echo "$VERSION_MAP" | jq -r --arg p "$plugin" '.[$p]') + echo "Setting $plugin to $NEW_VERSION in marketplace.json" + + # Update the version for this plugin in marketplace.json + jq --arg name "$plugin" --arg version "$NEW_VERSION" \ + '(.plugins[] | select(.name == $name)).version = $version' \ + .claude-plugin/marketplace.json > .claude-plugin/marketplace.json.tmp + mv .claude-plugin/marketplace.json.tmp .claude-plugin/marketplace.json + done + + echo "marketplace.json updated:" + jq '.plugins[] | {name, version}' .claude-plugin/marketplace.json + + - name: Commit changes + id: commit + run: | + # Stage version files + git add */.claude-plugin/plugin.json .claude-plugin/marketplace.json + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "No changes to commit" + echo "committed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Create commit + PLUGINS_LIST="${{ steps.versions.outputs.plugins_list }}" + git commit -m "chore: bump versions for ${PLUGINS_LIST}" + + # Push to main + git push origin main + + echo "committed=true" >> $GITHUB_OUTPUT + echo "commit_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - name: Dispatch tag creation + if: steps.commit.outputs.committed == 'true' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.VERSION_PAT }} + event-type: create-tags + client-payload: > + { + "version_map": ${{ steps.versions.outputs.version_map }}, + "commit_sha": "${{ steps.commit.outputs.commit_sha }}", + "trigger_sha": "${{ github.event.client_payload.trigger_sha }}" + } + + - name: Summary + run: | + echo "## Bump Plugin Versions Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Bump Type:** ${{ github.event.client_payload.bump_type }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Version Changes" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```json' >> $GITHUB_STEP_SUMMARY + echo '${{ steps.versions.outputs.version_map }}' | jq . >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.commit.outputs.committed }}" = "true" ]; then + echo "Committed changes and dispatched \`create-tags\` event." >> $GITHUB_STEP_SUMMARY + else + echo "No changes to commit." >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/create-release-tags.yml b/.github/workflows/create-release-tags.yml new file mode 100644 index 0000000..38078c6 --- /dev/null +++ b/.github/workflows/create-release-tags.yml @@ -0,0 +1,118 @@ +# Workflow 3 of 3: Auto Semantic Versioning +# Receives version map, creates git tags for each bumped plugin +# +# Triggered by: repository_dispatch event "create-tags" from bump-plugin-versions.yml +# Payload: { version_map: {"plugin1": "1.0.2", "plugin2": "1.0.1"}, commit_sha: "..." } +# +# Flow: Receive dispatch → create git tags → push tags + +name: Create Release Tags + +on: + repository_dispatch: + types: [create-tags] + +concurrency: + group: create-tags + cancel-in-progress: false + +permissions: + contents: write + +jobs: + tag: + name: Create & Push Tags + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.VERSION_PAT }} + fetch-depth: 0 # Need full history for proper tagging + ref: main # Ensure we're on latest main with version bump commit + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Pull latest changes + run: | + # Ensure we have the version bump commit + git pull origin main + + - name: Create tags + id: tags + run: | + VERSION_MAP='${{ toJson(github.event.client_payload.version_map) }}' + + echo "Version map: $VERSION_MAP" + + CREATED_TAGS=() + SKIPPED_TAGS=() + + for plugin in $(echo "$VERSION_MAP" | jq -r 'keys[]'); do + VERSION=$(echo "$VERSION_MAP" | jq -r --arg p "$plugin" '.[$p]') + TAG="${plugin}@v${VERSION}" + + echo "Processing tag: $TAG" + + # Check if tag already exists + if git tag -l "$TAG" | grep -q .; then + echo "::warning::Tag $TAG already exists, skipping" + SKIPPED_TAGS+=("$TAG") + continue + fi + + # Create annotated tag + git tag -a "$TAG" -m "Release ${plugin} v${VERSION} + +Auto-generated by GitHub Actions workflow. +Trigger commit: ${{ github.event.client_payload.trigger_sha }}" + + echo "Created tag: $TAG" + CREATED_TAGS+=("$TAG") + done + + # Output results + echo "created_tags=${CREATED_TAGS[*]}" >> $GITHUB_OUTPUT + echo "skipped_tags=${SKIPPED_TAGS[*]}" >> $GITHUB_OUTPUT + echo "created_count=${#CREATED_TAGS[@]}" >> $GITHUB_OUTPUT + + - name: Push tags + if: steps.tags.outputs.created_count != '0' + run: | + echo "Pushing tags to origin..." + git push origin --tags + + - name: Summary + run: | + echo "## Create Release Tags Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + echo "### Created Tags" >> $GITHUB_STEP_SUMMARY + CREATED="${{ steps.tags.outputs.created_tags }}" + if [ -n "$CREATED" ]; then + for tag in $CREATED; do + echo "- \`$tag\`" >> $GITHUB_STEP_SUMMARY + done + else + echo "_No tags created_" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Skipped Tags (already exist)" >> $GITHUB_STEP_SUMMARY + SKIPPED="${{ steps.tags.outputs.skipped_tags }}" + if [ -n "$SKIPPED" ]; then + for tag in $SKIPPED; do + echo "- \`$tag\`" >> $GITHUB_STEP_SUMMARY + done + else + echo "_None skipped_" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "**Trigger commit:** \`${{ github.event.client_payload.trigger_sha }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/detect-plugin-changes.yml b/.github/workflows/detect-plugin-changes.yml new file mode 100644 index 0000000..e64bf9d --- /dev/null +++ b/.github/workflows/detect-plugin-changes.yml @@ -0,0 +1,180 @@ +# Workflow 1 of 3: Auto Semantic Versioning +# Detects which plugins changed, analyzes bump type, dispatches to bump-plugin-versions.yml +# +# Prerequisites: +# - VERSION_PAT secret with Contents (write), Pull requests (read), Workflows (write) permissions +# +# Flow: Push to main → detect changes → analyze bump type → dispatch bump-versions event + +name: Detect Plugin Changes + +on: + push: + branches: [main] + paths: + - 'security-hooks/**' + - 'todo-log/**' + - 'version-control/**' + - 'bash-workflow/**' + - 'claude-code-guide/**' + - 'slash-command-guide/**' + # Exclude version files to prevent infinite loops + - '!**/.claude-plugin/plugin.json' + - '!.claude-plugin/marketplace.json' + + # Manual trigger for testing + workflow_dispatch: + inputs: + bump_type: + description: 'Version bump type' + required: false + default: 'patch' + type: choice + options: + - patch + - minor + - major + +concurrency: + group: detect-changes + cancel-in-progress: false + +permissions: + contents: read + pull-requests: read + +jobs: + detect: + name: Detect Changes & Dispatch + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 # Need HEAD~1 for diff comparison + + - name: Detect changed plugins + id: changes + run: | + echo "Detecting changed plugins..." + + # Local plugins to check (excludes external golang-workflow) + LOCAL_PLUGINS=("security-hooks" "todo-log" "version-control" "bash-workflow" "claude-code-guide" "slash-command-guide") + + # Get changed files between HEAD and HEAD~1 + CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD) + echo "Changed files:" + echo "$CHANGED_FILES" + + CHANGED_PLUGINS=() + + for plugin in "${LOCAL_PLUGINS[@]}"; do + # Check if any files in this plugin directory changed + if echo "$CHANGED_FILES" | grep -q "^${plugin}/"; then + # Exclude changes that are ONLY to plugin.json (manual version bump) + NON_VERSION_CHANGES=$(echo "$CHANGED_FILES" | grep "^${plugin}/" | grep -v "\.claude-plugin/plugin\.json$" | wc -l | tr -d ' ') + + if [ "$NON_VERSION_CHANGES" -gt 0 ]; then + echo "Plugin changed: $plugin (${NON_VERSION_CHANGES} non-version files)" + CHANGED_PLUGINS+=("\"$plugin\"") + else + echo "Plugin skipped: $plugin (only plugin.json changed - manual bump)" + fi + fi + done + + # Build JSON array + if [ ${#CHANGED_PLUGINS[@]} -eq 0 ]; then + PLUGINS_JSON="[]" + echo "No plugins with substantive changes detected" + else + PLUGINS_JSON="[$(IFS=,; echo "${CHANGED_PLUGINS[*]}")]" + echo "Plugins to bump: $PLUGINS_JSON" + fi + + echo "plugins=$PLUGINS_JSON" >> $GITHUB_OUTPUT + + - name: Analyze bump type + id: bump + env: + GH_TOKEN: ${{ github.token }} + run: | + # Use manual input if provided (workflow_dispatch) + if [ -n "${{ inputs.bump_type }}" ]; then + BUMP_TYPE="${{ inputs.bump_type }}" + echo "Using manual bump type: $BUMP_TYPE" + echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Analyzing commit for bump type..." + + # Try to find the merged PR for this commit + PR_NUMBER=$(gh pr list --state merged --search "${{ github.sha }}" --json number --jq '.[0].number' 2>/dev/null || echo "") + + BUMP_TYPE="patch" # Default + + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + echo "Found merged PR: #$PR_NUMBER" + + # Check PR labels + LABELS=$(gh pr view "$PR_NUMBER" --json labels --jq '.labels[].name' 2>/dev/null | tr '\n' ' ' || echo "") + echo "PR labels: $LABELS" + + if echo "$LABELS" | grep -qiE '\b(major|breaking)\b'; then + BUMP_TYPE="major" + echo "Detected major bump from PR labels" + elif echo "$LABELS" | grep -qiE '\b(minor|feat|feature)\b'; then + BUMP_TYPE="minor" + echo "Detected minor bump from PR labels" + elif echo "$LABELS" | grep -qiE '\b(patch|fix|bugfix)\b'; then + BUMP_TYPE="patch" + echo "Detected patch bump from PR labels" + fi + fi + + # Fallback: Check commit message for conventional commit format + if [ "$BUMP_TYPE" = "patch" ]; then + COMMIT_MSG=$(git log --format=%B -n 1 HEAD) + echo "Commit message: $COMMIT_MSG" + + if echo "$COMMIT_MSG" | grep -q "BREAKING CHANGE"; then + BUMP_TYPE="major" + echo "Detected major bump from commit message (BREAKING CHANGE)" + elif echo "$COMMIT_MSG" | grep -qiE "^feat(\(|:|\!)"; then + BUMP_TYPE="minor" + echo "Detected minor bump from commit message (feat:)" + fi + fi + + echo "Final bump type: $BUMP_TYPE" + echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT + + - name: Dispatch version bump + if: steps.changes.outputs.plugins != '[]' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.VERSION_PAT }} + event-type: bump-versions + client-payload: > + { + "plugins": ${{ steps.changes.outputs.plugins }}, + "bump_type": "${{ steps.bump.outputs.bump_type }}", + "trigger_sha": "${{ github.sha }}", + "trigger_ref": "${{ github.ref }}" + } + + - name: Summary + run: | + echo "## Detect Plugin Changes Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Changed Plugins:** ${{ steps.changes.outputs.plugins }}" >> $GITHUB_STEP_SUMMARY + echo "**Bump Type:** ${{ steps.bump.outputs.bump_type }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.changes.outputs.plugins }}" = "[]" ]; then + echo "No plugins require version bumping." >> $GITHUB_STEP_SUMMARY + else + echo "Dispatched \`bump-versions\` event to trigger version bumping workflow." >> $GITHUB_STEP_SUMMARY + fi From da2958b68258ea6cca64babc3d80a0cc73aad4f3 Mon Sep 17 00:00:00 2001 From: James Prial Date: Mon, 26 Jan 2026 02:32:41 -0500 Subject: [PATCH 2/2] docs: add auto-versioning workflow chain to CLAUDE.md Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9251007..7321657 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ Claude Code plugin marketplace with seven plugins: - `validate-versions.yml` - Blocks PRs if marketplace.json versions don't match plugin.json - `test.yml` - Runs pytest for all plugins - `source-branch-check.yml` - Enforces staging → main PR flow +- `detect-plugin-changes.yml` → `bump-plugin-versions.yml` → `create-release-tags.yml` - Auto semantic versioning chain (requires VERSION_PAT secret) ## Quick Reference