Skip to content

Commit 76b040a

Browse files
authored
chore: automate "Config" and "All Changes" sections of the upgrade guide (#10392)
1 parent cd312f3 commit 76b040a

4 files changed

Lines changed: 320 additions & 9 deletions

File tree

admin/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ are used as part of that process:
4848

4949
- **generate-changelog.php** prepends the CHANGELOG.md entry for a new
5050
release, built from GitHub's auto-generated release notes and the
51-
SECURITY section of the version's detailed changelog.
51+
SECURITY section of the version's detailed changelog.
5252
Usage: `php admin/generate-changelog.php 4.x.x [--dry-run]`
5353
- **prepare-release.php** creates the `release-4.x.x` branch and updates
5454
version references in the framework source, the user guide, and the
@@ -61,6 +61,10 @@ are used as part of that process:
6161
latest) that appear to be missing the labels used to generate the
6262
changelog.
6363
Usage: `php admin/check-pr-labels.php [<version>]`
64+
- **update-upgrade-guide.php** fills the "Config" and "All Changes" sections
65+
of the version's upgrade guide with the project space files changed since
66+
the last release.
67+
Usage: `php admin/update-upgrade-guide.php <version> [--dry-run]`
6468

6569
## Other Stuff
6670

admin/RELEASE.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,11 @@ existing content.
114114
* [ ] Update **user_guide_src/source/changelogs/v4.x.x.rst**
115115
* Remove the section titles that have no items
116116
* [ ] Update **user_guide_src/source/installation/upgrade_4xx.rst**
117-
* [ ] fill in the "All Changes" section using the following command, and add it to **upgrade_4xx.rst**:
118-
```
119-
git diff --name-status upstream/master -- . ':!.github/' ':!admin/' ':!changelogs/' ':!contributing/' \
120-
':!system/' ':!tests/' ':!user_guide_src/' ':!utils/' \
121-
':!*.json' ':!*.xml' ':!*.dist' ':!rector.php' ':!structarmed.php' \
122-
':!phpstan*' ':!psalm*' ':!.php-cs-fixer.*' ':!LICENSE' ':!CHANGELOG.md'
123-
```
124-
* Note: `tests/` is not used for distribution repos. See `admin/starter/tests/`.
117+
* [ ] Run `php admin/update-upgrade-guide.php 4.x.x` to fill in the "Config" and
118+
"All Changes" sections with the project space files changed since the last release.
119+
* The "Config" section is not modified if it already has content. The script
120+
prints any missing entries to merge manually.
121+
* Add notes to the "Config" entries as needed.
125122
* [ ] Remove the section titles that have no items
126123
* [ ] [Minor version only] Update the "from" version in the title, (e.g., `from 4.3.x``from 4.3.8`).
127124
* [ ] Run `php admin/prepare-release.php 4.x.x` and push to origin.

