Skip to content

Commit ec13c3c

Browse files
authored
ci: Create Linux installer (#358)
Adds a workflow to create a Linux deb installer and tar archive for releases.
1 parent 2e56768 commit ec13c3c

File tree

3 files changed

+383
-0
lines changed

3 files changed

+383
-0
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: celest_cli (Release)
2+
on:
3+
workflow_dispatch:
4+
push:
5+
tags:
6+
- 'celest_cli-v*.*.*'
7+
8+
env:
9+
CELEST_NO_ANALYTICS: true
10+
11+
permissions:
12+
contents: write
13+
14+
# Prevent duplicate runs due to Graphite
15+
# https://graphite.dev/docs/troubleshooting#why-are-my-actions-running-twice
16+
concurrency:
17+
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}-${{ github.ref == 'refs/heads/main' && github.sha || ''}}
18+
cancel-in-progress: true
19+
20+
jobs:
21+
bundle:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 10
24+
steps:
25+
- name: Git Checkout
26+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
27+
28+
- name: Setup Flutter
29+
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # 2.19.0
30+
with:
31+
cache: true
32+
channel: beta # Needed for cross-compilation
33+
34+
- name: Fix pub cache
35+
run: |
36+
dart pub get
37+
dart run fix_pub_cache.dart
38+
working-directory: tool
39+
40+
- name: Get Packages
41+
run: dart pub get
42+
working-directory: apps/cli
43+
44+
- name: Create Bundle
45+
id: bundle
46+
run: dart run tool/release.dart --target-os=linux --target-arch=x64 --target-arch=arm64
47+
working-directory: apps/cli
48+
49+
# # Test the new CLI before releasing
50+
# - name: Install Bundle
51+
# uses: celest-dev/setup-celest@main
52+
# with:
53+
# installer: ${{ steps.bundle.outputs.installer }}
54+
# env:
55+
# CELEST_VERBOSE: true
56+
# - name: Test
57+
# working-directory: apps/cli
58+
# run: dart test -t e2e-installed --fail-fast
59+
60+
- name: Create Release
61+
id: create-release
62+
if: github.ref_type == 'tag'
63+
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # 2.2.2
64+
with:
65+
release_name: v${{ steps.bundle.outputs.version }}
66+
files: ${{ join(fromJson(steps.bundle.outputs.artifacts), '\n') }}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Package: Celest
2+
Depends: gnome-keyring, libsecret-1-0, libsqlite3-dev
3+
Version: {{ version }}
4+
Maintainer: Celest
5+
Architecture: {{ arch }}
6+
Description: The CLI for Celest, the Flutter cloud platform

