diff --git a/app/lib/frontend/handlers/experimental.dart b/app/lib/frontend/handlers/experimental.dart index d745713e2b..c99f4483e2 100644 --- a/app/lib/frontend/handlers/experimental.dart +++ b/app/lib/frontend/handlers/experimental.dart @@ -14,6 +14,7 @@ const _publicFlags = { final _allFlags = { 'dark-as-default', + 'manual-publishing', ..._publicFlags.map((x) => x.name), }; @@ -88,6 +89,8 @@ class ExperimentalFlags { bool get isDarkModeDefault => isEnabled('dark-as-default'); + bool get isManualPublishingConfigAvailable => isEnabled('manual-publishing'); + String encodedAsCookie() => _enabled.join(':'); @override diff --git a/app/lib/frontend/templates/views/pkg/admin_page.dart b/app/lib/frontend/templates/views/pkg/admin_page.dart index 9f86a01776..5eb1324272 100644 --- a/app/lib/frontend/templates/views/pkg/admin_page.dart +++ b/app/lib/frontend/templates/views/pkg/admin_page.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'package:_pub_shared/data/package_api.dart'; +import 'package:pub_dev/frontend/request_context.dart'; import '../../../../account/models.dart'; import '../../../../package/models.dart'; @@ -32,17 +33,27 @@ d.Node packageAdminPageNode({ ], ), TocNode( - 'Automated publishing', - href: '#automated-publishing', + 'Publishing', + href: '#publishing', children: [ - TocNode('GitHub Actions', href: '#github-actions'), + if (requestContext + .experimentalFlags + .isManualPublishingConfigAvailable) + TocNode('Manual publishing', href: '#manual-publishing'), TocNode( - 'Google Cloud Service account', - href: '#google-cloud-service-account', + 'Automated publishing', + href: '#automated-publishing', + children: [ + TocNode('GitHub Actions', href: '#github-actions'), + TocNode( + 'Google Cloud Service account', + href: '#google-cloud-service-account', + ), + ], ), + TocNode('Version retraction', href: '#version-retraction'), ], ), - TocNode('Version retraction', href: '#version-retraction'), ]), d.a(name: 'ownership'), d.h2(text: 'Package ownership'), @@ -226,6 +237,10 @@ d.Node packageAdminPageNode({ ), ), ], + d.a(name: 'publishing'), + d.h2(text: 'Publishing'), + if (requestContext.experimentalFlags.isManualPublishingConfigAvailable) + _manualPublishing(package), _automatedPublishing(package), d.a(name: 'version-retraction'), d.h2(text: 'Version retraction'), @@ -304,7 +319,7 @@ d.Node _automatedPublishing(Package package) { final isGitHubEnabled = github?.isEnabled ?? false; return d.fragment([ d.a(name: 'automated-publishing'), - d.h2(text: 'Automated publishing'), + d.h3(text: 'Automated publishing'), d.markdown( 'You can automate publishing from the supported automated deployment environments. ' 'Instead of creating long-lived secrets, you may use temporary OpenID-Connect tokens ' @@ -453,6 +468,28 @@ d.Node _automatedPublishing(Package package) { ]); } +d.Node _manualPublishing(Package package) { + final manual = package.automatedPublishing?.manualConfig; + return d.fragment([ + d.a(name: 'manual-publishing'), + d.h3(text: 'Manual publishing'), + d.markdown(''' +Manual publishing, using personal credentials for the `pub` client (`pub login`) . + +Disable to prevent accidental publication from the command line. + +It is recommended to disable when automated publishing is enabled.'''), + d.div( + classes: ['-pub-form-checkbox-row'], + child: material.checkbox( + id: '-pkg-admin-manual-publishing-enabled', + label: 'Enable manual publishing', + checked: manual?.isEnabled ?? true, + ), + ), + ]); +} + d.Node _exampleGitHubWorkflow(GitHubPublishingConfig github) { final expandedTagPattern = (github.tagPattern ?? '{{version}}').replaceAll( '{{version}}', diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index 3c07806119..6943c0f897 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -635,6 +635,8 @@ class PackageBackend { final p = await tx.lookupValue(pkg.key); final githubConfig = body.github; final gcpConfig = body.gcp; + final manualConfig = body.manual; + if (githubConfig != null) { final isEnabled = githubConfig.isEnabled; @@ -648,7 +650,9 @@ class PackageBackend { final repository = githubConfig.repository?.trim() ?? ''; githubConfig.repository = repository.isEmpty ? null : repository; final tagPattern = githubConfig.tagPattern?.trim() ?? ''; - verifyTagPattern(tagPattern: tagPattern); + if (isEnabled) { + verifyTagPattern(tagPattern: tagPattern); + } githubConfig.tagPattern = tagPattern.isEmpty ? null : tagPattern; final environment = githubConfig.environment?.trim() ?? ''; githubConfig.environment = environment.isEmpty ? null : environment; @@ -726,9 +730,14 @@ class PackageBackend { } // finalize changes - p.automatedPublishing ??= AutomatedPublishing(); - p.automatedPublishing!.githubConfig = githubConfig; - p.automatedPublishing!.gcpConfig = gcpConfig; + final automatedPublishing = p.automatedPublishing ??= + AutomatedPublishing(); + automatedPublishing.githubConfig = + githubConfig ?? automatedPublishing.githubConfig; + automatedPublishing.gcpConfig = + gcpConfig ?? automatedPublishing.gcpConfig; + automatedPublishing.manualConfig = + manualConfig ?? automatedPublishing.manualConfig; p.updated = clock.now().toUtc(); tx.insert(p); @@ -742,6 +751,7 @@ class PackageBackend { return api.AutomatedPublishingConfig( github: p.automatedPublishing!.githubConfig, gcp: p.automatedPublishing!.gcpConfig, + manual: p.automatedPublishing!.manualConfig, ); }); } @@ -1606,6 +1616,11 @@ class PackageBackend { } if (agent is AuthenticatedUser && await packageBackend.isPackageAdmin(package, agent.user.userId)) { + final isEnabled = + package.automatedPublishing?.manualConfig?.isEnabled ?? true; + if (!isEnabled) { + throw AuthorizationException.manualPublishingDisabled(package.name!); + } return; } if (agent is AuthenticatedGitHubAction) { diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index a93ad39f91..d835998904 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -460,12 +460,14 @@ class AutomatedPublishing { GitHubPublishingLock? githubLock; GcpPublishingConfig? gcpConfig; GcpPublishingLock? gcpLock; + ManualPublishingConfig? manualConfig; AutomatedPublishing({ this.githubConfig, this.githubLock, this.gcpConfig, this.gcpLock, + this.manualConfig, }); factory AutomatedPublishing.fromJson(Map json) => diff --git a/app/lib/package/models.g.dart b/app/lib/package/models.g.dart index b49793ac16..15c3103151 100644 --- a/app/lib/package/models.g.dart +++ b/app/lib/package/models.g.dart @@ -54,6 +54,11 @@ AutomatedPublishing _$AutomatedPublishingFromJson(Map json) => gcpLock: json['gcpLock'] == null ? null : GcpPublishingLock.fromJson(json['gcpLock'] as Map), + manualConfig: json['manualConfig'] == null + ? null + : ManualPublishingConfig.fromJson( + json['manualConfig'] as Map, + ), ); Map _$AutomatedPublishingToJson( @@ -63,6 +68,7 @@ Map _$AutomatedPublishingToJson( 'githubLock': ?instance.githubLock?.toJson(), 'gcpConfig': ?instance.gcpConfig?.toJson(), 'gcpLock': ?instance.gcpLock?.toJson(), + 'manualConfig': ?instance.manualConfig?.toJson(), }; GitHubPublishingLock _$GitHubPublishingLockFromJson( diff --git a/app/lib/shared/exceptions.dart b/app/lib/shared/exceptions.dart index 3e24c32376..658c4235d8 100644 --- a/app/lib/shared/exceptions.dart +++ b/app/lib/shared/exceptions.dart @@ -16,6 +16,7 @@ library; import 'dart:io'; import 'package:api_builder/api_builder.dart' show ApiResponseException; +import 'package:pub_dev/shared/urls.dart'; import 'package:pub_dev/shared/utils.dart'; /// Base class for all exceptions that are intercepted by HTTP handler wrappers. @@ -572,6 +573,17 @@ class AuthorizationException extends ResponseException { 'The calling service account is not allowed to publish, because: $reason.\nSee https://dart.dev/go/publishing-with-service-account', ); + /// Signaling that the manual publishing was disabled and cannot be authorized. + factory AuthorizationException.manualPublishingDisabled(String package) { + return AuthorizationException._( + 'Manual publishing has been disabled. ' + 'This usually means this package should be published via automated publishing ' + '(see https://dart.dev/tools/pub/automated-publishing). ' + 'To re-enable manual publishing, go to the package admin page ' + '(see ${pkgAdminUrl(package, includeHost: true)}).', + ); + } + @override String toString() => '$code: $message'; // used by package:pub_server } diff --git a/app/test/frontend/golden/pkg_admin_page.html b/app/test/frontend/golden/pkg_admin_page.html index 042e02fde1..d8cac0068b 100644 --- a/app/test/frontend/golden/pkg_admin_page.html +++ b/app/test/frontend/golden/pkg_admin_page.html @@ -262,15 +262,18 @@

Metadata

Unlisted + -
+ - @@ -404,8 +407,10 @@

Unlisted

+ +

Publishing

-

Automated publishing

+

Automated publishing

You can automate publishing from the supported automated deployment environments. Instead of creating long-lived secrets, you may use temporary OpenID-Connect tokens signed by either GitHub Actions or Google Cloud IAM. See the pub automated publishing guide diff --git a/app/test/package/automated_publishing_test.dart b/app/test/package/automated_publishing_test.dart index 0ec8c7f5e4..69aa9b973d 100644 --- a/app/test/package/automated_publishing_test.dart +++ b/app/test/package/automated_publishing_test.dart @@ -182,7 +182,7 @@ void main() { 'oxygen', AutomatedPublishingConfig( github: GitHubPublishingConfig( - isEnabled: false, + isEnabled: true, repository: 'abcd/efgh', tagPattern: pattern, ), @@ -309,5 +309,79 @@ void main() { ); }, ); + + testWithProfile( + 'partial settings do not override the other', + fn: () async { + final client = await createFakeAuthPubApiClient( + email: adminAtPubDevEmail, + ); + + Future update({ + GitHubPublishingConfig? github, + GcpPublishingConfig? gcp, + ManualPublishingConfig? manual, + required Map expected, + }) async { + final rs = await client.setAutomatedPublishing( + 'oxygen', + AutomatedPublishingConfig(github: github, gcp: gcp, manual: manual), + ); + expect(rs.toJson(), expected); + } + + await update( + manual: ManualPublishingConfig(isEnabled: true), + expected: { + 'manual': {'isEnabled': true}, + }, + ); + + await update( + github: GitHubPublishingConfig(isEnabled: false), + expected: { + 'github': { + 'isEnabled': false, + 'requireEnvironment': false, + 'isPushEventEnabled': true, + 'isWorkflowDispatchEventEnabled': false, + }, + 'manual': {'isEnabled': true}, + }, + ); + + await update( + manual: ManualPublishingConfig(isEnabled: false), + expected: { + 'github': { + 'isEnabled': false, + 'requireEnvironment': false, + 'isPushEventEnabled': true, + 'isWorkflowDispatchEventEnabled': false, + }, + 'manual': {'isEnabled': false}, + }, + ); + + await update( + github: GitHubPublishingConfig( + isEnabled: true, + tagPattern: '{{version}}', + repository: 'user/repo', + ), + expected: { + 'github': { + 'isEnabled': true, + 'repository': 'user/repo', + 'tagPattern': '{{version}}', + 'requireEnvironment': false, + 'isPushEventEnabled': true, + 'isWorkflowDispatchEventEnabled': false, + }, + 'manual': {'isEnabled': false}, + }, + ); + }, + ); }); } diff --git a/app/test/package/upload_test.dart b/app/test/package/upload_test.dart index 91c8eff79a..dbf1ec923f 100644 --- a/app/test/package/upload_test.dart +++ b/app/test/package/upload_test.dart @@ -281,6 +281,37 @@ void main() { ); }); + group('Manual publishing overrides', () { + testWithProfile( + 'manual publishing disabled', + fn: () async { + await withFakeAuthRetryPubApiClient(email: adminAtPubDevEmail, ( + client, + ) async { + await client.setAutomatedPublishing( + 'oxygen', + AutomatedPublishingConfig( + manual: ManualPublishingConfig(isEnabled: false), + ), + ); + }); + + final bytes = await packageArchiveBytes( + pubspecContent: generatePubspecYaml('oxygen', '2.2.0'), + ); + final rs = createPubApiClient( + authToken: adminClientToken, + ).uploadPackageBytes(bytes); + await expectApiException( + rs, + status: 403, + code: 'InsufficientPermissions', + message: 'Manual publishing has been disabled.', + ); + }, + ); + }); + group('Uploading with service account', () { testWithProfile( 'service account cannot upload new package', diff --git a/pkg/_pub_shared/lib/data/package_api.dart b/pkg/_pub_shared/lib/data/package_api.dart index 0c4145930a..1936cdab5f 100644 --- a/pkg/_pub_shared/lib/data/package_api.dart +++ b/pkg/_pub_shared/lib/data/package_api.dart @@ -46,8 +46,9 @@ class PkgOptions { class AutomatedPublishingConfig { final GitHubPublishingConfig? github; final GcpPublishingConfig? gcp; + final ManualPublishingConfig? manual; - AutomatedPublishingConfig({this.github, this.gcp}); + AutomatedPublishingConfig({this.github, this.gcp, this.manual}); factory AutomatedPublishingConfig.fromJson(Map json) => _$AutomatedPublishingConfigFromJson(json); @@ -120,6 +121,18 @@ class GcpPublishingConfig { Map toJson() => _$GcpPublishingConfigToJson(this); } +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class ManualPublishingConfig { + bool isEnabled; + + ManualPublishingConfig({this.isEnabled = true}); + + factory ManualPublishingConfig.fromJson(Map json) => + _$ManualPublishingConfigFromJson(json); + + Map toJson() => _$ManualPublishingConfigToJson(this); +} + @JsonSerializable() class VersionOptions { final bool? isRetracted; diff --git a/pkg/_pub_shared/lib/data/package_api.g.dart b/pkg/_pub_shared/lib/data/package_api.g.dart index dcfd9902af..9dac845b04 100644 --- a/pkg/_pub_shared/lib/data/package_api.g.dart +++ b/pkg/_pub_shared/lib/data/package_api.g.dart @@ -38,6 +38,9 @@ AutomatedPublishingConfig _$AutomatedPublishingConfigFromJson( gcp: json['gcp'] == null ? null : GcpPublishingConfig.fromJson(json['gcp'] as Map), + manual: json['manual'] == null + ? null + : ManualPublishingConfig.fromJson(json['manual'] as Map), ); Map _$AutomatedPublishingConfigToJson( @@ -45,6 +48,7 @@ Map _$AutomatedPublishingConfigToJson( ) => { 'github': ?instance.github?.toJson(), 'gcp': ?instance.gcp?.toJson(), + 'manual': ?instance.manual?.toJson(), }; GitHubPublishingConfig _$GitHubPublishingConfigFromJson( @@ -85,6 +89,14 @@ Map _$GcpPublishingConfigToJson( 'serviceAccountEmail': ?instance.serviceAccountEmail, }; +ManualPublishingConfig _$ManualPublishingConfigFromJson( + Map json, +) => ManualPublishingConfig(isEnabled: json['isEnabled'] as bool? ?? true); + +Map _$ManualPublishingConfigToJson( + ManualPublishingConfig instance, +) => {'isEnabled': instance.isEnabled}; + VersionOptions _$VersionOptionsFromJson(Map json) => VersionOptions(isRetracted: json['isRetracted'] as bool?); diff --git a/pkg/pub_integration/lib/src/test_browser.dart b/pkg/pub_integration/lib/src/test_browser.dart index c3817f80ce..f19829d230 100644 --- a/pkg/pub_integration/lib/src/test_browser.dart +++ b/pkg/pub_integration/lib/src/test_browser.dart @@ -372,9 +372,9 @@ extension PageExt on Page { } /// Returns the [property] value of the first element by [selector]. - Future propertyValue(String selector, String property) async { + Future propertyValue(String selector, String property) async { final h = await $(selector); - return await h.propertyValue(property); + return await h.propertyValue(property); } } diff --git a/pkg/pub_integration/test/pkg_admin_page_test.dart b/pkg/pub_integration/test/pkg_admin_page_test.dart index 0ed68ac015..0f7ca1af4e 100644 --- a/pkg/pub_integration/test/pkg_admin_page_test.dart +++ b/pkg/pub_integration/test/pkg_admin_page_test.dart @@ -79,6 +79,26 @@ void main() { expect(value, githubRepository); }); + // disable manual publishing + await user.withBrowserPage((page) async { + await page.gotoOrigin('/experimental?manual-publishing=1'); + await page.gotoOrigin('/packages/test_pkg/admin'); + + await page.waitAndClick('#-pkg-admin-manual-publishing-enabled'); + await page.waitAndClick( + '#-pkg-admin-automated-button', + waitForOneResponse: true, + ); + await page.waitAndClickOnDialogOk(); + await page.reload(); + + final value = await page.propertyValue( + '#-pkg-admin-manual-publishing-enabled', + 'checked', + ); + expect(value, false); + }); + // visit activity log page await user.withBrowserPage((page) async { await page.gotoOrigin('/packages/test_pkg/activity-log'); diff --git a/pkg/web_app/lib/src/admin_pages.dart b/pkg/web_app/lib/src/admin_pages.dart index a44af4af75..68eab94aa2 100644 --- a/pkg/web_app/lib/src/admin_pages.dart +++ b/pkg/web_app/lib/src/admin_pages.dart @@ -149,6 +149,9 @@ class _PkgAdminWidget { } void _setupAutomatedPublishing() { + final manualPublishingEnabledCheckbox = + document.getElementById('-pkg-admin-manual-publishing-enabled') + as InputElement?; final githubEnabledCheckbox = document.getElementById('-pkg-admin-automated-github-enabled') as InputElement?; @@ -187,7 +190,7 @@ class _PkgAdminWidget { updateButton.onClick.listen((event) async { await api_client.rpc( confirmQuestion: await markdown( - 'Are you sure you want to update the automated publishing config?', + 'Are you sure you want to update the publishing config?', ), fn: () async { await api_client.client.setAutomatedPublishing( @@ -209,6 +212,9 @@ class _PkgAdminWidget { isEnabled: gcpEnabledCheckbox!.checked ?? false, serviceAccountEmail: gcpServiceAccountEmailInput!.value, ), + manual: ManualPublishingConfig( + isEnabled: manualPublishingEnabledCheckbox?.checked ?? true, + ), ), ); },