admin/update-upgrade-guide.php

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Updates the "Config" and "All Changes" sections of the version's upgrade
7+
* guide with the project space files changed since the last release.
8+
*
9+
* Usage: php admin/update-upgrade-guide.php <version> [--dry-run]
10+
*/
11+
function previous_release_tag(string $version): ?string
12+
{
13+
// Not `git describe`: the base must be the highest stable tag below the
14+
// given version regardless of the checked out branch, and prerelease
15+
// tags (e.g. v4.0.0-rc.4) must be excluded.
16+
exec("git tag -l 'v*' 2>&1", $tags, $exitCode);
17+
18+
if ($exitCode !== 0) {
19+
return null;
20+
}
21+
22+
$tags = array_filter(
23+
$tags,
24+
static fn (string $tag): bool => preg_match('/\Av\d+\.\d+\.\d+\z/', $tag) === 1
25+
&& version_compare($tag, "v{$version}", '<'),
26+
);
27+
28+
if ($tags === []) {
29+
return null;
30+
}
31+
32+
usort($tags, version_compare(...));
33+
34+
return end($tags);
35+
}
36+
37+
/**
38+
* @param list<string> $items
39+
*/
40+
function bullets(array $items): string
41+
{
42+
return implode("\n", array_map(static fn (string $item): string => "- {$item}", $items));
43+
}
44+
45+
chdir(__DIR__ . '/..');
46+
47+
$args = array_slice($argv, 1);
48+
$options = array_values(array_filter($args, static fn (string $arg): bool => str_starts_with($arg, '--')));
49+
$params = array_values(array_diff($args, $options));
50+
51+
if (count($params) !== 1 || preg_match('/\A\d+\.\d+\.\d+\z/', $params[0]) !== 1) {
52+
echo sprintf("Usage: php %s <version> [--dry-run]\n", $argv[0]);
53+
echo sprintf("E.g.,: php %s 4.7.5\n", $argv[0]);
54+
55+
exit(1);
56+
}
57+
58+
$version = $params[0];
59+
$dryRun = in_array('--dry-run', $options, true);
60+
61+
$upgradePath = sprintf('./user_guide_src/source/installation/upgrade_%s.rst', str_replace('.', '', $version));
62+
63+
if (! is_file($upgradePath)) {
64+
echo sprintf("%s is not found. Run \"php admin/create-new-changelog.php\" first.\n", $upgradePath);
65+
66+
exit(1);
67+
}
68+
69+
$baseTag = previous_release_tag($version);
70+
71+
if ($baseTag === null) {
72+
echo "Could not determine the previous release tag. Are the tags fetched?\n";
73+
74+
exit(1);
75+
}
76+
77+
echo sprintf("Checking the project space files changed since %s.\n", $baseTag);
78+
79+
// Paths that are not part of the project space. Keep in sync with the
80+
// distribution repos: `tests/` is not used there, see "admin/starter/tests".
81+
$excludes = [
82+
':!.github/', ':!admin/', ':!changelogs/', ':!contributing/',
83+
':!system/', ':!tests/', ':!user_guide_src/', ':!utils/',
84+
':!*.json', ':!*.xml', ':!*.dist', ':!rector.php', ':!structarmed.php',
85+
':!phpstan*', ':!psalm*', ':!.php-cs-fixer.*', ':!LICENSE', ':!CHANGELOG.md',
86+
];
87+
88+
$command = sprintf(
89+
'git diff --name-status %s -- . %s 2>&1',
90+
escapeshellarg($baseTag),
91+
implode(' ', array_map(escapeshellarg(...), $excludes)),
92+
);
93+
exec($command, $diffOutput, $exitCode);
94+
95+
if ($exitCode !== 0) {
96+
echo "Failed to diff against the latest release tag:\n";
97+
echo implode("\n", $diffOutput) . "\n";
98+
99+
exit(1);
100+
}
101+
102+
$allChanges = [];
103+
$configChanged = [];
104+
$configNew = [];
105+
106+
foreach ($diffOutput as $line) {
107+
$fields = preg_split('/\t/', $line);
108+
109+
if ($fields === false || count($fields) < 2) {
110+
continue;
111+
}
112+
113+
$status = $fields[0][0];
114+
$path = end($fields);
115+
116+
$allChanges[] = $status === 'D' ? "{$path} (deleted)" : $path;
117+
118+
if (! str_starts_with($path, 'app/Config/')) {
119+
continue;
120+
}
121+
122+
if ($status === 'A') {
123+
$configNew[] = $path;
124+
} elseif ($status !== 'D') {
125+
$configChanged[] = $path;
126+
}
127+
}
128+
129+
sort($allChanges);
130+
sort($configChanged);
131+
sort($configNew);
132+
133+
// Builds the "Config" section contents.
134+
$configContent = bullets($configChanged);
135+
136+
if ($configNew !== []) {
137+
$configContent .= ($configContent === '' ? '' : "\n\n") . "These files are new in this release:\n\n" . bullets($configNew);
138+
}
139+
140+
if ($configContent === '') {
141+
$configContent = '- No config files were changed in this release.';
142+
}
143+
144+
// Builds the "All Changes" section contents.
145+
$allChangesContent = $allChanges === []
146+
? '- No project files were changed in this release.'
147+
: bullets($allChanges);
148+
149+
if ($dryRun) {
150+
echo "\nConfig\n------\n\n{$configContent}\n";
151+
echo "\nAll Changes\n===========\n\n{$allChangesContent}\n";
152+
153+
exit(0);
154+
}
155+
156+
$rst = file_get_contents($upgradePath);
157+
158+
// Fills the "Config" section, only if it still has the placeholder. Entries
159+
// may have been added ahead of time for changes that need discussion, and
160+
// those must not be overwritten.
161+
if (preg_match('/^Config\n------\n\n(.*?)(?=^[^\s-][^\n]*\n(?:-+|=+)$|\z)/msu', $rst, $matches) !== 1) {
162+
echo "Could not find the \"Config\" section in {$upgradePath}. Update it manually.\n";
163+
} elseif (trim($matches[1]) === '- @TODO') {
164+
$rst = str_replace("Config\n------\n\n{$matches[1]}", "Config\n------\n\n{$configContent}\n\n", $rst);
165+
166+
echo "The \"Config\" section was updated. Add notes to each entry as needed.\n";
167+
} else {
168+
$mentioned = [];
169+
170+
if (preg_match_all('~app/Config/[\w./-]+~u', $matches[1], $found) > 0) {
171+
$mentioned = $found[0];
172+
}
173+
174+
$missing = array_diff([...$configChanged, ...$configNew], $mentioned);
175+
176+
if ($missing === []) {
177+
echo "The \"Config\" section already has content and mentions all changed config files. It was not modified.\n";
178+
} else {
179+
echo "The \"Config\" section already has content, so it was not modified.\n";
180+
echo "Merge these missing entries manually:\n" . bullets(array_values($missing)) . "\n";
181+
}
182+
}
183+
184+
// Fills the "All Changes" section, replacing any previous list.
185+
$pattern = '/^(All Changes\n===========\n\n.*?:\n\n)(.*?)(?=^\*+$|\z)/msu';
186+
$updated = preg_replace($pattern, "\$1{$allChangesContent}\n", $rst, 1, $count);
187+
188+
if ($count !== 1) {
189+
echo "Could not find the \"All Changes\" section in {$upgradePath}. Update it manually.\n";
190+
191+
exit(1);
192+
}
193+
194+
file_put_contents($upgradePath, $updated);
195+
196+
echo sprintf("The \"All Changes\" section of %s was updated.\n", $upgradePath);
197+
echo "Remember to remove the section titles that have no items.\n";
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\AutoReview;
15+
16+
use PHPUnit\Framework\Attributes\CoversNothing;
17+
use PHPUnit\Framework\Attributes\Group;
18+
use PHPUnit\Framework\TestCase;
19+
20+
/**
21+
* @internal
22+
*/
23+
#[CoversNothing]
24+
#[Group('AutoReview')]
25+
final class UpdateUpgradeGuideTest extends TestCase
26+
{
27+
private string $nextVersion;
28+
private string $upgradePath;
29+
30+
protected function setUp(): void
31+
{
32+
parent::setUp();
33+
34+
exec('git describe --tags --abbrev=0 2>&1', $output, $exitCode);
35+
36+
if ($exitCode !== 0) {
37+
$this->markTestSkipped(sprintf(
38+
"Unable to get the latest git tag.\nOutput: %s",
39+
implode("\n", $output),
40+
));
41+
}
42+
43+
$parts = explode('.', trim($output[0], 'v'));
44+
45+
$this->nextVersion = sprintf('%d.%d.%d', $parts[0], $parts[1], ++$parts[2]);
46+
$this->upgradePath = sprintf(
47+
'./user_guide_src/source/installation/upgrade_%s.rst',
48+
str_replace('.', '', $this->nextVersion),
49+
);
50+
}
51+
52+
public function testUsageErrorWithoutArguments(): void
53+
{
54+
exec('php ./admin/update-upgrade-guide.php 2>&1', $output, $exitCode);
55+
56+
$this->assertSame(1, $exitCode);
57+
$this->assertStringContainsString('Usage:', implode("\n", $output));
58+
}
59+
60+
public function testUsageErrorWithInvalidVersion(): void
61+
{
62+
exec('php ./admin/update-upgrade-guide.php 4.7 2>&1', $output, $exitCode);
63+
64+
$this->assertSame(1, $exitCode);
65+
$this->assertStringContainsString('Usage:', implode("\n", $output));
66+
}
67+
68+
public function testErrorWhenUpgradeGuideIsMissing(): void
69+
{
70+
exec('php ./admin/update-upgrade-guide.php 9.9.8 2>&1', $output, $exitCode);
71+
72+
$this->assertSame(1, $exitCode);
73+
$this->assertStringContainsString('is not found', implode("\n", $output));
74+
}
75+
76+
public function testDryRunPrintsSectionsWithoutWriting(): void
77+
{
78+
$this->assertFileExists($this->upgradePath);
79+
80+
exec(sprintf('php ./admin/update-upgrade-guide.php %s --dry-run 2>&1', $this->nextVersion), $output, $exitCode);
81+
$outputString = implode("\n", $output);
82+
83+
$this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}");
84+
$this->assertMatchesRegularExpression('/Checking the project space files changed since v\d+\.\d+\.\d+\./', $outputString);
85+
$this->assertStringContainsString('All Changes', $outputString);
86+
$this->assertSame('', trim((string) exec("git status --porcelain -- {$this->upgradePath}")));
87+
}
88+
89+
public function testUpdatesUpgradeGuide(): void
90+
{
91+
$this->assertFileExists($this->upgradePath);
92+
93+
if (trim((string) exec("git status --porcelain -- {$this->upgradePath}")) !== '') {
94+
$this->markTestSkipped('You have uncommitted changes to the upgrade guide that will be erased by this test.');
95+
}
96+
97+
try {
98+
exec(sprintf('php ./admin/update-upgrade-guide.php %s 2>&1', $this->nextVersion), $output, $exitCode);
99+
$outputString = implode("\n", $output);
100+
101+
$this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}");
102+
$this->assertStringContainsString('"All Changes" section', $outputString);
103+
104+
$contents = (string) file_get_contents($this->upgradePath);
105+
$allChanges = substr($contents, (int) strpos($contents, "All Changes\n"));
106+
107+
$this->assertStringNotContainsString('- @TODO', $contents);
108+
$this->assertMatchesRegularExpression('/^- \S/m', $allChanges);
109+
} finally {
110+
exec("git restore -- {$this->upgradePath}");
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)