feat(ci): overhaul changelog management and PR auto-labeling (#5254)

This commit is contained in:
James Rich
2026-04-27 15:00:29 -05:00
committed by GitHub
parent 9f12cf7bef
commit 6306e92a7b
7 changed files with 249 additions and 93 deletions

View File

@@ -1,71 +0,0 @@
name: Main Push Changelog
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: read
concurrency:
group: main-push-${{ github.ref }}
cancel-in-progress: true
jobs:
main-push-changelog:
name: Generate main push changelog
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine last tag
id: last_prod_tag
run: |
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "Found last tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Generate changelog from last tag to current
if: steps.last_prod_tag.outputs.tag != ''
uses: mikepenz/release-changelog-builder-action@v6
id: changelog
with:
configuration: .github/release.yml
fromTag: ${{ steps.last_prod_tag.outputs.tag }}
toTag: ${{ github.sha }}
outputFile: main-push-changelog.md
fetchViaCommits: true
fetchReviewers: false
fetchReleaseInformation: false
fetchReviews: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload changelog artifact
if: steps.last_prod_tag.outputs.tag != ''
uses: actions/upload-artifact@v7
with:
name: main-push-changelog
path: main-push-changelog.md
- name: Print main push summary
env:
LAST_TAG: ${{ steps.last_prod_tag.outputs.tag }}
run: |
echo "Pushed to main"
echo "SHA: $GITHUB_SHA"
echo "Actor: $GITHUB_ACTOR"
echo "Ref: $GITHUB_REF"
echo ""
if [ "$LAST_TAG" != "" ]; then
echo "Changelog since last tag ($LAST_TAG)":
echo "----------------------------------------"
cat main-push-changelog.md
else
echo "No tag found. Skipping changelog generation."
fi

View File

@@ -2,7 +2,7 @@ name: PR Triage (Models)
on:
pull_request_target:
types: [opened]
types: [opened, edited]
permissions:
pull-requests: write
@@ -27,7 +27,7 @@ jobs:
with:
script: |
const skipLabels = new Set(['automation', 'release']);
const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor']);
const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor', 'chore', 'ci', 'build', 'testing', 'documentation']);
const prLabels = context.payload.pull_request.labels.map(l => l.name);
const shouldSkipAll = prLabels.some(l => skipLabels.has(l));
@@ -105,15 +105,26 @@ jobs:
prompt: |
Classify this pull request for the Meshtastic Android app into exactly one category.
Return exactly one of: bugfix, enhancement, refactor
Return exactly one of: bugfix, enhancement, refactor, chore, ci, build, testing, documentation
Use bugfix if it fixes a bug, crash, or incorrect behavior.
Use enhancement if it adds a new feature, improves performance, or adds new functionality.
Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture.
Label definitions:
- bugfix: Fixes a bug, crash, or incorrect behavior
- enhancement: Adds a new feature, improves UX, adds new functionality, or improves performance
- refactor: Restructures code without changing behavior, cleans up code, or improves architecture
- chore: Routine maintenance (dependency updates, config tweaks, version bumps) that doesn't change app behavior
- ci: Changes to CI/CD workflows, GitHub Actions, or automation scripts
- build: Changes to build system, Gradle config, build-logic, or compilation settings
- testing: Adds or modifies tests without changing production code
- documentation: Documentation-only changes (README, CHANGELOG, code comments, KDoc)
Title: ${{ env.PR_TITLE }}
Body: ${{ env.PR_BODY }}
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
system-prompt: >
You classify pull requests into categories for changelog generation.
Pick the single most specific label. Prefer ci/build/chore/testing/documentation
over refactor when the change clearly fits those narrower categories.
Only use bugfix for actual bug fixes, not for CI fixes or build fixes.
Only use enhancement for user-facing features, not for internal improvements.
model: openai/gpt-4o-mini
- name: Apply type label
@@ -125,9 +136,14 @@ jobs:
script: |
const label = (process.env.TYPE_LABEL || '').trim().toLowerCase();
const labelMeta = {
'bugfix': { color: 'd73a4a', description: 'Bug fix' },
'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' },
'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' },
'bugfix': { color: 'd73a4a', description: 'Bug fix' },
'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' },
'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' },
'chore': { color: 'ededed', description: 'Routine maintenance' },
'ci': { color: 'bfd4f2', description: 'CI/CD and automation changes' },
'build': { color: 'bfd4f2', description: 'Build system changes' },
'testing': { color: 'f9d0c4', description: 'Test additions or modifications' },
'documentation': { color: '0075ca', description: 'Documentation changes' },
};
const meta = labelMeta[label];
if (!meta) return;

View File

@@ -29,7 +29,7 @@ jobs:
script: |
// Extract labels from the payload directly to avoid extra API calls
const latestLabels = context.payload.pull_request.labels.map(label => label.name);
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor'];
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor', 'chore', 'ci', 'build', 'testing', 'documentation'];
console.log('Labels from payload:', latestLabels);
const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label));
if (!hasRequiredLabel) {

View File

@@ -142,6 +142,72 @@ jobs:
--draft=false \
--prerelease=${{ inputs.channel != 'production' }}
- name: Stamp CHANGELOG.md for release
if: ${{ inputs.channel == 'production' }}
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.final_tag }}
VERSION: ${{ inputs.base_version }}
run: |
DATE=$(date -u +%Y-%m-%d)
# Checkout CHANGELOG.md from main (we're on a tag checkout)
git fetch origin main
git checkout -b "changelog/v${VERSION}" origin/main
# Move the [Unreleased] content into a versioned section
python3 -c "
import sys
version, date = sys.argv[1], sys.argv[2]
with open('CHANGELOG.md', 'r') as f:
content = f.read()
us = '<!-- UNRELEASED_START -->'
ue = '<!-- UNRELEASED_END -->'
rs = '<!-- RELEASED_START -->'
start = content.find(us)
end = content.find(ue)
rstart = content.find(rs)
if start == -1 or end == -1 or rstart == -1:
print('Markers not found in CHANGELOG.md')
sys.exit(1)
# Extract unreleased content (between markers, excluding the ## [Unreleased] header and 'Changes since' line)
unreleased = content[start + len(us):end].strip()
lines = unreleased.split('\n')
# Skip the header and the 'Changes since' reference line
body_lines = []
for line in lines:
if line.startswith('## [Unreleased]'):
continue
if line.startswith('Changes since'):
continue
body_lines.append(line)
body = '\n'.join(body_lines).strip()
# Build versioned entry
versioned = f'## [{version}] - {date}\n\n{body}'
# Reset unreleased section
fresh_unreleased = '## [Unreleased]\n\n*No changes yet.*'
# Replace unreleased with fresh, insert versioned before RELEASED_START
new_content = content[:start + len(us)] + '\n' + fresh_unreleased + '\n' + content[end:rstart + len(rs)] + '\n\n' + versioned + '\n' + content[rstart + len(rs):]
with open('CHANGELOG.md', 'w') as f:
f.write(new_content)
" "$VERSION" "$DATE"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git diff --cached --quiet || {
git commit -m "docs: release CHANGELOG.md for v${VERSION} [skip ci]"
git push origin "changelog/v${VERSION}":main
}
- name: Notify Discord
if: ${{ inputs.channel != 'internal' }}
env:

View File

