mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-18 19:56:34 -04:00
feat(ci): segmented changelog with channel dedup and production squash (#5259)
This commit is contained in:
65
.github/workflows/promote.yml
vendored
65
.github/workflows/promote.yml
vendored
@@ -59,7 +59,8 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
@@ -148,6 +149,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
|
||||
@@ -155,10 +157,31 @@ jobs:
|
||||
git fetch origin main
|
||||
git checkout -b "changelog/v${VERSION}" origin/main
|
||||
|
||||
# Move the [Unreleased] content into a versioned section
|
||||
# Find the previous production tag for the full-range notes
|
||||
PREV_PROD=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^v${VERSION}$" | head -1)
|
||||
|
||||
# Generate a single flat changelog for the entire release using GitHub API
|
||||
FLAT_NOTES=""
|
||||
if [ -n "$PREV_PROD" ]; then
|
||||
FLAT_NOTES=$(gh api "repos/${REPO}/releases/generate-notes" \
|
||||
-f tag_name="$TAG" \
|
||||
-f target_commitish="$(git rev-parse "$TAG")" \
|
||||
-f previous_tag_name="$PREV_PROD" \
|
||||
--jq '.body' 2>/dev/null || true)
|
||||
|
||||
# Strip boilerplate
|
||||
FLAT_NOTES=$(echo "$FLAT_NOTES" \
|
||||
| sed '/^<!-- .* -->/d' \
|
||||
| sed '/^## What'\''s Changed$/d' \
|
||||
| sed '/^\*\*Full Changelog\*\*/d' \
|
||||
| sed '/./,$!d' \
|
||||
| sed -e :a -e '/^\n*$/{$d;N;ba}')
|
||||
fi
|
||||
|
||||
# Build the versioned section and reset unreleased
|
||||
python3 -c "
|
||||
import sys
|
||||
version, date = sys.argv[1], sys.argv[2]
|
||||
version, date, notes = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
with open('CHANGELOG.md', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -174,31 +197,18 @@ jobs:
|
||||
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()
|
||||
versioned = f'## [{version}] - {date}\n\n{notes}'
|
||||
fresh = '## [Unreleased]\n\n*No changes yet.*'
|
||||
|
||||
# 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):]
|
||||
new_content = (
|
||||
content[:start + len(us)] + '\n' + fresh + '\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"
|
||||
" "$VERSION" "$DATE" "$FLAT_NOTES"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
@@ -216,6 +226,13 @@ jobs:
|
||||
--base main \
|
||||
--label "automation" \
|
||||
--label "skip-changelog"
|
||||
|
||||
# Post required commit status so the PR isn't blocked
|
||||
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"
|
||||
}
|
||||
|
||||
- name: Notify Discord
|
||||
|
||||
240
.github/workflows/update-changelog.yml
vendored
240
.github/workflows/update-changelog.yml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
group: changelog-${{ github.ref }}
|
||||
@@ -25,57 +26,209 @@ jobs:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine last production tag
|
||||
id: last_tag
|
||||
- name: Discover channel tags
|
||||
id: tags
|
||||
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"
|
||||
# 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"
|
||||
|
||||
- name: Generate release notes via GitHub API
|
||||
if: steps.last_tag.outputs.tag != ''
|
||||
id: notes
|
||||
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 }}
|
||||
TAG: ${{ steps.last_tag.outputs.tag }}
|
||||
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: |
|
||||
# Use GitHub's native release notes generator (same engine as generate_release_notes)
|
||||
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
|
||||
# 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="${{ github.sha }}" \
|
||||
-f previous_tag_name="$TAG" \
|
||||
--jq '.body')
|
||||
-f target_commitish="$HEAD_SHA" \
|
||||
-f previous_tag_name="$PROD_TAG" \
|
||||
--jq '.body' 2>/dev/null) || true
|
||||
|
||||
# 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}')
|
||||
CONTRIBUTORS=""
|
||||
if echo "$FULL_NOTES" | grep -q '## New Contributors'; then
|
||||
CONTRIBUTORS=$(echo "$FULL_NOTES" | sed -n '/^## New Contributors$/,$ p')
|
||||
fi
|
||||
|
||||
# Write to file for next step (avoids shell quoting issues)
|
||||
echo "$NOTES" > /tmp/unreleased-notes.md
|
||||
# === 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=()
|
||||
|
||||
- name: Update CHANGELOG.md
|
||||
if: steps.last_tag.outputs.tag != ''
|
||||
id: update
|
||||
env:
|
||||
TAG: ${{ steps.last_tag.outputs.tag }}
|
||||
run: |
|
||||
NOTES=$(cat /tmp/unreleased-notes.md)
|
||||
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
|
||||
|
||||
# Build the new unreleased section
|
||||
UNRELEASED=$(cat <<SECTION
|
||||
## [Unreleased]
|
||||
# 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
|
||||
|
||||
Changes since [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG}):
|
||||
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)
|
||||
|
||||
# Replace content between UNRELEASED markers
|
||||
python3 -c "
|
||||
import sys
|
||||
start_marker = '<!-- UNRELEASED_START -->'
|
||||
@@ -91,9 +244,8 @@ jobs:
|
||||
new_content = content[:start + len(start_marker)] + '\n' + section + '\n' + content[end:]
|
||||
with open('CHANGELOG.md', 'w') as f:
|
||||
f.write(new_content)
|
||||
" "$UNRELEASED"
|
||||
" "$SECTION"
|
||||
|
||||
# Check if there are actual changes
|
||||
if git diff --quiet CHANGELOG.md; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
@@ -101,7 +253,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Create or update changelog PR
|
||||
if: steps.last_tag.outputs.tag != '' && steps.update.outputs.changed == 'true'
|
||||
if: steps.tags.outputs.prod != '' && steps.update.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -130,3 +282,13 @@ jobs:
|
||||
--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"
|
||||
|
||||
Reference in New Issue
Block a user