Skip to content

Commit 4a7a4df

Browse files
committed
chore: automate CHANGELOG.md generation for releases
1 parent 192c22f commit 4a7a4df

5 files changed

Lines changed: 362 additions & 16 deletions

File tree

.github/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ changelog:
22
exclude:
33
authors:
44
- dependabot
5+
- dependabot[bot]
56
categories:
67
- title: Breaking Changes
78
labels:

admin/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ This folder contains tools or docs useful for project maintainers.
4646
The release process is detailed in [RELEASE.md](./RELEASE.md). These scripts
4747
are used as part of that process:
4848

49+
- **generate-changelog.php** prepends the CHANGELOG.md entry for a new
50+
release, built from GitHub's auto-generated release notes and the
51+
SECURITY section of the version's detailed changelog.
52+
Usage: `php admin/generate-changelog.php 4.x.x [--dry-run]`
4953
- **prepare-release.php** creates the `release-4.x.x` branch and updates
5054
version references in the framework source, the user guide, and the
5155
distribution build script.

admin/RELEASE.md

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,21 +76,26 @@ PRs with breaking changes must have the following additional label:
7676

7777
### Generate Changelog
7878

79-
To auto-generate the changelog, navigate to the
80-
[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page,
81-
click the "Draft a new release" button.
82-
83-
* Choose a tag: `v4.x.x` (Create new tag: v4.x.x on publish)
84-
* Target: `develop`
85-
86-
Click the "Generate release notes" button.
87-
88-
Check the resulting content. If there are items in the *Others* section which
89-
should be included in the changelog, add a label to the PR and regenerate
90-
the changelog.
91-
92-
Copy the resulting contents into **CHANGELOG.md** and adjust the format to match
93-
the existing content.
79+
The changelog is generated from GitHub's auto-generated release notes with a
80+
script. It requires the authenticated [GitHub CLI](https://cli.github.com/).
81+
82+
The script also copies the security fixes from the SECURITY section of
83+
**user_guide_src/source/changelogs/v4.x.x.rst**, so any security advisory PRs
84+
must be merged and documented there before generating the changelog.
85+
86+
* [ ] Run `php admin/generate-changelog.php 4.x.x --dry-run` and check the output.
87+
* The script lists the PRs that have none of the changelog labels. If any of
88+
them should be included in the changelog, add a label to the PR and run
89+
the script again.
90+
* [ ] Run `php admin/generate-changelog.php 4.x.x` to prepend the new entry to
91+
**CHANGELOG.md**.
92+
93+
If the script cannot be used, the same notes can be generated from the
94+
[Releases](https://github.com/codeigniter4/CodeIgniter4/releases) page:
95+
click "Draft a new release", choose the tag `v4.x.x` (create new tag on
96+
publish) with target `develop`, and click "Generate release notes". Copy the
97+
resulting contents into **CHANGELOG.md** and adjust the format to match the
98+
existing content.
9499

95100
## Process
96101

@@ -100,7 +105,7 @@ the existing content.
100105
> generating much new content.
101106
102107
* [ ] Merge any security advisory PRs in private forks.
103-
* [ ] Add the current version to **CHANGELOG.md** with the contents generated above.
108+
* [ ] Check that **CHANGELOG.md** contains the entry for the new version, generated above.
104109
* [ ] Update **user_guide_src/source/changelogs/v4.x.x.rst**
105110
* Remove the section titles that have no items
106111
* [ ] Update **user_guide_src/source/installation/upgrade_4xx.rst**

admin/generate-changelog.php

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Generates the CHANGELOG.md entry for a new release from GitHub's
7+
* auto-generated release notes and prepends it to CHANGELOG.md.
8+
*
9+
* Requires the authenticated GitHub CLI (`gh`).
10+
*
11+
* Usage: php admin/generate-changelog.php <version> [--dry-run]
12+
*/
13+
chdir(__DIR__ . '/..');
14+
15+
$args = array_slice($argv, 1);
16+
$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--')));
17+
$params = array_values(array_diff($args, $options));
18+
19+
if (count($params) !== 1 || preg_match('/\A\d+\.\d+\.\d+\z/', $params[0]) !== 1) {
20+
echo "Usage: php {$argv[0]} <version> [--dry-run]\n";
21+
echo "E.g.,: php {$argv[0]} 4.7.5\n";
22+
23+
exit(1);
24+
}
25+
26+
$version = $params[0];
27+
$dryRun = in_array('--dry-run', $options, true);
28+
$repo = 'codeigniter4/CodeIgniter4';
29+
30+
$changelogPath = './CHANGELOG.md';
31+
$changelog = file_get_contents($changelogPath);
32+
33+
if (! $dryRun && str_contains($changelog, "## [v{$version}]")) {
34+
echo "CHANGELOG.md already contains an entry for v{$version}.\n";
35+
36+
exit(1);
37+
}
38+
39+
// Fetches the auto-generated release notes.
40+
$command = sprintf(
41+
'gh api repos/%s/releases/generate-notes -f tag_name=v%s -f target_commitish=develop 2>&1',
42+
$repo,
43+
$version,
44+
);
45+
exec($command, $output, $exitCode);
46+
47+
if ($exitCode !== 0) {
48+
echo "Failed to fetch the release notes from GitHub:\n";
49+
echo implode("\n", $output) . "\n";
50+
51+
exit(1);
52+
}
53+
54+
$response = json_decode(implode("\n", $output), true);
55+
$body = $response['body'] ?? '';
56+
57+
// Parses the release notes into categories. See ".github/release.yml".
58+
$sections = [];
59+
$current = null;
60+
$previousTag = null;
61+
62+
foreach (preg_split('/\R/', $body) as $line) {
63+
if (preg_match('/^### (.+)$/', $line, $matches) === 1) {
64+
$current = $matches[1];
65+
$sections[$current] = [];
66+
67+
continue;
68+
}
69+
70+
if (str_starts_with($line, '## ')) {
71+
$current = null;
72+
73+
continue;
74+
}
75+
76+
if (preg_match('~^\*\*Full Changelog\*\*: https://github\.com/[^/]+/[^/]+/compare/(.+?)\.\.\.~', $line, $matches) === 1) {
77+
$previousTag = $matches[1];
78+
79+
continue;
80+
}
81+
82+
if ($current !== null && str_starts_with($line, '* ')) {
83+
$sections[$current][] = $line;
84+
}
85+
}
86+
87+
if ($previousTag === null) {
88+
echo "Could not determine the previous tag from the release notes.\n";
89+
90+
exit(1);
91+
}
92+
93+
// PRs in the catch-all category have none of the changelog labels. They are
94+
// listed for checking only and are not part of the generated entry.
95+
$othersTitle = 'Others (Only for checking. Remove this category)';
96+
$others = $sections[$othersTitle] ?? [];
97+
unset($sections[$othersTitle]);
98+
99+
// Extracts the security fixes from the SECURITY section of the detailed
100+
// changelog, if any. These come from security advisories, not from PRs, so
101+
// they are not part of the generated release notes.
102+
$rstPath = "./user_guide_src/source/changelogs/v{$version}.rst";
103+
$security = '';
104+
105+
if (is_file($rstPath)) {
106+
$rst = file_get_contents($rstPath);
107+
108+
if (preg_match('/^\*+\nSECURITY\n\*+\n(.*?)(?=^\*+$|\z)/msu', $rst, $matches) === 1) {
109+
$security = trim($matches[1], "\n");
110+
$security = preg_replace('/^- /mu', '* ', $security);
111+
}
112+
}
113+
114+
$date = date('Y-m-d');
115+
$entry = "## [v{$version}](https://github.com/{$repo}/tree/v{$version}) ({$date})\n";
116+
$entry .= "[Full Changelog](https://github.com/{$repo}/compare/{$previousTag}...v{$version})\n";
117+
118+
if ($security !== '') {
119+
$entry .= "\n### Security\n\n{$security}\n";
120+
}
121+
122+
foreach ($sections as $title => $items) {
123+
if ($items === []) {
124+
continue;
125+
}
126+
127+
$entry .= "\n### {$title}\n\n";
128+
$entry .= implode("\n", $items) . "\n";
129+
}
130+
131+
if ($others !== []) {
132+
echo "The following PRs have no changelog label and were NOT included.\n";
133+
echo "If any of them belong in the changelog, label the PR and run this script again:\n";
134+
echo implode("\n", $others) . "\n\n";
135+
}
136+
137+
if ($dryRun) {
138+
echo $entry;
139+
140+
exit(0);
141+
}
142+
143+
$updated = preg_replace('/\A# Changelog\n\n/', "# Changelog\n\n{$entry}\n", $changelog, 1, $count);
144+
145+
if ($count !== 1) {
146+
echo 'Could not find the "# Changelog" header in CHANGELOG.md.' . "\n";
147+
148+
exit(1);
149+
}
150+
151+
file_put_contents($changelogPath, $updated);
152+
153+
echo "Added the v{$version} entry to CHANGELOG.md.\n";

0 commit comments

Comments
 (0)