publish method

Future<void> publish({
  1. Uri? registry,
  2. String? tag,
  3. NpmAccess? access,
})

Publish a previous preparation. A retry never repacks or overwrites a registry version. Already published packages keep their existing tags.

Implementation

Future<void> publish({Uri? registry, String? tag, NpmAccess? access}) async {
  final destination = output.absolute;
  await checkOutputPath(destination.path);
  final record = await _readJson(
    File('${destination.path}/.napi-release.json'),
  );
  if (record['format'] != 1 ||
      record['name'] != name ||
      record['version'] != version) {
    throw const FormatException(
      'Prepared release name or version does not match this declaration.',
    );
  }
  packageMetadata({'name': name, 'version': version});
  final rows = record['packages'];
  if (rows is! List || rows.isEmpty) {
    throw const FormatException('Prepared release has no packages.');
  }
  final packages = <_PreparedPackage>[];
  final names = <String>{};
  for (final row in rows) {
    if (row is! Map ||
        row['version'] != version ||
        row['name'] is! String ||
        row['tarball'] is! String ||
        row['integrity'] is! String) {
      throw const FormatException('Invalid prepared package.');
    }
    final packageName = row['name'] as String;
    packageMetadata({'name': packageName, 'version': version});
    final relative = row['tarball'] as String;
    if (!names.add(packageName) ||
        (packageName != name && !packageName.startsWith('$name-')) ||
        path.posix.dirname(relative) != 'tarballs' ||
        path.posix.basename(relative).contains('\\') ||
        !relative.endsWith('.tgz')) {
      throw const FormatException(
        'Invalid prepared package name or tarball path.',
      );
    }
    final file = File('${destination.path}/$relative');
    if (await _integrity(file) != row['integrity']) {
      throw StateError('Prepared tarball changed: $relative');
    }
    packages.add(
      _PreparedPackage(packageName, file, row['integrity'] as String),
    );
  }
  if (packages.last.name != name) {
    throw const FormatException('The entry package must be last.');
  }
  final config = record['publishConfig'];
  if (config != null && config is! Map) {
    throw const FormatException('publishConfig must be a map.');
  }
  final scope = name.startsWith('@') ? name.split('/').first : null;
  final configured =
      registry?.toString() ??
      (scope == null ? null : (config as Map?)?['$scope:registry']) ??
      (config as Map?)?['registry'] ??
      (scope == null ? null : await _npmConfig('$scope:registry')) ??
      await _npmConfig('registry');
  final selected = configured is String ? Uri.tryParse(configured) : null;
  if (selected == null ||
      !{'https', 'http'}.contains(selected.scheme) ||
      selected.host.isEmpty ||
      selected.userInfo.isNotEmpty ||
      selected.hasQuery ||
      selected.hasFragment) {
    throw ArgumentError('npm registry must be an absolute HTTP(S) URL.');
  }
  final registryOptions = [
    '--registry=${selected.toString()}',
    if (scope != null) '--$scope:registry=${selected.toString()}',
  ];
  final done = <String>{};
  try {
    // Discover all conflicts before writing any new registry versions.
    for (final package in packages) {
      if (await _published(package, registryOptions)) done.add(package.name);
    }
    for (final package in packages) {
      if (done.contains(package.name)) {
        stdout.writeln('Already published ${package.name}@$version');
        continue;
      }
      // Verify again immediately before npm opens the prepared tarball.
      if (await _integrity(package.tarball) != package.integrity) {
        throw StateError('Prepared tarball changed: ${package.tarball.path}');
      }
      final command = await _npmCommand();
      final process = await Process.start(command.first, [
        ...command.skip(1),
        'publish',
        package.tarball.path,
        '--ignore-scripts',
        ...registryOptions,
        if (tag != null) '--tag=$tag',
        if (access != null) '--access=${access.name}',
      ], mode: ProcessStartMode.inheritStdio);
      final code = await process.exitCode;
      // A network failure can occur after the registry accepted the upload.
      // Read back the digest before deciding whether this package is pending.
      if (!await _published(package, registryOptions)) {
        throw StateError(
          'npm publish did not confirm ${package.name}@$version (exit $code).',
        );
      }
      done.add(package.name);
      stdout.writeln('Published ${package.name}@$version');
    }
  } catch (error) {
    throw StateError(
      '$error\nConfirmed: ${done.isEmpty ? 'none' : done.join(', ')}.\n'
      'Pending: ${packages.where((p) => !done.contains(p.name)).map((p) => p.name).join(', ')}. '
      'Retry publish with the same prepared release.',
    );
  }
}