chore: Scheduled updates (Firmware, Hardware, Translations) (#6349) #726
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@v7.0.1 | |
| 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 categorized notes via GitHub API, honoring | |
| # .github/release.yml categories + excludes. Works with ANY existing | |
| # tag as previous_tag (release OR channel), target = a tag or a SHA. | |
| 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 '/^## New Contributors$/,$d' \ | |
| | sed '/^\*\*Full Changelog\*\*/d' \ | |
| | sed '/./,$!d' \ | |
| | sed -e :a -e '/^\n*$/{$d;N;ba}' | |
| } | |
| # 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" | awk ' | |
| /^## New Contributors$/ { capture=1 } | |
| capture && /^\*\*Full Changelog\*\*/ { exit } | |
| capture { print } | |
| ') | |
| 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" | |
| for ((i=0; i<N; i++)); do | |
| LABEL="${TAG_LABELS[$i]}" | |
| TAG="${TAG_NAMES[$i]}" | |
| SHA="${TAG_SHAS[$i]}" | |
| NOTES=$(generate_notes_api "$SHA" "$PREV_REF" | demote_headings) | |
| 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" | |
| done | |
| # === Unreleased segment (HEAD -> newest tag) === | |
| UNRELEASED_SECTION="" | |
| # Indexing TAG_NAMES[-1] on an empty array is a fatal error under | |
| # bash -e, so the no-channel-tags state (right after a production | |
| # release) needs an explicit branch instead of a :- fallback. | |
| if (( N > 0 )); then | |
| LATEST_TAG="${TAG_NAMES[$((N-1))]}" | |
| else | |
| LATEST_TAG="$PROD_TAG" | |
| fi | |
| if [ "$HEAD_SHA" != "$(git rev-parse "$LATEST_TAG" 2>/dev/null)" ]; then | |
| UNRELEASED_NOTES=$(generate_notes_api "$HEAD_SHA" "$LATEST_TAG" | demote_headings) | |
| if [ -n "$UNRELEASED_NOTES" ]; then | |
| UNRELEASED_SECTION=$(cat <<SECTION | |
| ### Unreleased (not yet in any build) | |
| ${UNRELEASED_NOTES} | |
| SECTION | |
| ) | |
| fi | |
| fi | |
| # Assemble the full unreleased section (newest first). | |
| # Use if-statements, not `[ -n ... ] && ...` lists: a failed test as | |
| # the group's last command makes the whole step exit 1 under bash -e. | |
| { | |
| echo "## [Unreleased]" | |
| echo "" | |
| if [ -n "$UNRELEASED_SECTION" ]; then echo "$UNRELEASED_SECTION"; echo ""; fi | |
| if [ -n "$SECTIONS" ]; then echo "$SECTIONS"; echo ""; fi | |
| if [ -n "$CONTRIBUTORS" ]; then echo "$CONTRIBUTORS"; fi | |
| } > /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" |