Skip to content

Commit 503183b

Browse files
authored
Merge pull request #87 from wp-cli/release-generate-guards
Make release generate safe to run unattended
2 parents b49433d + 5971c02 commit 503183b

20 files changed

Lines changed: 4021 additions & 35 deletions

‎.maintenance/src/GitHub.php‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@ class GitHub {
99
const API_ROOT = 'https://api.github.com/';
1010

1111
/**
12-
* Minimum number of seconds between two write requests, as GitHub asks
13-
* of integrations to stay clear of its secondary rate limits.
12+
* Default minimum number of seconds between two write requests, as GitHub
13+
* asks of integrations to stay clear of its secondary rate limits.
1414
*/
15-
const WRITE_INTERVAL = 1;
15+
const DEFAULT_WRITE_INTERVAL = 1.0;
16+
17+
/**
18+
* Minimum number of seconds between two write requests, or null until it
19+
* is resolved from the WP_CLI_GITHUB_WRITE_INTERVAL environment variable.
20+
*
21+
* @var float|null
22+
*/
23+
private static $write_interval;
1624

1725
/**
1826
* Credentials in use, resolved on first use.
@@ -589,10 +597,38 @@ private static function get_error_hint( $code, $source ) {
589597
}
590598

591599
/**
592-
* Waits until enough time has passed since the last write request.
600+
* Gets the minimum number of seconds between two write requests.
601+
*
602+
* Set the WP_CLI_GITHUB_WRITE_INTERVAL environment variable to space the
603+
* writes out further when GitHub still applies secondary rate limits.
604+
*
605+
* @return float
606+
*/
607+
public static function get_write_interval() {
608+
if ( null === self::$write_interval ) {
609+
$interval = getenv( 'WP_CLI_GITHUB_WRITE_INTERVAL' );
610+
self::$write_interval = is_numeric( $interval ) && $interval >= 0 ? (float) $interval : self::DEFAULT_WRITE_INTERVAL;
611+
}
612+
613+
return self::$write_interval;
614+
}
615+
616+
/**
617+
* Sets the minimum number of seconds between two write requests, such as
618+
* 0 for tests against a fake API.
619+
*
620+
* @param float $seconds
621+
*/
622+
public static function set_write_interval( $seconds ) {
623+
self::$write_interval = max( 0.0, (float) $seconds );
624+
}
625+
626+
/**
627+
* Waits until the write interval has passed since the last write request.
628+
* When it already has, the write goes out right away.
593629
*/
594630
private static function pace_writes() {
595-
$wait = self::$last_write + self::WRITE_INTERVAL - microtime( true );
631+
$wait = self::$last_write + self::get_write_interval() - microtime( true );
596632

597633
if ( $wait > 0 ) {
598634
usleep( (int) ( $wait * 1000000 ) );

‎.maintenance/src/Release_Command.php‎

Lines changed: 208 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@ public function close_released( $args, $assoc_args ) {
4848
/**
4949
* Generate a new release out of an open milestone
5050
*
51+
* Tags the default branch with the version of the milestone, publishes a
52+
* release whose notes list the pull requests merged for that milestone,
53+
* and closes the milestone.
54+
*
55+
* A milestone is skipped, with a warning, when it still has open issues or
56+
* pull requests, has no merged pull requests, is not a version newer than
57+
* the latest tag, or was already tagged. When a repository has several
58+
* milestones that could be released, it is skipped unless --milestone says
59+
* which one, as they would all tag the same commit.
60+
*
61+
* It is also skipped unless `wp maintenance release plan` reports it as
62+
* ready for that milestone: the tag ships everything merged into the
63+
* default branch since the latest tag, so all of it has to belong to the
64+
* milestone, and all of the milestone's pull requests have to be in it.
65+
*
66+
* Run `wp maintenance release plan` first to see what is ready.
67+
*
5168
* ## OPTIONS
5269
*
5370
* [<repo>...]
@@ -60,52 +77,91 @@ public function close_released( $args, $assoc_args ) {
6077
* [--all]
6178
* : Generate releases for all repositories in the wp-cli organization.
6279
*
80+
* [--milestone=<milestone>]
81+
* : Only release the milestone with this title. Needs a single repository.
82+
*
83+
* [--notes-file=<file>]
84+
* : Use the contents of this file as release notes, instead of the
85+
* generated list of pull requests. Useful to publish notes that were
86+
* reviewed and tidied up first. Needs --milestone.
87+
*
88+
* [--yes]
89+
* : Publish without asking for confirmation.
90+
*
91+
* ## EXAMPLES
92+
*
93+
* # Review the generated notes, then publish them after fixing a typo.
94+
* $ wp maintenance release-notes cache-command 3.0.1 --source=pull-request > notes.md
95+
* $ wp maintenance release generate cache-command --milestone=3.0.1 --notes-file=notes.md --yes
96+
*
6397
* @when before_wp_load
6498
*/
6599
public function generate( $args, $assoc_args ) {
100+
$only_milestone = Utils\get_flag_value( $assoc_args, 'milestone' );
101+
$notes_file = Utils\get_flag_value( $assoc_args, 'notes-file' );
102+
$confirmed = Utils\get_flag_value( $assoc_args, 'yes', false );
103+
$repos = $this->get_repos( $args, $assoc_args );
66104

67-
foreach ( $this->get_repos( $args, $assoc_args ) as $repo ) {
105+
if ( null !== $only_milestone && 1 !== count( $repos ) ) {
106+
WP_CLI::error( '--milestone needs exactly one repository.' );
107+
}
108+
109+
if ( null !== $notes_file ) {
110+
if ( null === $only_milestone ) {
111+
WP_CLI::error( '--notes-file needs --milestone.' );
112+
}
113+
114+
$custom_notes = is_readable( $notes_file ) ? trim( (string) file_get_contents( $notes_file ) ) : '';
115+
if ( '' === $custom_notes ) {
116+
WP_CLI::error( "Could not read release notes from '{$notes_file}'." );
117+
}
118+
}
119+
120+
foreach ( $repos as $repo ) {
68121

69122
WP_CLI::log( "--- {$repo} ---" );
70123

71-
$releases = GitHub::get_project_releases( $repo );
72-
$milestones = GitHub::get_project_milestones( $repo );
124+
$candidates = $this->get_releasable_milestones( $repo, $only_milestone );
73125

74-
foreach ( $milestones as $milestone ) {
75-
WP_CLI::log( "Checking milestone '{$milestone->title}'..." );
76-
foreach ( $releases as $release ) {
77-
if ( $release->tag_name === $milestone->title || "v{$milestone->title}" === $release->tag_name ) {
78-
WP_CLI::log( "Found matching release '{$release->tag_name}', skipping milestone '{$milestone->title}'..." );
79-
continue 2;
80-
}
81-
}
82-
WP_CLI::log( "Milestone '{$milestone->title}' does not have a matching release, generating one..." );
126+
if ( count( $candidates ) > 1 ) {
127+
WP_CLI::warning(
128+
"Skipping {$repo} as several milestones could be released: "
129+
. implode( ', ', array_keys( $candidates ) ) . '. Pass --milestone to pick one.'
130+
);
131+
continue;
132+
}
83133

84-
if ( $this->has_open_items_on_milestone( $repo, $milestone->number ) ) {
85-
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it has open issues/PRs assigned to it." );
86-
continue;
87-
}
134+
if ( $candidates && ! $this->agrees_with_plan( $repo, $candidates ) ) {
135+
continue;
136+
}
137+
138+
foreach ( $candidates as $candidate ) {
139+
list( $milestone, $notes ) = $candidate;
88140

89141
$title = "Version {$milestone->title}";
90-
$tag = "v{$milestone->title}";
91-
$release_notes = Release_Notes::render(
92-
[ Release_Notes::get( $repo, [ $milestone ], 'pull-request' ) ],
93-
'markdown',
94-
false
95-
);
142+
$tag = 'v' . ltrim( $milestone->title, 'v' );
143+
$release_notes = isset( $custom_notes )
144+
? $custom_notes . "\n"
145+
: Release_Notes::render( [ $notes ], 'markdown', false );
96146

97147
WP_CLI::log( 'Generating the following release:' );
98148
WP_CLI::log( '-----' );
99149
WP_CLI::log( "{$title} ({$tag})\n{$release_notes}" );
100150
WP_CLI::log( '-----' );
101151

102-
fwrite( STDOUT, 'Is the above correct? [y/n] ' );
103-
$answer = fgets( STDIN );
104-
if ( false === $answer ) {
105-
WP_CLI::error( 'No answer received. This command needs an interactive terminal.' );
106-
}
107-
if ( 'y' !== strtolower( trim( $answer ) ) ) {
108-
continue;
152+
if ( ! $confirmed ) {
153+
fwrite( STDOUT, 'Is the above correct? [y/n] ' );
154+
$answer = fgets( STDIN );
155+
if ( false === $answer ) {
156+
WP_CLI::error( 'No answer received. Run this command in an interactive terminal, or pass --yes.' );
157+
}
158+
// A piped answer is not echoed, so end the prompt's line here.
159+
if ( ! stream_isatty( STDIN ) ) {
160+
fwrite( STDOUT, "\n" );
161+
}
162+
if ( 'y' !== strtolower( trim( $answer ) ) ) {
163+
continue;
164+
}
109165
}
110166

111167
$default_branch = GitHub::get_default_branch( $repo );
@@ -119,6 +175,129 @@ public function generate( $args, $assoc_args ) {
119175
}
120176
}
121177

178+
/**
179+
* Checks a milestone about to be released against the release plan of its
180+
* repository, which looks at what the default branch actually contains.
181+
*
182+
* The tag goes on the default branch, so it ships every change merged there
183+
* since the latest tag. Those changes must all belong to this milestone, and
184+
* every pull request in the release notes must be among them.
185+
*
186+
* @param string $repo Repository name, including the org.
187+
* @param array<string, array> $candidates The one milestone to release, see
188+
* get_releasable_milestones().
189+
*
190+
* @return bool Whether the release can go ahead. Warns when it can't.
191+
*/
192+
private function agrees_with_plan( $repo, array $candidates ) {
193+
$title = (string) key( $candidates );
194+
$plan = Release_Plan::fetch( $repo );
195+
196+
if ( Release_Plan::READY !== $plan['status'] ) {
197+
WP_CLI::warning( "Skipping milestone '{$title}' as {$repo} needs a maintainer's decision first:" );
198+
foreach ( $plan['blockers'] as $blocker ) {
199+
WP_CLI::log( " - {$blocker['message']}" );
200+
}
201+
return false;
202+
}
203+
204+
if ( $plan['release']['milestone'] !== $title ) {
205+
WP_CLI::warning( "Skipping milestone '{$title}' as the changes on the default branch belong to milestone '{$plan['release']['milestone']}'." );
206+
return false;
207+
}
208+
209+
$on_branch = array_column( $plan['pull_requests'], 'number' );
210+
$in_notes = array_column( $candidates[ $title ][1]['milestones'][0]['pull_requests'], 'number' );
211+
$missing = array_diff( $in_notes, $on_branch );
212+
213+
if ( $missing ) {
214+
WP_CLI::warning(
215+
"Skipping milestone '{$title}' as #" . implode( ', #', $missing )
216+
. " was not merged into the default branch since {$plan['latest_tag']}, so the release would not contain it."
217+
);
218+
return false;
219+
}
220+
221+
return true;
222+
}
223+
224+
/**
225+
* Finds the open milestones of a repository that can be released, and
226+
* warns about the ones that cannot.
227+
*
228+
* @param string $repo Repository name, including the org.
229+
* @param string|null $only_milestone Title of the only milestone to look at.
230+
*
231+
* @return array<string, array> Milestone title => milestone and its notes.
232+
*/
233+
private function get_releasable_milestones( $repo, $only_milestone ) {
234+
$releases = array_map(
235+
static function ( $release ) {
236+
return $release->tag_name;
237+
},
238+
GitHub::get_project_releases( $repo )
239+
);
240+
$tags = array_map(
241+
static function ( $tag ) {
242+
return $tag->name;
243+
},
244+
GitHub::get_tags( $repo )
245+
);
246+
247+
$milestones = GitHub::get_project_milestones( $repo );
248+
249+
if ( null !== $only_milestone ) {
250+
$milestones = array_filter(
251+
$milestones,
252+
static function ( $milestone ) use ( $only_milestone ) {
253+
return $milestone->title === $only_milestone;
254+
}
255+
);
256+
257+
if ( ! $milestones ) {
258+
WP_CLI::warning( "No open milestone '{$only_milestone}' in {$repo}." );
259+
}
260+
}
261+
262+
$candidates = [];
263+
foreach ( $milestones as $milestone ) {
264+
WP_CLI::log( "Checking milestone '{$milestone->title}'..." );
265+
266+
$tag_status = Release_Plan::get_tag_status( $milestone->title, $releases, $tags );
267+
268+
if ( Release_Plan::RELEASED === $tag_status ) {
269+
WP_CLI::log( "Found a matching release, skipping milestone '{$milestone->title}'..." );
270+
continue;
271+
}
272+
273+
if ( Release_Plan::TAGGED === $tag_status ) {
274+
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it was already tagged. Its pull requests need a new milestone." );
275+
continue;
276+
}
277+
278+
if ( Release_Plan::NOT_NEWER === $tag_status ) {
279+
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it is not a version newer than the latest tag." );
280+
continue;
281+
}
282+
283+
if ( $this->has_open_items_on_milestone( $repo, $milestone->number ) ) {
284+
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it has open issues/PRs assigned to it." );
285+
continue;
286+
}
287+
288+
$notes = Release_Notes::get( $repo, [ $milestone ], 'pull-request' );
289+
290+
if ( empty( $notes['milestones'][0]['pull_requests'] ) ) {
291+
WP_CLI::warning( "Skipping milestone '{$milestone->title}' as it has no merged pull requests." );
292+
continue;
293+
}
294+
295+
$candidates[ $milestone->title ] = [ $milestone, $notes ];
296+
}
297+
298+
return $candidates;
299+
}
300+
122301
/**
123302
* Checks which repositories have unreleased changes, and whether their
124303
* milestones are ready to release them.

‎.maintenance/src/Release_Plan.php‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ final class Release_Plan {
1414
const READY = 'ready';
1515
const NEEDS_HUMAN = 'needs_human';
1616

17+
const RELEASED = 'released';
18+
const TAGGED = 'tagged';
19+
const NOT_NEWER = 'not_newer';
20+
const TAGGABLE = 'taggable';
21+
1722
/**
1823
* Authors of routine pull requests that do not call for a release by
1924
* themselves, unless a maintainer added them to a milestone.
@@ -177,6 +182,37 @@ public static function latest_version_tag( array $tags ) {
177182
return $latest;
178183
}
179184

185+
/**
186+
* Tells whether a milestone can still be tagged: not when it has a
187+
* release or a tag already, or when it is not a version newer than the
188+
* latest tag.
189+
*
190+
* @param string $title Milestone title.
191+
* @param string[] $releases Tag names of the published releases.
192+
* @param string[] $tags Names of all tags.
193+
*
194+
* @return string One of RELEASED, TAGGED, NOT_NEWER or TAGGABLE.
195+
*/
196+
public static function get_tag_status( $title, array $releases, array $tags ) {
197+
$version = ltrim( $title, 'v' );
198+
$latest = self::latest_version_tag( $tags );
199+
200+
if ( self::has_tag( $releases, $version ) ) {
201+
return self::RELEASED;
202+
}
203+
204+
if ( self::has_tag( $tags, $version ) ) {
205+
return self::TAGGED;
206+
}
207+
208+
if ( ! Bundle::is_version( $version )
209+
|| ( null !== $latest && ! version_compare( $version, ltrim( $latest, 'v' ), '>' ) ) ) {
210+
return self::NOT_NEWER;
211+
}
212+
213+
return self::TAGGABLE;
214+
}
215+
180216
/**
181217
* Checks whether a version has been tagged, with or without a leading 'v'.
182218
*

‎.typos.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ extend-exclude = [
1515
"process/*",
1616
"restful/*",
1717
"spyc/*",
18+
"tests/fixtures/*",
1819
"vendor/*",
1920
"wordpress-core/*",
2021
"wp-cli/*",

0 commit comments

Comments
 (0)