apps/cli/tool/release.dart

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import 'dart:convert';
2+
import 'dart:ffi';
3+
import 'dart:io' show ProcessException, stderr, stdout;
4+
5+
import 'package:archive/archive_io.dart';
6+
import 'package:args/args.dart';
7+
import 'package:celest_cli/src/context.dart';
8+
import 'package:celest_cli/src/utils/error.dart';
9+
import 'package:celest_cli/src/version.dart';
10+
import 'package:file/file.dart';
11+
import 'package:mustache_template/mustache.dart';
12+
import 'package:path/path.dart' as p;
13+
14+
/// The directory containing this script and build assets.
15+
final Directory toolDir = fileSystem.file(platform.script).parent;
16+
17+
/// The directory with the built CLI.
18+
final String buildPath = platform.environment['BUILD_DIR'] ?? 'build';
19+
final Directory buildDir = fileSystem.directory(
20+
platform.script.resolve('../$buildPath'),
21+
);
22+
23+
/// The directory to use for bundled files.
24+
final Directory outputsDir = toolDir.parent.childDirectory('releases')
25+
..createSync();
26+
27+
/// The directory to use for temporary (non-bundled) files.
28+
final Directory tempDir = fileSystem.systemTempDirectory.createTempSync(
29+
'celest_build_',
30+
);
31+
32+
/// The current ABI which identifies the OS and architecture.
33+
final Abi osArch = Abi.current();
34+
final String hostOs = switch (osArch) {
35+
Abi.linuxArm64 || Abi.linuxX64 => 'linux',
36+
_ => throw UnsupportedError('Unsupported ABI: $osArch'),
37+
};
38+
final String hostArch = switch (osArch) {
39+
Abi.linuxArm64 => 'arm64',
40+
Abi.linuxX64 => 'x64',
41+
_ => throw UnsupportedError('Unsupported ABI: $osArch'),
42+
};
43+
44+
/// The current version of the CLI.
45+
final String version = packageVersion;
46+
47+
/// The current SHA of the branch being built.
48+
final String? currentSha = platform.environment.containsKey('CI')
49+
? (processManager.runSync(
50+
// <String>['git', 'log', '-1', '--format=format:%H'], ?
51+
<String>['git', 'rev-parse', 'HEAD'],
52+
stdoutEncoding: utf8,
53+
).stdout as String)
54+
.trim()
55+
: null;
56+
57+
/// Whether we're running in CI.
58+
final isCI = platform.environment['CI'] == 'true';
59+
60+
/// Builds and bundles the CLI for the current platform.
61+
///
62+
/// This script is used by the GitHub workflow `apps_cli_release.yaml` to create
63+
/// a zip of the CLI and its dependencies for the current platform.
64+
Future<void> main(List<String> args) async {
65+
final argParser = ArgParser()
66+
..addOption('target-os', allowed: ['linux'], defaultsTo: hostOs)
67+
..addMultiOption('target-arch',
68+
allowed: ['arm64', 'x64'], defaultsTo: [hostArch]);
69+
final argResults = argParser.parse(args);
70+
71+
final targetOs = argResults.option('target-os')!;
72+
final targetArchs = argResults.multiOption('target-arch');
73+
74+
final artifacts = <String>[];
75+
for (final targetArch in targetArchs) {
76+
final artifact = await _build(
77+
targetOs: targetOs,
78+
targetArch: targetArch,
79+
);
80+
artifacts.add(artifact);
81+
}
82+
83+
if (platform.environment['GITHUB_OUTPUT'] case final ciOutput?) {
84+
fileSystem.file(ciOutput).writeAsStringSync(
85+
'version=$version\n'
86+
'artifacts=${jsonEncode(artifacts)}\n',
87+
mode: FileMode.append,
88+
flush: true,
89+
);
90+
}
91+
}
92+
93+
Future<String> _build({
94+
required String targetOs,
95+
required String targetArch,
96+
}) async {
97+
print('Bundling CLI version $version for $targetOs-$targetArch...');
98+
99+
if (buildDir.existsSync()) {
100+
buildDir.deleteSync(recursive: true);
101+
}
102+
await buildDir.create(recursive: true);
103+
await _runProcess(
104+
'dart',
105+
[
106+
if (currentSha case final currentSha?) '--define=gitSha=$currentSha',
107+
'--define=version=$version',
108+
'compile',
109+
'exe',
110+
if (targetOs != hostOs || targetArch != hostArch) ...[
111+
'--target-os=$targetOs',
112+
'--target-arch=$targetArch',
113+
'--experimental-cross-compilation',
114+
],
115+
'--output=$buildPath/celest.exe',
116+
'bin/celest.dart',
117+
],
118+
workingDirectory: platform.script.resolve('..').toFilePath(),
119+
);
120+
if (!buildDir.existsSync()) {
121+
throw StateError('Build directory does not exist');
122+
}
123+
124+
if (!platform.isWindows) {
125+
final exeUri = platform.script.resolve('../$buildPath/celest.exe');
126+
final exe = fileSystem.file(exeUri);
127+
final destExe = p.withoutExtension(p.absolute(exeUri.path));
128+
if (!exe.existsSync() && !fileSystem.file(destExe).existsSync()) {
129+
throw StateError('Executable does not exist: $exe');
130+
}
131+
exe.renameSync(destExe);
132+
}
133+
134+
final bundler = switch (targetOs) {
135+
'linux' => LinuxDebBundler(arch: targetArch),
136+
_ => throw UnsupportedError('Unsupported OS: $targetOs'),
137+
};
138+
139+
print('Bundling with ${bundler.runtimeType}...');
140+
await bundler.bundle();
141+
142+
print('Successfully wrote ${bundler.outputFilepath}');
143+
return bundler.outputFilepath;
144+
}
145+
146+
abstract class Bundler {
147+
String get os;
148+
String get arch;
149+
String get extension;
150+
151+
/// The path to the output file, dependent on the OS/arch.
152+
String get outputFilepath => p.join(
153+
outputsDir.path,
154+
'celest_cli-$os-$arch.$extension',
155+
);
156+
157+
/// Bundles the CLI and its dependencies into a single file, performing
158+
/// code signing and notarization as needed.
159+
Future<void> bundle();
160+
}
161+
162+
final class LinuxArchiveBundler extends Bundler {
163+
LinuxArchiveBundler({required this.arch});
164+
165+
@override
166+
String get os => 'linux';
167+
168+
@override
169+
final String arch;
170+
171+
@override
172+
String get extension => 'tar.gz';
173+
174+
@override
175+
Future<void> bundle() async {
176+
// Encode a directory from disk to disk, no memory
177+
final encoder = TarFileEncoder();
178+
await encoder.tarDirectory(
179+
buildDir,
180+
compression: TarFileEncoder.gzip,
181+
filename: outputFilepath,
182+
);
183+
}
184+
}
185+
186+
final class LinuxDebBundler extends Bundler {
187+
LinuxDebBundler({required this.arch});
188+
189+
@override
190+
String get os => 'linux';
191+
192+
@override
193+
final String arch;
194+
195+
@override
196+
String get extension => 'deb';
197+
198+
@override
199+
Future<void> bundle() async {
200+
/// Creates the DEB file structure.
201+
///
202+
/// DEBIAN/
203+
/// control
204+
/// opt/
205+
/// celest/
206+
/// celest
207+
print('Creating Debian archive...');
208+
209+
final debDir = tempDir.childDirectory('deb')..createSync();
210+
final debControlDir = debDir.childDirectory('DEBIAN')..createSync();
211+
final debInstallDir = debDir.childDirectory('opt').childDirectory('celest')
212+
..createSync(recursive: true);
213+
214+
final toolDebianDir = toolDir.childDirectory('linux').childDirectory('deb');
215+
216+
for (final controlFile
217+
in toolDebianDir.childDirectory('DEBIAN').listSync().cast<File>()) {
218+
if (p.basename(controlFile.path) == 'control') {
219+
final outputControlFile = debControlDir.childFile('control');
220+
final outputControl = Template(
221+
controlFile.readAsStringSync(),
222+
).renderString({
223+
'arch': switch (osArch) {
224+
Abi.linuxArm64 => 'arm64',
225+
Abi.linuxX64 => 'amd64',
226+
_ => unreachable(),
227+
},
228+
'version': version,
229+
});
230+
print('Writing control contents:\n\n$outputControl\n');
231+
await outputControlFile.writeAsString(outputControl);
232+
} else {
233+
controlFile.copySync(
234+
p.join(debControlDir.path, p.basename(controlFile.path)),
235+
);
236+
}
237+
}
238+
239+
for (final installFile in buildDir.listSync().cast<File>()) {
240+
installFile.copySync(
241+
p.join(debInstallDir.path, p.basename(installFile.path)),
242+
);
243+
}
244+
245+
// Print directory structure
246+
_printFs(debDir);
247+
248+
await _runProcess(
249+
'dpkg-deb',
250+
[
251+
'--build',
252+
debDir.path,
253+
outputFilepath,
254+
],
255+
workingDirectory: tempDir.path,
256+
);
257+
}
258+
}
259+
260+
Future<String> _runProcess(
261+
String executable,
262+
List<String> args, {
263+
String? workingDirectory,
264+
Future<void> Function(String logs)? onError,
265+
}) async {
266+
print('Running process "$executable ${args.join(' ')}"...');
267+
final proc = await processManager.start(<String>[
268+
executable,
269+
...args,
270+
], workingDirectory: workingDirectory);
271+
272+
// For logging
273+
executable = executable == 'xcrun' ? args.first : executable;
274+
args = executable == 'xcrun' ? args.skip(1).toList() : args;
275+
276+
final logsBuf = StringBuffer();
277+
proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
278+
event,
279+
) {
280+
logsBuf.writeln(event);
281+
stdout.writeln('$executable: $event');
282+
});
283+
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((
284+
event,
285+
) {
286+
stderr.writeln('$executable: $event');
287+
});
288+
final exitCode = await proc.exitCode;
289+
final logs = logsBuf.toString();
290+
if (exitCode != 0) {
291+
await onError?.call(logs);
292+
throw ProcessException(executable, args, 'Process failed', exitCode);
293+
}
294+
return logs;
295+
}
296+
297+
void _printFs(Directory dir) {
298+
print('${dir.path} file structure:');
299+
print('---------------------');
300+
for (final entity in dir.listSync(recursive: true)) {
301+
final type = switch (fileSystem.typeSync(entity.path)) {
302+
FileSystemEntityType.directory => 'D',
303+
FileSystemEntityType.file => 'F',
304+
FileSystemEntityType.link => 'L',
305+
_ => '?',
306+
};
307+
final relativePath = p.relative(entity.path, from: dir.path);
308+
print('$type $relativePath');
309+
}
310+
print('---------------------');
311+
}

0 commit comments

Comments
 (0)