Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ed12f3f
chore: make project changelogs continuously regenerable
Isthimius Sep 1, 2026
5d207a2
chore: align release changelog generation
Isthimius Sep 1, 2026
c011282
ci: ignore generated project changelogs
Isthimius Sep 1, 2026
784be43
ci: refresh unreleased changelogs on master
Isthimius Sep 1, 2026
f743728
chore: harden changelog regeneration
Isthimius Sep 1, 2026
146cc9c
docs: document running project changelogs
Isthimius Sep 1, 2026
397b72e
fix: use regex instance for changelog section scan
Isthimius Sep 1, 2026
3dcff7d
ci: validate changelog generation on pull requests
Isthimius Sep 1, 2026
a5c52f5
chore: include current tooling projects in changelogs
Isthimius Sep 1, 2026
b667dd8
chore: align root changelog groups with current tooling
Isthimius Sep 1, 2026
178ba0b
ci: harden changelog generation validation
Isthimius Sep 1, 2026
4534b0b
chore: include tooling scripts in root changelog
Isthimius Sep 1, 2026
69f0e94
ci: show changelog idempotence differences
Isthimius Sep 1, 2026
b541208
fix: normalize generated changelog section boundary
Isthimius Sep 1, 2026
9734512
fix: canonicalize generated changelog structure
Isthimius Sep 1, 2026
bcf847d
ci: show tooling changelog idempotence differences
Isthimius Sep 1, 2026
cc0daae
fix: compose project changelogs deterministically
Isthimius Sep 1, 2026
37316ab
fix: preserve released history across changelog refreshes
Isthimius Sep 1, 2026
28c7a0f
fix: rebuild changelogs from explicit release ranges
Isthimius Sep 1, 2026
d2ea5e1
ci: show bootstrap changelog on validation failure
Isthimius Sep 1, 2026
13b9373
ci: compare git-cliff bootstrap ranges
Isthimius Sep 1, 2026
df74ccf
fix: use full git-cliff output for changelog bootstrap
Isthimius Sep 1, 2026
dc5f7b6
fix: canonicalize bootstrap release boundaries
Isthimius Sep 1, 2026
fe7639d
fix: compose bootstrap changelog with shared parser
Isthimius Sep 1, 2026
faed75f
fix: generate git-cliff output to new temp paths
Isthimius Sep 1, 2026
96fcba3
fix: bootstrap changelogs with direct git-cliff invocation
Isthimius Sep 1, 2026
7f24e39
fix: bootstrap changelogs from repository working directory
Isthimius Sep 1, 2026
c677b16
fix: make changelog regeneration deterministic
Isthimius Sep 1, 2026
1e22151
fix: publish release branch and tag atomically
Isthimius Sep 1, 2026
4f7dcc8
fix: exclude root changelog from automation
Isthimius Sep 1, 2026
af824ab
fix: generate root unreleased changelog
Isthimius Sep 1, 2026
6adce86
test: preserve original root release boundary
Isthimius Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/changelog-master.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Changelog (master)

on:
push:
branches: [ master ]
paths-ignore:
- 'CHANGELOG.md'
- '**/CHANGELOG.md'

permissions:
contents: write

concurrency:
group: changelog-master
cancel-in-progress: true

jobs:
update-changelogs:
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest

steps:
- name: Checkout latest master
uses: actions/checkout@v4
with:
ref: master
fetch-depth: 0

- name: Install git-cliff
uses: taiki-e/install-action@git-cliff

- name: Refresh project changelogs
shell: pwsh
run: ./Tooling/scripts/Generate-Project-Changelogs.ps1

- name: Commit changelog updates
shell: bash
run: |
if git diff --quiet; then
echo "No changelog changes detected."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "docs: update unreleased changelogs"

# A second master push can land while this run is generating files.
# If that happens, discard the stale generated commit, regenerate
# against the newest master, and retry once.
if ! git push origin HEAD:master; then
echo "master advanced during changelog generation; regenerating against latest master."
git fetch origin master --tags
git reset --hard origin/master

pwsh ./Tooling/scripts/Generate-Project-Changelogs.ps1

if git diff --quiet; then
echo "Latest master already contains the required changelog state."
exit 0
fi

git add -A
git commit -m "docs: update unreleased changelogs"
git push origin HEAD:master
fi
2 changes: 2 additions & 0 deletions .github/workflows/ci-master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ on:
- '.github/workflows/docs.yml'
- '.github/release.yml'
- 'CHANGELOG.md'
- '**/CHANGELOG.md'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths-ignore:
- 'docs/**'
- '.github/workflows/docs.yml'
- '.github/release.yml'
- 'CHANGELOG.md'
- '**/CHANGELOG.md'

