Files
Meshtastic-Android/.github/workflows/update-changelog.yml

295 lines
11 KiB
YAML

name: Update Changelog
on:
push:
branches:
- main
paths-ignore:
- 'CHANGELOG.md'
permissions:
contents: write
pull-requests: write
statuses: write
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: Discover channel tags
id: tags
run: |
# Find the latest production tag (vX.Y.Z)
PROD=$(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 "prod=${PROD:-}" >> "$GITHUB_OUTPUT"
if [ -z "$PROD" ]; then
echo "No production tag found. Skipping."
exit 0
fi
# Find the latest tag for each channel
INTERNAL=$(git tag --list 'v*-internal.*' --sort=-v:refname | head -1)
CLOSED=$(git tag --list 'v*-closed.*' --sort=-v:refname | head -1)
OPEN=$(git tag --list 'v*-open.*' --sort=-v:refname | head -1)
# Deduplicate: if multiple channel tags point to the same SHA,
# keep only the highest channel (open > closed > internal).
# This happens when a build is promoted between channels.
PROD_SHA=$(git rev-parse "$PROD" 2>/dev/null)
INTERNAL_SHA=$([ -n "$INTERNAL" ] && git rev-parse "$INTERNAL" 2>/dev/null || echo "")
CLOSED_SHA=$([ -n "$CLOSED" ] && git rev-parse "$CLOSED" 2>/dev/null || echo "")
OPEN_SHA=$([ -n "$OPEN" ] && git rev-parse "$OPEN" 2>/dev/null || echo "")
# Open subsumes closed and internal at the same SHA
if [ -n "$OPEN_SHA" ]; then
[ "$CLOSED_SHA" = "$OPEN_SHA" ] && CLOSED="" && CLOSED_SHA=""
[ "$INTERNAL_SHA" = "$OPEN_SHA" ] && INTERNAL="" && INTERNAL_SHA=""
fi
# Closed subsumes internal at the same SHA
if [ -n "$CLOSED_SHA" ]; then
[ "$INTERNAL_SHA" = "$CLOSED_SHA" ] && INTERNAL="" && INTERNAL_SHA=""
fi
# Drop any channel tag that points to the production SHA
[ "$INTERNAL_SHA" = "$PROD_SHA" ] && INTERNAL="" && INTERNAL_SHA=""
[ "$CLOSED_SHA" = "$PROD_SHA" ] && CLOSED="" && CLOSED_SHA=""
[ "$OPEN_SHA" = "$PROD_SHA" ] && OPEN="" && OPEN_SHA=""
echo "internal=${INTERNAL:-}" >> "$GITHUB_OUTPUT"
echo "closed=${CLOSED:-}" >> "$GITHUB_OUTPUT"
echo "open=${OPEN:-}" >> "$GITHUB_OUTPUT"
echo "Tags after dedup: prod=$PROD internal=$INTERNAL closed=$CLOSED open=$OPEN"
- name: Generate segmented changelog
if: steps.tags.outputs.prod != ''
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.sha }}
PROD_TAG: ${{ steps.tags.outputs.prod }}
INTERNAL_TAG: ${{ steps.tags.outputs.internal }}
CLOSED_TAG: ${{ steps.tags.outputs.closed }}
OPEN_TAG: ${{ steps.tags.outputs.open }}
run: |
# Helper: generate notes using GitHub API (only works when previous_tag is a release tag)
generate_notes_api() {
local target_sha="$1" previous_tag="$2"
local body
body=$(gh api "repos/${REPO}/releases/generate-notes" \
-f tag_name="unreleased" \
-f target_commitish="$target_sha" \
-f previous_tag_name="$previous_tag" \
--jq '.body' 2>/dev/null) || return 1
echo "$body" \
| sed '/^<!-- .* -->/d' \
| sed '/^## What'\''s Changed$/d' \
| sed '/^\*\*Full Changelog\*\*/d' \
| sed '/^## New Contributors$/,$ { /^## New Contributors$/! { /^\*/!d; }; }' \
| sed '/./,$!d' \
| sed -e :a -e '/^\n*$/{$d;N;ba}'
}
# Helper: generate notes using git log (works between any two refs)
generate_notes_git() {
local from_ref="$1" to_ref="$2"
git log --no-merges --format="* %s by @%an in [\`%h\`](https://github.com/${REPO}/commit/%H)" "$from_ref".."$to_ref" 2>/dev/null || true
}
# Helper: demote ### headings to #### for nesting under channel sections
demote_headings() {
sed 's/^### /#### /g'
}
# Extract New Contributors from the full range (prod -> HEAD)
FULL_NOTES=$(gh api "repos/${REPO}/releases/generate-notes" \
-f tag_name="unreleased" \
-f target_commitish="$HEAD_SHA" \
-f previous_tag_name="$PROD_TAG" \
--jq '.body' 2>/dev/null) || true
CONTRIBUTORS=""
if echo "$FULL_NOTES" | grep -q '## New Contributors'; then
CONTRIBUTORS=$(echo "$FULL_NOTES" | sed -n '/^## New Contributors$/,$ p')
fi
# === Build ordered segment chain by commit ancestry ===
# Collect surviving channel tags with their labels and SHAs
declare -a TAG_LABELS=()
declare -a TAG_NAMES=()
declare -a TAG_SHAS=()
for LABEL_TAG in "Internal:$INTERNAL_TAG" "Closed Beta:$CLOSED_TAG" "Open Beta:$OPEN_TAG"; do
LABEL="${LABEL_TAG%%:*}"
TAG="${LABEL_TAG#*:}"
if [ -n "$TAG" ]; then
TAG_LABELS+=("$LABEL")
TAG_NAMES+=("$TAG")
TAG_SHAS+=("$(git rev-parse "$TAG")")
fi
done
# Sort by topological order (oldest ancestor first) using git merge-base
# Bubble sort is fine for <=3 elements
N=${#TAG_SHAS[@]}
for ((i=0; i<N; i++)); do
for ((j=i+1; j<N; j++)); do
# If TAG_SHAS[j] is an ancestor of TAG_SHAS[i], swap (ancestor goes first)
if git merge-base --is-ancestor "${TAG_SHAS[$j]}" "${TAG_SHAS[$i]}" 2>/dev/null; then
TMP_L="${TAG_LABELS[$i]}"; TAG_LABELS[$i]="${TAG_LABELS[$j]}"; TAG_LABELS[$j]="$TMP_L"
TMP_N="${TAG_NAMES[$i]}"; TAG_NAMES[$i]="${TAG_NAMES[$j]}"; TAG_NAMES[$j]="$TMP_N"
TMP_S="${TAG_SHAS[$i]}"; TAG_SHAS[$i]="${TAG_SHAS[$j]}"; TAG_SHAS[$j]="$TMP_S"
fi
done
done
echo "Ordered chain: prod=${PROD_TAG}"
for ((i=0; i<N; i++)); do
echo " -> ${TAG_LABELS[$i]} (${TAG_NAMES[$i]}) @ ${TAG_SHAS[$i]:0:12}"
done
echo " -> HEAD @ ${HEAD_SHA:0:12}"
# === Generate segments (oldest to newest, each relative to its predecessor) ===
SECTIONS=""
PREV_REF="$PROD_TAG"
PREV_IS_PROD=true
for ((i=0; i<N; i++)); do
LABEL="${TAG_LABELS[$i]}"
TAG="${TAG_NAMES[$i]}"
SHA="${TAG_SHAS[$i]}"
# Use GitHub API when previous ref is a production tag, git log otherwise
if [ "$PREV_IS_PROD" = true ]; then
NOTES=$(generate_notes_api "$SHA" "$PREV_REF" | demote_headings)
else
NOTES=$(generate_notes_git "$PREV_REF" "$SHA")
fi
if [ -n "$NOTES" ]; then
SECTION=$(cat <<SECTION
### ${LABEL} (${TAG})
Changes since [\`${PREV_REF}\`](https://github.com/${REPO}/releases/tag/${PREV_REF}):
${NOTES}
SECTION
)
# Prepend (we want newest-first in final output)
if [ -n "$SECTIONS" ]; then
SECTIONS="${SECTION}
${SECTIONS}"
else
SECTIONS="$SECTION"
fi
fi
PREV_REF="$TAG"
PREV_IS_PROD=false
done
# === Unreleased segment (HEAD -> newest tag) ===
UNRELEASED_SECTION=""
LATEST_TAG="${TAG_NAMES[$((N-1))]:-$PROD_TAG}"
if [ "$HEAD_SHA" != "$(git rev-parse "$LATEST_TAG" 2>/dev/null)" ]; then
UNRELEASED_COMMITS=$(generate_notes_git "$LATEST_TAG" "$HEAD_SHA")
if [ -n "$UNRELEASED_COMMITS" ]; then
UNRELEASED_SECTION=$(cat <<SECTION
### Unreleased (not yet in any build)
${UNRELEASED_COMMITS}
SECTION
)
fi
fi
# Assemble the full unreleased section (newest first)
{
echo "## [Unreleased]"
echo ""
[ -n "$UNRELEASED_SECTION" ] && echo "$UNRELEASED_SECTION" && echo ""
[ -n "$SECTIONS" ] && echo "$SECTIONS" && echo ""
[ -n "$CONTRIBUTORS" ] && echo "$CONTRIBUTORS"
} > /tmp/unreleased-section.md
- name: Update CHANGELOG.md
if: steps.tags.outputs.prod != ''
id: update
run: |
SECTION=$(cat /tmp/unreleased-section.md)
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)
" "$SECTION"
if git diff --quiet CHANGELOG.md; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create or update changelog PR
if: steps.tags.outputs.prod != '' && steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
BRANCH="automation/update-changelog"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Force-update the automation branch
git checkout -B "$BRANCH"
git add CHANGELOG.md
git commit -m "docs: update CHANGELOG.md"
git push origin "$BRANCH" --force
# Create or update the PR
EXISTING_PR=$(gh pr list --head "$BRANCH" --state open --json number -q '.[0].number')
if [ -n "$EXISTING_PR" ]; then
echo "Updated existing PR #$EXISTING_PR"
else
gh pr create \
--title "docs: update CHANGELOG.md" \
--body "Automated changelog update from push to main." \
--head "$BRANCH" \
--base main \
--label "automation" \
--label "skip-changelog"
echo "Created new changelog PR"
fi
# Post the required "Check Workflow Status" commit status so the PR
# isn't blocked. PRs from GITHUB_TOKEN don't trigger pull_request
# workflows, so the normal CI never runs. CHANGELOG-only PRs don't
# need CI checks.
COMMIT_SHA=$(git rev-parse HEAD)
gh api "repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \
-f state="success" \
-f context="Check Workflow Status" \
-f description="Skipped — changelog-only PR"