blob: d689f989d8850d14658f55c7e1ae0e1dade15658 [file] [edit]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'engines.dart';
import 'gerrit.dart';
import 'git.dart';
import 'notifier.dart';
import 'process.dart';
import 'tool_proxy.dart';
import 'upstream.dart';
import 'version.dart';
/// The Gerrit hashtag identifying roll CLs uploaded by this tool.
const _rollHashtag = 'browser-roll';
/// How often to check normally, and after [_quietPeriod] without progress.
const _fastPoll = Duration(minutes: 1);
const _slowPoll = Duration(hours: 1);
const _quietPeriod = Duration(hours: 3);
/// Settings for a [BrowserRoller] run.
final class RollerOptions {
/// The engines to consider rolling.
final List<Engine> engines;
/// Report what would happen without requesting builds or uploading CLs.
final bool dryRun;
/// Roll to any newer version, ignoring each engine's [RollPolicy].
final bool ignoreCadence;
/// Reviewers for roll CLs, in addition to the MPA approvers.
final List<String> reviewers;
RollerOptions({
required this.engines,
required this.dryRun,
required this.ignoreCadence,
required this.reviewers,
});
}
/// Rolls the browsers and JS engines pinned in DEPS to newer versions:
///
/// 1. Compare each DEPS pin with the latest upstream release and the
/// engine's roll policy.
/// 2. Request CIPD packaging builds through the tool proxy for any new
/// versions whose packages are missing, and wait for them to finish.
/// 3. Upload a single CL updating DEPS, and wait for it to be approved and
/// land.
final class BrowserRoller {
final GitCheckout checkout;
final Upstream upstream;
final ToolProxy toolProxy;
final Gerrit gerrit;
final Notifier notifier;
final RollerOptions options;
bool _hadErrors = false;
BrowserRoller({
required this.checkout,
required this.upstream,
required this.toolProxy,
required this.gerrit,
required this.notifier,
required this.options,
});
/// Rolls the engines and returns whether it completed without errors.
Future<bool> run() async {
if (!options.dryRun) await _waitForCredentials();
await checkout.fetchUpstream();
final deps = DepsFile(await checkout.readUpstreamDeps());
final statuses = await _collectStatuses(deps);
_printStatuses(statuses);
final rollable = [
for (final status in statuses)
if (status.shouldRoll) status,
];
if (rollable.isEmpty) {
print('Nothing to roll.');
return !_hadErrors;
}
final existingChange = await _existingRollChange();
if (existingChange != null) {
if (!options.dryRun) await _waitForLanding(existingChange);
return !_hadErrors;
}
final (ready, requests) = await _resolvePackages(rollable);
final newChange = await _uploadIfReady(ready, requests);
if (newChange != null) await _waitForLanding(newChange);
return !_hadErrors;
}
// Step 1: compare pins with upstream releases.
/// Returns the status of each engine in [RollerOptions.engines].
///
/// Engines that follow another engine's version are evaluated after it.
/// A followed engine is evaluated even if it wasn't selected, but it is
/// only rolled if it was.
Future<List<EngineStatus>> _collectStatuses(DepsFile deps) async {
final selected = options.engines;
final upstreamStatuses = {
for (final status in await Future.wait([
for (final engine in {for (final e in selected) e.versionLeader})
if (engine.source case final UpstreamReleases source)
_upstreamStatusOrNull(engine, source, deps),
]))
if (status != null) status.engine: status,
};
return [
for (final engine in selected)
switch (engine.source) {
UpstreamReleases() => upstreamStatuses[engine],
SameVersionAs(:final leader) => switch (upstreamStatuses[leader]) {
final leaderStatus? =>
_followerStatus(engine, leaderStatus, deps),
null => null,
},
},
].nonNulls.toList();
}
Future<EngineStatus?> _upstreamStatusOrNull(
Engine engine,
UpstreamReleases source,
DepsFile deps,
) async {
try {
final current = engine.pinnedVersion(deps);
final latest = await source.fetchLatest(upstream, current);
return EngineStatus(
engine: engine,
current: current,
latest: latest,
holdReason: await _holdReason(
engine,
source.policy,
current,
latest.version,
),
);
} catch (e) {
await _reportFailure(
'failed to check ${engine.displayName}',
details: '$e',
);
return null;
}
}
/// Returns why rolling [engine] from [current] to [latest] should wait, or
/// `null` if it should roll now.
Future<String?> _holdReason(
Engine engine,
RollPolicy policy,
Version current,
Version latest,
) async {
if (!(latest > current)) return 'up to date';
if (options.ignoreCadence) return null;
final pinAge = await checkout.depsVarAge(engine.depsVar);
return policy.holdReason(current, latest, pinAge);
}
/// Returns the status of [engine], which pins the same version as the
/// engine of [leader].
///
/// It rolls to the leader's new version when the leader rolls, and
/// otherwise catches up to the leader's current pin if it fell behind.
EngineStatus _followerStatus(
Engine engine,
EngineStatus leader,
DepsFile deps,
) {
final current = engine.pinnedVersion(deps);
final leaderRolls =
leader.shouldRoll && options.engines.contains(leader.engine);
final target = leaderRolls ? leader.target : leader.current;
return EngineStatus(
engine: engine,
current: current,
latest: Release(target),
holdReason: target > current
? null
: 'up to date with ${leader.engine.displayName}',
);
}
void _printStatuses(List<EngineStatus> statuses) {
for (final status in statuses) {
final versions = '${status.current} -> ${status.target}';
final decision = status.holdReason ?? 'ROLL';
print('${status.engine.name.padRight(8)} ${versions.padRight(36)} '
'$decision');
}
}
// Step 2: make sure the CIPD packages exist.
/// Requests packaging builds for engines in [rollable] whose CIPD packages
/// are missing, waits for those builds to finish, and returns the engines
/// whose packages are ready to roll (along with the build requests).
///
/// Engines with the same [Engine.versionLeader] are only ready once all of
/// their packages exist, so that they roll in the same CL.
Future<(List<EngineStatus>, List<BuildRequest>)> _resolvePackages(
List<EngineStatus> rollable,
) async {
final missingPackages = await Future.wait([
for (final status in rollable) status.missingCipdPackages(),
]);
final withPackages = <EngineStatus>[];
final needBuilds = <EngineStatus>[];
for (final (i, status) in rollable.indexed) {
if (missingPackages[i].isEmpty) {
withPackages.add(status);
} else {
print('${status.engine.name} ${status.target}: missing CIPD packages '
'${missingPackages[i].join(', ')}');
needBuilds.add(status);
}
}
final pending = await _requestBuilds(needBuilds);
try {
withPackages.addAll(await _waitForBuilds(pending));
} finally {
for (final request in pending) {
request.kill();
}
}
final missing = [
for (final status in rollable)
if (!withPackages.contains(status)) status.engine,
];
final ready = [
for (final status in withPackages)
if (!_isWaitingOnLinkedEngine(status.engine, missing)) status,
];
return (ready, pending);
}
/// Starts CIPD packaging builds for [statuses], posts all of their MPA
/// approval links in a single message, and returns the requests that may
/// still produce packages.
Future<List<BuildRequest>> _requestBuilds(List<EngineStatus> statuses) async {
if (statuses.isEmpty) return const [];
if (options.dryRun) {
for (final status in statuses) {
print('[dry-run] Would request a CIPD build of ${status.buildName}.');
}
return const [];
}
await _waitForCredentials(minRemaining: const Duration(hours: 1));
final requests = await Future.wait([
for (final status in statuses) toolProxy.startBuild(status),
]);
final approvalUrls = await Future.wait([
for (final request in requests) request.waitForApprovalUrl(),
]);
final approvals = <String>[];
for (final (i, url) in approvalUrls.indexed) {
if (url == null) continue;
final status = requests[i].status;
print('${status.buildName}: MPA approval needed at $url');
approvals.add('• ${status.rollSummary}: $url');
}
if (approvals.isNotEmpty) {
await notifier.post('MPA approval needed to build the CIPD packages '
'for a browser roll:\n${approvals.join('\n')}');
}
final pending = <BuildRequest>[];
for (final request in requests) {
if (request.approvalUrl != null || request.exitCode == 0) {
pending.add(request);
} else if (request.exitCode != null) {
await _reportRequestFailure(request);
} else {
await _reportFailure(
'no MPA link for the ${request.status.buildName} CIPD build',
details: 'tool-proxy-cli is still running but has not printed an '
'approval link yet.\n\n${request.outputTail()}',
);
pending.add(request);
}
}
return pending;
}
/// Polls until every build in [pending] either produces its CIPD packages
/// or fails, returning the engines whose packages are ready.
Future<List<EngineStatus>> _waitForBuilds(List<BuildRequest> pending) async {
if (pending.isEmpty) return const [];
await notifier.flush();
print('Waiting for the CIPD builds of '
'${pending.map((r) => r.status.buildName).join(', ')}.');
final ready = <EngineStatus>[];
final remaining = pending.toList();
final triggered = <BuildRequest>{};
var lastProgress = DateTime.now();
while (remaining.isNotEmpty) {
await _sleep(lastProgress);
for (final request in remaining.toList()) {
final status = request.status;
if (request.exitCode == 0 && triggered.add(request)) {
print('${status.buildName}: MPA request approved, build triggered.');
lastProgress = DateTime.now();
}
if ((await status.missingCipdPackages()).isEmpty) {
print('${status.buildName}: CIPD packages are available.');
request.kill();
ready.add(status);
remaining.remove(request);
lastProgress = DateTime.now();
} else if (request.exitCode case final code? when code != 0) {
await _reportRequestFailure(request);
remaining.remove(request);
lastProgress = DateTime.now();
} else if (request.hasTimedOut) {
await _reportBuildTimeout(status);
remaining.remove(request);
lastProgress = DateTime.now();
}
}
}
return ready;
}
/// Whether [engine] must wait for a linked engine in [missing].
bool _isWaitingOnLinkedEngine(Engine engine, Iterable<Engine> missing) {
final blocking = [
for (final other in missing)
if (other.versionLeader == engine.versionLeader) other.name,
];
if (blocking.isEmpty) return false;
print('${engine.name}: waiting for ${blocking.join(', ')} CIPD packages '
'so they roll together.');
return true;
}
Future<void> _reportRequestFailure(BuildRequest request) => _reportFailure(
'requesting the ${request.status.buildName} CIPD build failed',
details: 'tool-proxy-cli exited with code '
'${request.exitCode ?? 'unknown'}.\n\n'
'${request.outputTail()}',
);
Future<void> _reportBuildTimeout(EngineStatus status) => _reportFailure(
'the ${status.buildName} CIPD build did not produce packages',
details: 'The build was triggered but its CIPD packages are still '
'missing.\n'
'Builders: https://ci.chromium.org/ui/p/dart-internal/builders/'
'ci/${status.engine.name}',
);
// Step 3: upload the DEPS roll and wait for it to land.
Future<GerritChange?> _existingRollChange() async {
final open = await gerrit.openChanges(hashtag: _rollHashtag);
if (open.isEmpty) return null;
print('Not uploading a roll while one is open: ${open.join(', ')}');
return open.first;
}
/// Uploads a CL rolling [rolls] on top of the latest `origin/main` and
/// returns it, or `null` if [rolls] is empty or in a dry run.
Future<GerritChange?> _uploadIfReady(
List<EngineStatus> rolls,
List<BuildRequest> requests,
) async {
if (rolls.isEmpty) return null;
final message = _rollCommitMessage(rolls);
if (options.dryRun) {
print('[dry-run] Would upload a CL with reviewers '
'[${options.reviewers.join(', ')}] and message:\n\n$message');
return null;
}
await _waitForCredentials();
final existing = await _existingRollChange();
if (existing != null) return existing;
// Re-fetch origin/main in case commits landed while waiting for builds.
await checkout.fetchUpstream();
final deps = DepsFile(await checkout.readUpstreamDeps());
final updatedDeps = rolls.fold(
deps,
(deps, roll) => roll.engine.withPinnedVersion(deps, roll.target),
);
if (updatedDeps.contents == deps.contents) {
print('DEPS on origin/main is already up to date.');
return null;
}
final reviewers = {
...options.reviewers,
...await _buildApprovers(rolls, requests),
};
final change = await gerrit.uploadDepsChange(
checkout: checkout,
depsContents: updatedDeps.contents,
commitMessage: message,
reviewers: reviewers,
hashtags: const ['web', _rollHashtag],
);
await notifier.post('Browser roll CL ready for review: ${change.url}\n'
'${rolls.map((roll) => '• ${roll.rollSummary}').join('\n')}');
return change;
}
/// Returns who approved the MPA requests for the CIPD builds of [rolls].
///
/// Rolls whose packages were already built have no approver. Lookup
/// failures are printed but don't block the roll.
Future<Set<String>> _buildApprovers(
List<EngineStatus> rolls,
List<BuildRequest> requests,
) async {
final approvers = <String>{};
for (final request in requests) {
if (!rolls.contains(request.status)) continue;
try {
approvers.addAll(await request.approvers());
} on Exception catch (e) {
print('Could not look up MPA approvers for '
'${request.status.buildName}: $e');
}
}
return approvers;
}
/// Polls [change] until it lands or is abandoned, reporting failed CQ runs
/// (including the dry run started on upload) and sending it to the commit
/// queue once it is approved.
///
/// The CL has Auto-Submit, which normally sends it to the commit queue on
/// approval; this makes sure it gets there even if that doesn't happen. A
/// failed full run is never retried automatically, so a human can decide
/// whether to fix and re-queue the CL or abandon it.
Future<void> _waitForLanding(GerritChange change) async {
await notifier.flush();
print('Waiting for $change to be approved and land.');
var lastProgress = DateTime.now();
var reachedCommitQueue = false;
final reportedFailures = <String>{};
(bool, bool)? lastSeen;
while (true) {
if (await _reviewStateOrNull(change) case final state?) {
switch (state.status) {
case ChangeStatus.merged:
print('$change landed.');
await notifier.post('✅ Browser roll CL landed: $change');
return;
case ChangeStatus.abandoned:
print('$change was abandoned.');
return;
case ChangeStatus.open:
break;
}
for (final failure in state.cqFailures) {
if (reportedFailures.add(failure.id)) {
await _reportCqFailure(change, failure);
lastProgress = DateTime.now();
}
}
final seen = (state.approved, state.inCommitQueue);
if (lastSeen != null && seen != lastSeen) lastProgress = DateTime.now();
lastSeen = seen;
reachedCommitQueue = reachedCommitQueue ||
state.inCommitQueue ||
state.cqFailures.any((failure) => !failure.dryRun);
if (state.approved && !reachedCommitQueue) {
reachedCommitQueue = true;
if (!await _sendToCommitQueue(change)) return;
}
}
await _sleep(lastProgress);
}
}
Future<void> _reportCqFailure(GerritChange change, CqFailure failure) =>
_reportFailure(
failure.dryRun
? 'the roll CL failed the CQ dry run'
: 'the roll CL failed the CQ',
details: '$change\n\n${failure.reason}\n\n'
'Still waiting for the CL to land. Fix the failures and vote '
'Commit-Queue+2 to retry, or abandon it.',
);
Future<ReviewState?> _reviewStateOrNull(GerritChange change) async {
try {
return await gerrit.reviewState(change);
} on Exception catch (e) {
print('Could not check $change: $e');
return null;
}
}
/// Votes Commit-Queue+2 on [change], returning whether it succeeded.
Future<bool> _sendToCommitQueue(GerritChange change) async {
print('$change was approved; sending it to the commit queue.');
await _waitForCredentials();
try {
await gerrit.sendToCommitQueue(checkout, change);
return true;
} on ProcessFailure catch (e) {
await _reportFailure(
'could not send the roll CL to the commit queue',
details: '$change\n\n$e',
);
return false;
}
}
/// Sleeps between poll checks: every [_fastPoll] normally, or every
/// [_slowPoll] once nothing has changed for [_quietPeriod].
Future<void> _sleep(DateTime lastProgress) {
final quiet = DateTime.now().difference(lastProgress) >= _quietPeriod;
return Future<void>.delayed(quiet ? _slowPoll : _fastPoll);
}
/// Waits until LOAS credentials (`gcert`) are valid for at least
/// [minRemaining], checking every [_fastPoll].
Future<void> _waitForCredentials({
Duration minRemaining = const Duration(minutes: 5),
}) async {
if (await hasLoasCredentials(minRemaining: minRemaining)) return;
print('LOAS credentials have expired; waiting for `gcert` to be renewed.');
while (!await hasLoasCredentials(minRemaining: minRemaining)) {
await Future<void>.delayed(_fastPoll);
}
print('LOAS credentials renewed; resuming.');
}
/// Posts a failure to the run's chat thread and fails the run.
Future<void> _reportFailure(String summary, {String? details}) async {
_hadErrors = true;
await notifier.reportFailure(summary, details: details);
}
}
/// The commit message of a CL rolling [rolls], including the trybots for
/// just the engines being rolled.
String _rollCommitMessage(List<EngineStatus> rolls) {
final names = [for (final roll in rolls) roll.engine.displayName];
final trybots = {for (final roll in rolls) ...roll.engine.trybots};
final subjectNames = switch (names) {
[final only] => only,
[...final head, final last] => '${head.join(', ')} and $last',
[] => '',
};
return [
'[web] Update $subjectNames versions in DEPS',
'',
for (final roll in rolls)
'${roll.engine.depsVar}: ${roll.current} -> ${roll.target}',
'',
'Uploaded by tools/roll_browsers.dart, see go/dart-engprod/browsers.md.',
if (trybots.isNotEmpty) ...[
'',
'Cq-Include-Trybots: dart/try:${trybots.join(',')}',
],
].join('\n');
}