From e451352412a46abb16777d9d036db01fb7963e4c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:50:46 -0500 Subject: [PATCH] feat(ci): segmented changelog with channel dedup and production squash (#5259) --- .github/workflows/promote.yml | 65 ++++--- .github/workflows/update-changelog.yml | 240 +++++++++++++++++++++---- 2 files changed, 242 insertions(+), 63 deletions(-) diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index a8744af3a..5d8072941 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -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 diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index ee6969324..aede958ac 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -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 <
/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 ${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 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 <
/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 = '' @@ -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"