Repository: dart-lang/pub-dev (dart)

Issue:
Create an allow-list of packages that can publish more than 1000 versions
By default we limit packages to publishing at-most 1000 package versions.
This aims to:
 * Keep the size of the version listing API end-point reasonable (which would otherwise slow down `pub get`).
 * Mitigate abuse of the system.

However, if a package hits this limit we should have an allow-list that a package can be added to such that the limit can be increased for a specific package.

We would ask package authors to reduce the publication frequency, when they are allow-listed to published more than 1000 package versions.

------------

Ideally, it shouldn't be necessary to publish updates more than once per week (if even that much). Obviously, there can be extenuating circumstances, if there is a serious bug, security vulnerability, etc. or perhaps you're making a big release and publishing a few quick fixes.

But in general, we don't believe that it's a good idea to publish tiny changes extremely frequently. This causes churn for all the people who depend on your package, as they will be prompted to upgrade.

If on average a package is published no more than once per week (which is extremely frequent), then back of the envelop math (`1000 / 52 = 19.2`) suggest that 1000 versions will be enough for 19 years of package publishing.

------------

We should probably add a section in our documentation with guidelines around publishing frequency.

Pull request description:
- Fixes #8053.
- Prior discussion concluded that #8063 has way more complexity than needed, but we could send a message to the uploader(s) that they are near the current version count limit. This PR creates a message for the pub client and also includes it in the notification email that is sent to the admins of the package / publisher.

Patch:
diff --git a/app/lib/frontend/handlers/pubapi.dart b/app/lib/frontend/handlers/pubapi.dart
index d35ebd3f3b..d218501a98 100644
--- a/app/lib/frontend/handlers/pubapi.dart
+++ b/app/lib/frontend/handlers/pubapi.dart
@@ -154,14 +154,9 @@ class PubApi {
   @EndPoint.get('/api/packages/versions/newUploadFinish/<uploadId>')
   Future<SuccessMessage> finishPackageUpload(
       Request request, String uploadId) async {
-    final pv = await packageBackend.publishUploadedBlob(uploadId);
+    final messages = await packageBackend.publishUploadedBlob(uploadId);
     return SuccessMessage(
-      success: Message(
-        message: 'Successfully uploaded '
-            '${urls.pkgPageUrl(pv.package, includeHost: true)} '
-            'version ${pv.version}, '
-            'it may take up-to 10 minutes before the new version is available.',
-      ),
+      success: Message(message: messages.join('\n')),
     );
   }
 
diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart
index 92fc67a18f..50f99b8343 100644
--- a/app/lib/package/backend.dart
+++ b/app/lib/package/backend.dart
@@ -882,8 +882,9 @@ class PackageBackend {
     );
   }
 
