Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ 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.
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
Expand All @@ -61,6 +61,10 @@ are used as part of that process:
latest) that appear to be missing the labels used to generate the
changelog.
Usage: `php admin/check-pr-labels.php [<version>]`
- **update-upgrade-guide.php** fills the "Config" and "All Changes" sections
of the version's upgrade guide with the project space files changed since
the last release.
Usage: `php admin/update-upgrade-guide.php <version> [--dry-run]`

## Other Stuff

Expand Down
13 changes: 5 additions & 8 deletions admin/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,11 @@ existing content.
* [ ] 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**
* [ ] fill in the "All Changes" section using the following command, and add it to **upgrade_4xx.rst**:
```
git diff --name-status upstream/master -- . ':!.github/' ':!admin/' ':!changelogs/' ':!contributing/' \
':!system/' ':!tests/' ':!user_guide_src/' ':!utils/' \
':!*.json' ':!*.xml' ':!*.dist' ':!rector.php' ':!structarmed.php' \
':!phpstan*' ':!psalm*' ':!.php-cs-fixer.*' ':!LICENSE' ':!CHANGELOG.md'
```
* Note: `tests/` is not used for distribution repos. See `admin/starter/tests/`.
* [ ] Run `php admin/update-upgrade-guide.php 4.x.x` to fill in the "Config" and
"All Changes" sections with the project space files changed since the last release.
* The "Config" section is not modified if it already has content. The script
prints any missing entries to merge manually.
* Add notes to the "Config" entries as needed.
* [ ] Remove the section titles that have no items
* [ ] [Minor version only] Update the "from" version in the title, (e.g., `from 4.3.x` → `from 4.3.8`).
* [ ] Run `php admin/prepare-release.php 4.x.x` and push to origin.
Expand Down
197 changes: 197 additions & 0 deletions admin/update-upgrade-guide.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php

declare(strict_types=1);

/**
* Updates the "Config" and "All Changes" sections of the version's upgrade
* guide with the project space files changed since the last release.
*
* Usage: php admin/update-upgrade-guide.php <version> [--dry-run]
*/
function previous_release_tag(string $version): ?string
{
// Not `git describe`: the base must be the highest stable tag below the
// given version regardless of the checked out branch, and prerelease
// tags (e.g. v4.0.0-rc.4) must be excluded.
exec("git tag -l 'v*' 2>&1", $tags, $exitCode);

if ($exitCode !== 0) {
return null;
}

$tags = array_filter(
$tags,
static fn (string $tag): bool => preg_match('/\Av\d+\.\d+\.\d+\z/', $tag) === 1
&& version_compare($tag, "v{$version}", '<'),
);

if ($tags === []) {
return null;
}

usort($tags, version_compare(...));

return end($tags);
}

/**
* @param list<string> $items
*/
function bullets(array $items): string
{
return implode("\n", array_map(static fn (string $item): string => "- {$item}", $items));
}

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 sprintf("Usage: php %s <version> [--dry-run]\n", $argv[0]);
echo sprintf("E.g.,: php %s 4.7.5\n", $argv[0]);

exit(1);
}

$version = $params[0];
$dryRun = in_array('--dry-run', $options, true);

$upgradePath = sprintf('./user_guide_src/source/installation/upgrade_%s.rst', str_replace('.', '', $version));

if (! is_file($upgradePath)) {
echo sprintf("%s is not found. Run \"php admin/create-new-changelog.php\" first.\n", $upgradePath);

exit(1);
}

$baseTag = previous_release_tag($version);

if ($baseTag === null) {
echo "Could not determine the previous release tag. Are the tags fetched?\n";

exit(1);
}

echo sprintf("Checking the project space files changed since %s.\n", $baseTag);

// Paths that are not part of the project space. Keep in sync with the
// distribution repos: `tests/` is not used there, see "admin/starter/tests".
$excludes = [
':!.github/', ':!admin/', ':!changelogs/', ':!contributing/',
':!system/', ':!tests/', ':!user_guide_src/', ':!utils/',
':!*.json', ':!*.xml', ':!*.dist', ':!rector.php', ':!structarmed.php',
':!phpstan*', ':!psalm*', ':!.php-cs-fixer.*', ':!LICENSE', ':!CHANGELOG.md',
];

$command = sprintf(
'git diff --name-status %s -- . %s 2>&1',
escapeshellarg($baseTag),
implode(' ', array_map(escapeshellarg(...), $excludes)),
);
exec($command, $diffOutput, $exitCode);

if ($exitCode !== 0) {
echo "Failed to diff against the latest release tag:\n";
echo implode("\n", $diffOutput) . "\n";

exit(1);
}

$allChanges = [];
$configChanged = [];
$configNew = [];

foreach ($diffOutput as $line) {
$fields = preg_split('/\t/', $line);

if ($fields === false || count($fields) < 2) {
continue;
}

$status = $fields[0][0];
$path = end($fields);

$allChanges[] = $status === 'D' ? "{$path} (deleted)" : $path;

if (! str_starts_with($path, 'app/Config/')) {
continue;
}

if ($status === 'A') {
$configNew[] = $path;
} elseif ($status !== 'D') {
$configChanged[] = $path;
}
}

sort($allChanges);
sort($configChanged);
sort($configNew);

// Builds the "Config" section contents.
$configContent = bullets($configChanged);

if ($configNew !== []) {
$configContent .= ($configContent === '' ? '' : "\n\n") . "These files are new in this release:\n\n" . bullets($configNew);
}

if ($configContent === '') {
$configContent = '- No config files were changed in this release.';
}

