From 4a7a4dfd701b085a754c796b23de59ff9d5c500e Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 8 Jul 2026 16:50:14 +0800 Subject: [PATCH] chore: automate CHANGELOG.md generation for releases --- .github/release.yml | 1 + admin/README.md | 4 + admin/RELEASE.md | 37 ++-- admin/generate-changelog.php | 153 +++++++++++++++ .../AutoReview/GenerateChangelogTest.php | 183 ++++++++++++++++++ 5 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 admin/generate-changelog.php create mode 100644 tests/system/AutoReview/GenerateChangelogTest.php diff --git a/.github/release.yml b/.github/release.yml index de6e982d91a6..09ead7ca89d2 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -2,6 +2,7 @@ changelog: exclude: authors: - dependabot + - dependabot[bot] categories: - title: Breaking Changes labels: diff --git a/admin/README.md b/admin/README.md index e12b4bea0af0..f070ca58b459 100644 --- a/admin/README.md +++ b/admin/README.md @@ -46,6 +46,10 @@ This folder contains tools or docs useful for project maintainers. The release process is detailed in [RELEASE.md](./RELEASE.md). These scripts are used as part of that process: +- **generate-changelog.php** prepends the CHANGELOG.md entry for a new + release, built from GitHub's auto-generated release notes and the + SECURITY section of the version's detailed changelog. + Usage: `php admin/generate-changelog.php 4.x.x [--dry-run]` - **prepare-release.php** creates the `release-4.x.x` branch and updates version references in the framework source, the user guide, and the distribution build script. diff --git a/admin/RELEASE.md b/admin/RELEASE.md index a6b3f680b6ad..cd2dbcababd3 100644 --- a/admin/RELEASE.md +++ b/admin/RELEASE.md @@ -76,21 +76,26 @@ PRs with breaking changes must have the following additional label: ### Generate Changelog -To auto-generate the changelog, navigate to the -[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page, -click the "Draft a new release" button. - -* Choose a tag: `v4.x.x` (Create new tag: v4.x.x on publish) -* Target: `develop` - -Click the "Generate release notes" button. - -Check the resulting content. If there are items in the *Others* section which -should be included in the changelog, add a label to the PR and regenerate -the changelog. - -Copy the resulting contents into **CHANGELOG.md** and adjust the format to match -the existing content. +The changelog is generated from GitHub's auto-generated release notes with a +script. It requires the authenticated [GitHub CLI](https://cli.github.com/). + +The script also copies the security fixes from the SECURITY section of +**user_guide_src/source/changelogs/v4.x.x.rst**, so any security advisory PRs +must be merged and documented there before generating the changelog. + +* [ ] Run `php admin/generate-changelog.php 4.x.x --dry-run` and check the output. + * The script lists the PRs that have none of the changelog labels. If any of + them should be included in the changelog, add a label to the PR and run + the script again. +* [ ] Run `php admin/generate-changelog.php 4.x.x` to prepend the new entry to + **CHANGELOG.md**. + +If the script cannot be used, the same notes can be generated from the +[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page: +click "Draft a new release", choose the tag `v4.x.x` (create new tag on +publish) with target `develop`, and click "Generate release notes". Copy the +resulting contents into **CHANGELOG.md** and adjust the format to match the +existing content. ## Process @@ -100,7 +105,7 @@ the existing content. > generating much new content. * [ ] Merge any security advisory PRs in private forks. -* [ ] Add the current version to **CHANGELOG.md** with the contents generated above. +* [ ] Check that **CHANGELOG.md** contains the entry for the new version, generated above. * [ ] Update **user_guide_src/source/changelogs/v4.x.x.rst** * Remove the section titles that have no items * [ ] Update **user_guide_src/source/installation/upgrade_4xx.rst** diff --git a/admin/generate-changelog.php b/admin/generate-changelog.php new file mode 100644 index 000000000000..f6578cb1372f --- /dev/null +++ b/admin/generate-changelog.php @@ -0,0 +1,153 @@ + [--dry-run] + */ +chdir(__DIR__ . '/..'); + +$args = array_slice($argv, 1); +$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--'))); +$params = array_values(array_diff($args, $options)); + +if (count($params) !== 1 || preg_match('/\A\d+\.\d+\.\d+\z/', $params[0]) !== 1) { + echo "Usage: php {$argv[0]} [--dry-run]\n"; + echo "E.g.,: php {$argv[0]} 4.7.5\n"; + + exit(1); +} + +$version = $params[0]; +$dryRun = in_array('--dry-run', $options, true); +$repo = 'codeigniter4/CodeIgniter4'; + +$changelogPath = './CHANGELOG.md'; +$changelog = file_get_contents($changelogPath); + +if (! $dryRun && str_contains($changelog, "## [v{$version}]")) { + echo "CHANGELOG.md already contains an entry for v{$version}.\n"; + + exit(1); +} + +// Fetches the auto-generated release notes. +$command = sprintf( + 'gh api repos/%s/releases/generate-notes -f tag_name=v%s -f target_commitish=develop 2>&1', + $repo, + $version, +); +exec($command, $output, $exitCode); + +if ($exitCode !== 0) { + echo "Failed to fetch the release notes from GitHub:\n"; + echo implode("\n", $output) . "\n"; + + exit(1); +} + +$response = json_decode(implode("\n", $output), true); +$body = $response['body'] ?? ''; + +// Parses the release notes into categories. See ".github/release.yml". +$sections = []; +$current = null; +$previousTag = null; + +foreach (preg_split('/\R/', $body) as $line) { + if (preg_match('/^### (.+)$/', $line, $matches) === 1) { + $current = $matches[1]; + $sections[$current] = []; + + continue; + } + + if (str_starts_with($line, '## ')) { + $current = null; + + continue; + } + + if (preg_match('~^\*\*Full Changelog\*\*: https://github\.com/[^/]+/[^/]+/compare/(.+?)\.\.\.~', $line, $matches) === 1) { + $previousTag = $matches[1]; + + continue; + } + + if ($current !== null && str_starts_with($line, '* ')) { + $sections[$current][] = $line; + } +} + +if ($previousTag === null) { + echo "Could not determine the previous tag from the release notes.\n"; + + exit(1); +} + +// PRs in the catch-all category have none of the changelog labels. They are +// listed for checking only and are not part of the generated entry. +$othersTitle = 'Others (Only for checking. Remove this category)'; +$others = $sections[$othersTitle] ?? []; +unset($sections[$othersTitle]); + +// Extracts the security fixes from the SECURITY section of the detailed +// changelog, if any. These come from security advisories, not from PRs, so +// they are not part of the generated release notes. +$rstPath = "./user_guide_src/source/changelogs/v{$version}.rst"; +$security = ''; + +if (is_file($rstPath)) { + $rst = file_get_contents($rstPath); + + if (preg_match('/^\*+\nSECURITY\n\*+\n(.*?)(?=^\*+$|\z)/msu', $rst, $matches) === 1) { + $security = trim($matches[1], "\n"); + $security = preg_replace('/^- /mu', '* ', $security); + } +} + +$date = date('Y-m-d'); +$entry = "## [v{$version}](https://github.com/{$repo}/tree/v{$version}) ({$date})\n"; +$entry .= "[Full Changelog](https://github.com/{$repo}/compare/{$previousTag}...v{$version})\n"; + +if ($security !== '') { + $entry .= "\n### Security\n\n{$security}\n"; +} + +foreach ($sections as $title => $items) { + if ($items === []) { + continue; + } + + $entry .= "\n### {$title}\n\n"; + $entry .= implode("\n", $items) . "\n"; +} + +if ($others !== []) { + echo "The following PRs have no changelog label and were NOT included.\n"; + echo "If any of them belong in the changelog, label the PR and run this script again:\n"; + echo implode("\n", $others) . "\n\n"; +} + +if ($dryRun) { + echo $entry; + + exit(0); +} + +$updated = preg_replace('/\A# Changelog\n\n/', "# Changelog\n\n{$entry}\n", $changelog, 1, $count); + +if ($count !== 1) { + echo 'Could not find the "# Changelog" header in CHANGELOG.md.' . "\n"; + + exit(1); +} + +file_put_contents($changelogPath, $updated); + +echo "Added the v{$version} entry to CHANGELOG.md.\n"; diff --git a/tests/system/AutoReview/GenerateChangelogTest.php b/tests/system/AutoReview/GenerateChangelogTest.php new file mode 100644 index 000000000000..cbb7e87e1e5c --- /dev/null +++ b/tests/system/AutoReview/GenerateChangelogTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\AutoReview; + +use Nexus\PHPUnit\Tachycardia\Attribute\TimeLimit; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; + +/** + * @internal + */ +#[CoversNothing] +#[Group('AutoReview')] +final class GenerateChangelogTest extends TestCase +{ + public function testUsageErrorWithoutArguments(): void + { + exec('php ./admin/generate-changelog.php 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + public function testUsageErrorWithInvalidVersion(): void + { + exec('php ./admin/generate-changelog.php 4.7 2>&1', $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Usage:', implode("\n", $output)); + } + + public function testRejectsVersionAlreadyInChangelog(): void + { + $version = $this->latestChangelogVersion(); + + exec("php ./admin/generate-changelog.php {$version} 2>&1", $output, $exitCode); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString( + "CHANGELOG.md already contains an entry for v{$version}.", + implode("\n", $output), + ); + } + + #[TimeLimit(10.0)] + public function testDryRunGeneratesEntryWithoutWriting(): void + { + $this->skipUnlessGhIsAuthenticated(); + + $version = $this->incrementPatch($this->latestChangelogVersion()); + + exec("php ./admin/generate-changelog.php {$version} --dry-run 2>&1", $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertStringContainsString( + "## [v{$version}](https://github.com/codeigniter4/CodeIgniter4/tree/v{$version})", + $outputString, + ); + $this->assertMatchesRegularExpression( + '~\[Full Changelog\]\(https://github\.com/codeigniter4/CodeIgniter4/compare/v[\d.]+\.\.\.v' . preg_quote($version, '~') . '\)~', + $outputString, + ); + $this->assertSame('', trim((string) exec('git status --porcelain -- CHANGELOG.md'))); + } + + #[TimeLimit(10.0)] + public function testDryRunIncludesSecurityFixesFromDetailedChangelog(): void + { + $this->skipUnlessGhIsAuthenticated(); + + $version = $this->latestVersionWithSecuritySection(); + + if ($version === null) { + $this->markTestSkipped('No detailed changelog with a SECURITY section found.'); + } + + exec("php ./admin/generate-changelog.php {$version} --dry-run 2>&1", $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertMatchesRegularExpression('/### Security\n\n\* /', $outputString); + } + + #[TimeLimit(10.0)] + public function testAddsEntryToChangelog(): void + { + $this->skipUnlessGhIsAuthenticated(); + + if (trim((string) exec('git status --porcelain -- CHANGELOG.md')) !== '') { + $this->markTestSkipped('You have uncommitted changes to CHANGELOG.md that will be erased by this test.'); + } + + $version = $this->incrementPatch($this->latestChangelogVersion()); + + try { + exec("php ./admin/generate-changelog.php {$version} 2>&1", $output, $exitCode); + $outputString = implode("\n", $output); + + $this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}"); + $this->assertStringContainsString("Added the v{$version} entry to CHANGELOG.md.", $outputString); + + $changelog = (string) file_get_contents('./CHANGELOG.md'); + $this->assertStringStartsWith( + "# Changelog\n\n## [v{$version}](https://github.com/codeigniter4/CodeIgniter4/tree/v{$version})", + $changelog, + ); + } finally { + exec('git restore -- CHANGELOG.md'); + } + } + + private function skipUnlessGhIsAuthenticated(): void + { + exec('gh auth status 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $this->markTestSkipped( + 'The GitHub CLI is not available or not authenticated. This test is expected ' + . 'to be skipped in CI and runs only where `gh` is authenticated, such as on ' + . 'a maintainer\'s machine.', + ); + } + } + + private function latestChangelogVersion(): string + { + $changelog = (string) file_get_contents('./CHANGELOG.md'); + + if (preg_match('/^## \[v(\d+\.\d+\.\d+)\]/m', $changelog, $matches) !== 1) { + $this->fail('Could not find a version entry in CHANGELOG.md.'); + } + + return $matches[1]; + } + + private function latestVersionWithSecuritySection(): ?string + { + $versions = []; + $paths = glob('./user_guide_src/source/changelogs/v*.rst'); + + if ($paths === false) { + return null; + } + + foreach ($paths as $path) { + if (preg_match('/v(\d+\.\d+\.\d+)\.rst$/', $path, $matches) !== 1) { + continue; + } + + if (str_contains((string) file_get_contents($path), "\nSECURITY\n")) { + $versions[] = $matches[1]; + } + } + + if ($versions === []) { + return null; + } + + usort($versions, version_compare(...)); + + return end($versions); + } + + private function incrementPatch(string $version): string + { + $parts = explode('.', $version); + + return sprintf('%d.%d.%d', $parts[0], $parts[1], ++$parts[2]); + } +}