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
- 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
- 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