-  /// Finishes the upload of a package.
-  Future<PackageVersion> publishUploadedBlob(String guid) async {
+  /// Finishes the upload of a package and returns the list of messages
+  /// related to the publishing.
+  Future<List<String>> publishUploadedBlob(String guid) async {
     final restriction = await getUploadRestrictionStatus();
     if (restriction == UploadRestrictionStatus.noUploads) {
       throw PackageRejectedException.uploadRestricted();
@@ -984,7 +985,7 @@ class PackageBackend {
       sw.reset();
       final entities = await _createUploadEntities(db, agent, archive,
           sha256Hash: sha256Hash);
-      final version = await _performTarballUpload(
+      final (version, uploadMessages) = await _performTarballUpload(
         entities: entities,
         agent: agent,
         archive: archive,
@@ -998,7 +999,13 @@ class PackageBackend {
       sw.reset();
       await _incomingBucket.deleteWithRetry(tmpObjectName(guid));
       _logger.info('Temporary object removed in ${sw.elapsed}.');
-      return version;
+      return [
+        'Successfully uploaded '
+            '${urls.pkgPageUrl(version.package, includeHost: true)} '
+            'version ${version.version}, '
+            'it may take up-to 10 minutes before the new version is available.',
+        ...uploadMessages,
+      ];
     });
   }
 
@@ -1057,7 +1064,7 @@ class PackageBackend {
     }
   }
 
-  Future<PackageVersion> _performTarballUpload({
+  Future<(PackageVersion, List<String>)> _performTarballUpload({
     required _UploadEntities entities,
     required AuthenticatedAgent agent,
     required PackageSummary archive,
@@ -1065,6 +1072,7 @@ class PackageBackend {
     required bool hasCanonicalArchiveObject,
   }) async {
     final sw = Stopwatch()..start();
+    final uploadMessages = <String>[];
     final newVersion = entities.packageVersion;
     final [currentDartSdk, currentFlutterSdk] = await Future.wait([
       getCachedDartSdkVersion(lastKnownStable: toolStableDartSdkVersion),
@@ -1100,14 +1108,6 @@ class PackageBackend {
       package: newVersion.package,
       isNew: isNew,
     );
-    final email = createPackageUploadedEmail(
-      packageName: newVersion.package,
-      packageVersion: newVersion.version!,
-      displayId: agent.displayId,
-      authorizedUploaders:
-          uploaderEmails.map((email) => EmailAddress(email)).toList(),
-    );
-    final outgoingEmail = emailBackend.prepareEntity(email);
     Package? package;
     final existingVersions = await db
         .query<PackageVersion>(ancestorKey: newVersion.packageKey!)
@@ -1116,7 +1116,7 @@ class PackageBackend {
 
     // Add the new package to the repository by storing the tarball and
     // inserting metadata to datastore (which happens atomically).
-    final pv = await withRetryTransaction(db, (tx) async {
+    final (pv, outgoingEmail) = await withRetryTransaction(db, (tx) async {
       _logger.info('Starting datastore transaction.');
 
       final tuple = (await tx.lookup([
@@ -1156,10 +1156,21 @@ class PackageBackend {
 
       final maxVersionCount = maxVersionsPerPackageOverrides[package!.name] ??
           maxVersionsPerPackage;
-      if (package!.versionCount >= maxVersionCount) {
+      final remainingVersionCount = maxVersionCount - package!.versionCount;
+      if (remainingVersionCount <= 0) {
         throw PackageRejectedException.maxVersionCountReached(
             newVersion.package, maxVersionCount);
       }
+      if (remainingVersionCount <= 100) {
+        // We need to decrease the remaining version count as the newly uploaded
+        // version is not yet in it.
+        final limitAfterUpload = remainingVersionCount - 1;
+        final s = limitAfterUpload == 1 ? '' : 's';
+        uploadMessages.add(
+            'The package "${package!.name!}" has $limitAfterUpload version$s left '
+            'before reaching the limit of $maxVersionCount. '
+            'Please contact support@pub.dev');
+      }
 
       if (package!.deletedVersions != null &&
           package!.deletedVersions!.contains(newVersion.version!)) {
@@ -1196,6 +1207,16 @@ class PackageBackend {
       await tarballStorage.copyArchiveFromCanonicalToPublicBucket(
           newVersion.package, newVersion.version!);
 
+      final email = createPackageUploadedEmail(
+        packageName: newVersion.package,
+        packageVersion: newVersion.version!,
+        displayId: agent.displayId,
+        authorizedUploaders:
+            uploaderEmails.map((email) => EmailAddress(email)).toList(),
+        uploadMessages: uploadMessages,
+      );
+      final outgoingEmail = emailBackend.prepareEntity(email);
+
       final inserts = <Model>[
         package!,
         newVersion,
@@ -1221,7 +1242,7 @@ class PackageBackend {
 
       _logger.info('Trying to commit datastore changes.');
       tx.queueMutations(inserts: inserts);
-      return newVersion;
+      return (newVersion, outgoingEmail);
     });
     _logger.info('Upload successful. [package-uploaded]');
     _logger.info('Upload transaction completed in ${sw.elapsed}.');
@@ -1237,7 +1258,7 @@ class PackageBackend {
         .addAsyncFn(() => _postUploadTasks(package, newVersion, outgoingEmail));
 
     _logger.info('Post-upload tasks completed in ${sw.elapsed}.');
-    return pv;
+    return (pv, uploadMessages);
   }
 
   /// The post-upload tasks are not critical and could fail without any impact on
diff --git a/app/lib/service/email/email_templates.dart b/app/lib/service/email/email_templates.dart
index 81e0b900dc..7037964ac9 100644
--- a/app/lib/service/email/email_templates.dart
+++ b/app/lib/service/email/email_templates.dart
@@ -204,21 +204,21 @@ EmailMessage createPackageUploadedEmail({
   required String packageVersion,
   required String displayId,
   required List<EmailAddress> authorizedUploaders,
+  required List<String> uploadMessages,
 }) {
   final url =
       pkgPageUrl(packageName, version: packageVersion, includeHost: true);
   final subject = 'Package uploaded: $packageName $packageVersion';
-  final bodyText = '''Dear package maintainer,  
-
-$displayId has published a new version ($packageVersion) of the $packageName package to the Dart package site ($primaryHost).
-
-For details, go to $url
-
-${_footer('package')}
-''';
-
-  return EmailMessage(
-      _notificationsFrom, authorizedUploaders, subject, bodyText);
+  final paragraphs = [
+    'Dear package maintainer,',
+    '$displayId has published a new version ($packageVersion) of the $packageName package to the Dart package site ($primaryHost).',
+    'For details, go to $url',
+    ...uploadMessages,
+    _footer('package'),
+  ];
+
+  return EmailMessage(_notificationsFrom, authorizedUploaders, subject,
+      paragraphs.join('\n\n'));
 }
 
 /// Creates the [EmailMessage] that will be sent to users about new invitations


Test patch:
diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart
index 608114a243..a75c74ba47 100644
--- a/app/test/package/upload_test.dart
+++ b/app/test/package/upload_test.dart
@@ -1238,7 +1238,30 @@ void main() {
         ],
       ),
       fn: () async {
-        packageBackend.maxVersionsPerPackage = 100;
+        packageBackend.maxVersionsPerPackage = 102;
+
+        final tarball101 = await packageArchiveBytes(
+            pubspecContent: generatePubspecYaml('busy_pkg', '1.0.101'));
+        final rs101 = await createPubApiClient(authToken: adminClientToken)
+            .uploadPackageBytes(tarball101);
+        expect(
+            rs101.success.message,
+            contains(
+                'The package "busy_pkg" has 1 version left before reaching the limit of 102. '
+                'Please contact support@pub.dev'));
+
+        final tarball102 = await packageArchiveBytes(
+            pubspecContent: generatePubspecYaml('busy_pkg', '1.0.102'));
+        final rs102 = await createPubApiClient(authToken: adminClientToken)
+            .uploadPackageBytes(tarball102);
+        expect(
+            rs102.success.message,
+            contains(
+                'The package "busy_pkg" has 0 versions left before reaching the limit of 102. '
+                'Please contact support@pub.dev'));
+        expect(fakeEmailSender.sentMessages.last.bodyText,
+            contains('has 0 versions left before reaching the limit'));
+
         final tarball = await packageArchiveBytes(
             pubspecContent: generatePubspecYaml('busy_pkg', '2.0.0'));
         final rs = createPubApiClient(authToken: adminClientToken)
diff --git a/app/test/service/email/email_templates_test.dart b/app/test/service/email/email_templates_test.dart
index adcd973cd3..1b482fd4ad 100644
--- a/app/test/service/email/email_templates_test.dart
+++ b/app/test/service/email/email_templates_test.dart
@@ -169,6 +169,7 @@ void main() {
           EmailAddress(name: 'Joe', 'joe@example.com'),
           EmailAddress('uploader@example.com')
         ],
+        uploadMessages: [],
       );
       expect(message.from.toString(), contains('@pub.dev'));
       expect(message.recipients.map((e) => e.toString()).toList(), [