Skip to content

Commit 0dc8051

Browse files
committed
ci(release): automate changelog promotion (2026.6.15.0-7E63)
1 parent 21d234d commit 0dc8051

7 files changed

Lines changed: 123 additions & 67 deletions

File tree

‎.github/RELEASE_WORKFLOW.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,19 @@ body via `gh release view --json body` and compares it byte-for-byte
173173
This catches GitHub-side normalisation surprises (which are rare but real)
174174
without letting a malformed body sit on the release indefinitely.
175175

176+
## Changelog promotion
177+
178+
The workflow promotes `## Unreleased` to the tagged section before the build
179+
so the embedded `CHANGELOG.md` matches the shipped release. Before promotion,
180+
it also replays the commit range since the previous stable tag into
181+
`## Unreleased`; that keeps the release notes correct when the tag job starts
182+
before the push-triggered changelog appender has committed back to main.
183+
184+
After the release publishes, the stashed promoted `CHANGELOG.md` is committed
185+
back to main with GitHub's `createCommitOnBranch` mutation. This is the same
186+
verified-commit path used by `changelog-append.yml` and avoids the old
187+
promotion-branch PR flow.
188+
176189
## Failure modes + remediations
177190

178191
| Symptom | Fix |
@@ -182,7 +195,7 @@ without letting a malformed body sit on the release indefinitely.
182195
| `Voice or internal-only-vocabulary patterns in release body` | Amend the offending commit subject. Or `[skip changelog]` it if the term is genuinely unavoidable. |
183196
| `Generate-ReleaseNotes.ps1 returned empty output` | The script failed silently or the slice was empty. Check the workflow log for warnings; if the slice really is empty, the empty-slice guard would have already thrown -- so this is a script bug. |
184197
| `Release body still differs after auto-correct` | A GitHub-side issue. Compare the input file in the runner artifacts against what `gh release view` returns. Often a trailing-whitespace or unicode-form difference. |
185-
| `Open promotion PR ... pull request create failed` | Repo setting `Allow GitHub Actions to create and approve pull requests` is OFF. The release publishes correctly; only the `## Unreleased` -> tagged-section promotion fails. Cosmetic; flip the setting if you want it. |
198+
| `createCommitOnBranch returned GraphQL errors` | Main moved after the workflow read its head, or GitHub rejected the file update. Re-run the workflow after main settles; if it repeats, inspect the GraphQL error text and push the same CHANGELOG.md promotion in the next source commit. |
186199

187200
## Updating the workflow
188201

‎.github/scripts/Test-UpdateChangelog.ps1‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ function Assert-Contains {
3939
}
4040
}
4141

42+
function Assert-NotContains {
43+
param([string]$Text, [string]$Unexpected)
44+
if ($Text.Contains($Unexpected)) {
45+
throw "Expected text not to contain '$Unexpected'."
46+
}
47+
}
48+
4249
try {
4350
New-Item -ItemType Directory -Force -Path $TempRoot | Out-Null
4451
Push-Location $TempRoot
@@ -79,6 +86,13 @@ _No notable changes since the last release._
7986
$notes = (& $Updater -Mode Notes -ForVersion -Version 'v2026.6.4.0' -RepoRoot $TempRoot) -join "`n"
8087
Assert-Contains -Text $notes -Expected '**mesh:** Test changelog append'
8188

89+
& $Updater -Mode Promote -Version 'v2026.6.5.0' -RepoRoot $TempRoot -Repo 'RealWhyKnot/WKVRCProxy'
90+
if ($LASTEXITCODE -ne 0) { throw "Update-Changelog empty Promote failed with exit code $LASTEXITCODE" }
91+
92+
$emptyPromoted = [System.IO.File]::ReadAllText((Join-Path $TempRoot 'CHANGELOG.md'), $Utf8NoBom)
93+
Assert-Contains -Text $emptyPromoted -Expected '_No user-visible changes in this release._'
94+
Assert-NotContains -Text $emptyPromoted -Unexpected 'Maintenance release'
95+
8296
Write-Host 'Update-Changelog tests passed.'
8397
}
8498
finally {

‎.github/scripts/Test-WorkflowSyntax.ps1‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,15 @@ if (Test-Path -LiteralPath $releaseWorkflow) {
115115
if ($releaseText -notmatch 'gh release create \$tag \$zip \$integrity') {
116116
$errors.Add('release.yml does not upload both the zip and integrity TSV assets.') | Out-Null
117117
}
118+
if ($releaseText -match 'gh pr create|Open promotion PR|promote-changelog') {
119+
$errors.Add('release.yml contains the deprecated changelog-promotion PR path.') | Out-Null
120+
}
121+
if ($releaseText -notmatch 'Preloading changelog entries from') {
122+
$errors.Add('release.yml does not preload changelog entries before promotion.') | Out-Null
123+
}
124+
if ($releaseText -notmatch 'Commit promoted CHANGELOG\.md back to main \(verified\)') {
125+
$errors.Add('release.yml does not commit promoted CHANGELOG.md back through the verified path.') | Out-Null
126+
}
118127
}
119128

120129
if ($errors.Count -gt 0) {

‎.github/scripts/Update-Changelog.ps1‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,10 +394,9 @@ if ($Mode -eq 'Promote') {
394394
if ($l -match '^\s*- ' -or $l -match '^###\s+') { $hasReal = $true; break }
395395
}
396396
if (-not $hasReal) {
397-
# Empty section: the released version still gets an entry, just
398-
# with a stub note. This keeps the heading -> release-page link
399-
# alive so users browsing the changelog can click through.
400-
$bodyLines = @('', '_Maintenance release; see commit log for details._', '')
397+
# Empty section: the released version still gets an entry, but avoid
398+
# describing the release type when there were no changelog bullets.
399+
$bodyLines = @('', '_No user-visible changes in this release._', '')
401400
}
402401

403402
# Build the new file:

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ name: CI
33
on:
44
push:
55
branches: [main]
6-
# Negations re-include CHANGELOG.md so the auto-merged
7-
# release-promotion PR clears its required status check (release.yml opens
8-
# it after every tag push).
6+
# Negations re-include CHANGELOG.md so changelog-only pulls and commits
7+
# still exercise the required status check.
98
paths-ignore:
109
- '**.md'
1110
- '!CHANGELOG.md'

‎.github/workflows/release.yml‎

Lines changed: 77 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,16 @@ name: Release
1212
#
1313
# Changelog promotion: before the build runs, .github/scripts/Update-Changelog.ps1 renames the
1414
# "## Unreleased" heading in CHANGELOG.md to "## [vTAG] - DATE", linking
15-
# to this release. After the GitHub release is created the promoted file is pushed to a
16-
# release/promote-changelog-<tag> branch and a PR is opened with auto-merge so main eventually
17-
# carries the promotion (squashed in by github-actions[bot] once CI passes). Direct push does
18-
# not work on protected main: the dotnet-build-+-test required check is missing on bot pushes,
19-
# so branch protection rejects them.
15+
# to this release. After the GitHub release is created, the promoted file is
16+
# committed back to main through the verified createCommitOnBranch path so main
17+
# carries the same CHANGELOG.md that shipped in the release artifact.
2018
on:
2119
push:
2220
tags:
2321
- 'v*'
2422

2523
permissions:
2624
contents: write
27-
pull-requests: write # release step opens a PR for the changelog promotion
2825

2926
# Share a concurrency group with changelog-append so the appender can't run
3027
# during a release build and add entries to main's Unreleased that the release
@@ -45,8 +42,8 @@ jobs:
4542
with:
4643
fetch-depth: 0
4744
fetch-tags: true
48-
# GITHUB_TOKEN is enough: the promotion-PR step pushes the bot's
49-
# branch (not main) and uses gh to open + auto-merge the PR.
45+
# GITHUB_TOKEN is enough for release publishing and the verified
46+
# createCommitOnBranch mutations used after publish.
5047
token: ${{ secrets.GITHUB_TOKEN }}
5148

5249
- name: Detect beta tag
@@ -89,7 +86,18 @@ jobs:
8986
env:
9087
GITHUB_REPOSITORY: ${{ github.repository }}
9188
run: |
92-
./.github/scripts/Update-Changelog.ps1 -Mode Promote -Version '${{ github.ref_name }}'
89+
$tag = '${{ github.ref_name }}'
90+
$prevTag = ''
91+
$prevOutput = & git describe --tags --abbrev=0 --exclude '*-*' "$tag^" 2>$null
92+
if ($LASTEXITCODE -eq 0 -and $prevOutput) {
93+
$prevTag = [string]$prevOutput
94+
Write-Host "Preloading changelog entries from $prevTag..$tag before promotion."
95+
./.github/scripts/Update-Changelog.ps1 -Mode Append -Range "$prevTag..$tag"
96+
} else {
97+
Write-Host "No previous stable tag found before $tag; promoting the existing Unreleased section."
98+
}
99+
100+
./.github/scripts/Update-Changelog.ps1 -Mode Promote -Version $tag
93101
# Stash the promoted file so we can re-apply it on main after the
94102
# release. Doing it now (before build.ps1 mutates the worktree) means
95103
# the bytes we push back to main are exactly what shipped in the exe.
@@ -402,66 +410,80 @@ jobs:
402410
fi
403411
echo "Hash append commit: $new_oid (verified)"
404412
405-
- name: Open promotion PR + enable auto-merge
413+
- name: Commit promoted CHANGELOG.md back to main (verified)
406414
if: ${{ steps.beta.outputs.is_beta != 'true' }}
407-
# We can't push directly to main: branch protection requires the
408-
# "dotnet build + test" status check, which a bot push bypasses (so
409-
# the push is rejected). Instead we open a PR off the tag and let
410-
# auto-merge squash it once CI goes green. ci.yml has CHANGELOG.md
411-
# carved out of paths-ignore so the required
412-
# check actually runs on changelog-only PRs.
413-
#
415+
# Uses the same createCommitOnBranch pattern as changelog-append.yml
416+
# and the wrapper-hash step so the commit lands verified on main.
414417
# Done last so a build/release failure doesn't leave a phantom
415-
# "promotion PR open but never released" state.
418+
# changelog promotion for a release that never shipped.
416419
shell: bash
417420
env:
418421
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
422+
GITHUB_REPOSITORY: ${{ github.repository }}
419423
TAG_NAME: ${{ github.ref_name }}
420424
run: |
421425
set -euo pipefail
422-
git config user.name 'github-actions[bot]'
423-
git config user.email 'github-actions[bot]@users.noreply.github.com'
424-
425-
branch="release/promote-changelog-${TAG_NAME}"
426-
git checkout -b "$branch"
427426
428427
# Replay the stashed file (captured before build.ps1 ran) so the
429428
# bytes we put on main are exactly what shipped in the embedded exe.
430429
cp .changelog-stash/CHANGELOG.md CHANGELOG.md
431430
432-
if [[ -z "$(git status --porcelain CHANGELOG.md)" ]]; then
433-
echo "Nothing to promote — main is already in promoted state."
431+
main_oid=$(gh api "repos/$GITHUB_REPOSITORY/branches/main" --jq '.commit.sha')
432+
main_changelog_blob=$(gh api "repos/$GITHUB_REPOSITORY/contents/CHANGELOG.md?ref=$main_oid" --jq '.sha' 2>/dev/null || echo "")
433+
local_changelog_blob=$(git hash-object CHANGELOG.md)
434+
435+
if [[ "$main_changelog_blob" == "$local_changelog_blob" ]]; then
436+
echo "main already carries the promoted changelog -- nothing to commit."
434437
exit 0
435438
fi
436439
437-
# [skip changelog] keeps changelog-append from re-bulleting this commit
438-
# if the squash trigger ever races past the bot-actor filter.
439-
git add CHANGELOG.md
440-
git commit -m "docs(changelog): promote Unreleased -> ${TAG_NAME} [skip changelog]"
441-
git push origin "$branch"
442-
443-
pr_body="Promotion PR opened by .github/workflows/release.yml after publishing **${TAG_NAME}**. Mirrors the embedded \`CHANGELOG.md\` that shipped inside the exe back onto \`main\` so the next push starts with a fresh \`## Unreleased\` section. Auto-merge enabled -- will squash once \`dotnet build + test\` passes."
444-
if pr_url="$(gh pr create \
445-
--base main \
446-
--head "$branch" \
447-
--title "docs(changelog): promote Unreleased -> ${TAG_NAME}" \
448-
--body "$pr_body" \
449-
2>pr-create.err)"; then
450-
echo "Opened: $pr_url"
451-
452-
# --auto queues the merge; it actually fires once required checks pass.
453-
# Use --subject so the squash commit subject keeps the [skip changelog]
454-
# marker (the appender also filters by bot actor, but belt-and-braces).
455-
if ! gh pr merge "$pr_url" \
456-
--auto \
457-
--squash \
458-
--delete-branch \
459-
--subject "docs(changelog): promote Unreleased -> ${TAG_NAME} [skip changelog]" \
460-
2>pr-merge.err; then
461-
cat pr-merge.err >&2
462-
echo "::warning::Release published, but auto-merge could not be enabled for promotion PR $pr_url."
463-
fi
464-
else
465-
cat pr-create.err >&2
466-
echo "::warning::Release published, but GitHub Actions could not create the promotion PR. Branch '$branch' was pushed; promote CHANGELOG.md manually."
440+
changelog_b64=$(base64 -w 0 CHANGELOG.md)
441+
headline="docs(changelog): promote Unreleased -> ${TAG_NAME} [skip changelog]"
442+
body=$(printf '%s\n%s' \
443+
"Promotes the CHANGELOG.md section published for ${TAG_NAME}." \
444+
'Mirrors the file that shipped in the release artifact back to main.')
445+
446+
payload=$(jq -n \
447+
--arg repo "$GITHUB_REPOSITORY" \
448+
--arg branch "main" \
449+
--arg headline "$headline" \
450+
--arg body "$body" \
451+
--arg oid "$main_oid" \
452+
--arg changelog "$changelog_b64" \
453+
'{
454+
query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }",
455+
variables: {
456+
input: {
457+
branch: { repositoryNameWithOwner: $repo, branchName: $branch },
458+
message: { headline: $headline, body: $body },
459+
fileChanges: {
460+
additions: [
461+
{ path: "CHANGELOG.md", contents: $changelog }
462+
]
463+
},
464+
expectedHeadOid: $oid
465+
}
466+
}
467+
}')
468+
469+
response=$(printf '%s' "$payload" | gh api graphql --input -)
470+
echo "$response" | jq .
471+
472+
if echo "$response" | jq -e '.errors // empty' >/dev/null; then
473+
echo "::error::createCommitOnBranch returned GraphQL errors"
474+
exit 1
475+
fi
476+
477+
new_oid=$(echo "$response" | jq -r '.data.createCommitOnBranch.commit.oid')
478+
if [[ -z "$new_oid" || "$new_oid" == "null" ]]; then
479+
echo "::error::createCommitOnBranch did not return a commit oid"
480+
exit 1
481+
fi
482+
483+
verified=$(gh api "repos/$GITHUB_REPOSITORY/commits/$new_oid" --jq '.commit.verification.verified')
484+
if [[ "$verified" != "true" ]]; then
485+
echo "::error::Commit $new_oid is not verified (got: $verified)"
486+
gh api "repos/$GITHUB_REPOSITORY/commits/$new_oid" --jq '.commit.verification'
487+
exit 1
467488
fi
489+
echo "Changelog promotion commit: $new_oid (verified)"

‎CHANGELOG.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ Release entries are listed newest first. This changelog starts with the first pu
1111

1212
## Unreleased
1313

14-
### Fixed
15-
- **vrc:** Arm native fallback on playback failure (4c506cd)
16-
- **updater:** Recover release update flow (a70a452)
14+
_No notable changes since the last release._
1715

1816
---
1917

2018
## [v2026.6.15.0](https://github.com/RealWhyKnot/WKVRCProxy/releases/tag/v2026.6.15.0) - 2026-06-15
2119

22-
_Maintenance release; see commit log for details._
20+
### Fixed
21+
- **vrc:** Arm native fallback on playback failure (4c506cd)
22+
- **updater:** Recover release update flow (a70a452)
2323

2424
---
2525

0 commit comments

Comments
 (0)