// Builds the "All Changes" section contents.
$allChangesContent = $allChanges === []
? '- No project files were changed in this release.'
: bullets($allChanges);

if ($dryRun) {
echo "\nConfig\n------\n\n{$configContent}\n";
echo "\nAll Changes\n===========\n\n{$allChangesContent}\n";

exit(0);
}

$rst = file_get_contents($upgradePath);

// Fills the "Config" section, only if it still has the placeholder. Entries
// may have been added ahead of time for changes that need discussion, and
// those must not be overwritten.
if (preg_match('/^Config\n------\n\n(.*?)(?=^[^\s-][^\n]*\n(?:-+|=+)$|\z)/msu', $rst, $matches) !== 1) {
echo "Could not find the \"Config\" section in {$upgradePath}. Update it manually.\n";
} elseif (trim($matches[1]) === '- @TODO') {
$rst = str_replace("Config\n------\n\n{$matches[1]}", "Config\n------\n\n{$configContent}\n\n", $rst);

echo "The \"Config\" section was updated. Add notes to each entry as needed.\n";
} else {
$mentioned = [];

if (preg_match_all('~app/Config/[\w./-]+~u', $matches[1], $found) > 0) {
$mentioned = $found[0];
}

$missing = array_diff([...$configChanged, ...$configNew], $mentioned);

if ($missing === []) {
echo "The \"Config\" section already has content and mentions all changed config files. It was not modified.\n";
} else {
echo "The \"Config\" section already has content, so it was not modified.\n";
echo "Merge these missing entries manually:\n" . bullets(array_values($missing)) . "\n";
}
}

// Fills the "All Changes" section, replacing any previous list.
$pattern = '/^(All Changes\n===========\n\n.*?:\n\n)(.*?)(?=^\*+$|\z)/msu';
$updated = preg_replace($pattern, "\$1{$allChangesContent}\n", $rst, 1, $count);

if ($count !== 1) {
echo "Could not find the \"All Changes\" section in {$upgradePath}. Update it manually.\n";

exit(1);
}

file_put_contents($upgradePath, $updated);

echo sprintf("The \"All Changes\" section of %s was updated.\n", $upgradePath);
echo "Remember to remove the section titles that have no items.\n";
113 changes: 113 additions & 0 deletions tests/system/AutoReview/UpdateUpgradeGuideTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\AutoReview;

use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;

/**
* @internal
*/
#[CoversNothing]
#[Group('AutoReview')]
final class UpdateUpgradeGuideTest extends TestCase
{
private string $nextVersion;
private string $upgradePath;

protected function setUp(): void
{
parent::setUp();

exec('git describe --tags --abbrev=0 2>&1', $output, $exitCode);

if ($exitCode !== 0) {
$this->markTestSkipped(sprintf(
"Unable to get the latest git tag.\nOutput: %s",
implode("\n", $output),
));
}

$parts = explode('.', trim($output[0], 'v'));

$this->nextVersion = sprintf('%d.%d.%d', $parts[0], $parts[1], ++$parts[2]);
$this->upgradePath = sprintf(
'./user_guide_src/source/installation/upgrade_%s.rst',
str_replace('.', '', $this->nextVersion),
);
}

public function testUsageErrorWithoutArguments(): void
{
exec('php ./admin/update-upgrade-guide.php 2>&1', $output, $exitCode);

$this->assertSame(1, $exitCode);
$this->assertStringContainsString('Usage:', implode("\n", $output));
}

public function testUsageErrorWithInvalidVersion(): void
{
exec('php ./admin/update-upgrade-guide.php 4.7 2>&1', $output, $exitCode);

$this->assertSame(1, $exitCode);
$this->assertStringContainsString('Usage:', implode("\n", $output));
}

public function testErrorWhenUpgradeGuideIsMissing(): void
{
exec('php ./admin/update-upgrade-guide.php 9.9.8 2>&1', $output, $exitCode);

$this->assertSame(1, $exitCode);
$this->assertStringContainsString('is not found', implode("\n", $output));
}

public function testDryRunPrintsSectionsWithoutWriting(): void
{
$this->assertFileExists($this->upgradePath);

exec(sprintf('php ./admin/update-upgrade-guide.php %s --dry-run 2>&1', $this->nextVersion), $output, $exitCode);
$outputString = implode("\n", $output);

$this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}");
$this->assertMatchesRegularExpression('/Checking the project space files changed since v\d+\.\d+\.\d+\./', $outputString);
$this->assertStringContainsString('All Changes', $outputString);
$this->assertSame('', trim((string) exec("git status --porcelain -- {$this->upgradePath}")));
}

public function testUpdatesUpgradeGuide(): void
{
$this->assertFileExists($this->upgradePath);

if (trim((string) exec("git status --porcelain -- {$this->upgradePath}")) !== '') {
$this->markTestSkipped('You have uncommitted changes to the upgrade guide that will be erased by this test.');
}

try {
exec(sprintf('php ./admin/update-upgrade-guide.php %s 2>&1', $this->nextVersion), $output, $exitCode);
$outputString = implode("\n", $output);

$this->assertSame(0, $exitCode, "Script exited with code {$exitCode}. Output: {$outputString}");
$this->assertStringContainsString('"All Changes" section', $outputString);

$contents = (string) file_get_contents($this->upgradePath);
$allChanges = substr($contents, (int) strpos($contents, "All Changes\n"));

$this->assertStringNotContainsString('- @TODO', $contents);
$this->assertMatchesRegularExpression('/^- \S/m', $allChanges);
} finally {
exec("git restore -- {$this->upgradePath}");
}
}
}
Loading