Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
50 changes: 43 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,27 @@ 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(
'The manual publishing of new versions using the `pub` tool is enabled by default in all packages. '
'Disabling it may protect the package from accidental publishing events when the package is otherwise using '
'automated publishing, or in other cases, is discontinued.',
),
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();
}
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.

6 changes: 6 additions & 0 deletions app/lib/shared/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,12 @@ 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() =>
AuthorizationException._(
'Manual publishing with the `pub` tool 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.',
);

@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 with the `pub` tool has been disabled.',
);
},
);
});

group('Uploading with service account', () {
testWithProfile(
'service account cannot upload new package',
Expand Down
Loading