changelogs 0.5.0

Manage versioning and changelogs for Cargo workspaces
Documentation
name: 'Changelogs'
description: 'Create a PR with version bumps and changelogs, and publish packages when merged'
author: 'wevm'

branding:
  icon: 'package'
  color: 'orange'

inputs:
  ecosystem:
    description: 'Ecosystem to use (rust, python). Auto-detected if not specified.'
    required: false
  crate-token:
    description: 'Crates.io API token for publishing (Rust)'
    required: false
  pypi-token:
    description: 'PyPI API token for publishing (Python)'
    required: false
  commit:
    description: 'Commit message for version bump (overrides conventional-commit)'
    required: false
  conventional-commit:
    description: 'Use conventional commit format (chore: prefix)'
    required: false
    default: 'false'
  branch:
    description: 'Branch name for the version PR'
    required: false
    default: 'changelog-release/main'
  github-token:
    description: 'GitHub token for creating PRs'
    required: false
    default: ${{ github.token }}

outputs:
  hasChangelogs:
    description: 'Whether there are pending changelogs'
    value: ${{ steps.check.outputs.hasChangelogs }}
  pullRequestNumber:
    description: 'The pull request number if created or updated'
    value: ${{ steps.pr.outputs.pull-request-number }}
  published:
    description: 'Whether packages were published'
    value: ${{ steps.publish.outputs.published }}
  publishedPackages:
    description: 'JSON array of published packages'
    value: ${{ steps.publish.outputs.publishedPackages }}

