Skip to content

Commit

Permalink
Add publish command, auto-publish on post-submit. (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
matanlurey authored Dec 21, 2024
1 parent 15de63b commit 43c856f
Show file tree
Hide file tree
Showing 16 changed files with 359 additions and 1 deletion.
10 changes: 10 additions & 0 deletions .github/workflows/package_iota.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,13 @@ jobs:
flags: iota
file: packages/iota/coverage/lcov.info
fail_ci_if_error: true
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: dart-lang/[email protected]
- run: dart pub get
working-directory: packages/iota
- run: ./dev.sh publish --packages packages/iota
10 changes: 10 additions & 0 deletions .github/workflows/package_jsonut.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ jobs:
flags: jsonut
file: packages/jsonut/coverage/lcov.info
fail_ci_if_error: true
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: dart-lang/[email protected]
- run: dart pub get
working-directory: packages/jsonut
- run: ./dev.sh publish --packages packages/jsonut
10 changes: 10 additions & 0 deletions .github/workflows/package_lore.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ jobs:
flags: lore
file: packages/lore/coverage/lcov.info
fail_ci_if_error: true
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: dart-lang/[email protected]
- run: dart pub get
working-directory: packages/lore
- run: ./dev.sh publish --packages packages/lore
10 changes: 10 additions & 0 deletions .github/workflows/package_mansion.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ jobs:
flags: mansion
file: packages/mansion/coverage/lcov.info
fail_ci_if_error: true
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: dart-lang/[email protected]
- run: dart pub get
working-directory: packages/mansion
- run: ./dev.sh publish --packages packages/mansion
10 changes: 10 additions & 0 deletions .github/workflows/package_oath.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ jobs:
flags: oath
file: packages/oath/coverage/lcov.info
fail_ci_if_error: true
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: dart-lang/[email protected]
- run: dart pub get
working-directory: packages/oath
- run: ./dev.sh publish --packages packages/oath
10 changes: 10 additions & 0 deletions .github/workflows/package_proc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ jobs:
flags: proc
file: packages/proc/coverage/lcov.info
fail_ci_if_error: true
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: dart-lang/[email protected]
- run: dart pub get
working-directory: packages/proc
- run: ./dev.sh publish --packages packages/proc
10 changes: 10 additions & 0 deletions .github/workflows/package_webby.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,13 @@ jobs:
flags: webby
file: packages/webby/coverage/lcov.info
fail_ci_if_error: true
publish:
if: github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- uses: dart-lang/[email protected]
- run: dart pub get
working-directory: packages/webby
- run: ./dev.sh publish --packages packages/webby
1 change: 1 addition & 0 deletions dev/lib/src/commands/generate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ final class Generate extends BaseCommand {
await workflowFile.writeAsString(
generateGithubPackageWorkflow(
package: package.name,
publishable: package.isPublishable,
usesChrome: package.testDependencies.contains(TestDependency.chrome),
uploadCoverage: package.supportsCoverage,
),
Expand Down
20 changes: 20 additions & 0 deletions dev/lib/src/generators/github_package_workflow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:strink/strink.dart';
/// Generates a GitHub Actions workflow file for a package.
String generateGithubPackageWorkflow({
required String package,
required bool publishable,
required bool usesChrome,
required bool uploadCoverage,
}) {
Expand Down Expand Up @@ -63,5 +64,24 @@ String generateGithubPackageWorkflow({
writer.endObjectOrList();
}

writer.endObjectOrList();
writer.endObjectOrList();

if (publishable) {
// Add a post-submit job for publishing.
writer.startObjectOrList('publish');
writer.writeKeyValue('if', "github.event_name == 'push'");
writer.writeKeyValue('needs', 'build');
writer.writeKeyValue('runs-on', 'ubuntu-latest');
writer.startObjectOrList('steps');
writer.writeListValue('uses: actions/[email protected]');
writer.writeListValue('uses: dart-lang/[email protected]');
writer.writeListObject('run', 'dart pub get');
writer.writeKeyValue('working-directory', 'packages/$package');
writer.endObjectOrList();

writer.writeListValue('run: ./dev.sh publish --packages packages/$package');
}

return buffer.toString();
}
114 changes: 114 additions & 0 deletions packages/chore/lib/src/command/publish.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import 'dart:io' as io;

import 'package:chore/chore.dart';
import 'package:chore/src/internal/pub_service.dart';
import 'package:proc/proc.dart';

/// A command that publishes a package.
final class Publish extends BaseCommand {
/// Creates a new publish command.
Publish(
super.context,
super.environment, {
PubService? pubService,
}) : _pubService = pubService ?? PubService() {
argParser.addFlag(
'skip-if-already-published',
defaultsTo: environment.isCI,
help: 'Skip publishing if the current version is already published.',
);
argParser.addFlag(
'confirm',
defaultsTo: !environment.isCI,
help: 'Confirm before publishing, even if there are no issues.',
);
}

@override
String get name => 'publish';

@override
String get description => 'Publishes packages.\n'
'\n'
'If no --packages are provided, all publishable packages are attempted.\n'
'\n'
'Locally, this uses stored pub credentials, and on CI, it uses the '
'PUB_CREDENTIALS environment variable to authenticate.';

/// Service to check the status of the package.
final PubService _pubService;

/// Whether to confirm before publishing.
bool get _confirm => argResults!.flag('confirm');

/// Whether to skip publishing if the current version is already published.
bool get _skipIfAlreadyPublished {
return argResults!.flag('skip-if-already-published');
}

@override
Future<void> run() async {
Iterable<Package> packages = await context.resolve(globalResults!);
if (!globalResults!.wasParsed('packages')) {
// Skip packages that are not publishable.
packages = packages.where((package) => package.isPublishable);
}
for (final package in packages) {
await _runForPackage(package);
}
}

Future<void> _runForPackage(Package package) async {
final dartBin = environment.getDartSdk()?.dart;
if (dartBin == null) {
throw StateError('Unable to find dart executable.');
}

// The package must be publishable and have a version.
if (!package.isPublishable) {
io.exitCode = 1;
io.stderr.writeln('❌ ${package.name} is not publishable.');
return;
}
if (package.version == null) {
io.exitCode = 1;
io.stderr.writeln('❌ ${package.name} has no version.');
return;
}

// Check if the package is already published as the current version.
if (_skipIfAlreadyPublished) {
final publishedVersion = await _pubService.fetchLatestVersion(
package.name,
);
if (publishedVersion == package.version) {
io.stderr.writeln(
'❕ ${package.name} is already published as ${package.version}.',
);
return;
}
}

// Run dart publish.
io.stderr.writeln('Publishing ${package.name}...');
{
final process = await environment.processHost.start(
dartBin.binPath,
[
'pub',
'lish',
if (!_confirm) '--force',
],
runMode: ProcessRunMode.inheritStdio,
workingDirectory: package.path,
);
if ((await process.exitCode).isFailure) {
io.exitCode = 1;
io.stderr.writeln('❌ Publishing failed.');
} else {
io.stderr.writeln('✅ Publishing succeeded.');
}
io.stderr.writeln();
}
}
}
68 changes: 68 additions & 0 deletions packages/chore/lib/src/internal/pub_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Adapted from <https://github.com/flutter/packages/blob/3515abab07d0bb2441277f43c2411c9b5e4ecf94/script/tool/lib/src/common/pub_version_finder.dart>.

import 'dart:io' as io;
import 'dart:typed_data';

import 'package:jsonut/jsonut.dart';

/// Interfaces with a pub host, such as `pub.dev`.
final class PubService {
/// Creates a new pub service.
///
/// The [pubHost] defaults to `https://pub.dev` if not provided.
///
/// The [httpGet] function defaults to using `dart:io` if not provided.
PubService({
Uri? pubHost,
Future<HttpResponse> Function(Uri)? httpGet,
}) : _pubHost = pubHost ?? _defaultHost,
_httpGet = httpGet ?? _defaultHttpGet;
final Uri _pubHost;
static final _defaultHost = Uri(scheme: 'https', host: 'pub.dev');

final Future<HttpResponse> Function(Uri) _httpGet;
static Future<HttpResponse> _defaultHttpGet(Uri uri) async {
final client = io.HttpClient();
try {
final request = await client.getUrl(uri);
final response = await request.close();
final builder = BytesBuilder(copy: false);
await response.forEach(builder.add);
return HttpResponse(
statusCode: response.statusCode,
body: builder.takeBytes(),
);
} finally {
client.close();
}
}

/// Fetches the latest version of a package from the pub host.
Future<String> fetchLatestVersion(String package) async {
final uri = _pubHost.replace(path: '/packages/$package.json');
final response = await _httpGet(uri);
return switch (response.statusCode) {
404 => throw ArgumentError('Package not found: $package'),
200 => response.decodeAsJson()['versions'].array().first.string(),
_ => throw StateError('Error fetching $package: ${response.statusCode}'),
};
}
}

/// A minimal HTTP response.
final class HttpResponse {
/// Creates a new HTTP response.
const HttpResponse({
required this.statusCode,
required this.body,
});

/// The status code of the response.
final int statusCode;

/// The response body.
final Uint8List body;

/// Decodes the body as a JSON object.
JsonObject decodeAsJson() => JsonObject.parseUtf8(body);
}
5 changes: 5 additions & 0 deletions packages/chore/lib/src/internal/pubspec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ final class Pubspec extends YamlWrapper {
/// Name of the package.
String get name => loadString('name') ?? isRequired('name');

/// Version of the package.
///
/// If omitted, the default is `null`.
String? get version => loadString('version');

/// If publishing is enabled.
bool get isPublishable => root['publish_to'] != 'none';

Expand Down
11 changes: 11 additions & 0 deletions packages/chore/lib/src/package.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ sealed class Package {
return Package(
path: path,
name: pubspec.name,
version: pubspec.version,
isPublishable: pubspec.isPublishable,
description: pubspec.description,
shortDescription: pubspec.shortDescription,
Expand All @@ -71,6 +72,7 @@ sealed class Package {
required String name,
required bool isPublishable,
required bool supportsCoverage,
String? version,
String? description,
String? shortDescription,
Set<TestDependency> testDependencies,
Expand All @@ -81,6 +83,7 @@ sealed class Package {
required this.name,
required this.isPublishable,
required this.supportsCoverage,
this.version,
this.description,
this.shortDescription,
Set<TestDependency> testDependencies = const {},
Expand All @@ -92,6 +95,11 @@ sealed class Package {
/// Name of the package.
final String name;

/// Version of the package.
///
/// If omitted, the package is considered to have no version.
final String? version;

/// Description of the package.
///
/// If omitted, the package is considered to have no description.
Expand All @@ -115,6 +123,7 @@ sealed class Package {
bool operator ==(Object other) {
return other is Package &&
name == other.name &&
version == other.version &&
description == other.description &&
shortDescription == other.shortDescription &&
isPublishable == other.isPublishable &&
Expand All @@ -126,6 +135,7 @@ sealed class Package {
int get hashCode {
return Object.hash(
name,
version,
description,
shortDescription,
isPublishable,
Expand All @@ -145,6 +155,7 @@ final class _Package extends Package {
required super.name,
required super.isPublishable,
required super.supportsCoverage,
super.version,
super.description,
super.shortDescription,
super.testDependencies,
Expand Down
Loading

0 comments on commit 43c856f

Please sign in to comment.