Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/lib/frontend/handlers/experimental.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const _publicFlags = <PublicFlag>{

final _allFlags = <String>{
'dark-as-default',
'manual-publishing',
..._publicFlags.map((x) => x.name),
};

Expand Down Expand Up @@ -88,6 +89,8 @@ class ExperimentalFlags {

bool get isDarkModeDefault => isEnabled('dark-as-default');

bool get isManualPublishingConfigAvailable => isEnabled('manual-publishing');

String encodedAsCookie() => _enabled.join(':');

@override
Expand Down
51 changes: 44 additions & 7 deletions app/lib/frontend/templates/views/pkg/admin_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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 '
Expand Down Expand Up @@ -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}}',
Expand Down
23 changes: 19 additions & 4 deletions app/lib/package/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,8 @@ class PackageBackend {
final p = await tx.lookupValue<Package>(pkg.key);
final githubConfig = body.github;
final gcpConfig = body.gcp;
final manualConfig = body.manual;

if (githubConfig != null) {
final isEnabled = githubConfig.isEnabled;

Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -742,6 +751,7 @@ class PackageBackend {
return api.AutomatedPublishingConfig(
github: p.automatedPublishing!.githubConfig,
gcp: p.automatedPublishing!.gcpConfig,
manual: p.automatedPublishing!.manualConfig,
);
});
}
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions app/lib/package/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> json) =>
Expand Down
6 changes: 6 additions & 0 deletions app/lib/package/models.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions app/lib/shared/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
13 changes: 9 additions & 4 deletions app/test/frontend/golden/pkg_admin_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -262,15 +262,18 @@ <h3 class="detail-lead-title">Metadata</h3>
<a href="#unlisted">Unlisted</a>
</div>
<div class="pub-toc-node pub-toc-node-0">
<a href="#automated-publishing">Automated publishing</a>
<a href="#publishing">Publishing</a>
</div>
<div class="pub-toc-node pub-toc-node-1">
<a href="#automated-publishing">Automated publishing</a>
</div>
<div class="pub-toc-node pub-toc-node-2">
<a href="#github-actions">GitHub Actions</a>
</div>
<div class="pub-toc-node pub-toc-node-1">
<div class="pub-toc-node pub-toc-node-2">
<a href="#google-cloud-service-account">Google Cloud Service account</a>
</div>
<div class="pub-toc-node pub-toc-node-0">
<div class="pub-toc-node pub-toc-node-1">
<a href="#version-retraction">Version retraction</a>
</div>
</div>
Expand Down Expand Up @@ -404,8 +407,10 @@ <h3>Unlisted</h3>
<label for="-admin-is-unlisted-checkbox">Mark "unlisted"</label>
</div>
</div>
<a name="publishing"></a>
<h2>Publishing</h2>
<a name="automated-publishing"></a>
<h2>Automated publishing</h2>
<h3>Automated publishing</h3>
<p>
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
<a href="https://dart.dev/tools/pub/automated-publishing">pub automated publishing guide</a>
Expand Down
76 changes: 75 additions & 1 deletion app/test/package/automated_publishing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ void main() {
'oxygen',
AutomatedPublishingConfig(
github: GitHubPublishingConfig(
isEnabled: false,
isEnabled: true,
repository: 'abcd/efgh',
tagPattern: pattern,
),
Expand Down Expand Up @@ -309,5 +309,79 @@ void main() {
);
},
);

testWithProfile(
'partial settings do not override the other',
fn: () async {
final client = await createFakeAuthPubApiClient(
email: adminAtPubDevEmail,
);

Future<void> update({
GitHubPublishingConfig? github,
GcpPublishingConfig? gcp,
ManualPublishingConfig? manual,
required Map<String, dynamic> 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},
},
);
},
);
});
}
31 changes: 31 additions & 0 deletions app/test/package/upload_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading