Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ build-flutter:
- specific:true
script:
- !reference [.pre, script]
- pushd tools/releaser && dart pub get && dart test && popd
- melos dartfmt_check
- melos run analyze:dart
- melos run unit_test:flutter
Expand Down
22 changes: 14 additions & 8 deletions tools/releaser/bin/releaser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ void main(List<String> arguments) async {
});

final argParser = ArgParser()
..addOption('packages',
help: 'A comma separated list of package:version pairs to release.')
..addOption(
'packages',
help: 'A comma separated list of package:version pairs to release.',
)
..addOption('version', abbr: 'v')
..addOption('repo-root', help: 'The root of the repo to release from')
..addFlag(
Expand Down Expand Up @@ -108,16 +110,17 @@ void main(List<String> arguments) async {
var versionBumpType = VersionBumpType.minor;
// If we're on a release branch, bump by a revision
if (currentBranch.branchName.contains('release')) {
versionBumpType = VersionBumpType.rev;
versionBumpType = VersionBumpType.patch;
}
// If we're releasing a pre-release, bump by pre-release
// if (commandArgs.version.contains('-')) {
// versionBumpType = VersionBumpType.prerelease;
// }

// If there are any initial releases, having no changes on the chore branch is okay (though unlikely)
final isInitialRelease =
commandArgs.packages.where((e) => e.version == '1.0.0').isNotEmpty;
final isInitialRelease = commandArgs.packages
.where((e) => e.version == '1.0.0')
.isNotEmpty;

final commitPackageName = commandArgs.packages.length == 1
? '${commandArgs.packages.first.name} ${commandArgs.packages.first.version}'
Expand All @@ -128,7 +131,7 @@ void main(List<String> arguments) async {
commitBody = 'Releasing the following packages:\n';
commitBody += [
for (final package in commandArgs.packages)
' - ${package.name} ${package.version}'
' - ${package.name} ${package.version}',
].join('\n');
}

Expand Down Expand Up @@ -173,7 +176,9 @@ void main(List<String> arguments) async {
}

Future<CommandArguments?> _validateArguments(
ArgResults argResults, Logger logger) async {
ArgResults argResults,
Logger logger,
) async {
var packages = _parsePackages(argResults['packages'], logger);
if (packages == null) {
if (argResults.rest.isEmpty) {
Expand Down Expand Up @@ -228,7 +233,8 @@ List<PackageRelease>? _parsePackages(String? packages, Logger logger) {
final colonIndex = e.indexOf(':');
if (colonIndex < 0) {
logger.shout(
'❌ Invalid package specification $e. Missing : to specify version.');
'❌ Invalid package specification $e. Missing : to specify version.',
);
throw Error();
}

Expand Down
129 changes: 129 additions & 0 deletions tools/releaser/lib/cmake_util.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import 'dart:io';

import 'package:logging/logging.dart';

import 'helpers.dart';

/// Matches the `GIT_TAG <ref>` argument anywhere on a line -- not anchored to
/// the line start, since `FetchContent_Declare(dd-sdk-cpp ... GIT_TAG develop)`
/// is valid CMake written on a single line.
final _gitTagPattern = RegExp(r'(?<prefix>GIT_TAG\s+)(?<ref>[\w./-]+)');

/// A trailing `# ...` comment, stripped before [_gitTagPattern] is applied so
/// a previous run's `# <tag>` annotation is replaced rather than duplicated --
/// and so a `#`-commented mention of `GIT_TAG` can't be mistaken for the pin.
final _trailingCommentPattern = RegExp(r'\s*#.*$');

final _ddSdkCppDeclareStartPattern = RegExp(
r'FetchContent_Declare\(\s*dd-sdk-cpp\b',
);

/// Tracks whether a line belongs to the `FetchContent_Declare(dd-sdk-cpp ...)`
/// call, which can span several lines -- a `CMakeLists.txt` is free to declare
/// other `FetchContent` dependencies, and only dd-sdk-cpp's `GIT_TAG` is this
/// tooling's to read or rewrite.
///
/// Shared by [hasDdSdkCppGitTag] (which decides whether a file counts as a
/// dd-sdk-cpp dependency file at all) and [pinCppVersion] (which rewrites it),
/// so the two can't disagree about where the block starts and ends.
class _DdSdkCppBlockScanner {
var _inBlock = false;
var _parenDepth = 0;

static int _parenBalance(String line) =>
'('.allMatches(line).length - ')'.allMatches(line).length;

/// Advances the scanner over [line] and returns whether that line is inside
/// the block -- the opening `FetchContent_Declare(dd-sdk-cpp` line included,
/// since a single-line declaration carries its `GIT_TAG` there.
bool accept(String line) {
if (_ddSdkCppDeclareStartPattern.hasMatch(line)) {
_inBlock = true;
_parenDepth = _parenBalance(line);
} else if (_inBlock) {
_parenDepth += _parenBalance(line);
}

final result = _inBlock;
if (_inBlock && _parenDepth <= 0) _inBlock = false;
return result;
}
}

/// Whether [cmakeListsContent] pins dd-sdk-cpp -- a `GIT_TAG` line inside a
/// `FetchContent_Declare(dd-sdk-cpp ...)` call. This is what makes a
/// `windows/`/`linux/` `CMakeLists.txt` a native-dependency file as far as
/// `native_sdk.dart`'s discovery is concerned; a file whose only `GIT_TAG`
/// belongs to some other vendored dependency isn't one.
bool hasDdSdkCppGitTag(String cmakeListsContent) {
final scanner = _DdSdkCppBlockScanner();
for (final line in cmakeListsContent.split('\n')) {
if (scanner.accept(line) &&
_gitTagPattern.hasMatch(
line.replaceFirst(_trailingCommentPattern, ''),
)) {
return true;
}
}
return false;
}

/// Rewrites [line]'s `GIT_TAG` ref to [targetSha], re-annotated with
/// `# [targetTag]`, leaving everything else on the line as-is -- a trailing
/// `)` closing the `FetchContent_Declare(...` call, a `GIT_REPOSITORY` sharing
/// the line in a single-line declaration, the original indentation. The
/// comment goes at end of line, since a `#` placed before the closing paren
/// would comment it out and break the call.
String _pinGitTagLine(String line, String targetTag, String targetSha) {
final bare = line.replaceFirst(_trailingCommentPattern, '');
final match = _gitTagPattern.firstMatch(bare);
if (match == null) return line;

final pinned = bare.replaceRange(
match.start,
match.end,
'${match.namedGroup('prefix')}$targetSha',
);
return '${pinned.trimRight()} # $targetTag';
}

/// Rewrites a CMakeLists.txt's dd-sdk-cpp `GIT_TAG` line to pin at
/// [targetSha] -- the resolved commit SHA for [targetTag] (e.g. `v1.4.0`),
/// kept as a trailing `# <tag>` comment since CMake's `FetchContent_Declare`
/// has no field of its own for pairing a tag with a verified commit. A
/// full commit SHA is used as the actual pin (not the tag) because it's
/// immutable, unlike a tag, which can be moved to point elsewhere later.
///
/// Preserves the line's existing whitespace and everything around the ref --
/// see [_pinGitTagLine].
///
/// Only ever called against a release-prep/patch/pre-release branch's copy
/// of the file -- `develop`'s own floating `GIT_TAG develop` is never
/// touched by release tooling.
Future<void> pinCppVersion(
File cmakeListsFile,
String targetTag,
String targetSha,
Logger logger,
bool dryRun,
Comment thread
fuzzybinary marked this conversation as resolved.
) async {
logger.info(
'ℹ️ Pinning dd-sdk-cpp GIT_TAG to $targetSha ($targetTag) in '
'${cmakeListsFile.path}',
);

final scanner = _DdSdkCppBlockScanner();

await transformFile(
cmakeListsFile,
logger,
dryRun,
(line) => scanner.accept(line)
? _pinGitTagLine(line, targetTag, targetSha)
: line,
);
}
52 changes: 34 additions & 18 deletions tools/releaser/lib/cocoapod_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,30 @@ import 'package:path/path.dart' as path;

import 'command.dart';
import 'helpers.dart';
import 'native_sdk.dart';
import 'package_list.dart';

final overridesStartPattern = RegExp(r'\s+# Datadog Pod Overrides');
final overridesEndPattern = RegExp(r'\s+# End Datadog Pod Overrides');
final specDependencyPattern = RegExp(
r"\s+s\.dependency\s+'(?<dependency>Datadog.+)', '.+",
);

/// Rewrites a podspec's `s.dependency 'Datadog...'` constraint to [version],
/// or returns [line] unchanged if it isn't one.
///
/// Replaces only the matched range rather than reconstructing the line.
/// [iosPodspecDependencyPattern] tolerates any spacing around the comma, so
/// rebuilding a canonical ` s.dependency 'X', 'Y'` reformats whatever
/// spacing and indentation the podspec actually used and drops anything
/// trailing the constraint.
String pinIosPodspecDependencyLine(String line, String version) {
final match = iosPodspecDependencyPattern.firstMatch(line);
if (match == null) return line;

return line.replaceRange(
match.start,
match.end,
"${match.namedGroup('prefix')}$version'",
);
}

class PinCocoapodsVersionCommand extends Command {
@override
Expand All @@ -22,8 +39,9 @@ class PinCocoapodsVersionCommand extends Command {
}

// Other packages can keep looser version constraints
final pinedPackage = args.packages
.firstWhereOrNull((e) => e.name == 'datadog_flutter_plugin');
final pinedPackage = args.packages.firstWhereOrNull(
(e) => e.name == 'datadog_flutter_plugin',
);
if (pinedPackage != null) {
if (!await _pinPodspecVersion(args, pinedPackage, logger)) {
return false;
Expand Down Expand Up @@ -64,14 +82,14 @@ class PinCocoapodsVersionCommand extends Command {
}

Future<bool> _pinPodspecVersion(
CommandArguments args, PackageRelease package, Logger logger) async {
CommandArguments args,
PackageRelease package,
Logger logger,
) async {
final podspecLocation = 'ios/${package.name}.podspec';

final file = File(
path.join(
getPackageRoot(args, package),
podspecLocation,
),
path.join(getPackageRoot(args, package), podspecLocation),
);

if (!file.existsSync()) {
Expand All @@ -82,14 +100,12 @@ class PinCocoapodsVersionCommand extends Command {
}

logger.info('ℹ️ Setting the iOS Pod Dependency to ${args.iOSRelease}');
await transformFile(file, logger, args.dryRun, (element) {
final match = specDependencyPattern.firstMatch(element);
if (match != null) {
element =
" s.dependency '${match.namedGroup('dependency')}', '${args.iOSRelease}'";
}
return element;
});
await transformFile(
file,
logger,
args.dryRun,
(element) => pinIosPodspecDependencyLine(element, args.iOSRelease!),
);

return true;
}
Expand Down
105 changes: 105 additions & 0 deletions tools/releaser/lib/conventional_commits.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import 'version_updater.dart';

/// A parsed conventional commit: header (`type(scope)!: description`) plus
/// the footers other tooling in this package cares about (a `BREAKING
/// CHANGE:` footer, and `refs:` lines referencing GitHub issues).
class ConventionalCommit {
final String type;
final String? scope;

/// Whether the header itself carries a `!` breaking marker.
final bool hasBreakingMarker;
final String description;

/// Whether a `BREAKING CHANGE:`/`BREAKING-CHANGE:` footer is present,
/// independent of [hasBreakingMarker] -- either one marks the commit
/// breaking, see [isBreaking].
final bool hasBreakingFooter;

/// Raw reference tokens from `refs:` footer lines (e.g. `refs: #123,
/// RUM-456` -> `['#123', 'RUM-456']`) -- a GitHub issue number, a JIRA
/// ticket, or anything else a `refs:` footer might carry. Not filtered
/// or interpreted here; a consumer that only wants GitHub issue links
/// (like `generate_changelog.dart`) picks those out itself.
final List<String> refs;

ConventionalCommit({
required this.type,
required this.scope,
required this.hasBreakingMarker,
required this.description,
required this.hasBreakingFooter,
required this.refs,
});

bool get isBreaking => hasBreakingMarker || hasBreakingFooter;

/// The semver bump this commit implies on its own: major if breaking
/// (marker or footer), minor for `feat`, patch for `fix`/`perf`, or null
/// for a type that doesn't carry semver weight by itself (`chore:`,
/// `docs:`, `test:`, etc.) and isn't marked breaking.
VersionBumpType? get bumpType {
if (isBreaking) return VersionBumpType.major;

switch (type) {
case 'feat':
return VersionBumpType.minor;
case 'fix':
case 'perf':
return VersionBumpType.patch;
default:
return null;
}
}

static final _headerPattern = RegExp(
r'^(?<type>\w+)(\((?<scope>[^)]*)\))?(?<breaking>!)?:\s*(?<rest>.*)',
);
static final _breakingFooterPattern = RegExp(
r'^BREAKING[ -]CHANGE:',
multiLine: true,
);

/// Parses a full commit message (subject line plus body/footers) into
/// its conventional-commit parts, or returns null if the subject line
/// doesn't match the convention at all.
static ConventionalCommit? parse(String commitMessage) {
final lines = commitMessage.split('\n');
final match = _headerPattern.firstMatch(lines.first);
if (match == null) return null;

final refs = [
for (final refLine in lines.where((l) => l.startsWith('refs:')))
for (final token
in refLine.substring('refs:'.length).split(RegExp(r'[,\s]+')))
if (token.isNotEmpty) token,
];

return ConventionalCommit(
type: match.namedGroup('type')!,
scope: match.namedGroup('scope'),
hasBreakingMarker: match.namedGroup('breaking') == '!',
description: match.namedGroup('rest')!,
hasBreakingFooter: _breakingFooterPattern.hasMatch(commitMessage),
refs: refs,
);
}
}

/// The highest-severity bump implied by [commits] (major > minor > patch),
/// or null if none of them carry semver weight.
VersionBumpType? aggregateBumpLevel(Iterable<ConventionalCommit> commits) {
VersionBumpType? highest;
for (final commit in commits) {
final bump = commit.bumpType;
if (bump == null) continue;
if (highest == null || bump.severity > highest.severity) {
highest = bump;
}
}
return highest;
}
Loading