runs:
  using: 'composite'
  steps:
    - name: Install Rust
      uses: dtolnay/rust-toolchain@stable

    - name: Setup Python (for Python ecosystem)
      if: inputs.ecosystem == 'python' || inputs.pypi-token != ''
      uses: actions/setup-python@v5
      with:
        python-version: '3.11'

    - name: Install Python build tools (for Python ecosystem)
      if: inputs.ecosystem == 'python' || inputs.pypi-token != ''
      shell: bash
      run: pip install build twine

    - name: Cache changelogs binary
      id: cache
      uses: actions/cache@v4
      with:
        path: ~/.cargo/bin/changelogs
        key: changelogs-${{ runner.os }}-${{ runner.arch }}

    - name: Install changelogs
      if: steps.cache.outputs.cache-hit != 'true'
      shell: bash
      run: cargo install changelogs

    - name: Check for changelogs
      id: check
      shell: bash
      run: |
        if [ -d ".changelog" ] && [ "$(find .changelog -name '*.md' ! -name 'README.md' 2>/dev/null | head -1)" ]; then
          echo "hasChangelogs=true" >> $GITHUB_OUTPUT
          echo "Found pending changelogs"
        else
          echo "hasChangelogs=false" >> $GITHUB_OUTPUT
          echo "No pending changelogs"
        fi

    # Version mode: Create PR when changelogs exist
    - name: Setup Git user
      if: steps.check.outputs.hasChangelogs == 'true'
      shell: bash
      run: |
        git config user.name "github-actions[bot]"
        git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

    - name: Run version command
      if: steps.check.outputs.hasChangelogs == 'true'
      id: version
      shell: bash
      run: |
        ECOSYSTEM_FLAG=""
        if [ -n "${{ inputs.ecosystem }}" ]; then
          ECOSYSTEM_FLAG="--ecosystem ${{ inputs.ecosystem }}"
        fi
        output=$(changelogs $ECOSYSTEM_FLAG version 2>&1)
        echo "$output"
        
        # Extract versions from output (e.g., "changelogs 0.0.1 → 0.1.0" -> "changelogs@0.1.0")
        all_versions=$(echo "$output" | grep -oE '[a-zA-Z0-9_-]+ [0-9]+\.[0-9]+\.[0-9]+ → [0-9]+\.[0-9]+\.[0-9]+' | sed 's/ .* → /@/')
        count=$(echo "$all_versions" | grep -c . || echo 0)
        
        if [ "$count" -eq 0 ]; then
          versions=""
        elif [ "$count" -eq 1 ]; then
          versions="\`$(echo "$all_versions" | head -1)\`"
        elif [ "$count" -eq 2 ]; then
          first=$(echo "$all_versions" | head -1)
          second=$(echo "$all_versions" | tail -1)
          versions="\`$first\` and \`$second\`"
        else
          first=$(echo "$all_versions" | head -1)
          second=$(echo "$all_versions" | sed -n '2p')
          remaining=$((count - 2))
          versions="\`$first\`, \`$second\` and $remaining more"
        fi
        echo "versions=$versions" >> $GITHUB_OUTPUT
        
        # Build title and commit message with optional conventional commit prefix
        if [ "${{ inputs.conventional-commit }}" = "true" ]; then
          echo "title=chore: release $versions" >> $GITHUB_OUTPUT
          echo "commit-msg=chore: release $versions" >> $GITHUB_OUTPUT
        else
          echo "title=Release $versions" >> $GITHUB_OUTPUT
          echo "commit-msg=Release $versions" >> $GITHUB_OUTPUT
        fi

    - name: Generate PR body
      if: steps.check.outputs.hasChangelogs == 'true'
      id: body
      shell: bash
      run: |
        {
          echo "This PR was opened by the Changelogs release workflow."
          echo ""
          echo "When you're ready to release, merge this PR and the packages will be published."
          echo ""
          echo "---"
          echo ""
          
          # Show the new changelog entries (uncommitted changes from changelogs version)
          if [ -f "CHANGELOG.md" ]; then
            git diff -- CHANGELOG.md | grep '^+' | grep -v '^+++' | sed 's/^+//' | head -100 || true
          fi
        } > /tmp/pr-body.md
        
        echo "body-file=/tmp/pr-body.md" >> $GITHUB_OUTPUT

    - name: Create or update PR
      if: steps.check.outputs.hasChangelogs == 'true'
      id: pr
      uses: peter-evans/create-pull-request@v7
      with:
        token: ${{ inputs.github-token }}
        branch: ${{ inputs.branch }}
        title: ${{ steps.version.outputs.title }}
        body-path: ${{ steps.body.outputs.body-file }}
        base: ${{ github.ref_name }}
        commit-message: ${{ inputs.commit || steps.version.outputs.commit-msg }}
        delete-branch: true

    # Publish mode: Publish when no changelogs (PR was just merged)
    # Will publish to registry if tokens provided, otherwise just creates git tags
    - name: Setup Git user for tags
      if: steps.check.outputs.hasChangelogs == 'false'
      shell: bash
      run: |
        git config user.name "github-actions[bot]"
        git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

    - name: Publish packages
      if: steps.check.outputs.hasChangelogs == 'false'
      id: publish
      shell: bash
      env:
        CARGO_REGISTRY_TOKEN: ${{ inputs.crate-token }}
        TWINE_USERNAME: __token__
        TWINE_PASSWORD: ${{ inputs.pypi-token }}
      run: |
        ECOSYSTEM_FLAG=""
        if [ -n "${{ inputs.ecosystem }}" ]; then
          ECOSYSTEM_FLAG="--ecosystem ${{ inputs.ecosystem }}"
        fi
        output=$(changelogs $ECOSYSTEM_FLAG publish 2>&1) || true
        echo "$output"
        
        # Parse published packages from output (✓ = published, ⊘ = skipped/tags-only)
        packages=$(echo "$output" | { grep -E "^\s+\S+\s+v[0-9]" || true; } | { grep -E "✓|⊘" || true; } | awk '{print $1}' | jq -R -s -c 'split("\n") | map(select(length > 0))')
        
        if [ "$packages" != "[]" ] && [ -n "$packages" ]; then
          echo "published=true" >> $GITHUB_OUTPUT
          echo "publishedPackages=$packages" >> $GITHUB_OUTPUT
        else
          echo "published=false" >> $GITHUB_OUTPUT
          echo "publishedPackages=[]" >> $GITHUB_OUTPUT
        fi

    - name: Push git tags
      if: steps.check.outputs.hasChangelogs == 'false'
      shell: bash
      run: git push --follow-tags

    - name: Create GitHub releases
      if: steps.check.outputs.hasChangelogs == 'false'
      shell: bash
      env:
        GH_TOKEN: ${{ inputs.github-token }}
      run: |
        # Get tags that were just pushed
        for tag in $(git tag --points-at HEAD); do
          echo "Creating release for $tag"
          
          # Extract changelog section for this tag from CHANGELOG.md
          changelog_notes=""
          if [ -f "CHANGELOG.md" ]; then
            changelog_notes=$(awk -v tag="$tag" '
              BEGIN { found=0; printing=0 }
              /^## `.*`/ {
                if (printing) exit
                gsub(/`/, "", $0)
                gsub(/^## /, "", $0)
                if ($0 == tag) { found=1; printing=1; next }
              }
              printing { print }
            ' CHANGELOG.md)
          fi
          
          # Generate GitHub's notes to get "New Contributors" and "Full Changelog"
          github_notes=$(gh api repos/${{ github.repository }}/releases/generate-notes \
            -f tag_name="$tag" \
            --jq '.body' 2>/dev/null || echo "")
          
          # Remove "What's Changed" section, keep "New Contributors" and "Full Changelog"
          github_extras=$(echo "$github_notes" | awk '
            BEGIN { skip=0 }
            /^## What'\''s Changed/ { skip=1; next }
            /^## New Contributors/ { skip=0 }
            /^\*\*Full Changelog\*\*/ { skip=0 }
            !skip { print }
          ')
          
          # Combine: changelog content + GitHub extras (New Contributors, Full Changelog)
          if [ -n "$changelog_notes" ]; then
            notes="$changelog_notes"
            if [ -n "$github_extras" ]; then
              notes="$notes"$'\n\n'"$github_extras"
            fi
            echo "$notes" | gh release create "$tag" --notes-file - || true
          else
            gh release create "$tag" --generate-notes || true
          fi
        done