@@ -21,22 +21,48 @@ jobs:
with:
script: |
const branch = context.payload.pull_request.head.ref;
const title = context.payload.pull_request.title || '';
const labels = new Set();
// enhancement: branch contains feat
if (/feat/i.test(branch)) labels.add('enhancement');
// Match branch prefix (e.g. feat/..., fix/..., chore-something)
// Also parse conventional-commit-style PR titles as a fallback
const branchPrefix = branch.split(/[\/\-_]/)[0].toLowerCase();
const titlePrefix = (title.match(/^(\w+)[\(:]/) || [])[1]?.toLowerCase();
// bugfix: branch starts with fix or bug
if (/^(fix|bug)/i.test(branch)) labels.add('bugfix');
const prefix = branchPrefix || titlePrefix;
// refactor: branch starts with refactor
if (/^refactor/i.test(branch)) labels.add('refactor');
// Map prefixes to changelog-aligned labels
const prefixMap = {
'feat': 'enhancement',
'feature': 'enhancement',
'fix': 'bugfix',
'bug': 'bugfix',
'bugfix': 'bugfix',
'hotfix': 'bugfix',
'refactor': 'refactor',
'chore': 'chore',
'build': 'build',
'ci': 'ci',
'test': 'testing',
'tests': 'testing',
'docs': 'documentation',
'doc': 'documentation',
'perf': 'enhancement',
'style': 'refactor',
'repo': 'repo',
'release': 'release',
};
// repo: branch contains repo or ci
if (/repo|ci/i.test(branch)) {
labels.add('repo');
} else {
// Also label 'repo' if .github files were changed (needs one API call)
// Label from branch prefix
if (prefixMap[branchPrefix]) labels.add(prefixMap[branchPrefix]);
// Label from PR title prefix (if different from branch)
if (titlePrefix && prefixMap[titlePrefix] && !labels.has(prefixMap[titlePrefix])) {
labels.add(prefixMap[titlePrefix]);
}
// Also label 'repo' if .github/ or build-logic/ files were changed
if (!labels.has('repo') && !labels.has('ci') && !labels.has('build')) {
try {
const files = await github.paginate(
github.rest.pulls.listFiles,
@@ -44,6 +70,7 @@ jobs:
(res) => res.data.map(f => f.filename)
);
if (files.some(f => f.startsWith('.github/'))) labels.add('repo');
if (files.some(f => f.startsWith('build-logic/'))) labels.add('build');
} catch (e) {
core.warning(`Could not list PR files (rate limited?): ${e.message}`);
}

104
.github/workflows/update-changelog.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
name: Update Changelog
on:
push:
branches:
- main
paths-ignore:
- 'CHANGELOG.md'
permissions:
contents: write
pull-requests: read
concurrency:
group: changelog-${{ github.ref }}
cancel-in-progress: true
jobs:
update-changelog:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- name: Determine last production tag
id: last_tag
run: |
# Find the latest production tag (vX.Y.Z without pre-release suffix)
TAG=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
echo "Found last production tag: ${TAG:-none}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Generate release notes via GitHub API
if: steps.last_tag.outputs.tag != ''
id: notes
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.last_tag.outputs.tag }}
run: |
# Use GitHub's native release notes generator (same engine as generate_release_notes)
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="unreleased" \
-f target_commitish="${{ github.sha }}" \
-f previous_tag_name="$TAG" \
--jq '.body')
# Strip GitHub comment headers and the "What's Changed" heading
NOTES=$(echo "$NOTES" | sed '/^<!-- .* -->/d' | sed '/^## What'\''s Changed$/d')
# Strip the trailing "Full Changelog" link
NOTES=$(echo "$NOTES" | sed '/^\*\*Full Changelog\*\*/d')
# Strip leading and trailing blank lines
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
# Write to file for next step (avoids shell quoting issues)
echo "$NOTES" > /tmp/unreleased-notes.md
- name: Update CHANGELOG.md
if: steps.last_tag.outputs.tag != ''
env:
TAG: ${{ steps.last_tag.outputs.tag }}
run: |
NOTES=$(cat /tmp/unreleased-notes.md)
# Build the new unreleased section
UNRELEASED=$(cat <<SECTION
## [Unreleased]
Changes since [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG}):
${NOTES}
SECTION
)
# Replace content between UNRELEASED markers
python3 -c "
import sys
start_marker = '<!-- UNRELEASED_START -->'
end_marker = '<!-- UNRELEASED_END -->'
section = sys.argv[1]
with open('CHANGELOG.md', 'r') as f:
content = f.read()
start = content.find(start_marker)
end = content.find(end_marker)
if start == -1 or end == -1:
print('Markers not found in CHANGELOG.md')
sys.exit(1)
new_content = content[:start + len(start_marker)] + '\n' + section + '\n' + content[end:]
with open('CHANGELOG.md', 'w') as f:
f.write(new_content)
" "$UNRELEASED"
- name: Commit and push
if: steps.last_tag.outputs.tag != ''
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git diff --cached --quiet || {
git commit -m "docs: update CHANGELOG.md [skip ci]"
git push origin main
}