@@ -120,6 +120,14 @@ class VersionCheckCommand extends PackageLoopingCommand {
120120 '(e.g., PR description or commit message).\n\n '
121121 'If supplied, this is used to allow overrides to some version '
122122 'checks.' );
123+ argParser.addFlag (_checkForMissingChanges,
124+ help: 'Validates that changes to packages include CHANGELOG and '
125+ 'version changes unless they meet an established exemption.\n\n '
126+ 'If used with --$_changeDescriptionFile , this is should only be '
127+ 'used in pre-submit CI checks, to prevent the possibility of '
128+ 'post-submit breakage if an override justification is not '
129+ 'transferred into the commit message.' ,
130+ hide: true );
123131 argParser.addFlag (_ignorePlatformInterfaceBreaks,
124132 help: 'Bypasses the check that platform interfaces do not contain '
125133 'breaking changes.\n\n '
@@ -133,6 +141,7 @@ class VersionCheckCommand extends PackageLoopingCommand {
133141
134142 static const String _againstPubFlag = 'against-pub' ;
135143 static const String _changeDescriptionFile = 'change-description-file' ;
144+ static const String _checkForMissingChanges = 'check-for-missing-changes' ;
136145 static const String _ignorePlatformInterfaceBreaks =
137146 'ignore-platform-interface-breaks' ;
138147
@@ -141,8 +150,26 @@ class VersionCheckCommand extends PackageLoopingCommand {
141150 static const String _breakingChangeJustificationMarker =
142151 '## Breaking change justification' ;
143152
153+ /// The string that must be at the start of a line in [_changeDescriptionFile]
154+ /// to allow skipping a version change for a PR that would normally require
155+ /// one.
156+ static const String _missingVersionChangeJustificationMarker =
157+ 'No version change:' ;
158+
159+ /// The string that must be at the start of a line in [_changeDescriptionFile]
160+ /// to allow skipping a CHANGELOG change for a PR that would normally require
161+ /// one.
162+ static const String _missingChangelogChangeJustificationMarker =
163+ 'No CHANGELOG change:' ;
164+
144165 final PubVersionFinder _pubVersionFinder;
145166
167+ late final GitVersionFinder _gitVersionFinder;
168+ late final String _mergeBase;
169+ late final List <String > _changedFiles;
170+
171+ late final String _changeDescription = _loadChangeDescription ();
172+
146173 @override
147174 final String name = 'version-check' ;
148175
@@ -156,7 +183,11 @@ class VersionCheckCommand extends PackageLoopingCommand {
156183 bool get hasLongOutput => false ;
157184
158185 @override
159- Future <void > initializeRun () async {}
186+ Future <void > initializeRun () async {
187+ _gitVersionFinder = await retrieveVersionFinder ();
188+ _mergeBase = await _gitVersionFinder.getBaseSha ();
189+ _changedFiles = await _gitVersionFinder.getChangedFiles ();
190+ }
160191
161192 @override
162193 Future <PackageResult > runForPackage (RepositoryPackage package) async {
@@ -206,6 +237,17 @@ class VersionCheckCommand extends PackageLoopingCommand {
206237 errors.add ('CHANGELOG.md failed validation.' );
207238 }
208239
240+ // If there are no other issues, make sure that there isn't a missing
241+ // change to the version and/or CHANGELOG.
242+ if (getBoolArg (_checkForMissingChanges) &&
243+ ! versionChanged &&
244+ errors.isEmpty) {
245+ final String ? error = await _checkForMissingChangeError (package);
246+ if (error != null ) {
247+ errors.add (error);
248+ }
249+ }
250+
209251 return errors.isEmpty
210252 ? PackageResult .success ()
211253 : PackageResult .fail (errors);
@@ -239,18 +281,16 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body}
239281 }
240282
241283 /// Returns the version of [package] from git at the base comparison hash.
242- Future <Version ?> _getPreviousVersionFromGit (
243- RepositoryPackage package, {
244- required GitVersionFinder gitVersionFinder,
245- }) async {
284+ Future <Version ?> _getPreviousVersionFromGit (RepositoryPackage package) async {
246285 final File pubspecFile = package.pubspecFile;
247286 final String relativePath =
248287 path.relative (pubspecFile.absolute.path, from: (await gitDir).path);
249288 // Use Posix-style paths for git.
250289 final String gitPath = path.style == p.Style .windows
251290 ? p.posix.joinAll (path.split (relativePath))
252291 : relativePath;
253- return await gitVersionFinder.getPackageVersion (gitPath);
292+ return await _gitVersionFinder.getPackageVersion (gitPath,
293+ gitRef: _mergeBase);
254294 }
255295
256296 /// Returns the state of the verison of [package] relative to the comparison
@@ -274,11 +314,9 @@ ${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body}
274314 '$indentation ${pubspec .name }: Current largest version on pub: $previousVersion ' );
275315 }
276316 } else {
277- final GitVersionFinder gitVersionFinder = await retrieveVersionFinder ();
278- previousVersionSource = await gitVersionFinder.getBaseSha ();
279- previousVersion = await _getPreviousVersionFromGit (package,
280- gitVersionFinder: gitVersionFinder) ??
281- Version .none;
317+ previousVersionSource = _mergeBase;
318+ previousVersion =
319+ await _getPreviousVersionFromGit (package) ?? Version .none;
282320 }
283321 if (previousVersion == Version .none) {
284322 print ('${indentation }Unable to find previous version '
@@ -462,9 +500,11 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog.
462500 return false ;
463501 }
464502
503+ String _getChangeDescription () => _changeDescription;
504+
465505 /// Returns the contents of the file pointed to by [_changeDescriptionFile] ,
466506 /// or an empty string if that flag is not provided.
467- String _getChangeDescription () {
507+ String _loadChangeDescription () {
468508 final String path = getStringArg (_changeDescriptionFile);
469509 if (path.isEmpty) {
470510 return '' ;
@@ -476,4 +516,91 @@ ${indentation}The first version listed in CHANGELOG.md is $fromChangeLog.
476516 }
477517 return file.readAsStringSync ();
478518 }
519+
520+ /// Returns an error string if the changes to this package should have
521+ /// resulted in a version change, or shoud have resulted in a CHANGELOG change
522+ /// but didn't.
523+ ///
524+ /// This should only be called if the version did not change.
525+ Future <String ?> _checkForMissingChangeError (RepositoryPackage package) async {
526+ // Find the relative path to the current package, as it would appear at the
527+ // beginning of a path reported by getChangedFiles() (which always uses
528+ // Posix paths).
529+ final Directory gitRoot =
530+ packagesDir.fileSystem.directory ((await gitDir).path);
531+ final String relativePackagePath =
532+ getRelativePosixPath (package.directory, from: gitRoot) + '/' ;
533+ bool hasChanges = false ;
534+ bool needsVersionChange = false ;
535+ bool hasChangelogChange = false ;
536+ for (final String path in _changedFiles) {
537+ // Only consider files within the package.
538+ if (! path.startsWith (relativePackagePath)) {
539+ continue ;
540+ }
541+ hasChanges = true ;
542+
543+ final List <String > components = p.posix.split (path);
544+ final bool isChangelog = components.last == 'CHANGELOG.md' ;
545+ if (isChangelog) {
546+ hasChangelogChange = true ;
547+ }
548+
549+ if (! needsVersionChange &&
550+ ! isChangelog &&
551+ // The example's main.dart is shown on pub.dev, but for anything else
552+ // in the example publishing has no purpose.
553+ ! (components.contains ('example' ) && components.last != 'main.dart' ) &&
554+ // Changes to tests don't need to be published.
555+ ! components.contains ('test' ) &&
556+ ! components.contains ('androidTest' ) &&
557+ ! components.contains ('RunnerTests' ) &&
558+ ! components.contains ('RunnerUITests' ) &&
559+ // Ignoring lints doesn't affect clients.
560+ ! components.contains ('lint-baseline.xml' )) {
561+ needsVersionChange = true ;
562+ }
563+ }
564+
565+ if (! hasChanges) {
566+ return null ;
567+ }
568+
569+ if (needsVersionChange) {
570+ if (_getChangeDescription ().split ('\n ' ).any ((String line) =>
571+ line.startsWith (_missingVersionChangeJustificationMarker))) {
572+ logWarning ('Ignoring lack of version change due to '
573+ '"$_missingVersionChangeJustificationMarker " in the '
574+ 'change description.' );
575+ } else {
576+ printError (
577+ 'No version change found, but the change to this package could '
578+ 'not be verified to be exempt from version changes according to '
579+ 'repository policy. If this is a false positive, please '
580+ 'add a line starting with\n '
581+ '$_missingVersionChangeJustificationMarker \n '
582+ 'to your PR description with an explanation of why it is exempt.' );
583+ return 'Missing version change' ;
584+ }
585+ }
586+
587+ if (! hasChangelogChange) {
588+ if (_getChangeDescription ().split ('\n ' ).any ((String line) =>
589+ line.startsWith (_missingChangelogChangeJustificationMarker))) {
590+ logWarning ('Ignoring lack of CHANGELOG update due to '
591+ '"$_missingChangelogChangeJustificationMarker " in the '
592+ 'change description.' );
593+ } else {
594+ printError (
595+ 'No CHANGELOG change found. If this PR needs an exemption from'
596+ 'the standard policy of listing all changes in the CHANGELOG, '
597+ 'please add a line starting with\n '
598+ '$_missingChangelogChangeJustificationMarker \n '
599+ 'to your PR description with an explanation of why.' );
600+ return 'Missing CHANGELOG change' ;
601+ }
602+ }
603+
604+ return null ;
605+ }
479606}
0 commit comments