From 5408fd4d6b3d6cc0ac339b843a0dd9906e054cee Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Mon, 20 Apr 2026 10:11:34 +0000 Subject: [PATCH 01/13] Cooldown feature --- doc/repository-spec-v2.md | 4 +- lib/src/command/outdated.dart | 28 ++ lib/src/package.dart | 16 + lib/src/pubspec.dart | 135 ++++++++- lib/src/solver/package_lister.dart | 85 +++++- lib/src/solver/report.dart | 20 +- lib/src/solver/solve_suggestions.dart | 11 + lib/src/solver/version_solver.dart | 7 + lib/src/source.dart | 2 + lib/src/source/hosted.dart | 12 + pubspec.lock | 2 +- test/cooldown_test.dart | 401 ++++++++++++++++++++++++++ test/package_server.dart | 12 +- 13 files changed, 725 insertions(+), 10 deletions(-) create mode 100644 test/cooldown_test.dart diff --git a/doc/repository-spec-v2.md b/doc/repository-spec-v2.md index 592b0f88cd6..e21e174e030 100644 --- a/doc/repository-spec-v2.md +++ b/doc/repository-spec-v2.md @@ -233,6 +233,7 @@ server, this could work in many different ways. "latest": { "version": "", "retracted": true || false, /* optional field, false if omitted */ + "published": "", /* optional field, ISO 8601 format, timestamp of when this version was published */ "archive_url": "https://.../archive.tar.gz", "archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27" "pubspec": { @@ -241,8 +242,9 @@ server, this could work in many different ways. }, "versions": [ { - "version": "", + "version": "", "retracted": true || false, /* optional field, false if omitted */ + "published": "", /* optional field, ISO 8601 format, timestamp of when this version was published */ "archive_url": "https://.../archive.tar.gz", "archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27" "pubspec": { diff --git a/lib/src/command/outdated.dart b/lib/src/command/outdated.dart index 91afe727e80..c35b447babc 100644 --- a/lib/src/command/outdated.dart +++ b/lib/src/command/outdated.dart @@ -269,6 +269,25 @@ Consider using the Dart 2.19 sdk to migrate to null safety.'''); cache, ); + final policy = entrypoint.workspaceRoot.pubspec.policy?.cooldown; + var isLatestBlockedByCooldown = false; + if (policy != null && latest != null) { + final desc = latest.description; + if (desc is ResolvedHostedDescription) { + final status = await latest.toRef().source.status( + latest.toRef(), + latest.version, + cache, + ); + isLatestBlockedByCooldown = policy.isBlocked( + latest.name, + latest.version, + status.published, + [], + ); + } + } + final id = current ?? upgradable ?? resolvable ?? latest; var packageAdvisories = await id?.source.getAdvisoriesForPackage( @@ -339,6 +358,7 @@ Consider using the Dart 2.19 sdk to migrate to null safety.'''); discontinuedReplacedBy: discontinuedReplacedBy, isCurrentRetracted: isCurrentRetracted, isLatest: isLatest, + isLatestBlockedByCooldown: isLatestBlockedByCooldown, advisories: packageAdvisories, isCurrentAffectedBySecurityAdvisory: isCurrentAffectedByAdvisory, ); @@ -744,6 +764,7 @@ Future _outputHuman( bool displayExtraInfo(_PackageDetails package) => package.isDiscontinued || package.isCurrentRetracted || + package.isLatestBlockedByCooldown || (advisoriesToDisplay[package.name]!.isNotEmpty); if (rows.any(displayExtraInfo)) { @@ -766,6 +787,11 @@ Future _outputHuman( 'See https://dart.dev/go/package-retraction', ); } + if (package.isLatestBlockedByCooldown) { + log.message( + ' Version ${package.latest!._id.version} is too new for cooldown policy.', + ); + } final displayedAdvisories = advisoriesToDisplay[package.name]!; if (displayedAdvisories.isNotEmpty) { final advisoriesText = @@ -982,6 +1008,7 @@ class _PackageDetails implements Comparable<_PackageDetails> { final String? discontinuedReplacedBy; final bool isCurrentRetracted; final bool isLatest; + final bool isLatestBlockedByCooldown; /// List of advisories affecting this package which are not present in the /// `ignored_advisories` list in the pubspec. @@ -999,6 +1026,7 @@ class _PackageDetails implements Comparable<_PackageDetails> { required this.discontinuedReplacedBy, required this.isCurrentRetracted, required this.isLatest, + required this.isLatestBlockedByCooldown, required this.advisories, required this.isCurrentAffectedBySecurityAdvisory, }); diff --git a/lib/src/package.dart b/lib/src/package.dart index 2ccdfd95830..25ae0665a4a 100644 --- a/lib/src/package.dart +++ b/lib/src/package.dart @@ -519,6 +519,22 @@ Workspace members must have unique names. namesSeen[package.name] = package; } + // Check that at most one policy is specified. + Policy? policySeen; + for (final package in root.transitiveWorkspace) { + final currentPolicy = package.pubspec.policy; + if (currentPolicy != null) { + if (policySeen != null) { + fail(''' +Only a single policy specification is allowed across all workspace pubspec.yaml files. +Found policies in both: +* ${policySeen.span.sourceUrl?.path ?? 'pubspec.yaml'} +* ${currentPolicy.span.sourceUrl?.path ?? 'pubspec.yaml'}'''); + } + policySeen = currentPolicy; + } + } + // Check that the workspace doesn't contain two overrides of the same package. // Also check that workspace packages are not overridden. final overridesSeen = {}; diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index a0e2ab2b62d..807e2f1c818 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -132,6 +132,91 @@ environment: }; }(); + /// The policy configuration. + Policy? get policy => _policy ??= _parsePolicy(); + Policy? _policy; + + Policy? _parsePolicy() { + final policyNode = fields.nodes['policy']; + if (policyNode == null || policyNode.value == null) return null; + + if (policyNode is! YamlMap) { + _error('"policy" must be a map', policyNode.span); + } + + final cooldownNode = policyNode.nodes['cooldown']; + CooldownPolicy? cooldown; + if (cooldownNode != null && cooldownNode.value != null) { + if (cooldownNode is! YamlMap) { + _error('"cooldown" must be a map', cooldownNode.span); + } + + final minAgeNode = cooldownNode.nodes['min-age']; + Duration? minAge; + if (minAgeNode != null) { + final value = minAgeNode.value; + if (value is! String) { + _error('"min-age" must be a string', minAgeNode.span); + } + try { + minAge = _parseDuration(value); + } on FormatException catch (e) { + _error('Invalid "min-age": ${e.message}', minAgeNode.span); + } + } + + final excludeNode = cooldownNode.nodes['exclude']; + final exclude = []; + if (excludeNode != null) { + if (excludeNode is! YamlList) { + _error('"exclude" must be a list', excludeNode.span); + } + for (final node in excludeNode.nodes) { + final val = node.value; + if (val is! String) { + _error('"exclude" members must be strings', node.span); + } + exclude.add(val); + } + } + + final stabilityNode = cooldownNode.nodes['stability']; + var stability = false; + if (stabilityNode != null) { + final value = stabilityNode.value; + if (value is! bool) { + _error('"stability" must be a boolean', stabilityNode.span); + } + stability = value; + } + + if (minAge == null) { + _error('"min-age" is required in cooldown policy', cooldownNode.span); + } + + cooldown = CooldownPolicy(minAge: minAge, exclude: exclude, stability: stability); + } + + return Policy(cooldown: cooldown, span: policyNode.span); + } + + Duration _parseDuration(String s) { + final match = RegExp(r'^(\d+)([dw])$').firstMatch(s); + if (match == null) { + throw const FormatException( + 'Invalid duration format. Expected e.g. "7d" or "2w"', + ); + } + final value = int.parse(match.group(1)!); + final unit = match.group(2)!; + if (unit == 'd') { + return Duration(days: value); + } else if (unit == 'w') { + return Duration(days: value * 7); + } + throw const FormatException('Invalid duration unit'); + } + /// The additional packages this package depends on. Map get dependencies => _dependencies ??= _parseDependencies( @@ -341,7 +426,9 @@ environment: this.workspace = const [], this.dependencyOverridesFromOverridesFile = false, this.resolution = Resolution.none, - }) : _dependencies = + Policy? policy, + }) : _policy = policy, + _dependencies = dependencies == null ? null : {for (final d in dependencies) d.name: d}, @@ -469,6 +556,7 @@ environment: List? workspace, //this.dependencyOverridesFromOverridesFile = false, Resolution? resolution, + Policy? policy, }) { return Pubspec( name ?? this.name, @@ -480,6 +568,7 @@ environment: sdkConstraints: sdkConstraints ?? this.sdkConstraints, workspace: workspace ?? this.workspace, resolution: resolution ?? this.resolution, + policy: policy ?? this.policy, ); } @@ -869,3 +958,47 @@ enum Resolution { // This package is at the root of a workspace. none, } + +class Policy { + final CooldownPolicy? cooldown; + final SourceSpan span; + Policy({this.cooldown, required this.span}); +} + +class CooldownPolicy { + final Duration minAge; + final List exclude; + final bool stability; + CooldownPolicy({required this.minAge, required this.exclude, this.stability = false}); + + /// Returns `true` if the package version is blocked by this policy. + bool isBlocked( + String packageName, + Version version, + DateTime? published, + List<(Version, DateTime?)> allVersions, + ) { + if (exclude.contains(packageName)) return false; + if (published == null) return true; + final age = DateTime.now().difference(published); + if (age < minAge) return true; + + if (stability) { + for (final (v, pubDate) in allVersions) { + if (v > version) { + if (pubDate != null) { + final diff = pubDate.difference(published); + if (diff < minAge) { + return true; // Blocked by stability! + } + } else { + // If a newer version has no published date, it is considered "too new" + // and thus breaks stability of older versions. + return true; + } + } + } + } + return false; + } +} diff --git a/lib/src/solver/package_lister.dart b/lib/src/solver/package_lister.dart index 554df7afe0d..6379c6c42e1 100644 --- a/lib/src/solver/package_lister.dart +++ b/lib/src/solver/package_lister.dart @@ -14,7 +14,9 @@ import '../package.dart'; import '../package_name.dart'; import '../pubspec.dart'; import '../sdk.dart'; +import '../source/hosted.dart'; import '../source/root.dart'; +import '../source.dart'; import '../system_cache.dart'; import '../utils.dart'; import 'incompatibility.dart'; @@ -30,6 +32,9 @@ class PackageLister { /// Only used when _ref is root. final Package? _rootPackage; + /// The policy to apply, if any. + final Policy? _policy; + /// The version of this package in the lockfile. /// /// This is `null` if this package isn't locked or if the current version @@ -115,8 +120,10 @@ class PackageLister { this._allowedRetractedVersion, { bool downgrade = false, this.sdkOverrides = const {}, + Policy? policy, }) : _isDowngrade = downgrade, - _rootPackage = null; + _rootPackage = null, + _policy = policy; /// Creates a package lister for the root [package]. PackageLister.root( @@ -124,6 +131,7 @@ class PackageLister { this._systemCache, { required Set overriddenPackages, required Map? sdkOverrides, + Policy? policy, }) : _ref = PackageRef.root(package), // Treat the package as locked so we avoid the logic for finding the // boundaries of various constraints, which is useless for the root @@ -133,7 +141,8 @@ class PackageLister { _isDowngrade = false, _allowedRetractedVersion = null, sdkOverrides = sdkOverrides ?? {}, - _rootPackage = package; + _rootPackage = package, + _policy = policy ?? package.pubspec.policy; /// Returns the number of versions of this package that match [constraint]. Future countVersions(VersionConstraint constraint) async { @@ -204,6 +213,48 @@ class PackageLister { /// workspace-children are also added. Future> incompatibilitiesFor(PackageId id) async { if (_knownInvalidVersions.allows(id.version)) return const []; + + final policyWrapper = _policy; + final policy = policyWrapper?.cooldown; + + final allVersions = <(Version, DateTime?)>[]; + final statusMap = {}; + if (policy != null && !id.isRoot) { + final versions = await _versions; + for (final v in versions) { + final desc = v.description; + if (desc is ResolvedHostedDescription) { + final status = await v.toRef().source.status(v.toRef(), v.version, _systemCache); + allVersions.add((v.version, status.published)); + statusMap[v.version] = status; + } + } + + var allForbidden = true; + for (final v in versions) { + final desc = v.description; + if (desc is ResolvedHostedDescription) { + final status = statusMap[v.version]!; + if (!policy.isBlocked(id.name, v.version, status.published, allVersions)) { + allForbidden = false; + break; + } + } + } + if (allForbidden && versions.isNotEmpty) { + return [ + Incompatibility( + [Term(PackageRange(id.toRef(), VersionConstraint.any), true)], + PackageVersionForbiddenCause( + reason: + 'all versions of ${id.name} are too new for cooldown policy\n' + 'Cooldown policy defined at ${policyWrapper!.span.sourceUrl?.path ?? 'pubspec.yaml'}:${policyWrapper.span.start.line + 1}', + ), + ), + ]; + } + } + Pubspec pubspec; if (id.isRoot) { pubspec = _rootPackage!.pubspec; @@ -231,6 +282,36 @@ class PackageLister { } } + final description = id.description; + if (description is ResolvedHostedDescription) { + final policy = _policy?.cooldown; + if (policy != null) { + final status = await id.toRef().source.status(id.toRef(), id.version, _systemCache); + final published = status.published; + if (policy.isBlocked(id.name, id.version, published, allVersions)) { + _knownInvalidVersions = _knownInvalidVersions.union(id.version); + String reasonPrefix; + if (published != null) { + final age = DateTime.now().difference(published); + final blockedByAge = age < policy.minAge; + reasonPrefix = blockedByAge + ? 'version ${id.version} of ${id.name} is too new (released less than ${policy.minAge.inDays} days ago)\n' + : 'version ${id.version} of ${id.name} is unstable (newer release within ${policy.minAge.inDays} days)\n'; + } else { + reasonPrefix = 'version ${id.version} of ${id.name} lacks publication date required by policy\n'; + } + final reason = '$reasonPrefix' + 'Cooldown policy defined at ${policyWrapper!.span.sourceUrl?.path ?? 'pubspec.yaml'}:${policyWrapper.span.start.line + 1}'; + return [ + Incompatibility( + [Term(id.toRange(), true)], + PackageVersionForbiddenCause(reason: reason), + ), + ]; + } + } + } + if (_cachedVersions == null && _locked != null && id.version == _locked.version) { diff --git a/lib/src/solver/report.dart b/lib/src/solver/report.dart index 60c246a416a..51b077cfdde 100644 --- a/lib/src/solver/report.dart +++ b/lib/src/solver/report.dart @@ -453,13 +453,25 @@ $contentHashesDocumentationUrl notes.add(advisoriesMessage); } } + + final latestVersion = newerStable + ? maxAll(versions, Version.prioritize) + : maxAll(versions); + final policy = _rootPubspec.policy?.cooldown; + var isLatestBlocked = false; + final desc = newId.description; + if (policy != null && desc is ResolvedHostedDescription) { + final latestStatus = await newId.toRef().source.status(newId.toRef(), latestVersion, _cache); + isLatestBlocked = policy.isBlocked(newId.name, latestVersion, latestStatus.published, []); + } + if (status.isRetracted) { if (newerStable) { notes.add( - 'retracted, ${maxAll(versions, Version.prioritize)} available', + 'retracted, $latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}', ); } else if (newId.version.isPreRelease && newerUnstable) { - notes.add('retracted, ${maxAll(versions)} available'); + notes.add('retracted, $latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}'); } else { notes.add('retracted'); } @@ -477,12 +489,12 @@ $contentHashesDocumentationUrl } } else if (newerStable) { // If there are newer stable versions, only show those. - notes.add('${maxAll(versions, Version.prioritize)} available'); + notes.add('$latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}'); } else if ( // Only show newer prereleases for versions where a prerelease is // already chosen. newId.version.isPreRelease && newerUnstable) { - notes.add('${maxAll(versions)} available'); + notes.add('$latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}'); } message = notes.isEmpty ? null : '(${notes.join(', ')})'; diff --git a/lib/src/solver/solve_suggestions.dart b/lib/src/solver/solve_suggestions.dart index 36408ec98d7..c20c1ffd7dc 100644 --- a/lib/src/solver/solve_suggestions.dart +++ b/lib/src/solver/solve_suggestions.dart @@ -63,6 +63,17 @@ Future suggestResolutionAlternatives( for (final term in externalIncompatibility.terms) { final name = term.package.name; + if (cause is PackageVersionForbiddenCause && + cause.reason != null && + cause.reason!.contains('cooldown policy')) { + suggestions.add( + _ResolutionSuggestion( + '* Consider excluding "$name" from the cooldown policy in your pubspec.yaml.', + priority: 1, + ), + ); + } + if (!visited.add(name)) { continue; } diff --git a/lib/src/solver/version_solver.dart b/lib/src/solver/version_solver.dart index 1293b9a7395..9bb4e4fe0a5 100644 --- a/lib/src/solver/version_solver.dart +++ b/lib/src/solver/version_solver.dart @@ -68,6 +68,11 @@ class VersionSolver { for (final package in _root.transitiveWorkspace) package.name: package, }; + /// The policy to apply, if any, found in the workspace. + late final Policy? _policy = _root.transitiveWorkspace + .map((p) => p.pubspec.policy) + .firstWhereOrNull((policy) => policy != null); + /// The lockfile, indicating which package versions were previously selected. final LockFile _lockFile; @@ -541,6 +546,7 @@ class VersionSolver { _systemCache, overriddenPackages: _overriddenPackages, sdkOverrides: _sdkOverrides, + policy: _policy, ); } @@ -565,6 +571,7 @@ class VersionSolver { _getAllowedRetracted(ref.name), downgrade: _type == SolveType.downgrade, sdkOverrides: _sdkOverrides, + policy: _policy, ); }); } diff --git a/lib/src/source.dart b/lib/src/source.dart index 7c44d055d25..f8c32dd923a 100644 --- a/lib/src/source.dart +++ b/lib/src/source.dart @@ -261,10 +261,12 @@ class PackageStatus { /// package has been synchronized into pub, `null` if this package is not /// affected by a security advisory. final DateTime? advisoriesUpdated; + final DateTime? published; PackageStatus({ this.isDiscontinued = false, this.discontinuedReplacedBy, this.isRetracted = false, this.advisoriesUpdated, + this.published, }); } diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index d13db32e3a5..da99189f737 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -429,6 +429,14 @@ class HostedSource extends CachedSource { if (retracted is! bool) { throw const FormatException('retracted must be a bool'); } + final publishedData = map['published']; + DateTime? publishedDate; + if (publishedData != null) { + if (publishedData is! String) { + throw const FormatException('published must be a String'); + } + publishedDate = DateTime.parse(publishedData); + } DateTime? advisoriesDate; final advisoriesUpdated = body['advisoriesUpdated']; if (advisoriesUpdated != null) { @@ -442,6 +450,7 @@ class HostedSource extends CachedSource { discontinuedReplacedBy: replacedBy, isRetracted: retracted, advisoriesUpdated: advisoriesDate, + published: publishedDate, ); return _VersionInfo( pubspec.version, @@ -449,6 +458,7 @@ class HostedSource extends CachedSource { Uri.parse(archiveUrl), status, parsedContentHash, + publishedDate, ); }).toList(); } @@ -1952,6 +1962,7 @@ class _VersionInfo { final Pubspec pubspec; final Uri archiveUrl; final Version version; + final DateTime? published; /// The sha256 digest of the archive according to the package-repository. final Uint8List? archiveSha256; @@ -1963,6 +1974,7 @@ class _VersionInfo { this.archiveUrl, this.status, this.archiveSha256, + this.published, ); } diff --git a/pubspec.lock b/pubspec.lock index 52755d8de6c..65fe6125e90 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -466,4 +466,4 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.11.0 <4.0.0" + dart: ">=3.9.0 <4.0.0" diff --git a/test/cooldown_test.dart b/test/cooldown_test.dart new file mode 100644 index 00000000000..aede68df693 --- /dev/null +++ b/test/cooldown_test.dart @@ -0,0 +1,401 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'package:path/path.dart' as p; +import 'package:pub/src/lock_file.dart'; +import 'package:pub/src/pubspec.dart'; +import 'package:pub/src/source/hosted.dart'; +import 'package:pub/src/source/root.dart'; +import 'package:pub/src/system_cache.dart'; +import 'package:test/test.dart'; + +import 'descriptor.dart' as d; +import 'test_pub.dart'; + +void main() { + test('cooldown policy prevents resolving new versions', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + await expectResolves(result: {'foo': '1.0.0'}); + }); + + test('cooldown policy respects exclusions', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': { + 'min-age': '7d', + 'exclude': ['foo'], + }, + }, + }), + ]).create(); + + await expectResolves(result: {'foo': '1.0.1'}); + }); + + test('strict policy treats missing publication date as violation', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve('foo', '1.0.1'); // Missing date + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + await expectResolves(result: {'foo': '1.0.0'}); + }); + + test('strict policy allows missing publication date if excluded', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve('foo', '1.0.1'); // Missing date + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': { + 'min-age': '7d', + 'exclude': ['foo'], + }, + }, + }), + ]).create(); + + await expectResolves(result: {'foo': '1.0.1'}); + }); + + test('nice error message when no matching versions old enough', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + await expectResolves( + error: allOf( + contains('version solving failed'), + contains('all versions of foo are too new'), + contains('Cooldown policy defined at'), + contains('pubspec.yaml'), + contains('Consider excluding "foo" from the cooldown policy'), + ), + ); + }); + + test( + 'nice error message when one version is too new and other is blocked by constraint', + () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + server.serve( + 'bar', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + pubspec: { + 'dependencies': {'foo': '>1.0.0'}, + }, + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any', 'bar': 'any'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + await expectResolves( + error: allOf( + contains('version solving failed'), + contains('version 1.0.1 of foo is too new'), + contains('depends on foo >1.0.0'), + ), + ); + }, + ); + + test('pub outdated shows cooldown blocked versions', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + // Run pub get first to create lockfile + await expectResolves(result: {'foo': '1.0.0'}); + + await runPub( + args: ['outdated'], + output: allOf([ + contains('foo'), + contains('1.0.0'), // Current + contains('1.0.0'), // Upgradable + contains('1.0.0'), // Resolvable + contains('1.0.1'), // Latest + contains('Version 1.0.1 is too new for cooldown policy.'), + ]), + ); + }); + + test('pub get report shows cooldown blocked versions', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': '^1.0.0'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + output: allOf([ + contains('foo 1.0.0'), + contains('(1.0.1 available (blocked by cooldown))'), + ]), + ); + }); + test('stability: true blocks version if newer release within window', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 8)), // Released 2 days after 1.0.0! + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': '1.0.0'}, + 'policy': { + 'cooldown': { + 'min-age': '7d', + 'stability': true, + }, + }, + }), + ]).create(); + + await expectResolves( + error: contains('version 1.0.0 of foo is unstable (newer release within 7 days)'), + ); + }); + + test('stability: true allows version if old enough and no newer release within window', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), // Released 8 days after 1.0.0! + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': '1.0.0'}, + 'policy': { + 'cooldown': { + 'min-age': '7d', + 'stability': true, + }, + }, + }), + ]).create(); + + await expectResolves( + result: {'foo': '1.0.0'}, + ); + }); +} + +/// Runs "pub get" and makes assertions about its results. +/// +/// If [result] is passed, it's parsed as a pubspec-style dependency map, and +/// this asserts that the resulting lockfile matches those dependencies, and +/// that it contains only packages listed in [result]. +/// +/// If [error] is passed, this asserts that pub's error output matches the +/// value. It may be a String, a [RegExp], or a [Matcher]. +/// +/// If [output] is passed, this asserts that the results match. It may be a +/// [String], a [RegExp], or a [Matcher]. +/// +/// Asserts that version solving looks at exactly [tries] solutions. It defaults +/// to allowing only a single solution. +/// +/// If [environment] is passed, it's added to the OS environment when running +/// pub. +/// +/// If [downgrade] is `true`, this runs "pub downgrade" instead of "pub get". +Future expectResolves({ + Map? result, + Object? error, + Object? output, + int? tries, + Map? environment, + bool downgrade = false, +}) async { + await runPub( + args: [downgrade ? 'downgrade' : 'get'], + environment: environment, + output: + output ?? + (error == null + ? anyOf( + contains('Got dependencies!'), + matches(RegExp(r'Changed \d+ dependenc(ies|y)!')), + ) + : null), + error: error, + silent: contains('Tried ${tries ?? 1} solutions'), + exitCode: error == null ? 0 : 1, + ); + + if (result == null) return; + + final cache = SystemCache(); + final registry = cache.sources; + final lockFile = LockFile.load( + p.join(d.sandbox, appPath, 'pubspec.lock'), + registry, + ); + final resultPubspec = Pubspec.fromMap( + {'dependencies': result}, + registry, + containingDescription: ResolvedRootDescription.fromDir('.'), + ); + + final ids = {...lockFile.packages}; + for (var dep in resultPubspec.dependencies.values) { + expect(ids, contains(dep.name)); + final id = ids.remove(dep.name)!; + final description = dep.description; + if (description is HostedDescription && + (description.url == SystemCache().hosted.defaultUrl)) { + // If the dep uses the default hosted source, grab it from the test + // package server rather than pub.dev. + dep = cache.hosted + .refFor(dep.name, url: globalServer.url) + .withConstraint(dep.constraint); + } + expect(dep.allows(id), isTrue, reason: 'Expected $id to match $dep.'); + } + + expect(ids, isEmpty, reason: 'Expected no additional packages.'); +} diff --git a/test/package_server.dart b/test/package_server.dart index c9d57ef01cc..da9b5e25022 100644 --- a/test/package_server.dart +++ b/test/package_server.dart @@ -108,6 +108,8 @@ class PackageServer { 'archive_url': '${server.url}/packages/$name/versions/${version.version}.tar.gz', if (version.isRetracted) 'retracted': true, + if (version.published != null) + 'published': version.published!.toIso8601String(), if (version.sha256 != null || server.serveContentHashes) 'archive_sha256': version.sha256 ?? @@ -280,6 +282,7 @@ class PackageServer { List? contents, String? sdk, Map>? headers, + DateTime? published, }) { final pubspecFields = { 'name': name, @@ -297,6 +300,7 @@ class PackageServer { pubspecFields, headers: headers, contents: () => tarFromDescriptors(contents ?? []), + published: published, ); } @@ -404,10 +408,16 @@ class _ServedPackageVersion { bool isRetracted = false; // Overrides the calculated sha256. String? sha256; + DateTime? published; Version get version => Version.parse(pubspec['version'] as String); - _ServedPackageVersion(this.pubspec, {required this.contents, this.headers}); + _ServedPackageVersion( + this.pubspec, { + required this.contents, + this.headers, + this.published, + }); Future computeArchiveCrc32c() async { return await Crc32c.computeByConsumingStream(contents()); From 300c80ad2bcb30eca4297182bad113b4acec227f Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 21 May 2026 09:47:06 +0000 Subject: [PATCH 02/13] Fix stability and non-hosted dependency edge cases in cooldown policy --- lib/src/command/outdated.dart | 3 +- lib/src/pubspec.dart | 18 ++- lib/src/solver/package_lister.dart | 60 ++++++--- lib/src/solver/report.dart | 30 +++-- lib/src/solver/solve_suggestions.dart | 3 +- test/cooldown_test.dart | 178 +++++++++++++++++--------- 6 files changed, 197 insertions(+), 95 deletions(-) diff --git a/lib/src/command/outdated.dart b/lib/src/command/outdated.dart index c35b447babc..6e53823a8b9 100644 --- a/lib/src/command/outdated.dart +++ b/lib/src/command/outdated.dart @@ -789,7 +789,8 @@ Future _outputHuman( } if (package.isLatestBlockedByCooldown) { log.message( - ' Version ${package.latest!._id.version} is too new for cooldown policy.', + ' Version ${package.latest!._id.version} is too new for ' + 'cooldown policy.', ); } final displayedAdvisories = advisoriesToDisplay[package.name]!; diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index 807e2f1c818..cbe187720f8 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -194,7 +194,11 @@ environment: _error('"min-age" is required in cooldown policy', cooldownNode.span); } - cooldown = CooldownPolicy(minAge: minAge, exclude: exclude, stability: stability); + cooldown = CooldownPolicy( + minAge: minAge, + exclude: exclude, + stability: stability, + ); } return Policy(cooldown: cooldown, span: policyNode.span); @@ -969,7 +973,11 @@ class CooldownPolicy { final Duration minAge; final List exclude; final bool stability; - CooldownPolicy({required this.minAge, required this.exclude, this.stability = false}); + CooldownPolicy({ + required this.minAge, + required this.exclude, + this.stability = false, + }); /// Returns `true` if the package version is blocked by this policy. bool isBlocked( @@ -988,12 +996,12 @@ class CooldownPolicy { if (v > version) { if (pubDate != null) { final diff = pubDate.difference(published); - if (diff < minAge) { + if (diff >= Duration.zero && diff < minAge) { return true; // Blocked by stability! } } else { - // If a newer version has no published date, it is considered "too new" - // and thus breaks stability of older versions. + // If a newer version has no published date, it is considered + // "too new" and thus breaks stability of older versions. return true; } } diff --git a/lib/src/solver/package_lister.dart b/lib/src/solver/package_lister.dart index 6379c6c42e1..9eaa3714b23 100644 --- a/lib/src/solver/package_lister.dart +++ b/lib/src/solver/package_lister.dart @@ -14,9 +14,9 @@ import '../package.dart'; import '../package_name.dart'; import '../pubspec.dart'; import '../sdk.dart'; +import '../source.dart'; import '../source/hosted.dart'; import '../source/root.dart'; -import '../source.dart'; import '../system_cache.dart'; import '../utils.dart'; import 'incompatibility.dart'; @@ -216,15 +216,21 @@ class PackageLister { final policyWrapper = _policy; final policy = policyWrapper?.cooldown; - + final allVersions = <(Version, DateTime?)>[]; final statusMap = {}; - if (policy != null && !id.isRoot) { + if (policy != null && + !id.isRoot && + id.description is ResolvedHostedDescription) { final versions = await _versions; for (final v in versions) { final desc = v.description; if (desc is ResolvedHostedDescription) { - final status = await v.toRef().source.status(v.toRef(), v.version, _systemCache); + final status = await v.toRef().source.status( + v.toRef(), + v.version, + _systemCache, + ); allVersions.add((v.version, status.published)); statusMap[v.version] = status; } @@ -235,7 +241,12 @@ class PackageLister { final desc = v.description; if (desc is ResolvedHostedDescription) { final status = statusMap[v.version]!; - if (!policy.isBlocked(id.name, v.version, status.published, allVersions)) { + if (!policy.isBlocked( + id.name, + v.version, + status.published, + allVersions, + )) { allForbidden = false; break; } @@ -248,7 +259,9 @@ class PackageLister { PackageVersionForbiddenCause( reason: 'all versions of ${id.name} are too new for cooldown policy\n' - 'Cooldown policy defined at ${policyWrapper!.span.sourceUrl?.path ?? 'pubspec.yaml'}:${policyWrapper.span.start.line + 1}', + 'Cooldown policy defined at ' + '${policyWrapper!.span.sourceUrl?.path ?? 'pubspec.yaml'}:' + '${policyWrapper.span.start.line + 1}', ), ), ]; @@ -286,7 +299,11 @@ class PackageLister { if (description is ResolvedHostedDescription) { final policy = _policy?.cooldown; if (policy != null) { - final status = await id.toRef().source.status(id.toRef(), id.version, _systemCache); + final status = await id.toRef().source.status( + id.toRef(), + id.version, + _systemCache, + ); final published = status.published; if (policy.isBlocked(id.name, id.version, published, allVersions)) { _knownInvalidVersions = _knownInvalidVersions.union(id.version); @@ -294,19 +311,28 @@ class PackageLister { if (published != null) { final age = DateTime.now().difference(published); final blockedByAge = age < policy.minAge; - reasonPrefix = blockedByAge - ? 'version ${id.version} of ${id.name} is too new (released less than ${policy.minAge.inDays} days ago)\n' - : 'version ${id.version} of ${id.name} is unstable (newer release within ${policy.minAge.inDays} days)\n'; + reasonPrefix = + blockedByAge + ? 'version ${id.version} of ${id.name} is too new ' + '(released less than ' + '${policy.minAge.inDays} days ago)\n' + : 'version ${id.version} of ${id.name} is unstable ' + '(newer release within ' + '${policy.minAge.inDays} days)\n'; } else { - reasonPrefix = 'version ${id.version} of ${id.name} lacks publication date required by policy\n'; + reasonPrefix = + 'version ${id.version} of ${id.name} lacks publication date ' + 'required by policy\n'; } - final reason = '$reasonPrefix' - 'Cooldown policy defined at ${policyWrapper!.span.sourceUrl?.path ?? 'pubspec.yaml'}:${policyWrapper.span.start.line + 1}'; + final reason = + '$reasonPrefix' + 'Cooldown policy defined at ' + '${policyWrapper!.span.sourceUrl?.path ?? 'pubspec.yaml'}:' + '${policyWrapper.span.start.line + 1}'; return [ - Incompatibility( - [Term(id.toRange(), true)], - PackageVersionForbiddenCause(reason: reason), - ), + Incompatibility([ + Term(id.toRange(), true), + ], PackageVersionForbiddenCause(reason: reason)), ]; } } diff --git a/lib/src/solver/report.dart b/lib/src/solver/report.dart index 51b077cfdde..ad4ac7675ba 100644 --- a/lib/src/solver/report.dart +++ b/lib/src/solver/report.dart @@ -454,24 +454,32 @@ $contentHashesDocumentationUrl } } - final latestVersion = newerStable - ? maxAll(versions, Version.prioritize) - : maxAll(versions); + final latestVersion = + newerStable ? maxAll(versions, Version.prioritize) : maxAll(versions); final policy = _rootPubspec.policy?.cooldown; var isLatestBlocked = false; final desc = newId.description; if (policy != null && desc is ResolvedHostedDescription) { - final latestStatus = await newId.toRef().source.status(newId.toRef(), latestVersion, _cache); - isLatestBlocked = policy.isBlocked(newId.name, latestVersion, latestStatus.published, []); + final latestStatus = await newId.toRef().source.status( + newId.toRef(), + latestVersion, + _cache, + ); + isLatestBlocked = policy.isBlocked( + newId.name, + latestVersion, + latestStatus.published, + [], + ); } + final cooldownSuffix = isLatestBlocked ? ' (blocked by cooldown)' : ''; + if (status.isRetracted) { if (newerStable) { - notes.add( - 'retracted, $latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}', - ); + notes.add('retracted, $latestVersion available$cooldownSuffix'); } else if (newId.version.isPreRelease && newerUnstable) { - notes.add('retracted, $latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}'); + notes.add('retracted, $latestVersion available$cooldownSuffix'); } else { notes.add('retracted'); } @@ -489,12 +497,12 @@ $contentHashesDocumentationUrl } } else if (newerStable) { // If there are newer stable versions, only show those. - notes.add('$latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}'); + notes.add('$latestVersion available$cooldownSuffix'); } else if ( // Only show newer prereleases for versions where a prerelease is // already chosen. newId.version.isPreRelease && newerUnstable) { - notes.add('$latestVersion available${isLatestBlocked ? ' (blocked by cooldown)' : ''}'); + notes.add('$latestVersion available$cooldownSuffix'); } message = notes.isEmpty ? null : '(${notes.join(', ')})'; diff --git a/lib/src/solver/solve_suggestions.dart b/lib/src/solver/solve_suggestions.dart index c20c1ffd7dc..2376447fba3 100644 --- a/lib/src/solver/solve_suggestions.dart +++ b/lib/src/solver/solve_suggestions.dart @@ -68,7 +68,8 @@ Future suggestResolutionAlternatives( cause.reason!.contains('cooldown policy')) { suggestions.add( _ResolutionSuggestion( - '* Consider excluding "$name" from the cooldown policy in your pubspec.yaml.', + '* Consider excluding "$name" from the cooldown policy in ' + 'your pubspec.yaml.', priority: 1, ), ); diff --git a/test/cooldown_test.dart b/test/cooldown_test.dart index aede68df693..441543802c6 100644 --- a/test/cooldown_test.dart +++ b/test/cooldown_test.dart @@ -148,48 +148,46 @@ void main() { ); }); - test( - 'nice error message when one version is too new and other is blocked by constraint', - () async { - final server = await servePackages(); - server.serve( - 'foo', - '1.0.0', - published: DateTime.now().subtract(const Duration(days: 10)), - ); - server.serve( - 'foo', - '1.0.1', - published: DateTime.now().subtract(const Duration(days: 2)), - ); - server.serve( - 'bar', - '1.0.0', - published: DateTime.now().subtract(const Duration(days: 10)), - pubspec: { - 'dependencies': {'foo': '>1.0.0'}, - }, - ); + test('nice error message when one version is too new and ' + 'other is blocked by constraint', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + server.serve( + 'bar', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + pubspec: { + 'dependencies': {'foo': '>1.0.0'}, + }, + ); - await d.dir(appPath, [ - d.pubspec({ - 'name': 'myapp', - 'dependencies': {'foo': 'any', 'bar': 'any'}, - 'policy': { - 'cooldown': {'min-age': '7d'}, - }, - }), - ]).create(); + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': 'any', 'bar': 'any'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); - await expectResolves( - error: allOf( - contains('version solving failed'), - contains('version 1.0.1 of foo is too new'), - contains('depends on foo >1.0.0'), - ), - ); - }, - ); + await expectResolves( + error: allOf( + contains('version solving failed'), + contains('version 1.0.1 of foo is too new'), + contains('depends on foo >1.0.0'), + ), + ); + }); test('pub outdated shows cooldown blocked versions', () async { final server = await servePackages(); @@ -261,7 +259,43 @@ void main() { ]), ); }); - test('stability: true blocks version if newer release within window', () async { + test( + 'stability: true blocks version if newer release within window', + () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract( + const Duration(days: 8), + ), // Released 2 days after 1.0.0! + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': {'foo': '1.0.0'}, + 'policy': { + 'cooldown': {'min-age': '7d', 'stability': true}, + }, + }), + ]).create(); + + await expectResolves( + error: contains( + 'version 1.0.0 of foo is unstable (newer release within 7 days)', + ), + ); + }, + ); + + test('stability: true allows version if old enough and no ' + 'newer release within window', () async { final server = await servePackages(); server.serve( 'foo', @@ -271,7 +305,9 @@ void main() { server.serve( 'foo', '1.0.1', - published: DateTime.now().subtract(const Duration(days: 8)), // Released 2 days after 1.0.0! + published: DateTime.now().subtract( + const Duration(days: 2), + ), // Released 8 days after 1.0.0! ); await d.dir(appPath, [ @@ -279,48 +315,70 @@ void main() { 'name': 'myapp', 'dependencies': {'foo': '1.0.0'}, 'policy': { - 'cooldown': { - 'min-age': '7d', - 'stability': true, - }, + 'cooldown': {'min-age': '7d', 'stability': true}, }, }), ]).create(); - await expectResolves( - error: contains('version 1.0.0 of foo is unstable (newer release within 7 days)'), - ); + await expectResolves(result: {'foo': '1.0.0'}); }); - test('stability: true allows version if old enough and no newer release within window', () async { + test('stability: true does not block version if newer release ' + 'was published before this version', () async { final server = await servePackages(); server.serve( 'foo', '1.0.0', - published: DateTime.now().subtract(const Duration(days: 10)), + published: DateTime.now().subtract(const Duration(days: 20)), ); + // A newer version 2.0.0 is published. + server.serve( + 'foo', + '2.0.0', + published: DateTime.now().subtract(const Duration(days: 15)), + ); + // A patch/backport version 1.0.1 is published *after* 2.0.0 + // (e.g. 10 days ago). + // 1.0.1 is older than 7 days (min-age), and 2.0.0 was published + // *before* 1.0.1, so stability shouldn't block 1.0.1. server.serve( 'foo', '1.0.1', - published: DateTime.now().subtract(const Duration(days: 2)), // Released 8 days after 1.0.0! + published: DateTime.now().subtract(const Duration(days: 10)), ); await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', - 'dependencies': {'foo': '1.0.0'}, + 'dependencies': {'foo': '1.0.1'}, 'policy': { - 'cooldown': { - 'min-age': '7d', - 'stability': true, - }, + 'cooldown': {'min-age': '7d', 'stability': true}, }, }), ]).create(); - await expectResolves( - result: {'foo': '1.0.0'}, - ); + await expectResolves(result: {'foo': '1.0.1'}); + }); + + test('cooldown policy does not apply to non-hosted packages ' + '(e.g. path dependencies)', () async { + await d.dir('foo', [ + d.pubspec({'name': 'foo', 'version': '1.0.0'}), + ]).create(); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'dependencies': { + 'foo': {'path': '../foo'}, + }, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + await expectResolves(); }); } From 54617253be648a7d706e3bc3607f6dcd04adff2f Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 21 May 2026 09:56:21 +0000 Subject: [PATCH 03/13] doc: Add thorough comment with backport example explaining stability age check --- lib/src/pubspec.dart | 13 +++++++++++++ tool/test.dart | 1 - 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index 9a61728aeff..7136b73970a 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -992,6 +992,19 @@ class CooldownPolicy { if (v > version) { if (pubDate != null) { final diff = pubDate.difference(published); + // We only care if the newer version was published *after* this + // version (i.e., diff >= Duration.zero). + // + // If the newer version was published *before* this version, diff is + // negative (which is always less than minAge). This can happen if + // we publish a backported patch version for an older release line + // long after a newer major version was already released. + // + // For example, if 2.0.0 was published 30 days ago, and we publish + // a backport 1.0.1 today (0 days ago), then: + // pubDate(2.0.0) - published(1.0.1) = -30 days. + // Since -30 days < 7 days (minAge), 1.0.1 would be incorrectly + // blocked by stability without the diff >= 0 check. if (diff >= Duration.zero && diff < minAge) { return true; // Blocked by stability! } diff --git a/tool/test.dart b/tool/test.dart index 98468b9943d..700a2b6ba94 100755 --- a/tool/test.dart +++ b/tool/test.dart @@ -1,5 +1,4 @@ #!/usr/bin/env -S dart run -r - // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. From 4aef74aebd1a77171ed1b8ad70a662916037a3fe Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 21 May 2026 10:49:18 +0000 Subject: [PATCH 04/13] feat: Language version gate cooldown policy feature behind 3.14.0 --- lib/src/language_version.dart | 3 +++ lib/src/pubspec.dart | 9 +++++++++ test/cooldown_test.dart | 37 ++++++++++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/src/language_version.dart b/lib/src/language_version.dart index c00f0993c27..4975997377c 100644 --- a/lib/src/language_version.dart +++ b/lib/src/language_version.dart @@ -75,6 +75,8 @@ class LanguageVersion implements Comparable { bool get respectsFlutterBoundInRoots => this >= firstVersionRespectingFlutterBoundInRoots; + bool get supportsCooldown => this >= firstVersionWithCooldown; + /// Minimum language version at which short hosted syntax is supported. /// /// This allows `hosted` dependencies to be expressed as: @@ -122,6 +124,7 @@ class LanguageVersion implements Comparable { 3, 9, ); + static const firstVersionWithCooldown = LanguageVersion(3, 14); /// Transform language version to string that can be parsed with /// [LanguageVersion.parse]. diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index 7136b73970a..ffc3f548749 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -140,6 +140,15 @@ environment: final policyNode = fields.nodes['policy']; if (policyNode == null || policyNode.value == null) return null; + if (!languageVersion.supportsCooldown) { + _error( + 'The "policy" field requires at least language version ' + '${LanguageVersion.firstVersionWithCooldown}, ' + 'current is $languageVersion.', + policyNode.span, + ); + } + if (policyNode is! YamlMap) { _error('"policy" must be a map', policyNode.span); } diff --git a/test/cooldown_test.dart b/test/cooldown_test.dart index 441543802c6..67fe60ef518 100644 --- a/test/cooldown_test.dart +++ b/test/cooldown_test.dart @@ -17,6 +17,27 @@ import 'descriptor.dart' as d; import 'test_pub.dart'; void main() { + test('policy field requires language version 3.14', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp_gating', + 'environment': {'sdk': '^3.13.0'}, + 'policy': { + 'cooldown': {'min-age': '7d'}, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains( + 'The "policy" field requires at least language version 3.14', + ), + exitCode: 65, + ); + }); + test('cooldown policy prevents resolving new versions', () async { final server = await servePackages(); server.serve( @@ -33,6 +54,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': {'min-age': '7d'}, @@ -59,6 +81,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': { @@ -84,6 +107,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': {'min-age': '7d'}, @@ -106,6 +130,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': { @@ -130,6 +155,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': {'min-age': '7d'}, @@ -173,6 +199,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any', 'bar': 'any'}, 'policy': { 'cooldown': {'min-age': '7d'}, @@ -205,6 +232,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': {'min-age': '7d'}, @@ -217,6 +245,7 @@ void main() { await runPub( args: ['outdated'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, output: allOf([ contains('foo'), contains('1.0.0'), // Current @@ -244,6 +273,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '^1.0.0'}, 'policy': { 'cooldown': {'min-age': '7d'}, @@ -253,6 +283,7 @@ void main() { await runPub( args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, output: allOf([ contains('foo 1.0.0'), contains('(1.0.1 available (blocked by cooldown))'), @@ -279,6 +310,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '1.0.0'}, 'policy': { 'cooldown': {'min-age': '7d', 'stability': true}, @@ -313,6 +345,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '1.0.0'}, 'policy': { 'cooldown': {'min-age': '7d', 'stability': true}, @@ -350,6 +383,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '1.0.1'}, 'policy': { 'cooldown': {'min-age': '7d', 'stability': true}, @@ -369,6 +403,7 @@ void main() { await d.dir(appPath, [ d.pubspec({ 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, 'dependencies': { 'foo': {'path': '../foo'}, }, @@ -411,7 +446,7 @@ Future expectResolves({ }) async { await runPub( args: [downgrade ? 'downgrade' : 'get'], - environment: environment, + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0', ...?environment}, output: output ?? (error == null From fc9871fa73cac28695041cd495e0d24a7ec9bc54 Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Tue, 26 May 2026 09:17:54 +0000 Subject: [PATCH 05/13] dartfmt --- tool/test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/tool/test.dart b/tool/test.dart index 700a2b6ba94..98468b9943d 100755 --- a/tool/test.dart +++ b/tool/test.dart @@ -1,4 +1,5 @@ #!/usr/bin/env -S dart run -r + // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. From d113629a5daffa15a75bdff9868c9a720573f8cd Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Tue, 26 May 2026 10:21:56 +0000 Subject: [PATCH 06/13] fix: Prevent SolveReport crash on packages with empty available versions (e.g., only retracted versions) --- lib/src/solver/report.dart | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/lib/src/solver/report.dart b/lib/src/solver/report.dart index 758960a9e8f..82ea3f7b2c1 100644 --- a/lib/src/solver/report.dart +++ b/lib/src/solver/report.dart @@ -460,23 +460,28 @@ $contentHashesDocumentationUrl } } - final latestVersion = - newerStable ? maxAll(versions, Version.prioritize) : maxAll(versions); - final policy = _rootPubspec.policy?.cooldown; + Version? latestVersion; var isLatestBlocked = false; - final desc = newId.description; - if (policy != null && desc is ResolvedHostedDescription) { - final latestStatus = await newId.toRef().source.status( - newId.toRef(), - latestVersion, - _cache, - ); - isLatestBlocked = policy.isBlocked( - newId.name, - latestVersion, - latestStatus.published, - [], - ); + if (versions.isNotEmpty) { + latestVersion = + newerStable + ? maxAll(versions, Version.prioritize) + : maxAll(versions); + final policy = _rootPubspec.policy?.cooldown; + final desc = newId.description; + if (policy != null && desc is ResolvedHostedDescription) { + final latestStatus = await newId.toRef().source.status( + newId.toRef(), + latestVersion, + _cache, + ); + isLatestBlocked = policy.isBlocked( + newId.name, + latestVersion, + latestStatus.published, + [], + ); + } } final cooldownSuffix = isLatestBlocked ? ' (blocked by cooldown)' : ''; From c70ec30c9cb685d6d466dc90f0f9cd341f56751c Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 28 May 2026 07:59:07 +0000 Subject: [PATCH 07/13] Only allow known policies --- lib/src/pubspec.dart | 13 +++++++++++++ test/cooldown_test.dart | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index ffc3f548749..15acc72f268 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -153,6 +153,19 @@ environment: _error('"policy" must be a map', policyNode.span); } + const knownPolicies = {'cooldown'}; + for (final keyNode in policyNode.nodes.keys) { + if (keyNode is! YamlNode) continue; + final value = keyNode.value; + if (value is! String || !knownPolicies.contains(value)) { + _error( + 'Invalid policy field "$value". ' + 'Only ${knownPolicies.map((e) => '"$e"').join(', ')} is supported.', + keyNode.span, + ); + } + } + final cooldownNode = policyNode.nodes['cooldown']; CooldownPolicy? cooldown; if (cooldownNode != null && cooldownNode.value != null) { diff --git a/test/cooldown_test.dart b/test/cooldown_test.dart index 67fe60ef518..1cf5239ced2 100644 --- a/test/cooldown_test.dart +++ b/test/cooldown_test.dart @@ -38,6 +38,25 @@ void main() { ); }); + test('policy field does not allow unknown fields', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp_gating', + 'environment': {'sdk': '^3.14.0'}, + 'policy': {'unknown_field': 'value'}, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains( + 'Invalid policy field "unknown_field". Only "cooldown" is supported.', + ), + exitCode: 65, + ); + }); + test('cooldown policy prevents resolving new versions', () async { final server = await servePackages(); server.serve( From 8148f1798c23b35c6b43a0e4d1536c612c4b0f86 Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 28 May 2026 09:16:57 +0000 Subject: [PATCH 08/13] min-age -> min_age, require timezone in protocol --- doc/repository-spec-v2.md | 4 +- lib/src/pubspec.dart | 8 ++-- lib/src/source/hosted.dart | 6 +++ test/cooldown_test.dart | 78 +++++++++++++++++++++++++++++++------- test/package_server.dart | 2 +- 5 files changed, 77 insertions(+), 21 deletions(-) diff --git a/doc/repository-spec-v2.md b/doc/repository-spec-v2.md index e21e174e030..e68295799ee 100644 --- a/doc/repository-spec-v2.md +++ b/doc/repository-spec-v2.md @@ -233,7 +233,7 @@ server, this could work in many different ways. "latest": { "version": "", "retracted": true || false, /* optional field, false if omitted */ - "published": "", /* optional field, ISO 8601 format, timestamp of when this version was published */ + "published": "", /* optional field, ISO 8601 format (must include timezone), timestamp of when this version was published */ "archive_url": "https://.../archive.tar.gz", "archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27" "pubspec": { @@ -244,7 +244,7 @@ server, this could work in many different ways. { "version": "", "retracted": true || false, /* optional field, false if omitted */ - "published": "", /* optional field, ISO 8601 format, timestamp of when this version was published */ + "published": "", /* optional field, ISO 8601 format (must include timezone), timestamp of when this version was published */ "archive_url": "https://.../archive.tar.gz", "archive_sha256": "95cbaad58e2cf32d1aa852f20af1fcda1820ead92a4b1447ea7ba1ba18195d27" "pubspec": { diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index 15acc72f268..88402f00b67 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -173,17 +173,17 @@ environment: _error('"cooldown" must be a map', cooldownNode.span); } - final minAgeNode = cooldownNode.nodes['min-age']; + final minAgeNode = cooldownNode.nodes['min_age']; Duration? minAge; if (minAgeNode != null) { final value = minAgeNode.value; if (value is! String) { - _error('"min-age" must be a string', minAgeNode.span); + _error('"min_age" must be a string', minAgeNode.span); } try { minAge = _parseDuration(value); } on FormatException catch (e) { - _error('Invalid "min-age": ${e.message}', minAgeNode.span); + _error('Invalid "min_age": ${e.message}', minAgeNode.span); } } @@ -213,7 +213,7 @@ environment: } if (minAge == null) { - _error('"min-age" is required in cooldown policy', cooldownNode.span); + _error('"min_age" is required in cooldown policy', cooldownNode.span); } cooldown = CooldownPolicy( diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 8d47e123afb..f5e57988b87 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart @@ -432,6 +432,12 @@ class HostedSource extends CachedSource { if (publishedData is! String) { throw const FormatException('published must be a String'); } + if (!RegExp(r'(?:Z|[+-]\d{2}(?::?\d{2})?)$').hasMatch(publishedData)) { + throw const FormatException( + 'published must contain timezone information ' + '(e.g. "2026-05-28T09:09:29Z")', + ); + } publishedDate = DateTime.parse(publishedData); } DateTime? advisoriesDate; diff --git a/test/cooldown_test.dart b/test/cooldown_test.dart index 1cf5239ced2..3525208950a 100644 --- a/test/cooldown_test.dart +++ b/test/cooldown_test.dart @@ -5,12 +5,15 @@ @TestOn('vm') library; +import 'dart:convert'; + import 'package:path/path.dart' as p; import 'package:pub/src/lock_file.dart'; import 'package:pub/src/pubspec.dart'; import 'package:pub/src/source/hosted.dart'; import 'package:pub/src/source/root.dart'; import 'package:pub/src/system_cache.dart'; +import 'package:shelf/shelf.dart' as shelf; import 'package:test/test.dart'; import 'descriptor.dart' as d; @@ -23,7 +26,7 @@ void main() { 'name': 'myapp_gating', 'environment': {'sdk': '^3.13.0'}, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); @@ -76,7 +79,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); @@ -104,7 +107,7 @@ void main() { 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': { - 'min-age': '7d', + 'min_age': '7d', 'exclude': ['foo'], }, }, @@ -129,7 +132,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); @@ -137,6 +140,53 @@ void main() { await expectResolves(result: {'foo': '1.0.0'}); }); + test('fails when publication date does not contain timezone', () async { + final server = await servePackages(); + server.handle( + RegExp(r'/api/packages/foo'), + (request) => shelf.Response.ok( + jsonEncode({ + 'name': 'foo', + 'uploaders': ['nweiz@google.com'], + 'versions': [ + { + 'pubspec': { + 'name': 'foo', + 'version': '1.0.0', + 'environment': {'sdk': '^3.0.0'}, + }, + 'version': '1.0.0', + 'archive_url': '${server.url}/packages/foo/versions/1.0.0.tar.gz', + 'published': '2026-05-28T09:09:29', // Missing timezone! + }, + ], + }), + headers: {'content-type': 'application/vnd.pub.v2+json'}, + ), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': {'min_age': '7d'}, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: allOf( + contains('version solving failed'), + contains('Got badly formatted response trying to find package foo'), + ), + exitCode: 65, + ); + }); + test('strict policy allows missing publication date if excluded', () async { final server = await servePackages(); server.serve( @@ -153,7 +203,7 @@ void main() { 'dependencies': {'foo': 'any'}, 'policy': { 'cooldown': { - 'min-age': '7d', + 'min_age': '7d', 'exclude': ['foo'], }, }, @@ -177,7 +227,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); @@ -221,7 +271,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any', 'bar': 'any'}, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); @@ -254,7 +304,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': 'any'}, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); @@ -295,7 +345,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '^1.0.0'}, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); @@ -332,7 +382,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '1.0.0'}, 'policy': { - 'cooldown': {'min-age': '7d', 'stability': true}, + 'cooldown': {'min_age': '7d', 'stability': true}, }, }), ]).create(); @@ -367,7 +417,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '1.0.0'}, 'policy': { - 'cooldown': {'min-age': '7d', 'stability': true}, + 'cooldown': {'min_age': '7d', 'stability': true}, }, }), ]).create(); @@ -391,7 +441,7 @@ void main() { ); // A patch/backport version 1.0.1 is published *after* 2.0.0 // (e.g. 10 days ago). - // 1.0.1 is older than 7 days (min-age), and 2.0.0 was published + // 1.0.1 is older than 7 days (min_age), and 2.0.0 was published // *before* 1.0.1, so stability shouldn't block 1.0.1. server.serve( 'foo', @@ -405,7 +455,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'dependencies': {'foo': '1.0.1'}, 'policy': { - 'cooldown': {'min-age': '7d', 'stability': true}, + 'cooldown': {'min_age': '7d', 'stability': true}, }, }), ]).create(); @@ -427,7 +477,7 @@ void main() { 'foo': {'path': '../foo'}, }, 'policy': { - 'cooldown': {'min-age': '7d'}, + 'cooldown': {'min_age': '7d'}, }, }), ]).create(); diff --git a/test/package_server.dart b/test/package_server.dart index da9b5e25022..393204a880f 100644 --- a/test/package_server.dart +++ b/test/package_server.dart @@ -109,7 +109,7 @@ class PackageServer { '${server.url}/packages/$name/versions/${version.version}.tar.gz', if (version.isRetracted) 'retracted': true, if (version.published != null) - 'published': version.published!.toIso8601String(), + 'published': version.published!.toUtc().toIso8601String(), if (version.sha256 != null || server.serveContentHashes) 'archive_sha256': version.sha256 ?? From 2f5d7fa428be55b6cf47bd76307e8eb0720cfcdb Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 28 May 2026 09:42:58 +0000 Subject: [PATCH 09/13] Implement before --- lib/src/dart.dart | 1 + lib/src/pubspec.dart | 117 +++++++++++++++++++------- lib/src/solver/package_lister.dart | 30 ++++--- test/cooldown_test.dart | 131 +++++++++++++++++++++++++++++ tool/test.dart | 1 + 5 files changed, 240 insertions(+), 40 deletions(-) diff --git a/lib/src/dart.dart b/lib/src/dart.dart index 78d8baca59a..132ce700f06 100644 --- a/lib/src/dart.dart +++ b/lib/src/dart.dart @@ -114,6 +114,7 @@ Future precompile({ required String packageConfigPath, List additionalSources = const [], String? nativeAssets, + bool enableAsserts = false }) async { const platformDill = 'lib/_internal/vm_platform_strong.dill'; final sdkRoot = p.relative(p.dirname(p.dirname(platform.resolvedExecutable))); diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index 88402f00b67..56db7bef23b 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -187,6 +187,40 @@ environment: } } + final beforeNode = cooldownNode.nodes['before']; + DateTime? before; + if (beforeNode != null) { + final value = beforeNode.value; + if (value is! String) { + _error('"before" must be a string', beforeNode.span); + } + if (!RegExp(r'(?:Z|[+-]\d{2}(?::?\d{2})?)$').hasMatch(value)) { + _error( + '"before" must contain timezone information ' + '(e.g. "2026-05-28T09:09:29Z")', + beforeNode.span, + ); + } + try { + before = DateTime.parse(value); + } on FormatException catch (e) { + _error('Invalid "before": ${e.message}', beforeNode.span); + } + } + + if (minAgeNode != null && beforeNode != null) { + _error( + 'Only one of "min_age" and "before" can be specified.', + cooldownNode.span, + ); + } + if (minAgeNode == null && beforeNode == null) { + _error( + 'One of "min_age" or "before" must be specified.', + cooldownNode.span, + ); + } + final excludeNode = cooldownNode.nodes['exclude']; final exclude = []; if (excludeNode != null) { @@ -212,12 +246,16 @@ environment: stability = value; } - if (minAge == null) { - _error('"min_age" is required in cooldown policy', cooldownNode.span); + if (stabilityNode != null && beforeNode != null) { + _error( + '"stability" is only supported when "min_age" is specified.', + stabilityNode.span, + ); } cooldown = CooldownPolicy( minAge: minAge, + before: before, exclude: exclude, stability: stability, ); @@ -988,14 +1026,20 @@ class Policy { } class CooldownPolicy { - final Duration minAge; + final Duration? minAge; + final DateTime? before; final List exclude; final bool stability; CooldownPolicy({ - required this.minAge, + this.minAge, + this.before, required this.exclude, this.stability = false, - }); + }) : assert( + (minAge == null) != (before == null), + 'Exactly one of minAge or before must be specified', + ), + assert(before == null || !stability, 'stability requires minAge'); /// Returns `true` if the package version is blocked by this policy. bool isBlocked( @@ -1006,34 +1050,47 @@ class CooldownPolicy { ) { if (exclude.contains(packageName)) return false; if (published == null) return true; - final age = DateTime.now().difference(published); - if (age < minAge) return true; + + final minAge = this.minAge; + if (minAge != null) { + final age = DateTime.now().difference(published); + if (age < minAge) return true; + } + + final before = this.before; + if (before != null) { + if (published.isAfter(before)) return true; + } if (stability) { - for (final (v, pubDate) in allVersions) { - if (v > version) { - if (pubDate != null) { - final diff = pubDate.difference(published); - // We only care if the newer version was published *after* this - // version (i.e., diff >= Duration.zero). - // - // If the newer version was published *before* this version, diff is - // negative (which is always less than minAge). This can happen if - // we publish a backported patch version for an older release line - // long after a newer major version was already released. - // - // For example, if 2.0.0 was published 30 days ago, and we publish - // a backport 1.0.1 today (0 days ago), then: - // pubDate(2.0.0) - published(1.0.1) = -30 days. - // Since -30 days < 7 days (minAge), 1.0.1 would be incorrectly - // blocked by stability without the diff >= 0 check. - if (diff >= Duration.zero && diff < minAge) { - return true; // Blocked by stability! + final minAge = this.minAge; + if (minAge != null) { + for (final (v, pubDate) in allVersions) { + if (v > version) { + if (pubDate != null) { + final diff = pubDate.difference(published); + // We only care if the newer version was published *after* this + // version (i.e., diff >= Duration.zero). + // + // If the newer version was published *before* this version, + // diff is negative (which is always less than minAge). This can + // happen if we publish a backported patch version for an older + // release line long after a newer major version was already + // released. + // + // For example, if 2.0.0 was published 30 days ago, and we publish + // a backport 1.0.1 today (0 days ago), then: + // pubDate(2.0.0) - published(1.0.1) = -30 days. + // Since -30 days < 7 days (minAge), 1.0.1 would be incorrectly + // blocked by stability without the diff >= 0 check. + if (diff >= Duration.zero && diff < minAge) { + return true; // Blocked by stability! + } + } else { + // If a newer version has no published date, it is considered + // "too new" and thus breaks stability of older versions. + return true; } - } else { - // If a newer version has no published date, it is considered - // "too new" and thus breaks stability of older versions. - return true; } } } diff --git a/lib/src/solver/package_lister.dart b/lib/src/solver/package_lister.dart index 9eaa3714b23..a9384a8c6e4 100644 --- a/lib/src/solver/package_lister.dart +++ b/lib/src/solver/package_lister.dart @@ -309,16 +309,26 @@ class PackageLister { _knownInvalidVersions = _knownInvalidVersions.union(id.version); String reasonPrefix; if (published != null) { - final age = DateTime.now().difference(published); - final blockedByAge = age < policy.minAge; - reasonPrefix = - blockedByAge - ? 'version ${id.version} of ${id.name} is too new ' - '(released less than ' - '${policy.minAge.inDays} days ago)\n' - : 'version ${id.version} of ${id.name} is unstable ' - '(newer release within ' - '${policy.minAge.inDays} days)\n'; + final minAge = policy.minAge; + final before = policy.before; + if (minAge != null) { + final age = DateTime.now().difference(published); + final blockedByAge = age < minAge; + reasonPrefix = + blockedByAge + ? 'version ${id.version} of ${id.name} is too new ' + '(released less than ' + '${minAge.inDays} days ago)\n' + : 'version ${id.version} of ${id.name} is unstable ' + '(newer release within ' + '${minAge.inDays} days)\n'; + } else if (before != null) { + reasonPrefix = + 'version ${id.version} of ${id.name} is too new ' + '(released after $before)\n'; + } else { + reasonPrefix = 'version ${id.version} of ${id.name} is blocked\n'; + } } else { reasonPrefix = 'version ${id.version} of ${id.name} lacks publication date ' diff --git a/test/cooldown_test.dart b/test/cooldown_test.dart index 3525208950a..af645a620ea 100644 --- a/test/cooldown_test.dart +++ b/test/cooldown_test.dart @@ -484,6 +484,137 @@ void main() { await expectResolves(); }); + + test( + 'before policy restricts newer versions but allows older ones', + () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.parse('2026-05-27T12:00:00Z'), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.parse('2026-05-28T13:00:00Z'), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': {'before': '2026-05-28T12:00:00Z'}, + }, + }), + ]).create(); + + await expectResolves(result: {'foo': '1.0.0'}); + }, + ); + + test('fails when both min_age and before are specified', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'policy': { + 'cooldown': {'min_age': '7d', 'before': '2026-05-28T12:00:00Z'}, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains('Only one of "min_age" and "before" can be specified.'), + exitCode: 65, + ); + }); + + test('fails when neither min_age nor before are specified', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'policy': { + 'cooldown': { + 'exclude': ['foo'], + }, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains('One of "min_age" or "before" must be specified.'), + exitCode: 65, + ); + }); + + test('fails when before lacks timezone', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'policy': { + 'cooldown': {'before': '2026-05-28T12:00:00'}, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains('"before" must contain timezone information'), + exitCode: 65, + ); + }); + + test('fails when stability is used with before', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'policy': { + 'cooldown': {'before': '2026-05-28T12:00:00Z', 'stability': true}, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains( + '"stability" is only supported when "min_age" is specified.', + ), + exitCode: 65, + ); + }); + + test('fails when stability: false is used with before', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'policy': { + 'cooldown': {'before': '2026-05-28T12:00:00Z', 'stability': false}, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains( + '"stability" is only supported when "min_age" is specified.', + ), + exitCode: 65, + ); + }); } /// Runs "pub get" and makes assertions about its results. diff --git a/tool/test.dart b/tool/test.dart index 98468b9943d..8390400349f 100755 --- a/tool/test.dart +++ b/tool/test.dart @@ -41,6 +41,7 @@ Future main(List args) async { outputPath: pubSnapshotFilename, name: 'bin/pub.dart', packageConfigPath: p.join('.dart_tool', 'package_config.json'), + ); stderr.writeln(' (${stopwatch.elapsed.inMilliseconds}ms)'); testProcess = await Process.start( From 9984a2dfe1bfced5b78961bc79ac67a4808830ed Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 28 May 2026 09:47:03 +0000 Subject: [PATCH 10/13] remove stray line: --- lib/src/dart.dart | 1 - tool/test.dart | 1 - 2 files changed, 2 deletions(-) diff --git a/lib/src/dart.dart b/lib/src/dart.dart index 132ce700f06..78d8baca59a 100644 --- a/lib/src/dart.dart +++ b/lib/src/dart.dart @@ -114,7 +114,6 @@ Future precompile({ required String packageConfigPath, List additionalSources = const [], String? nativeAssets, - bool enableAsserts = false }) async { const platformDill = 'lib/_internal/vm_platform_strong.dill'; final sdkRoot = p.relative(p.dirname(p.dirname(platform.resolvedExecutable))); diff --git a/tool/test.dart b/tool/test.dart index 8390400349f..98468b9943d 100755 --- a/tool/test.dart +++ b/tool/test.dart @@ -41,7 +41,6 @@ Future main(List args) async { outputPath: pubSnapshotFilename, name: 'bin/pub.dart', packageConfigPath: p.join('.dart_tool', 'package_config.json'), - ); stderr.writeln(' (${stopwatch.elapsed.inMilliseconds}ms)'); testProcess = await Process.start( From 0a6390ed639cced3ba470055f4a2aca469090d76 Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 28 May 2026 09:47:53 +0000 Subject: [PATCH 11/13] remove stray line2 --- tool/test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/tool/test.dart b/tool/test.dart index 98468b9943d..700a2b6ba94 100755 --- a/tool/test.dart +++ b/tool/test.dart @@ -1,5 +1,4 @@ #!/usr/bin/env -S dart run -r - // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. From 02fbc6924db28d81b69b5ce61b61d8e62ab6dcfd Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 28 May 2026 10:00:39 +0000 Subject: [PATCH 12/13] fmt --- tool/test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/tool/test.dart b/tool/test.dart index 700a2b6ba94..98468b9943d 100755 --- a/tool/test.dart +++ b/tool/test.dart @@ -1,4 +1,5 @@ #!/usr/bin/env -S dart run -r + // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. From 7af6e57908389710482511c093bccf212c0c9269 Mon Sep 17 00:00:00 2001 From: Sigurd Meldgaard Date: Thu, 28 May 2026 12:53:50 +0000 Subject: [PATCH 13/13] Support Map-based cooldown exclude with version constraints --- lib/src/pubspec.dart | 35 +++++++++++------ test/cooldown_test.dart | 87 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart index 56db7bef23b..4275550c653 100644 --- a/lib/src/pubspec.dart +++ b/lib/src/pubspec.dart @@ -222,18 +222,30 @@ environment: } final excludeNode = cooldownNode.nodes['exclude']; - final exclude = []; + final exclude = {}; if (excludeNode != null) { - if (excludeNode is! YamlList) { - _error('"exclude" must be a list', excludeNode.span); + if (excludeNode is! YamlMap) { + _error('"exclude" must be a map', excludeNode.span); } - for (final node in excludeNode.nodes) { - final val = node.value; - if (val is! String) { - _error('"exclude" members must be strings', node.span); + excludeNode.nodes.forEach((key, constraintNode) { + final keyNode = key as YamlNode; + final packageName = keyNode.value; + if (packageName is! String) { + _error( + 'Exclude keys must be package names (strings).', + keyNode.span, + ); } - exclude.add(val); - } + if (!packageNameRegExp.hasMatch(packageName)) { + _error('Not a valid package name.', keyNode.span); + } + final constraint = _parseVersionConstraint( + constraintNode as YamlNode?, + _packageName, + _FileType.pubspec, + ); + exclude[packageName] = constraint; + }); } final stabilityNode = cooldownNode.nodes['stability']; @@ -1028,7 +1040,7 @@ class Policy { class CooldownPolicy { final Duration? minAge; final DateTime? before; - final List exclude; + final Map exclude; final bool stability; CooldownPolicy({ this.minAge, @@ -1048,7 +1060,8 @@ class CooldownPolicy { DateTime? published, List<(Version, DateTime?)> allVersions, ) { - if (exclude.contains(packageName)) return false; + final constraint = exclude[packageName]; + if (constraint != null && constraint.allows(version)) return false; if (published == null) return true; final minAge = this.minAge; diff --git a/test/cooldown_test.dart b/test/cooldown_test.dart index af645a620ea..3231ae79b72 100644 --- a/test/cooldown_test.dart +++ b/test/cooldown_test.dart @@ -108,7 +108,7 @@ void main() { 'policy': { 'cooldown': { 'min_age': '7d', - 'exclude': ['foo'], + 'exclude': {'foo': 'any'}, }, }, }), @@ -204,7 +204,7 @@ void main() { 'policy': { 'cooldown': { 'min_age': '7d', - 'exclude': ['foo'], + 'exclude': {'foo': 'any'}, }, }, }), @@ -541,7 +541,7 @@ void main() { 'environment': {'sdk': '^3.14.0'}, 'policy': { 'cooldown': { - 'exclude': ['foo'], + 'exclude': {'foo': 'any'}, }, }, }), @@ -615,6 +615,87 @@ void main() { exitCode: 65, ); }); + + test('exclude policy supports version-specific constraints', () async { + final server = await servePackages(); + server.serve( + 'foo', + '1.0.0', + published: DateTime.now().subtract(const Duration(days: 10)), + ); + server.serve( + 'foo', + '1.0.1', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + server.serve( + 'foo', + '2.0.0', + published: DateTime.now().subtract(const Duration(days: 2)), + ); + + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'dependencies': {'foo': 'any'}, + 'policy': { + 'cooldown': { + 'min_age': '7d', + 'exclude': {'foo': '^1.0.0'}, + }, + }, + }), + ]).create(); + + // 1.0.1 matches ^1.0.0 and is allowed despite cooldown. + // 2.0.0 is in cooldown and does not match ^1.0.0, so it is blocked. + await expectResolves(result: {'foo': '1.0.1'}); + }); + + test('fails when exclude is not a map', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'policy': { + 'cooldown': { + 'min_age': '7d', + 'exclude': ['foo'], // List instead of Map + }, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains('"exclude" must be a map'), + exitCode: 65, + ); + }); + + test('fails when exclude map has invalid package name', () async { + await d.dir(appPath, [ + d.pubspec({ + 'name': 'myapp', + 'environment': {'sdk': '^3.14.0'}, + 'policy': { + 'cooldown': { + 'min_age': '7d', + 'exclude': {'not a valid name!': 'any'}, + }, + }, + }), + ]).create(); + + await runPub( + args: ['get'], + environment: {'_PUB_TEST_SDK_VERSION': '3.14.0'}, + error: contains('Not a valid package name.'), + exitCode: 65, + ); + }); } /// Runs "pub get" and makes assertions about its results.