permissions:
contents: read
Expand Down
204 changes: 168 additions & 36 deletions Tooling/scripts/Generate-Project-Changelogs.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@

Excluded from generation: Demos, Gondwana.Tests (test-only project).

Behaviour matches release.ps1:
- If the project's CHANGELOG.md is new/empty, --output is used so the
header block is written.
- If it already exists, --prepend is used to prepend only the new section.
Behaviour:
- If the project's CHANGELOG.md is new/empty, the complete project history
is generated. Existing Git tags become versioned sections and current
untagged commits are emitted as [Unreleased], unless -Tag is supplied.
- If the changelog already exists, a leading [Unreleased] section from a
previous run is removed and regenerated from commits since the latest tag.
- With -Tag, current unreleased commits are written under that version tag.
- Without -Tag, current unreleased commits are written under [Unreleased].

.PARAMETER Tag
Version tag to pass to git-cliff (e.g. "v1.2.3"). When omitted, git-cliff
uses the most recent tag it can find; combine with -Unreleased to preview
unreleased changes without a tag.

.PARAMETER Unreleased
Pass --unreleased to git-cliff so only commits since the last tag are shown.
Version tag to stamp on the current unreleased commits (e.g. "v1.2.3").
When omitted, current unreleased commits remain under [Unreleased].

.PARAMETER PreviewOnly
Print the generated changelog section to the console instead of writing to
Print the generated changelog output to the console instead of writing to
disk. Nothing is modified on disk when this switch is set.

.PARAMETER Projects
Expand All @@ -36,20 +36,23 @@
Path to the cliff.toml config file. Defaults to cliff.toml in the repo root.

.EXAMPLE
# Generate changelogs for all library projects for the current unreleased commits
.\Generate-Project-Changelogs.ps1 -Unreleased
# Refresh [Unreleased] sections for all library projects
.\Generate-Project-Changelogs.ps1

.EXAMPLE
# Preview the current unreleased changes without touching disk
.\Generate-Project-Changelogs.ps1 -PreviewOnly

.EXAMPLE
# Preview what would be written for a specific tag, without touching disk
.\Generate-Project-Changelogs.ps1 -Tag v1.2.3 -Unreleased -PreviewOnly
# Convert the current unreleased changes to a versioned release section
.\Generate-Project-Changelogs.ps1 -Tag v1.2.3

