Merge pull request #274 from LerianStudio/feat/gandalf-webhook-v3 #415
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 | |
| on: | |
| push: | |
| branches: | |
| - main | |
| # Prevent concurrent version bumps | |
| concurrency: | |
| group: version-bump | |
| cancel-in-progress: false | |
| jobs: | |
| version-bump: | |
| runs-on: ubuntu-latest | |
| # Explicit minimal permissions | |
| permissions: | |
| contents: write | |
| # Skip if commit is from GitHub Actions bot (avoid infinite loops) | |
| if: "!contains(github.event.head_commit.message, '[skip-version-bump]')" | |
| steps: | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_APP_ID }} | |
| private-key: ${{ secrets.LERIAN_STUDIO_MIDAZ_PUSH_BOT_PRIVATE_KEY }} | |
| - name: Import GPG key | |
| uses: crazy-max/ghaction-import-gpg@v6 | |
| id: import_gpg | |
| with: | |
| gpg_private_key: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY }} | |
| passphrase: ${{ secrets.LERIAN_CI_CD_USER_GPG_KEY_PASSWORD }} | |
| git_committer_name: ${{ secrets.LERIAN_CI_CD_USER_NAME }} | |
| git_committer_email: ${{ secrets.LERIAN_CI_CD_USER_EMAIL }} | |
| git_config_global: true | |
| git_user_signingkey: true | |
| git_commit_gpgsign: true | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # Fetch full history for commit analysis | |
| token: ${{ steps.app-token.outputs.token }} | |
| - name: Verify jq is available | |
| run: jq --version | |
| - name: Detect changed plugins and bump versions | |
| id: bump | |
| run: | | |
| set -e | |
| MARKETPLACE_JSON=".claude-plugin/marketplace.json" | |
| PLUGINS_CHANGED=false | |
| CHANGED_PLUGINS="" | |
| HIGHEST_BUMP="none" | |
| BUMPED_PLUGINS="" # Track which plugins were bumped for tag creation | |
| # Verify marketplace.json exists | |
| if [ ! -f "$MARKETPLACE_JSON" ]; then | |
| echo "ERROR: $MARKETPLACE_JSON not found" | |
| exit 1 | |
| fi | |
| # Get plugin directories from marketplace.json | |
| PLUGIN_COUNT=$(jq '.plugins | length' "$MARKETPLACE_JSON") | |
| # Get commits since last version bump (or all commits if none) | |
| LAST_BUMP=$(git log --all --grep="\[skip-version-bump\]" -1 --format="%H" 2>/dev/null || echo "") | |
| if [ -z "$LAST_BUMP" ]; then | |
| # No previous version bump, determine safe commit range | |
| COMMIT_COUNT=$(git rev-list --count HEAD 2>/dev/null || echo "0") | |
| if [ "$COMMIT_COUNT" -eq 0 ]; then | |
| echo "No commits found, skipping" | |
| echo "changed=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| elif [ "$COMMIT_COUNT" -eq 1 ]; then | |
| # Single commit: analyze from empty tree to include first commit | |
| EMPTY_TREE=$(git hash-object -t tree /dev/null) | |
| COMMIT_RANGE="$EMPTY_TREE..HEAD" | |
| echo "Single commit repository, including first commit in analysis" | |
| elif [ "$COMMIT_COUNT" -lt 10 ]; then | |
| # Multiple commits less than 10: use empty tree to include all | |
| EMPTY_TREE=$(git hash-object -t tree /dev/null) | |
| COMMIT_RANGE="$EMPTY_TREE..HEAD" | |
| echo "Small repository (<10 commits), analyzing all commits" | |
| else | |
| COMMIT_RANGE="HEAD~10..HEAD" | |
| fi | |
| else | |
| COMMIT_RANGE="$LAST_BUMP..HEAD" | |
| fi | |
| echo "Analyzing commits: $COMMIT_RANGE ($(git rev-list --count $COMMIT_RANGE) commits)" | |
| # For each plugin in marketplace.json | |
| for ((i=0; i<$PLUGIN_COUNT; i++)); do | |
| PLUGIN_NAME=$(jq -r ".plugins[$i].name" "$MARKETPLACE_JSON") | |
| PLUGIN_SOURCE=$(jq -r ".plugins[$i].source" "$MARKETPLACE_JSON" | sed 's|^\./||') | |
| echo "Checking plugin: $PLUGIN_NAME (source: $PLUGIN_SOURCE)" | |
| # Validate source is not null/empty (HIGH severity fix) | |
| if [ -z "$PLUGIN_SOURCE" ] || [ "$PLUGIN_SOURCE" = "null" ]; then | |
| echo " ERROR: Plugin $PLUGIN_NAME has missing or null source field" | |
| exit 1 | |
| fi | |
| # Verify plugin directory exists | |
| if [ ! -d "$PLUGIN_SOURCE" ]; then | |
| echo " WARNING: Directory $PLUGIN_SOURCE not found, skipping" | |
| continue | |
| fi | |
| # Check if plugin directory has changes (CRITICAL fix: explicit empty check) | |
| DIFF_OUTPUT=$(git diff --name-only $COMMIT_RANGE -- "$PLUGIN_SOURCE" 2>/dev/null | grep -v "^docs/" | grep -v "README.md" || true) | |
| if [ -n "$DIFF_OUTPUT" ]; then | |
| CHANGES=$(echo "$DIFF_OUTPUT" | wc -l | tr -d ' ') | |
| else | |
| CHANGES=0 | |
| fi | |
| if [ "$CHANGES" -gt 0 ]; then | |
| echo " Changes detected: $CHANGES files" | |
| # Get commits affecting this plugin (full messages for body parsing) | |
| FULL_COMMITS=$(git log $COMMIT_RANGE --pretty=format:"%B%n---COMMIT---" -- "$PLUGIN_SOURCE" 2>/dev/null || echo "") | |
| # Filter out docs: and chore: commits from subjects | |
| COMMIT_SUBJECTS=$(git log $COMMIT_RANGE --pretty=format:"%s" -- "$PLUGIN_SOURCE" 2>/dev/null | grep -v "^docs" | grep -v "^chore" || echo "") | |
| if [ -z "$COMMIT_SUBJECTS" ]; then | |
| echo " Only docs/chore commits, skipping version bump" | |
| continue | |
| fi | |
| # Determine version bump type | |
| BUMP_TYPE="patch" | |
| # Check for breaking changes (MAJOR) | |
| # Patterns: feat!:, fix!:, refactor!:, BREAKING CHANGE: (in body or subject) | |
| if echo "$FULL_COMMITS" | grep -qE "(^[a-z]+(\([^)]+\))?!:|BREAKING[ -]CHANGE:)"; then | |
| BUMP_TYPE="major" | |
| echo " Breaking changes detected → MAJOR bump" | |
| # Track highest bump type across all plugins | |
| HIGHEST_BUMP="major" | |
| # Check for features (MINOR) | |
| elif echo "$COMMIT_SUBJECTS" | grep -qE "^feat(\([^)]+\))?:"; then | |
| BUMP_TYPE="minor" | |
| echo " Features detected → MINOR bump" | |
| # Track highest bump (don't override major) | |
| if [ "$HIGHEST_BUMP" != "major" ]; then | |
| HIGHEST_BUMP="minor" | |
| fi | |
| # Everything else is PATCH | |
| else | |
| echo " Fixes/other changes → PATCH bump" | |
| # Track highest bump (don't override major/minor) | |
| if [ "$HIGHEST_BUMP" = "none" ]; then | |
| HIGHEST_BUMP="patch" | |
| fi | |
| fi | |
| # Get current version from marketplace.json | |
| CURRENT_VERSION=$(jq -r ".plugins[$i].version" "$MARKETPLACE_JSON") | |
| echo " Current version: $CURRENT_VERSION" | |
| # Validate version format | |
| if ! [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo " ERROR: Invalid version format: $CURRENT_VERSION (expected X.Y.Z)" | |
| exit 1 | |
| fi | |
| # Parse version | |
| IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" | |
| # Bump version | |
| case $BUMP_TYPE in | |
| major) | |
| MAJOR=$((MAJOR + 1)) | |
| MINOR=0 | |
| PATCH=0 | |
| ;; | |
| minor) | |
| MINOR=$((MINOR + 1)) | |
| PATCH=0 | |
| ;; | |
| patch) | |
| PATCH=$((PATCH + 1)) | |
| ;; | |
| esac | |
| NEW_VERSION="$MAJOR.$MINOR.$PATCH" | |
| echo " New version: $NEW_VERSION" | |
| # Update version in marketplace.json | |
| jq --argjson index "$i" --arg version "$NEW_VERSION" \ | |
| '.plugins[$index].version = $version' "$MARKETPLACE_JSON" > "${MARKETPLACE_JSON}.tmp" | |
| # Validate the generated JSON before replacing | |
| if jq empty "${MARKETPLACE_JSON}.tmp" 2>/dev/null; then | |
| mv "${MARKETPLACE_JSON}.tmp" "$MARKETPLACE_JSON" | |
| else | |
| echo " ERROR: Generated invalid JSON" | |
| rm -f "${MARKETPLACE_JSON}.tmp" | |
| exit 1 | |
| fi | |
| # Track changes | |
| PLUGINS_CHANGED=true | |
| CHANGED_PLUGINS="$CHANGED_PLUGINS\n - $PLUGIN_NAME: $CURRENT_VERSION → $NEW_VERSION ($BUMP_TYPE)" | |
| # Track bumped plugins for tag creation (MEDIUM fix) | |
| BUMPED_PLUGINS="$BUMPED_PLUGINS $PLUGIN_NAME:$NEW_VERSION" | |
| # Stage the file | |
| git add "$MARKETPLACE_JSON" | |
| else | |
| echo " No relevant changes detected" | |
| fi | |
| done | |
| # Bump marketplace version if any plugin changed | |
| if [ "$PLUGINS_CHANGED" = true ]; then | |
| echo "Plugins changed, bumping marketplace version" | |
| CURRENT_MKT_VERSION=$(jq -r '.version // "0.0.0"' "$MARKETPLACE_JSON") | |
| echo "Current marketplace version: $CURRENT_MKT_VERSION" | |
| # Validate marketplace version format | |
| if ! [[ "$CURRENT_MKT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| echo "WARNING: Invalid marketplace version format: $CURRENT_MKT_VERSION, defaulting to 0.0.0" | |
| CURRENT_MKT_VERSION="0.0.0" | |
| fi | |
| # Parse marketplace version | |
| IFS='.' read -r MKT_MAJOR MKT_MINOR MKT_PATCH <<< "$CURRENT_MKT_VERSION" | |
| # Marketplace gets independent MINOR bump (per requirements) | |
| MKT_MINOR=$((MKT_MINOR + 1)) | |
| MKT_PATCH=0 | |
| NEW_MKT_VERSION="$MKT_MAJOR.$MKT_MINOR.$MKT_PATCH" | |
| echo "New marketplace version: $NEW_MKT_VERSION" | |
| # Update marketplace version | |
| jq --arg version "$NEW_MKT_VERSION" '.version = $version' "$MARKETPLACE_JSON" > "${MARKETPLACE_JSON}.tmp" | |
| # Validate JSON before replacing | |
| if jq empty "${MARKETPLACE_JSON}.tmp" 2>/dev/null; then | |
| mv "${MARKETPLACE_JSON}.tmp" "$MARKETPLACE_JSON" | |
| else | |
| echo "ERROR: Generated invalid marketplace JSON" | |
| rm -f "${MARKETPLACE_JSON}.tmp" | |
| exit 1 | |
| fi | |
| git add "$MARKETPLACE_JSON" | |
| # Use random delimiter for security (MEDIUM fix) | |
| DELIMITER="EOF_CHANGES_$(date +%s%N 2>/dev/null || echo $RANDOM)" | |
| echo "changed=true" >> $GITHUB_OUTPUT | |
| echo "changes<<$DELIMITER" >> $GITHUB_OUTPUT | |
| echo -e "$CHANGED_PLUGINS" >> $GITHUB_OUTPUT | |
| echo "$DELIMITER" >> $GITHUB_OUTPUT | |
| echo "highest_bump=$HIGHEST_BUMP" >> $GITHUB_OUTPUT | |
| echo "bumped_plugins=$BUMPED_PLUGINS" >> $GITHUB_OUTPUT | |
| else | |
| echo "No plugin changes requiring version bumps" | |
| echo "changed=false" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Commit version bumps | |
| if: steps.bump.outputs.changed == 'true' | |
| run: | | |
| git commit -m "chore: bump versions [skip-version-bump] | |
| Automated version bumps: | |
| ${{ steps.bump.outputs.changes }} | |
| Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>" | |
| git push origin main | |
| - name: Create release tags | |
| if: steps.bump.outputs.changed == 'true' | |
| run: | | |
| set -e | |
| # Use tracked bumped plugins from bump step (MEDIUM fix: more reliable) | |
| BUMPED_PLUGINS="${{ steps.bump.outputs.bumped_plugins }}" | |
| if [ -n "$BUMPED_PLUGINS" ]; then | |
| # Tag each bumped plugin | |
| for entry in $BUMPED_PLUGINS; do | |
| PLUGIN_NAME=$(echo "$entry" | cut -d: -f1) | |
| VERSION=$(echo "$entry" | cut -d: -f2) | |
| # Validate plugin name format for security (MEDIUM fix) | |
| if ! [[ "$PLUGIN_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then | |
| echo "ERROR: Invalid plugin name format: $PLUGIN_NAME" | |
| exit 1 | |
| fi | |
| TAG_NAME="${PLUGIN_NAME}@${VERSION}" | |
| # Check if tag already exists (avoid overwrite) | |
| if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then | |
| echo "Tag $TAG_NAME already exists, skipping" | |
| continue | |
| fi | |
| echo "Creating tag: $TAG_NAME" | |
| if ! git tag -a "$TAG_NAME" -m "Release $PLUGIN_NAME version $VERSION" 2>/dev/null; then | |
| echo "WARNING: Failed to create tag $TAG_NAME (may already exist)" | |
| continue | |
| fi | |
| if ! git push origin "$TAG_NAME" 2>/dev/null; then | |
| echo "WARNING: Failed to push tag $TAG_NAME" | |
| fi | |
| done | |
| fi | |
| # Tag marketplace version | |
| MARKETPLACE_JSON=".claude-plugin/marketplace.json" | |
| MKT_VERSION=$(jq -r '.version' "$MARKETPLACE_JSON") | |
| MKT_TAG="marketplace@${MKT_VERSION}" | |
| # Check if marketplace tag already exists | |
| if git rev-parse "$MKT_TAG" >/dev/null 2>&1; then | |
| echo "Marketplace tag $MKT_TAG already exists, skipping" | |
| else | |
| echo "Creating marketplace tag: $MKT_TAG" | |
| if ! git tag -a "$MKT_TAG" -m "Release marketplace version $MKT_VERSION" 2>/dev/null; then | |
| echo "WARNING: Failed to create marketplace tag (may already exist)" | |
| elif ! git push origin "$MKT_TAG" 2>/dev/null; then | |
| echo "WARNING: Failed to push marketplace tag" | |
| fi | |
| fi |