Skip to content

Commit 9a32495

Browse files
authored
Option to disable manual publishing (#8995)
1 parent 6069224 commit 9a32495

File tree

14 files changed

+256
-20
lines changed

14 files changed

+256
-20
lines changed

app/lib/frontend/handlers/experimental.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const _publicFlags = <PublicFlag>{
1414

1515
final _allFlags = <String>{
1616
'dark-as-default',
17+
'manual-publishing',
1718
..._publicFlags.map((x) => x.name),
1819
};
1920

@@ -88,6 +89,8 @@ class ExperimentalFlags {
8889

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

92+
bool get isManualPublishingConfigAvailable => isEnabled('manual-publishing');
93+
9194
String encodedAsCookie() => _enabled.join(':');
9295

9396
@override

app/lib/frontend/templates/views/pkg/admin_page.dart

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'package:_pub_shared/data/package_api.dart';
6+
import 'package:pub_dev/frontend/request_context.dart';
67

78
import '../../../../account/models.dart';
89
import '../../../../package/models.dart';
@@ -32,17 +33,27 @@ d.Node packageAdminPageNode({
3233
],
3334
),
3435
TocNode(
35-
'Automated publishing',
36-
href: '#automated-publishing',
36+
'Publishing',
37+
href: '#publishing',
3738
children: [
38-
TocNode('GitHub Actions', href: '#github-actions'),
39+
if (requestContext
40+
.experimentalFlags
41+
.isManualPublishingConfigAvailable)
42+
TocNode('Manual publishing', href: '#manual-publishing'),
3943
TocNode(
40-
'Google Cloud Service account',
41-
href: '#google-cloud-service-account',
44+
'Automated publishing',
45+
href: '#automated-publishing',
46+
children: [
47+
TocNode('GitHub Actions', href: '#github-actions'),
48+
TocNode(
49+
'Google Cloud Service account',
50+
href: '#google-cloud-service-account',
51+
),
52+
],
4253
),
54+
TocNode('Version retraction', href: '#version-retraction'),
4355
],
4456
),
45-
TocNode('Version retraction', href: '#version-retraction'),
4657
]),
4758
d.a(name: 'ownership'),
4859
d.h2(text: 'Package ownership'),
@@ -226,6 +237,10 @@ d.Node packageAdminPageNode({
226237
),
227238
),
228239
],
240+
d.a(name: 'publishing'),
241+
d.h2(text: 'Publishing'),
242+
if (requestContext.experimentalFlags.isManualPublishingConfigAvailable)
243+
_manualPublishing(package),
229244
_automatedPublishing(package),
230245
d.a(name: 'version-retraction'),
231246
d.h2(text: 'Version retraction'),
@@ -304,7 +319,7 @@ d.Node _automatedPublishing(Package package) {
304319
final isGitHubEnabled = github?.isEnabled ?? false;
305320
return d.fragment([
306321
d.a(name: 'automated-publishing'),
307-
d.h2(text: 'Automated publishing'),
322+
d.h3(text: 'Automated publishing'),
308323
d.markdown(
309324
'You can automate publishing from the supported automated deployment environments. '
310325
'Instead of creating long-lived secrets, you may use temporary OpenID-Connect tokens '
@@ -453,6 +468,28 @@ d.Node _automatedPublishing(Package package) {
453468
]);
454469
}
455470

471+
d.Node _manualPublishing(Package package) {
472+
final manual = package.automatedPublishing?.manualConfig;
473+
return d.fragment([
474+
d.a(name: 'manual-publishing'),
475+
d.h3(text: 'Manual publishing'),
476+
d.markdown('''
477+
Manual publishing, using personal credentials for the `pub` client (`pub login`) .
478+
479+
Disable to prevent accidental publication from the command line.
480+
481+
It is recommended to disable when automated publishing is enabled.'''),
482+
d.div(
483+
classes: ['-pub-form-checkbox-row'],
484+
child: material.checkbox(
485+
id: '-pkg-admin-manual-publishing-enabled',
486+
label: 'Enable manual publishing',
487+
checked: manual?.isEnabled ?? true,
488+
),
489+
),
490+
]);
491+
}
492+
456493
d.Node _exampleGitHubWorkflow(GitHubPublishingConfig github) {
457494
final expandedTagPattern = (github.tagPattern ?? '{{version}}').replaceAll(
458495
'{{version}}',

app/lib/package/backend.dart

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,8 @@ class PackageBackend {
635635
final p = await tx.lookupValue<Package>(pkg.key);
636636
final githubConfig = body.github;
637637
final gcpConfig = body.gcp;
638+
final manualConfig = body.manual;
639+
638640
if (githubConfig != null) {
639641
final isEnabled = githubConfig.isEnabled;
640642

@@ -648,7 +650,9 @@ class PackageBackend {
648650
final repository = githubConfig.repository?.trim() ?? '';
649651
githubConfig.repository = repository.isEmpty ? null : repository;
650652
final tagPattern = githubConfig.tagPattern?.trim() ?? '';
651-
verifyTagPattern(tagPattern: tagPattern);
653+
if (isEnabled) {
654+
verifyTagPattern(tagPattern: tagPattern);
655+
}
652656
githubConfig.tagPattern = tagPattern.isEmpty ? null : tagPattern;
653657
final environment = githubConfig.environment?.trim() ?? '';
654658
githubConfig.environment = environment.isEmpty ? null : environment;
@@ -726,9 +730,14 @@ class PackageBackend {
726730
}
727731

728732
// finalize changes
729-
p.automatedPublishing ??= AutomatedPublishing();
730-
p.automatedPublishing!.githubConfig = githubConfig;
731-
p.automatedPublishing!.gcpConfig = gcpConfig;
733+
final automatedPublishing = p.automatedPublishing ??=
734+
AutomatedPublishing();
735+
automatedPublishing.githubConfig =
736+
githubConfig ?? automatedPublishing.githubConfig;
737+
automatedPublishing.gcpConfig =
738+
gcpConfig ?? automatedPublishing.gcpConfig;
739+
automatedPublishing.manualConfig =
740+
manualConfig ?? automatedPublishing.manualConfig;
732741

733742
p.updated = clock.now().toUtc();
734743
tx.insert(p);
@@ -742,6 +751,7 @@ class PackageBackend {
742751
return api.AutomatedPublishingConfig(
743752
github: p.automatedPublishing!.githubConfig,
744753
gcp: p.automatedPublishing!.gcpConfig,
754+
manual: p.automatedPublishing!.manualConfig,
745755
);
746756
});
747757
}
@@ -1606,6 +1616,11 @@ class PackageBackend {
16061616
}
16071617
if (agent is AuthenticatedUser &&
16081618
await packageBackend.isPackageAdmin(package, agent.user.userId)) {
1619+
final isEnabled =
1620+
package.automatedPublishing?.manualConfig?.isEnabled ?? true;
1621+
if (!isEnabled) {
1622+
throw AuthorizationException.manualPublishingDisabled(package.name!);
1623+
}
16091624
return;
16101625
}
16111626
if (agent is AuthenticatedGitHubAction) {

app/lib/package/models.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,12 +460,14 @@ class AutomatedPublishing {
460460
GitHubPublishingLock? githubLock;
461461
GcpPublishingConfig? gcpConfig;
462462
GcpPublishingLock? gcpLock;
463+
ManualPublishingConfig? manualConfig;
463464

464465
AutomatedPublishing({
465466
this.githubConfig,
466467
this.githubLock,
467468
this.gcpConfig,
468469
this.gcpLock,
470+
this.manualConfig,
469471
});
470472

471473
factory AutomatedPublishing.fromJson(Map<String, dynamic> json) =>

app/lib/package/models.g.dart

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/lib/shared/exceptions.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ library;
1616
import 'dart:io';
1717

1818
import 'package:api_builder/api_builder.dart' show ApiResponseException;
19+
import 'package:pub_dev/shared/urls.dart';
1920
import 'package:pub_dev/shared/utils.dart';
2021

2122
/// Base class for all exceptions that are intercepted by HTTP handler wrappers.
@@ -572,6 +573,17 @@ class AuthorizationException extends ResponseException {
572573
'The calling service account is not allowed to publish, because: $reason.\nSee https://dart.dev/go/publishing-with-service-account',
573574
);
574575

576+
/// Signaling that the manual publishing was disabled and cannot be authorized.
577+
factory AuthorizationException.manualPublishingDisabled(String package) {
578+
return AuthorizationException._(
579+
'Manual publishing has been disabled. '
580+
'This usually means this package should be published via automated publishing '
581+
'(see https://dart.dev/tools/pub/automated-publishing). '
582+
'To re-enable manual publishing, go to the package admin page '
583+
'(see ${pkgAdminUrl(package, includeHost: true)}).',
584+
);
585+
}
586+
575587
@override
576588
String toString() => '$code: $message'; // used by package:pub_server
577589
}

app/test/frontend/golden/pkg_admin_page.html

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,18 @@ <h3 class="detail-lead-title">Metadata</h3>
262262
<a href="#unlisted">Unlisted</a>
263263
</div>
264264
<div class="pub-toc-node pub-toc-node-0">
265-
<a href="#automated-publishing">Automated publishing</a>
265+
<a href="#publishing">Publishing</a>
266266
</div>
267267
<div class="pub-toc-node pub-toc-node-1">
268+
<a href="#automated-publishing">Automated publishing</a>
269+
</div>
270+
<div class="pub-toc-node pub-toc-node-2">
268271
<a href="#github-actions">GitHub Actions</a>
269272
</div>
270-
<div class="pub-toc-node pub-toc-node-1">
273+
<div class="pub-toc-node pub-toc-node-2">
271274
<a href="#google-cloud-service-account">Google Cloud Service account</a>
272275
</div>
273-
<div class="pub-toc-node pub-toc-node-0">
276+
<div class="pub-toc-node pub-toc-node-1">
274277
<a href="#version-retraction">Version retraction</a>
275278
</div>
276279
</div>
@@ -404,8 +407,10 @@ <h3>Unlisted</h3>
404407
<label for="-admin-is-unlisted-checkbox">Mark "unlisted"</label>
405408
</div>
406409
</div>
410+
<a name="publishing"></a>
411+
<h2>Publishing</h2>
407412
<a name="automated-publishing"></a>
408-
<h2>Automated publishing</h2>
413+
<h3>Automated publishing</h3>
409414
<p>
410415
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
411416
<a href="https://dart.dev/tools/pub/automated-publishing">pub automated publishing guide</a>

app/test/package/automated_publishing_test.dart

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ void main() {
182182
'oxygen',
183183
AutomatedPublishingConfig(
184184
github: GitHubPublishingConfig(
185-
isEnabled: false,
185+
isEnabled: true,
186186
repository: 'abcd/efgh',
187187
tagPattern: pattern,
188188
),
@@ -309,5 +309,79 @@ void main() {
309309
);
310310
},
311311
);
312+
313+
testWithProfile(
314+
'partial settings do not override the other',
315+
fn: () async {
316+
final client = await createFakeAuthPubApiClient(
317+
email: adminAtPubDevEmail,
318+
);
319+
320+
Future<void> update({
321+
GitHubPublishingConfig? github,
322+
GcpPublishingConfig? gcp,
323+
ManualPublishingConfig? manual,
324+
required Map<String, dynamic> expected,
325+
}) async {
326+
final rs = await client.setAutomatedPublishing(
327+
'oxygen',
328+
AutomatedPublishingConfig(github: github, gcp: gcp, manual: manual),
329+
);
330+
expect(rs.toJson(), expected);
331+
}
332+
333+
await update(
334+
manual: ManualPublishingConfig(isEnabled: true),
335+
expected: {
336+
'manual': {'isEnabled': true},
337+
},
338+
);
339+
340+
await update(
341+
github: GitHubPublishingConfig(isEnabled: false),
342+
expected: {
343+
'github': {
344+
'isEnabled': false,
345+
'requireEnvironment': false,
346+
'isPushEventEnabled': true,
347+
'isWorkflowDispatchEventEnabled': false,
348+
},
349+
'manual': {'isEnabled': true},
350+
},
351+
);
352+
353+
await update(
354+
manual: ManualPublishingConfig(isEnabled: false),
355+
expected: {
356+
'github': {
357+
'isEnabled': false,
358+
'requireEnvironment': false,
359+
'isPushEventEnabled': true,
360+
'isWorkflowDispatchEventEnabled': false,
361+
},
362+
'manual': {'isEnabled': false},
363+
},
364+
);
365+
366+
await update(
367+
github: GitHubPublishingConfig(
368+
isEnabled: true,
369+
tagPattern: '{{version}}',
370+
repository: 'user/repo',
371+
),
372+
expected: {
373+
'github': {
374+
'isEnabled': true,
375+
'repository': 'user/repo',
376+
'tagPattern': '{{version}}',
377+
'requireEnvironment': false,
378+
'isPushEventEnabled': true,
379+
'isWorkflowDispatchEventEnabled': false,
380+
},
381+
'manual': {'isEnabled': false},
382+
},
383+
);
384+
},
385+
);
312386
});
313387
}

app/test/package/upload_test.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,37 @@ void main() {
281281
);
282282
});
283283

284+
group('Manual publishing overrides', () {
285+
testWithProfile(
286+
'manual publishing disabled',
287+
fn: () async {
288+
await withFakeAuthRetryPubApiClient(email: adminAtPubDevEmail, (
289+
client,
290+
) async {
291+
await client.setAutomatedPublishing(
292+
'oxygen',
293+
AutomatedPublishingConfig(
294+
manual: ManualPublishingConfig(isEnabled: false),
295+
),
296+
);
297+
});
298+
299+
final bytes = await packageArchiveBytes(
300+
pubspecContent: generatePubspecYaml('oxygen', '2.2.0'),
301+
);
302+
final rs = createPubApiClient(
303+
authToken: adminClientToken,
304+
).uploadPackageBytes(bytes);
305+
await expectApiException(
306+
rs,
307+
status: 403,
308+
code: 'InsufficientPermissions',
309+
message: 'Manual publishing has been disabled.',
310+
);
311+
},
312+
);
313+
});
314+
284315
group('Uploading with service account', () {
285316
testWithProfile(
286317
'service account cannot upload new package',

0 commit comments

Comments
 (0)