.EXAMPLE
# Generate changelogs for a subset of projects only
.\Generate-Project-Changelogs.ps1 -Projects @("Gondwana", "Gondwana.WinForms") -Unreleased
.\Generate-Project-Changelogs.ps1 -Projects @("Gondwana", "Gondwana.WinForms")
#>
param(
[string]$Tag,
[switch]$Unreleased,
[switch]$PreviewOnly,
[string[]]$Projects,
[string]$CliffConfigPath = "cliff.toml"
Expand Down Expand Up @@ -86,7 +89,7 @@ if (-not $Projects) {
}

# Resolve the repo root from the script's location:
# Solution Items/scripts/ → Solution Items/ → root
# Tooling/scripts/ -> Tooling/ -> root
$repoRoot = (Get-Item (Join-Path $PSScriptRoot '../..')).FullName

if (-not [System.IO.Path]::IsPathRooted($CliffConfigPath)) {
Expand All @@ -101,6 +104,68 @@ if (-not (Get-Command git-cliff -ErrorAction SilentlyContinue)) {
throw "git-cliff was not found on PATH. Install with: winget install --id orhun.git-cliff"
}

function Remove-LeadingUnreleasedSection {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Content
)

if ([string]::IsNullOrWhiteSpace($Content)) {
return $Content
}

# Only remove [Unreleased] when it is the first release-like top-level
# heading. This preserves released history even if unusual text appears
# elsewhere in the document.
$releaseHeadingPattern = '(?m)^#\s+(?:\[Unreleased\]|\[?v?\d+\.\d+\.\d+)'
$firstReleaseHeading = [regex]::Match($Content, $releaseHeadingPattern)

if (-not $firstReleaseHeading.Success -or $firstReleaseHeading.Value -notmatch '\[Unreleased\]') {
return $Content
}

$versionHeadingPattern = '(?m)^#\s+\[?v?\d+\.\d+\.\d+'
$nextVersionHeading = [regex]::Match(
$Content,
$versionHeadingPattern,
$firstReleaseHeading.Index + $firstReleaseHeading.Length
)

$before = $Content.Substring(0, $firstReleaseHeading.Index).TrimEnd()

if ($nextVersionHeading.Success) {
$after = $Content.Substring($nextVersionHeading.Index).TrimStart()
}
else {
$after = ""
}

if ([string]::IsNullOrWhiteSpace($before)) {
return $after
}

if ([string]::IsNullOrWhiteSpace($after)) {
return $before + [Environment]::NewLine
}

return $before + [Environment]::NewLine + [Environment]::NewLine + $after
}

function Write-Utf8NoBom {
param(
[Parameter(Mandatory = $true)]
[string]$Path,

[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Content
)

$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
}

$failed = @()

foreach ($project in $Projects) {
Expand All @@ -112,54 +177,121 @@ foreach ($project in $Projects) {

$includePath = "$($project -replace '\\', '/')/**/*"
$changelogPath = Join-Path $projectFolder "CHANGELOG.md"
$changelogIsNew = (-not (Test-Path $changelogPath)) -or ((Get-Item $changelogPath).Length -eq 0)

if ($changelogIsNew) {
$mode = "bootstrap full history"
}
elseif ($Tag) {
$mode = "release $Tag"
}
else {
$mode = "refresh [Unreleased]"
}

Write-Host ""
Write-Host "--- $project ---"
Write-Host " include-path : $includePath"
Write-Host " changelog : $changelogPath"
Write-Host " mode : $mode"

# Build the git-cliff argument list.
$cliffArgs = @(
"--config", $CliffConfigPath,
"--repository", $repoRoot,
"--include-path", $includePath
)

if ($Tag) {
$cliffArgs += "--tag", $Tag
if ($changelogIsNew) {
# No changelog exists yet: build the complete history. git-cliff includes
# current untagged commits as [Unreleased] by default; supplying --tag
# stamps those commits with the requested release version instead.
if ($Tag) {
$cliffArgs += "--tag", $Tag
}

if ($PreviewOnly) {
Write-Host " (preview only)"
& git-cliff @cliffArgs
if ($LASTEXITCODE -ne 0) {
Write-Warning "git-cliff failed for project '$project' (exit $LASTEXITCODE)."
$failed += $project
}
continue
}

$tempChangelog = [System.IO.Path]::GetTempFileName()
try {
& git-cliff @cliffArgs "--output" $tempChangelog
if ($LASTEXITCODE -ne 0) {
Write-Warning "git-cliff failed for project '$project' (exit $LASTEXITCODE)."
$failed += $project
continue
}

$generatedContent = Get-Content $tempChangelog -Raw
if ($null -eq $generatedContent) {
$generatedContent = ""
}

Write-Utf8NoBom -Path $changelogPath -Content $generatedContent
Write-Host " Written."
}
finally {
if (Test-Path $tempChangelog) {
Remove-Item $tempChangelog -Force
}
}

continue
}

if ($Unreleased) {
$cliffArgs += "--unreleased"
# Existing changelog: released history is authoritative. Regenerate only
# the current range since the latest tag and replace any previous top
# [Unreleased] section instead of stacking duplicate generated sections.
$cliffArgs += "--unreleased"
if ($Tag) {
$cliffArgs += "--tag", $Tag
}

if ($PreviewOnly) {
# Write to stdout; nothing touches disk.
Write-Host " (preview only)"
& git-cliff @cliffArgs
if ($LASTEXITCODE -ne 0) {
Write-Warning "git-cliff failed for project '$project' (exit $LASTEXITCODE)."
$failed += $project
}
continue
}
else {
$changelogIsNew = (-not (Test-Path $changelogPath)) -or ((Get-Item $changelogPath).Length -eq 0)

if ($changelogIsNew) {
# First-ever changelog: --output so the "# Changelog" header is written.
& git-cliff @cliffArgs "--output" $changelogPath
}
else {
# Existing changelog: prepend only the new unreleased section.
& git-cliff @cliffArgs "--prepend" $changelogPath
}
$existingContent = Get-Content $changelogPath -Raw
if ($null -eq $existingContent) {
$existingContent = ""
}

$baseContent = Remove-LeadingUnreleasedSection -Content $existingContent
$tempChangelog = [System.IO.Path]::GetTempFileName()

try {
Write-Utf8NoBom -Path $tempChangelog -Content $baseContent

& git-cliff @cliffArgs "--prepend" $tempChangelog
if ($LASTEXITCODE -ne 0) {
Write-Warning "git-cliff failed for project '$project' (exit $LASTEXITCODE)."
$failed += $project
continue
}
else {
Write-Host " Written."

$generatedContent = Get-Content $tempChangelog -Raw
if ($null -eq $generatedContent) {
$generatedContent = ""
}

Write-Utf8NoBom -Path $changelogPath -Content $generatedContent
Write-Host " Written."
}
finally {
if (Test-Path $tempChangelog) {
Remove-Item $tempChangelog -Force
}
}
}
Expand Down
Loading
Loading