mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 16:55:02 -04:00
295 lines
11 KiB
YAML
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"
|