Skip to content

Commit 548678a

Browse files
authored
Add support for deploying to Homebrew (#22)
Closes #7
1 parent f5d63df commit 548678a

10 files changed

+587
-4
lines changed

.travis.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ before_install:
1616
choco install nodejs.install;
1717
export PATH="$PATH:/c/Program Files/nodejs";
1818
fi
19+
- git config --global user.email "travis@local"
20+
- git config --global user.name "Travis CI"
1921

2022
matrix:
2123
include:

doc/homebrew.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
This task updates an existing Homebrew formula to point to the latest source
2+
archive for this package. It's enabled by calling [`pkg.addHomebrewTasks()`][].
3+
4+
[`pkg.addHomebrewTasks()`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/addHomebrewTasks.html
5+
6+
The Homebrew task treats the package's Homebrew repository as the source of
7+
truth for all configuration and metadata. This means that it's the user's
8+
responsibility to set up a reasonable installation formula ([Dart Sass's
9+
formula][] is a good starting point). All this task does is update the formula's
10+
`url` and `sha256` fields to the appropriate values for the latest version.
11+
12+
[Dart Sass's formula]: https://github.com/sass/homebrew-sass/blob/master/sass.rb
13+
14+
This task assumes that the package is published on GitHub (specifically to
15+
[`pkg.githubRepo`][]), and that the task is running in a clone of that GitHub
16+
repo.
17+
18+
[`pkg.githubRepo`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/githubRepo.html
19+
20+
21+
## `pkg-homebrew-update`
22+
23+
Uses configuration: [`pkg.version`][], [`pkg.humanName`][], [`pkg.botName`][],
24+
[`pkg.botEmail`][], [`pkg.githubRepo`][], [`pkg.githubUser`][],
25+
[`pkg.githubPassword`][], [`pkg.homebrewRepo`][], [`pkg.homebrewFormula`][],
26+
[`pkg.homebrewTag`][]
27+
28+
[`pkg.version`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/version.html
29+
[`pkg.humanName`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/humanName.html
30+
[`pkg.botName`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/botName.html
31+
[`pkg.botEmail`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/botEmail.html
32+
[`pkg.githubUser`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/githubUser.html
33+
[`pkg.githubPassword`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/githubPassword.html
34+
[`pkg.homebrewRepo`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/homebrewRepo.html
35+
[`pkg.homebrewFormula`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/homebrewFormula.html
36+
[`pkg.homebrewTag`]: https://pub.dev/documentation/dart_cli_pkg/latest/cli_pkg/homebrewTag.html
37+
38+
Checks out [`pkg.homebrewRepo`][] and pushes a commit updating
39+
[`pkg.homebrewFormula`][]'s `url` and `sha256` fields to point to the
40+
appropriate values for [`pkg.version`][].

lib/cli_pkg.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414

1515
import 'src/chocolatey.dart';
1616
import 'src/github.dart';
17+
import 'src/homebrew.dart';
1718
import 'src/npm.dart';
1819
import 'src/pub.dart';
1920
import 'src/standalone.dart';
2021

2122
export 'src/chocolatey.dart';
2223
export 'src/info.dart';
2324
export 'src/github.dart';
25+
export 'src/homebrew.dart';
2426
export 'src/npm.dart';
2527
export 'src/pub.dart';
2628
export 'src/standalone.dart';
@@ -29,6 +31,7 @@ export 'src/standalone.dart';
2931
void addAllTasks() {
3032
addChocolateyTasks();
3133
addGithubTasks();
34+
addHomebrewTasks();
3235
addNpmTasks();
3336
addPubTasks();
3437
addStandaloneTasks();

lib/src/homebrew.dart

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright 2019 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import 'dart:convert';
6+
import 'dart:io';
7+
8+
import 'package:crypto/crypto.dart';
9+
import 'package:grinder/grinder.dart';
10+
import 'package:path/path.dart' as p;
11+
12+
import 'github.dart';
13+
import 'info.dart';
14+
import 'utils.dart';
15+
16+
/// The GitHub repository slug (for example, `username/repo`) of the Homebrew
17+
/// repository for this package.
18+
///
19+
/// This must be set explicitly.
20+
String get homebrewRepo {
21+
if (_homebrewRepo != null) return _homebrewRepo;
22+
fail("pkg.homebrewRepo must be set to deploy to Homebrew.");
23+
}
24+
25+
set homebrewRepo(String value) => _homebrewRepo = value;
26+
String _homebrewRepo;
27+
28+
/// The path to the formula file within the Homebrew repository to update with
29+
/// the new package version.
30+
///
31+
/// If this isn't set, the task will default to looking for a single `.rb` file
32+
/// at the root of the repo without an `@` in its filename and modifying that.
33+
/// If there isn't exactly one such file, the task will fail.
34+
String homebrewFormula;
35+
36+
/// Whether [addHomebrewTasks] has been called yet.
37+
var _addedHomebrewTasks = false;
38+
39+
/// The Git tag for version of the package being released.
40+
///
41+
/// This tag must already exist in the local clone of the repo; it's not created
42+
/// by this task. It defaults to [version].
43+
String get homebrewTag => _homebrewTag ?? version.toString();
44+
set homebrewTag(String value) => _homebrewTag = value;
45+
String _homebrewTag;
46+
47+
/// Enables tasks for uploading the package to Homebrew.
48+
void addHomebrewTasks() {
49+
if (_addedHomebrewTasks) return;
50+
_addedHomebrewTasks = true;
51+
52+
addTask(GrinderTask('pkg-homebrew-update',
53+
taskFunction: () => _update(),
54+
description: 'Update the Homebrew formula.'));
55+
}
56+
57+
/// Updates the Homebrew formula to point at the current version of the package.
58+
Future<void> _update() async {
59+
ensureBuild();
60+
61+
var process = await Process.start("git", [
62+
"archive",
63+
"--prefix=${githubRepo.split("/").last}-$homebrewTag/",
64+
"--format=tar.gz",
65+
homebrewTag
66+
]);
67+
var digest = await sha256.bind(process.stdout).first;
68+
var stderr = await utf8.decodeStream(process.stderr);
69+
if ((await process.exitCode) != 0) {
70+
fail('git archive "$homebrewTag" failed:\n$stderr');
71+
}
72+
73+
var repo =
74+
await cloneOrPull(url("https://github.com/$homebrewRepo.git").toString());
75+
76+
var formulaPath = _formulaFile(repo);
77+
var formula = _replaceFirstMappedMandatory(
78+
File(formulaPath).readAsStringSync(),
79+
RegExp(r'\n( *)url "[^"]+"'),
80+
(match) => '\n${match[1]}url '
81+
'"https://github.com/$githubRepo/archive/$homebrewTag.tar.gz"',
82+
"Couldn't find a url field in $formulaPath.");
83+
formula = _replaceFirstMappedMandatory(
84+
formula,
85+
RegExp(r'\n( *)sha256 "[^"]+"'),
86+
(match) => '\n${match[1]}sha256 "$digest"',
87+
"Couldn't find a sha256 field in $formulaPath.");
88+
89+
writeString(formulaPath, formula);
90+
91+
run("git",
92+
arguments: [
93+
"commit",
94+
"--all",
95+
"--message",
96+
"Update $humanName to $version"
97+
],
98+
workingDirectory: repo,
99+
runOptions: botEnvironment);
100+
101+
await runAsync("git",
102+
arguments: [
103+
"push",
104+
url("https://$githubUser:$githubPassword@github.com/$homebrewRepo.git")
105+
.toString(),
106+
"HEAD:master"
107+
],
108+
workingDirectory: repo);
109+
}
110+
111+
/// Like [String.replaceFirstMapped], but fails with [error] if no match is found.
112+
String _replaceFirstMappedMandatory(
113+
String string, Pattern from, String replace(Match match), String error) {
114+
var found = false;
115+
var result = string.replaceFirstMapped(from, (match) {
116+
found = true;
117+
return replace(match);
118+
});
119+
120+
if (!found) fail(error);
121+
return result;
122+
}
123+
124+
/// Returns the path to the formula file to update in [repo].
125+
String _formulaFile(String repo) {
126+
if (homebrewFormula != null) return p.join(repo, homebrewFormula);
127+
128+
var entries = [
129+
for (var entry in Directory(repo).listSync())
130+
if (entry is File &&
131+
entry.path.endsWith(".rb") &&
132+
!p.basename(entry.path).contains("@"))
133+
entry.path
134+
];
135+
136+
if (entries.isEmpty) {
137+
fail("No formulas found in the repo, please set pkg.homebrewFormula.");
138+
} else if (entries.length > 1) {
139+
fail("Multiple formulas found in the repo, please set "
140+
"pkg.homebrewFormula.");
141+
} else {
142+
return entries.single;
143+
}
144+
}

lib/src/info.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ String get humanName => _humanName ?? name;
4949
set humanName(String value) => _humanName = value;
5050
String _humanName;
5151

52+
/// The human-friendly name to use for non-authentication-related recordings by
53+
/// this automation tool, such as Git commit metadata.
54+
///
55+
/// Defaults to `"cli_pkg"`.
56+
String get botName => _botName ?? "cli_pkg";
57+
set botName(String value) => _botName = value;
58+
String _botName;
59+
60+
/// The email address to use for non-authentication-related recordings, such as
61+
/// Git commit metadata.
62+
///
63+
/// Defaults to `"cli_pkg@none"`.
64+
String get botEmail => _botEmail ?? "cli_pkg@none";
65+
set botEmail(String value) => _botEmail = value;
66+
String _botEmail;
67+
5268
/// A mutable map from executable names to those executables' paths in `bin/`.
5369
///
5470
/// This defaults to a map derived from the pubspec's `executables` field. It

lib/src/utils.dart

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,13 @@ Uri url(String url) {
184184

185185
var parsedHost = Uri.parse(host);
186186
return parsed.replace(
187-
scheme: parsedHost.scheme, host: parsedHost.host, port: parsedHost.port);
187+
scheme: parsedHost.scheme,
188+
// Git doesn't accept Windows `file:` URLs with user info components.
189+
userInfo: parsedHost.scheme == 'file' ? "" : null,
190+
host: parsedHost.host,
191+
port: parsedHost.port,
192+
path:
193+
p.url.join(parsedHost.path, p.url.relative(parsed.path, from: "/")));
188194
}
189195

190196
/// Returns the human-friendly name for the given [os] string.
@@ -260,3 +266,39 @@ void safeCopy(String source, String destination) {
260266
log("copying $source to $destination");
261267
File(source).copySync(p.join(destination, p.basename(source)));
262268
}
269+
270+
/// Options for [run] that tell Git to commit using [botName] and [botemail.
271+
final botEnvironment = RunOptions(environment: {
272+
"GIT_AUTHOR_NAME": botName,
273+
"GIT_AUTHOR_EMAIL": botEmail,
274+
"GIT_COMMITTER_NAME": botName,
275+
"GIT_COMMITTER_EMAIL": botEmail
276+
});
277+
278+
/// Ensure that the repository at [url] is cloned into the build directory and
279+
/// pointing to the latest master revision.
280+
///
281+
/// Returns the path to the repository.
282+
Future<String> cloneOrPull(String url) async {
283+
var name = p.url.basename(url);
284+
if (p.url.extension(name) == ".git") name = p.url.withoutExtension(name);
285+
286+
var path = p.join("build", name);
287+
288+
if (Directory(p.join(path, '.git')).existsSync()) {
289+
log("Updating $url");
290+
await runAsync("git",
291+
arguments: ["fetch", "origin"], workingDirectory: path);
292+
} else {
293+
delete(Directory(path));
294+
await runAsync("git", arguments: ["clone", url, path]);
295+
await runAsync("git",
296+
arguments: ["config", "advice.detachedHead", "false"],
297+
workingDirectory: path);
298+
}
299+
await runAsync("git",
300+
arguments: ["checkout", "origin/master"], workingDirectory: path);
301+
log("");
302+
303+
return path;
304+
}

pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ environment:
55

66
dependencies:
77
archive: ">=1.0.0 <3.0.0"
8+
crypto: "^2.0.0"
89
collection: ">=1.8.0 <2.0.0"
910
grinder: '^0.8.0'
1011
http: ">=0.11.0 <0.13.0"

test/descriptor.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ final _ourGrinderDependency = _ourPubpsec["dependencies"]["grinder"] as String;
4444
DirectoryDescriptor package(Map<String, Object> pubspec, String grindDotDart,
4545
[List<Descriptor> files]) {
4646
pubspec = {
47-
"executables": {},
47+
"executables": <String, Object>{},
4848
...pubspec,
4949
"dev_dependencies": {
5050
"grinder": _ourGrinderDependency,

0 commit comments

Comments
 (0)