archive_publisher.dart 6.63 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// 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) {
25
      output += ':\n${result.stderr}';
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
    }
    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)');
    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.
108
  void checkForGSUtilAccess() {
109 110 111 112
    // Fetching ACLs requires FULL_CONTROL access.
    final ProcessResult result = _runGsUtil(<String>['acl', 'get', metadataGsPath]);
    if (result.exitCode != 0) {
      throw new ArchivePublisherException(
113 114 115
        'GSUtil cannot get ACLs for metadata file $metadataGsPath',
        result,
      );
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
    }
  }

  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);
    }
  }
}