Unverified Commit ddfc322d authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Creates a package publishing script to publish packages as part of the dev roll process. (#14294)

This script will update release metadata in the cloud, and copy the already-built package to the right location and name on cloud storage.

The release metadata will be located in gs://flutter_infra/releases/releases.json, and the published packages will end up in gs://flutter_infra/releases/<channel>/<platform>/flutter_<platform>_<version><archive suffix>, where <channel>, <platform>, <version>, and <archive suffix> are determined by the script.

At the moment, it only supports dev rolls, but (once we know how those will work) should easily support beta rolls as well.
parent b7f6be6f
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:process/process.dart';
class ArchivePublisherException implements Exception {
ArchivePublisherException(this.message, [this.result]);
final String message;
final ProcessResult result;
@override
String toString() {
String output = 'ArchivePublisherException';
if (message != null) {
output += ': $message';
}
final String stderr = result?.stderr ?? '';
if (stderr.isNotEmpty) {
output += ':\n$result.stderr';
}
return output;
}
}
enum Channel { dev, beta }
/// Publishes the archive created for a particular version and git hash to
/// the releases directory on cloud storage, and updates the metadata for
/// releases.
///
/// See https://github.com/flutter/flutter/wiki/Release-process for more
/// information on the release process.
class ArchivePublisher {
ArchivePublisher(
this.revision,
this.version,
this.channel, {
this.processManager = const LocalProcessManager(),
this.tempDir,
}) : assert(revision.length == 40, 'Git hash must be 40 characters long (i.e. the entire hash).');
/// A git hash describing the revision to publish. It should be the complete
/// hash, not just a prefix.
final String revision;
/// A version number for the release (e.g. "1.2.3").
final String version;
/// The channel to publish to.
// TODO(gspencer): support Channel.beta: it is currently unimplemented.
final Channel channel;
/// Get the name of the channel as a string.
String get channelName {
switch (channel) {
case Channel.beta:
return 'beta';
case Channel.dev:
default:
return 'dev';
}
}
/// The process manager to use for invoking commands. Typically only
/// used for testing purposes.
final ProcessManager processManager;
/// The temporary directory used for this publisher. If not set, one will
/// be created, used, and then removed automatically. If set, it will not be
/// deleted when done: that is left to the caller. Typically used by tests.
Directory tempDir;
static String gsBase = 'gs://flutter_infra';
static String releaseFolder = '/releases';
static String baseUrl = 'https://storage.googleapis.com/flutter_infra';
static String archivePrefix = 'flutter_';
static String releaseNotesPrefix = 'release_notes_';
final String metadataGsPath = '$gsBase$releaseFolder/releases.json';
/// Publishes the archive for the given constructor parameters.
bool publishArchive() {
assert(channel == Channel.dev, 'Channel must be dev (beta not yet supported)');
// Check for access early so that we don't try to publish things if the
// user doesn't have access to the metadata file.
_checkForGSUtilAccess();
final List<String> platforms = <String>['linux', 'mac', 'win'];
final Map<String, String> metadata = <String, String>{};
for (String platform in platforms) {
final String src = _builtArchivePath(platform);
final String dest = _destinationArchivePath(platform);
final String srcGsPath = '$gsBase$src';
final String destGsPath = '$gsBase$releaseFolder$dest';
_cloudCopy(srcGsPath, destGsPath);
metadata['${platform}_archive'] = '$channelName/$platform$dest';
}
metadata['release_date'] = new DateTime.now().toUtc().toIso8601String();
metadata['version'] = version;
_updateMetadata(metadata);
return true;
}
/// Checks to make sure the user has access to the Google Storage bucket
/// required to publish. Will throw an [ArchivePublisherException] if not.
void _checkForGSUtilAccess() {
// Fetching ACLs requires FULL_CONTROL access.
final ProcessResult result = _runGsUtil(<String>['acl', 'get', metadataGsPath]);
if (result.exitCode != 0) {
throw new ArchivePublisherException(
'GSUtil cannot get ACLs for metadata file $metadataGsPath', result);
}
}
void _updateMetadata(Map<String, String> metadata) {
final ProcessResult result = _runGsUtil(<String>['cat', metadataGsPath]);
if (result.exitCode != 0) {
throw new ArchivePublisherException(
'Unable to get existing metadata at $metadataGsPath', result);
}
final String currentMetadata = result.stdout;
if (currentMetadata.isEmpty) {
throw new ArchivePublisherException('Empty metadata received from server', result);
}
Map<String, dynamic> jsonData;
try {
jsonData = json.decode(currentMetadata);
} on FormatException catch (e) {
throw new ArchivePublisherException('Unable to parse JSON metadata received from cloud: $e');
}
jsonData['current_$channelName'] = revision;
if (!jsonData.containsKey('releases')) {
jsonData['releases'] = <String, dynamic>{};
}
if (jsonData['releases'].containsKey(revision)) {
throw new ArchivePublisherException(
'Revision $revision already exists in metadata! Aborting.');
}
jsonData['releases'][revision] = metadata;
final Directory localTempDir = tempDir ?? Directory.systemTemp.createTempSync('flutter_');
final File tempFile = new File(path.join(localTempDir.absolute.path, 'releases.json'));
final JsonEncoder encoder = const JsonEncoder.withIndent(' ');
tempFile.writeAsStringSync(encoder.convert(jsonData));
_cloudCopy(tempFile.absolute.path, metadataGsPath);
if (tempDir == null) {
localTempDir.delete(recursive: true);
}
}
String _getArchiveSuffix(String platform) {
switch (platform) {
case 'linux':
case 'mac':
return '.tar.xz';
case 'win':
return '.zip';
default:
assert(false, 'platform $platform not recognized.');
return null;
}
}
String _builtArchivePath(String platform) {
final String shortRevision = revision.substring(0, revision.length > 10 ? 10 : revision.length);
final String archivePathBase = '/flutter/$revision/$archivePrefix';
final String suffix = _getArchiveSuffix(platform);
return '$archivePathBase${platform}_$shortRevision$suffix';
}
String _destinationArchivePath(String platform) {
final String archivePathBase = '/$channelName/$platform/$archivePrefix';
final String suffix = _getArchiveSuffix(platform);
return '$archivePathBase${platform}_$version-$channelName$suffix';
}
ProcessResult _runGsUtil(List<String> args) {
return processManager.runSync(<String>['gsutil']..addAll(args));
}
void _cloudCopy(String src, String dest) {
final ProcessResult result = _runGsUtil(<String>['cp', src, dest]);
if (result.exitCode != 0) {
throw new ArchivePublisherException('GSUtil copy command failed: ${result.stderr}', result);
}
}
}
......@@ -10,7 +10,7 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:path/path.dart' as path;
import 'archive_publisher.dart';
const String kIncrement = 'increment';
const String kX = 'x';
......@@ -19,9 +19,9 @@ const String kZ = 'z';
const String kHelp = 'help';
void main(List<String> args) {
// If we're run from the `tools` dir, set the cwd to the repo root.
if (path.basename(Directory.current.path) == 'tools')
Directory.current = Directory.current.parent.parent;
// Set the cwd to the repo root, since we know where this script is located.
final Directory scriptLocation = new Directory(Platform.script.toFilePath());
Directory.current = scriptLocation.parent.parent.parent;
final ArgParser argParser = new ArgParser(allowTrailingOptions: false);
argParser.addOption(
......@@ -100,8 +100,10 @@ void main(List<String> args) {
runGit('fetch upstream', 'fetch upstream');
runGit('reset upstream/master --hard', 'check out master branch');
runGit('tag $version', 'tag the commit with the version label');
final String hash = getGitOutput('rev-parse HEAD', 'Get git hash for $version tag');
print('Your tree is ready to publish Flutter $version to the "dev" channel.');
print('Your tree is ready to publish Flutter $version (${hash.substring(0, 10)}) '
'to the "dev" channel.');
stdout.write('Are you? [yes/no] ');
if (stdin.readLineSync() != 'yes') {
runGit('tag -d $version', 'remove the tag you did not want to publish');
......@@ -109,6 +111,17 @@ void main(List<String> args) {
exit(0);
}
// Publish the archive before pushing the tag so that if something fails in
// the publish step, we can clean up.
try {
new ArchivePublisher(hash, version, Channel.dev)..publishArchive();
} on ArchivePublisherException catch (e) {
print('Archive publishing failed.\n$e');
runGit('tag -d $version', 'remove the tag that was not published');
print('The dev roll has been aborted.');
exit(0);
}
runGit('push upstream $version', 'publish the version');
runGit('push upstream HEAD:dev', 'land the new version on the "dev" branch');
print('Flutter version $version has been rolled to the "dev" channel!');
......
......@@ -8,13 +8,53 @@ dependencies:
intl: 0.15.2
meta: 1.1.1
path: 1.5.1
process: 2.0.7
dev_dependencies:
test: 0.12.30+1
mockito: 2.2.1
async: 2.0.3 # TRANSITIVE DEPENDENCY
barback: 0.15.2+14 # TRANSITIVE DEPENDENCY
boolean_selector: 1.0.2 # TRANSITIVE DEPENDENCY
charcode: 1.1.1 # TRANSITIVE DEPENDENCY
cli_util: 0.1.2+1 # TRANSITIVE DEPENDENCY
collection: 1.14.5 # TRANSITIVE DEPENDENCY
convert: 2.0.1 # TRANSITIVE DEPENDENCY
crypto: 2.0.2+1 # TRANSITIVE DEPENDENCY
csslib: 0.14.1 # TRANSITIVE DEPENDENCY
file: 2.3.5 # TRANSITIVE DEPENDENCY
glob: 1.1.5 # TRANSITIVE DEPENDENCY
html: 0.13.2+2 # TRANSITIVE DEPENDENCY
http_multi_server: 2.0.4 # TRANSITIVE DEPENDENCY
http_parser: 3.1.1 # TRANSITIVE DEPENDENCY
io: 0.3.1 # TRANSITIVE DEPENDENCY
isolate: 1.1.0 # TRANSITIVE DEPENDENCY
js: 0.6.1 # TRANSITIVE DEPENDENCY
logging: 0.11.3+1 # TRANSITIVE DEPENDENCY
matcher: 0.12.1+4 # TRANSITIVE DEPENDENCY
mime: 0.9.5 # TRANSITIVE DEPENDENCY
multi_server_socket: 1.0.1 # TRANSITIVE DEPENDENCY
node_preamble: 1.4.0 # TRANSITIVE DEPENDENCY
package_config: 1.0.3 # TRANSITIVE DEPENDENCY
package_resolver: 1.0.2 # TRANSITIVE DEPENDENCY
platform: 2.1.1 # TRANSITIVE DEPENDENCY
plugin: 0.2.0+2 # TRANSITIVE DEPENDENCY
pool: 1.3.4 # TRANSITIVE DEPENDENCY
pub_semver: 1.3.2 # TRANSITIVE DEPENDENCY
shelf: 0.7.2 # TRANSITIVE DEPENDENCY
shelf_packages_handler: 1.0.3 # TRANSITIVE DEPENDENCY
shelf_static: 0.2.7 # TRANSITIVE DEPENDENCY
shelf_web_socket: 0.2.2 # TRANSITIVE DEPENDENCY
source_map_stack_trace: 1.1.4 # TRANSITIVE DEPENDENCY
source_maps: 0.10.4 # TRANSITIVE DEPENDENCY
source_span: 1.4.0 # TRANSITIVE DEPENDENCY
stack_trace: 1.9.1 # TRANSITIVE DEPENDENCY
stream_channel: 1.6.3 # TRANSITIVE DEPENDENCY
string_scanner: 1.0.2 # TRANSITIVE DEPENDENCY
term_glyph: 1.0.0 # TRANSITIVE DEPENDENCY
typed_data: 1.1.4 # TRANSITIVE DEPENDENCY
utf: 0.9.0+3 # TRANSITIVE DEPENDENCY
watcher: 0.9.7+6 # TRANSITIVE DEPENDENCY
web_socket_channel: 1.0.6 # TRANSITIVE DEPENDENCY
yaml: 2.1.13 # TRANSITIVE DEPENDENCY
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io';
import 'package:test/test.dart';
import 'package:path/path.dart' as path;
import '../lib/archive_publisher.dart';
import 'fake_process_manager.dart';
void main() {
group('ArchivePublisher', () {
final List<String> emptyStdout = <String>[''];
FakeProcessManager processManager;
Directory tempDir;
setUp(() async {
processManager = new FakeProcessManager();
tempDir = await Directory.systemTemp.createTemp('flutter_');
});
tearDown(() async {
// On Windows, the directory is locked and not able to be deleted, because it is a
// temporary directory. So we just leave some (very small, because we're not actually
// building archives here) trash around to be deleted at the next reboot.
if (!Platform.isWindows) {
await tempDir.delete(recursive: true);
}
});
test('calls the right processes', () {
final Map<String, List<String>> calls = <String, List<String>>{
'gsutil acl get gs://flutter_infra/releases/releases.json': emptyStdout,
'gsutil cp gs://flutter_infra/flutter/deadbeef/flutter_linux_deadbeef.tar.xz '
'gs://flutter_infra/releases/dev/linux/flutter_linux_1.2.3-dev.tar.xz': emptyStdout,
'gsutil cp gs://flutter_infra/flutter/deadbeef/flutter_mac_deadbeef.tar.xz '
'gs://flutter_infra/releases/dev/mac/flutter_mac_1.2.3-dev.tar.xz': emptyStdout,
'gsutil cp gs://flutter_infra/flutter/deadbeef/flutter_win_deadbeef.zip '
'gs://flutter_infra/releases/dev/win/flutter_win_1.2.3-dev.zip': emptyStdout,
'gsutil cat gs://flutter_infra/releases/releases.json': <String>[
'''{
"base_url": "https://storage.googleapis.com/flutter_infra/releases",
"current_beta": "6da8ec6bd0c4801b80d666869e4069698561c043",
"current_dev": "f88c60b38c3a5ef92115d24e3da4175b4890daba",
"releases": {
"6da8ec6bd0c4801b80d666869e4069698561c043": {
"linux_archive": "beta/linux/flutter_linux_0.21.0-beta.tar.xz",
"mac_archive": "beta/mac/flutter_mac_0.21.0-beta.tar.xz",
"windows_archive": "beta/win/flutter_win_0.21.0-beta.tar.xz",
"release_date": "2017-12-19T10:30:00,847287019-08:00",
"release_notes": "beta/release_notes_0.21.0-beta.html",
"version": "0.21.0-beta"
},
"f88c60b38c3a5ef92115d24e3da4175b4890daba": {
"linux_archive": "dev/linux/flutter_linux_0.22.0-dev.tar.xz",
"mac_archive": "dev/mac/flutter_mac_0.22.0-dev.tar.xz",
"windows_archive": "dev/win/flutter_win_0.22.0-dev.tar.xz",
"release_date": "2018-01-19T13:30:09,728487019-08:00",
"release_notes": "dev/release_notes_0.22.0-dev.html",
"version": "0.22.0-dev"
}
}
}
'''],
'gsutil cp ${tempDir.path}/releases.json gs://flutter_infra/releases/releases.json':
emptyStdout,
};
processManager.setResults(calls);
new ArchivePublisher('deadbeef', '1.2.3', Channel.dev,
processManager: processManager, tempDir: tempDir)
..publishArchive();
processManager.verifyCalls(calls.keys);
final File outputFile = new File(path.join(tempDir.path, 'releases.json'));
expect(outputFile.existsSync(), isTrue);
final String contents = outputFile.readAsStringSync();
expect(contents, contains('"current_dev": "deadbeef"'));
expect(contents, contains('"deadbeef": {'));
});
});
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:process/process.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
/// A mock that can be used to fake a process manager that runs commands
/// and returns results.
///
/// Call [setResults] to provide a list of results that will return from
/// each command line (with arguments).
///
/// Call [verifyCalls] to verify that each desired call occurred.
class FakeProcessManager extends Mock implements ProcessManager {
FakeProcessManager({this.stdinResults}) {
_setupMock();
}
/// The callback that will be called each time stdin input is supplied to
/// a call.
final StringReceivedCallback stdinResults;
/// The list of results that will be sent back, organized by the command line
/// that will produce them. Each command line has a list of returned stdout
/// output that will be returned on each successive call.
Map<String, List<ProcessResult>> fakeResults = <String, List<ProcessResult>>{};
/// The list of invocations that occurred, in the order they occurred.
List<Invocation> invocations = <Invocation>[];
/// Verify that the given command lines were called, in the given order.
void verifyCalls(List<String> calls) {
int index = 0;
expect(invocations.length, equals(calls.length));
for (String call in calls) {
expect(call.split(' '), orderedEquals(invocations[index].positionalArguments[0]));
index++;
}
}
/// Sets the list of results that will be returned from each successive call.
void setResults(Map<String, List<String>> results) {
final Map<String, List<ProcessResult>> resultCodeUnits = <String, List<ProcessResult>>{};
for (String key in results.keys) {
resultCodeUnits[key] =
results[key].map((String result) => new ProcessResult(0, 0, result, '')).toList();
}
fakeResults = resultCodeUnits;
}
ProcessResult _popResult(String key) {
expect(fakeResults, isNotEmpty);
expect(fakeResults, contains(key));
expect(fakeResults[key], isNotEmpty);
return fakeResults[key].removeAt(0);
}
FakeProcess _popProcess(String key) =>
new FakeProcess(_popResult(key), stdinResults: stdinResults);
Future<Process> _nextProcess(Invocation invocation) async {
invocations.add(invocation);
return new Future<Process>.value(_popProcess(invocation.positionalArguments[0].join(' ')));
}
ProcessResult _nextResultSync(Invocation invocation) {
invocations.add(invocation);
return _popResult(invocation.positionalArguments[0].join(' '));
}
Future<ProcessResult> _nextResult(Invocation invocation) async {
invocations.add(invocation);
return new Future<ProcessResult>.value(_popResult(invocation.positionalArguments[0].join(' ')));
}
void _setupMock() {
// Note that not all possible types of invocations are covered here, just the ones
// expected to be called.
// TODO(gspencer): make this more general so that any call will be captured.
when(
start(
typed(captureAny),
environment: typed(captureAny, named: 'environment'),
workingDirectory: typed(captureAny, named: 'workingDirectory'),
),
).thenAnswer(_nextProcess);
when(
start(
typed(captureAny),
),
).thenAnswer(_nextProcess);
when(
run(
typed(captureAny),
environment: typed(captureAny, named: 'environment'),
workingDirectory: typed(captureAny, named: 'workingDirectory'),
),
).thenAnswer(_nextResult);
when(
run(
typed(captureAny),
),
).thenAnswer(_nextResult);
when(
runSync(
typed(captureAny),
environment: typed(captureAny, named: 'environment'),
workingDirectory: typed(captureAny, named: 'workingDirectory'),
),
).thenAnswer(_nextResultSync);
when(
runSync(
typed(captureAny),
),
).thenAnswer(_nextResultSync);
when(killPid(typed(captureAny), typed(captureAny))).thenReturn(true);
when(
canRun(captureAny,
workingDirectory: typed(
captureAny,
named: 'workingDirectory',
)),
).thenReturn(true);
}
}
/// A fake process that can be used to interact with a process "started" by the FakeProcessManager.
class FakeProcess extends Mock implements Process {
FakeProcess(ProcessResult result, {void stdinResults(String input)})
: stdoutStream = new Stream<List<int>>.fromIterable(<List<int>>[result.stdout.codeUnits]),
stderrStream = new Stream<List<int>>.fromIterable(<List<int>>[result.stderr.codeUnits]),
desiredExitCode = result.exitCode,
stdinSink = new IOSink(new StringStreamConsumer(stdinResults)) {
_setupMock();
}
final IOSink stdinSink;
final Stream<List<int>> stdoutStream;
final Stream<List<int>> stderrStream;
final int desiredExitCode;
void _setupMock() {
when(kill(typed(captureAny))).thenReturn(true);
}
@override
Future<int> get exitCode => new Future<int>.value(desiredExitCode);
@override
int get pid => 0;
@override
IOSink get stdin => stdinSink;
@override
Stream<List<int>> get stderr => stderrStream;
@override
Stream<List<int>> get stdout => stdoutStream;
}
/// Callback used to receive stdin input when it occurs.
typedef void StringReceivedCallback(String received);
/// A stream consumer class that consumes UTF8 strings as lists of ints.
class StringStreamConsumer implements StreamConsumer<List<int>> {
StringStreamConsumer(this.sendString);
List<Stream<List<int>>> streams = <Stream<List<int>>>[];
List<StreamSubscription<List<int>>> subscriptions = <StreamSubscription<List<int>>>[];
List<Completer<dynamic>> completers = <Completer<dynamic>>[];
/// The callback called when this consumer receives input.
StringReceivedCallback sendString;
@override
Future<dynamic> addStream(Stream<List<int>> value) {
streams.add(value);
completers.add(new Completer<dynamic>());
subscriptions.add(value.listen((List<int> data) {
sendString(utf8.decode(data));
}));
subscriptions.last.onDone(() => completers.last.complete(null));
return new Future<dynamic>.value(null);
}
@override
Future<dynamic> close() async {
for (Completer<dynamic> completer in completers) {
await completer.future;
}
completers.clear();
streams.clear();
subscriptions.clear();
return new Future<dynamic>.value(null);
}
}
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
import 'fake_process_manager.dart';
void main() {
group('ArchivePublisher', () {
FakeProcessManager processManager;
final List<String> stdinCaptured = <String>[];
void _captureStdin(String item) {
stdinCaptured.add(item);
}
setUp(() async {
processManager = new FakeProcessManager(stdinResults: _captureStdin);
});
tearDown(() async {});
test('start works', () async {
final Map<String, List<String>> calls = <String, List<String>>{
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['test'],
};
processManager.setResults(calls);
for (String key in calls.keys) {
final Process process = await processManager.start(key.split(' '));
String output = '';
process.stdout.listen((List<int> item) {
output += utf8.decode(item);
});
await process.exitCode;
expect(output, equals(calls[key][0]));
}
processManager.verifyCalls(calls.keys);
});
test('run works', () async {
final Map<String, List<String>> calls = <String, List<String>>{
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['test'],
};
processManager.setResults(calls);
for (String key in calls.keys) {
final ProcessResult result = await processManager.run(key.split(' '));
expect(result.stdout, equals(calls[key][0]));
}
processManager.verifyCalls(calls.keys);
});
test('runSync works', () async {
final Map<String, List<String>> calls = <String, List<String>>{
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['test'],
};
processManager.setResults(calls);
for (String key in calls.keys) {
final ProcessResult result = processManager.runSync(key.split(' '));
expect(result.stdout, equals(calls[key][0]));
}
processManager.verifyCalls(calls.keys);
});
test('captures stdin', () async {
final Map<String, List<String>> calls = <String, List<String>>{
'gsutil acl get gs://flutter_infra/releases/releases.json': <String>['output1'],
'gsutil cat gs://flutter_infra/releases/releases.json': <String>['test'],
};
processManager.setResults(calls);
for (String key in calls.keys) {
final Process process = await processManager.start(key.split(' '));
String output = '';
process.stdout.listen((List<int> item) {
output += utf8.decode(item);
});
final String testInput = '${calls[key][0]} input';
process.stdin.add(testInput.codeUnits);
await process.exitCode;
expect(output, equals(calls[key][0]));
expect(stdinCaptured.last, equals(testInput));
}
processManager.verifyCalls(calls.keys);
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment