This command is a sibling to `reassign!`, in the sense that it also creates a commit with a subcommand tag to guide a rebase event. In this case though, the `drop!` indicates that a target commit is supposed to be dropped during rebase. Similar to `ressign!` handling, this type of pin can only be processed with `--squash-minor-bumps`, and the target commit cannot be another pinned commit. This change is being implemented for rebase v2 only. Bug: https://github.com/brave/brave-browser/issues/56150
3729 lines
154 KiB
Python
Executable File
3729 lines
154 KiB
Python
Executable File
#!/usr/bin/env vpython3
|
||
# Copyright (c) 2024 The Brave Authors. All rights reserved.
|
||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||
# You can obtain one at https://mozilla.org/MPL/2.0/.
|
||
"""# 🚀Brockit! Guide
|
||
|
||
```
|
||
⠀⠀⠀⠀⠀⢀⣴⣶⣶⣶⣶⣶⣶⣶⣶⣦⡀⠀⠀⠀⠀⠀
|
||
⠀⣀⣤⣤⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⣤⣀⠀ 🚀
|
||
⣾⣿⣿⣿⣿⡿⠛⠻⠿⠋⠁⠈⠙⠛⠛⠛⢿⣿⣿⣿⣿⣷ .
|
||
⢈⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿⡁ .
|
||
⣿⣿⣟⠁⠀⠴⣿⣿⣿⡄⠀⠀⢠⣿⣿⣿⠦⠀⠈⣻⣿⣿ .
|
||
⣿⣿⣿⣧⡀⠀⠀⠀⣽⠇⠀⠀⠸⣯⠀⠀⠀⢀⣴⣿⣿⣿ .
|
||
⠸⣿⣿⣿⣿⣄⠀⢼⣯⣀⠀⠀⣀⣽⡧⠀⢠⣾⣿⣿⣿⠇ .
|
||
⠀⣿⣿⣿⣿⡟⠀⠀⠉⢻⣿⣿⡟⠉⠀⠀⢹⣿⣿⣿⣿⠀ .
|
||
⠀⢹⣿⣿⣿⣧⣀⣀⣤⣾⣿⣿⣷⣤⣀⣀⣴⣿⣿⣿⡏⠀ .
|
||
⠀⠈⣿⣿⣿⣿⣿⣿⣿⠛⠋⠙⠛⣿⣿⣿⣿⣿⣿⣿⠁⠀ .
|
||
⠀⠀⠸⣿⣿⣿⣿⣿⣿⣷⣤⣠⣾⣿⣿⣿⣿⣿⣿⠇⠀⠀ .
|
||
⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⠀⠀⠀⠀ .
|
||
⠀⠀⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⠟⠋⠀⠀⠀⠀⠀⠀⠀💥
|
||
```
|
||
|
||
### `brockit.py lift`
|
||
This is *🚀Brockit!* (Brave Rocket? Brave Rock it? Broke it?): a tool to help
|
||
upgrade Brave to a newer Chromium base version. The main goal is to produce
|
||
use it to commit the following changes:
|
||
|
||
* Update from Chromium [from] to [to].
|
||
* Conflict-resolved patches from Chromium [from] to [to].
|
||
* Update patches from Chromium [from] to [to].
|
||
* Updated strings for Chromium [to].
|
||
|
||
To start it off, provide a `--to` target version, and a base branch to be used,
|
||
either by providing a `--from-ref` argument, or by having an upstream branch to
|
||
to your current branch.
|
||
|
||
```sh
|
||
tools/cr/brockit.py lift --to=135.0.7037.1 --from-ref=origin/master
|
||
```
|
||
|
||
When using `--from-ref`, any valid git reference can be used, such as a branch,
|
||
or even hashes. Additionally there are special labels that can be passed to
|
||
this flag.
|
||
|
||
* `--from-ref=@upstream`: This will use the upstream branch as the base for
|
||
the lift. This requires the user to set an upstream branch. This value is
|
||
the default when none is provided.
|
||
* `--from-ref=@previous`: This tag means the previous version since the last
|
||
upgrade in the current branch. This is useful when telling brockit that you
|
||
are doing a minor version bump.
|
||
* `--from-ref=@previous-major`: This label means the reference the parent
|
||
commit for the current major. This is useful when doing rebases, and
|
||
wanting to select from the version change.
|
||
|
||
Using upstream:
|
||
|
||
```sh
|
||
git branch --set-upstream-to=origin/master
|
||
tools/cr/brockit.py lift --to=135.0.7037.1
|
||
```
|
||
|
||
The `--to` flag also accepts special labels:
|
||
* `--to=@latest-tag`: Uses the latest tag from the Chromium repository.
|
||
* `--to=@latest-m{MAJOR}`: Uses the latest tag for the given major version
|
||
(e.g. `--to=@latest-m135`).
|
||
* `--to=@latest-for-branch`: Infers the major version from the current branch
|
||
name, which must follow the `cr{MAJOR}` convention (e.g. `cr135`), and
|
||
uses the latest tag for that major.
|
||
* `--to=@latest-canary`: Uses the latest canary version from Chromium Dash.
|
||
Both the *Canary* and the *Canary (DCHECK)* channels are queried and the
|
||
highest version is used.
|
||
* `--to=@latest-dev`, `--to=@latest-beta`, `--to=@latest-stable`: Same as
|
||
the flag above, but for the respective channel.
|
||
|
||
The following steps will take place:
|
||
|
||
1. A *Update from Chromium [from] to [to]* commit will be created. This commit
|
||
contains changes to `package.json` and to the pinlist timestamp file.
|
||
2. `npm run init` will be run with the newer version.
|
||
3. For any patches that fail to apply during `init`, there will be another
|
||
attempt to apply them using `--3way`.
|
||
4. If any patches still fail to apply, the process will stop. A summary of
|
||
files with conflicts will be provided for resolution.
|
||
5. Deleted patches will also cause the *🚀Brockit!* to stop . The user is
|
||
expected to provide separate commits for deleted patches, explaining the
|
||
reason.
|
||
6. Having resolved all conflicts. Restart *🚀Brockit!* with `--continue` and
|
||
other similar arguments you may want to keep.
|
||
7. *🚀Brockit!* will pick up from where it stopped, possibly running
|
||
`npm run update_patches`, staging all patches, and committing the under
|
||
*Conflict-resolved patches from Chromium [from] to [to].*
|
||
8. *Update patches from Chromium [from] to [to]* will be committed.
|
||
9. *Updated strings for Chromium [to]* will be committed.
|
||
10. `gnrt` will be rerun and the changes produced under `third_party/rust/` will
|
||
be committed.
|
||
|
||
Steps 3-7 may end up being skipped altogether if no failures take place, or in
|
||
part if resolution is possible without manual intervention.
|
||
|
||
The `--restart` flag can be used to start the process from scratch, discarding
|
||
everything committed in the last run.
|
||
|
||
```sh
|
||
tools/cr/brockit.py lift --to=135.0.7037.1 --from-ref=origin/master --restart
|
||
```
|
||
|
||
### `brockit.py regen`
|
||
Additionally, *🚀Brockit!* can be run with `regen`. This is useful to generate
|
||
the "Update patches", "Updated strings", and `gnrt` run commits on their own
|
||
when rebasing branches, regenerating these files as desired. If you want to run
|
||
these operations and yet, not commit any changes, you can use `--dry-run`.
|
||
|
||
```sh
|
||
tools/cr/brockit.py update-version-issue
|
||
````
|
||
|
||
### `brockit.py update-version-issue`
|
||
The `lift` command supports the use of `--with-github`, which either creates a
|
||
new GitHub issue or updates an existing one with the details of the run.
|
||
However it is also possible to run this task on standalone as well.
|
||
|
||
```sh
|
||
tools/cr/brockit.py update-version-issue
|
||
````
|
||
|
||
Different from a lift using `--with-github`, the command above will
|
||
also attempt to create a PR, if none exists, however it is necessary that the
|
||
current branch has an upstream branch set, as creating a PR involves having an
|
||
upstream branch.
|
||
|
||
### Infra mode
|
||
When running on infra, the `--infra-mode` flag should be provided. This will
|
||
suppress all status updates, and rather provide a keep-alive type of feedback
|
||
to make sure that the CI doesn't time out.
|
||
|
||
### `brockit.py rebase`
|
||
This command is provide for two purposes: to generally rebase the current
|
||
branch before doing a small lift, and to provide a standard way to recommit
|
||
all changes in the branch, which is useful when it is necessary to separate the
|
||
committing stage from the signing stage.
|
||
|
||
A simple rebase should look like this:
|
||
```sh
|
||
script/version_up.py rebase
|
||
```
|
||
|
||
To rebase a lift of a major bump, you can either set the upstream for your
|
||
local branch and rely on the defaults, or pass `--from-ref`/`--to-ref`
|
||
yourself.
|
||
|
||
```sh
|
||
script/version_up.py rebase --from-ref=@previous-major --to-ref=origin/master
|
||
```
|
||
|
||
To recommit everything while rebase you can do:
|
||
```sh
|
||
script/version_up.py rebase --from-ref=origin/master --recommit
|
||
```
|
||
|
||
For convenience, `--discard-regen-change` is provided to discard the types of
|
||
changes that are supposed to be regenerated during the next lift (e.g.
|
||
"Update patches").
|
||
|
||
This command uses `--interactive` under the hood, so it may require extra steps
|
||
from the user to complete.
|
||
|
||
Furthermore, there is also `--squash-minor-bumps`, which can be used to squash
|
||
away all minor bumps in a branch down to a single one. This is useful when
|
||
when running a cr branch, as it is common to have multiple minor daily bumps
|
||
in a branch.
|
||
|
||
### `brockit.py update-xcode-toolchain`
|
||
The hermetic Xcode toolchain is generated in CI for the macOS SDK that Chromium
|
||
pins in `build/config/mac/mac_sdk.gni`, and published to Brave's download bucket
|
||
alongside a sibling YAML index. This command repins
|
||
`build/mac/download_hermetic_xcode.py` to that published toolchain.
|
||
|
||
Pass `--to` with the Chromium reference whose pinned macOS SDK to repin against.
|
||
It accepts a concrete version or the same `@latest-*` labels as `lift --to`:
|
||
|
||
```sh
|
||
tools/cr/brockit.py update-xcode-toolchain --to=150.0.7850.1
|
||
```
|
||
|
||
`brockit` reads the SDK version/build from `mac_sdk.gni` at that ref, downloads
|
||
the matching toolchain index, and rewrites the archive hash and the SDK
|
||
version/build constants (and the provenance comment) in
|
||
`build/mac/download_hermetic_xcode.py`. It also lifts the
|
||
`MAC_MINIMUM_OS_VERSION` block (the minimum-OS gate and its comment) verbatim
|
||
from Chromium's `build/mac_toolchain.py` at the same ref.
|
||
|
||
Pass `--culprit=<hash>` to provide a specific culprit for the toolchain update.
|
||
If none is provided, `brockit` determines the culprit by looking for the last
|
||
commit that pinned the macOS SDK in `mac_sdk.gni` up to the `--to` ref.
|
||
|
||
### `brockit.py gen-rust-toolchain`
|
||
This command triggers the Rust/WASM toolchain Jenkins pipelines (one per
|
||
platform) for a given Chromium tag. It reads credentials from `~/.jenkins.json`
|
||
and kicks off a parameterized build for each pipeline.
|
||
|
||
```sh
|
||
tools/cr/brockit.py gen-rust-toolchain 150.0.7850.1
|
||
```
|
||
|
||
The `tag` argument also accepts the same `@latest-*` labels (e.g. `@latest-tag`,
|
||
etc).
|
||
|
||
Pass `--watch` to show a live-updating table of each pipeline's stage and
|
||
status until all finish. Pressing Ctrl+C stops watching, but the builds keep
|
||
running.
|
||
|
||
```sh
|
||
tools/cr/brockit.py gen-rust-toolchain @latest-canary --watch
|
||
```
|
||
|
||
### `brockit.py update-rust-wasm-toolchain`
|
||
This command repins the Rust/WASM toolchain objects in
|
||
`tools/cr/toolchains/install_extra_deps.py` to the latest published archives for
|
||
a given Chromium tag's Rust+Clang revision, and commits the change. The tag's
|
||
`tools/rust/update_rust.py` and `tools/clang/scripts/update.py` are read to
|
||
identify the toolchain to pin.
|
||
|
||
```sh
|
||
tools/cr/brockit.py update-rust-wasm-toolchain --to=150.0.7850.1
|
||
```
|
||
|
||
The `--to` expects a Chromium referecence, and this includes reference labels
|
||
(e.g. `@latest-tag`, etc).
|
||
|
||
Pass `--culprit=<hash>` to reference a specific Chromium commit in the commit
|
||
body. If none is provided, `brockit` uses the last Chromium commit that has
|
||
changed the versioning of the rust toolchain.
|
||
|
||
### `brockit.py reassign`
|
||
This command is used to change the authorship of a given commit in the branch.
|
||
It generates an empty commit with the message `reassign! {original_subject}`
|
||
where the author is the person running the command.
|
||
|
||
```sh
|
||
tools/cr/brockit.py reassign <commit_hash>
|
||
```
|
||
|
||
As a shortcut, `git cr commit --fixup=reassign:<commit_hash>` runs this exact
|
||
command. This mirrors git's native `--fixup=amend:` / `--fixup=reword:` modes,
|
||
adding a brave-core `reassign:` mode to the same family.
|
||
|
||
This commit acts similarly to a `fixup!` commit but works in tandem with
|
||
`brockit.py rebase`. When `brockit.py rebase` is run, the rebase process detects
|
||
the `reassign! ` commit. It moves this commit directly above the original target
|
||
commit in the interactive rebase sequence, changing the target commit's command
|
||
from `pick` to `squash`. (This only works when using `rebase` with
|
||
`--squash-minor-bumps`.)
|
||
|
||
During the squash operation, `brockit.py rebase` also automatically edits the
|
||
squashed commit message by removing the `reassign! ` line and preserving the
|
||
original commit's subject and body. Because the original commit is squashed into
|
||
the reassignment commit, the resulting commit adopts the reassignment commit's
|
||
authorship.
|
||
|
||
### `brockit.py drop`
|
||
This is the counterpart to `reassign`. It generates an empty commit with the
|
||
message `drop! {original_subject}`, marking the referenced change to be dropped
|
||
by `brockit.py rebase`.
|
||
|
||
```sh
|
||
tools/cr/brockit.py drop <commit_hash>
|
||
```
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import ast
|
||
from dataclasses import dataclass, field, replace
|
||
from datetime import datetime
|
||
import json
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
import pickle
|
||
import platform
|
||
import re
|
||
import requests
|
||
from rich.box import Box
|
||
from rich.live import Live
|
||
from rich.markdown import Markdown
|
||
from rich.padding import Padding
|
||
from rich.spinner import Spinner
|
||
from rich.table import Table
|
||
from rich.text import Text
|
||
import subprocess
|
||
import sys
|
||
import textwrap
|
||
import time
|
||
|
||
from git_status import GitStatus
|
||
from patchfile import Patchfile
|
||
import plaster
|
||
from plaster import PlasterFile, PlasterFileNeedsRegen
|
||
import rebase_v2
|
||
from rebase_v2 import DROP_COMMIT_MSG_PREFIX, REASSIGN_COMMIT_MSG_PREFIX
|
||
import repository
|
||
from repository import Repository
|
||
from terminal import (IncendiaryErrorHandler, Task as BaseTask, console,
|
||
is_verbose, terminal)
|
||
from toolchains import build_rust_toolchain, build_xcode_toolchain
|
||
import versioning
|
||
from versioning import Version
|
||
from vpython_utils import VPYTHON3_PATH
|
||
from vscode import VsCodeIpcConnection
|
||
|
||
# This file is updated whenever the version number is updated in package.json
|
||
PINSLIST_TIMESTAMP_FILE = (
|
||
'chromium_src/net/tools/transport_security_state_generator/'
|
||
'input_file_parsers.cc')
|
||
VERSION_UPGRADE_FILE = Path('.version_upgrade')
|
||
|
||
# Commit subject prefixes that identify brockit-managed upgrade commits.
|
||
# Patches whose most recent branch commit carries one of these subjects are
|
||
# NOT dev-cycle changes and therefore must NOT be turned into fixups.
|
||
_UPGRADE_COMMIT_WITH_PATCHES_PREFIXES = (
|
||
'Conflict-resolved patches from Chromium ',
|
||
'Apply-fixed 🩹 patches from Chromium ',
|
||
'Update patches from Chromium ',
|
||
)
|
||
|
||
# The link to a specific commit in the Chromium source code.
|
||
GOOGLESOURCE_COMMIT_LINK = f'{versioning.GOOGLESOURCE_LINK}' '/+/{commit}'
|
||
|
||
# A basic url to the rust toolchain that can be used to check for the toolchain
|
||
# availability for a given version.
|
||
RUST_TOOLCHAIN_URL = 'https://brave-build-deps-public.s3.brave.com/rust-toolchain-aux/linux-x64-rust-toolchain-{revision}.tar.xz'
|
||
|
||
# Brave-side script that pins the hermetic Xcode toolchain. Its
|
||
# `MAC_BINARIES_HASH` (archive SHA-256) and `MAC_SDK_OFFICIAL_VERSION` /
|
||
# `MAC_SDK_OFFICIAL_BUILD_VERSION` constants -- plus the human-readable
|
||
# provenance comment above them and the `MAC_MINIMUM_OS_VERSION` block mirrored
|
||
# from `CHROMIUM_MAC_TOOLCHAIN_PY` -- are what `update-xcode-toolchain` edits.
|
||
HERMETIC_XCODE_SCRIPT = 'build/mac/download_hermetic_xcode.py'
|
||
|
||
# Chromium-side file pinning the macOS SDK that we cross-reference to find the
|
||
# upstream commit that triggered this toolchain bump.
|
||
CHROMIUM_MAC_SDK_GNI = 'build/config/mac/mac_sdk.gni'
|
||
|
||
# Chromium-side installer whose `MAC_MINIMUM_OS_VERSION` block (the minimum-OS
|
||
# gate plus its explanatory comment) the hermetic downloader mirrors verbatim,
|
||
# so the OS gate tracks upstream as the toolchain moves.
|
||
CHROMIUM_MAC_TOOLCHAIN_PY = 'build/mac_toolchain.py'
|
||
|
||
# Google dash link used to check the latest version for a given channel
|
||
CHROMIUMDASH_LATEST_RELEASE = 'https://chromiumdash.appspot.com/fetch_releases?channel={channel}&platform={platform}&num=1'
|
||
|
||
# Configuration file holding the Jenkins credentials used to trigger toolchain
|
||
# pipelines. See `GenRustToolchain` for the expected schema.
|
||
JENKINS_CONFIG_FILE = Path.home() / '.jenkins.json'
|
||
|
||
# The Jenkins jobs that build and publish the Rust/WASM toolchain, one per
|
||
# platform. Each accepts a single `CHROMIUM_TAG` build parameter.
|
||
RUST_TOOLCHAIN_JOBS = (
|
||
'brave-browser-rust-toolchain-aux-build-linux-x64',
|
||
'brave-browser-rust-toolchain-aux-build-macos-arm64',
|
||
'brave-browser-rust-toolchain-aux-build-macos-x64',
|
||
'brave-browser-rust-toolchain-aux-build-windows-x64',
|
||
)
|
||
|
||
# How often `gen-rust-toolchain --watch` polls Jenkins for pipeline progress.
|
||
WATCH_POLL_INTERVAL_SECONDS = 8
|
||
|
||
# Maps Pipeline Stage View (`wfapi`) statuses onto the canonical states the
|
||
# watch table tracks. Statuses not listed (e.g. NOT_EXECUTED) leave the state
|
||
# unchanged.
|
||
_WFAPI_STATUS_TO_STATE = {
|
||
'IN_PROGRESS': 'RUNNING',
|
||
'PAUSED_PENDING_INPUT': 'RUNNING',
|
||
'SUCCESS': 'SUCCESS',
|
||
'FAILED': 'FAILURE',
|
||
'ABORTED': 'ABORTED',
|
||
'UNSTABLE': 'UNSTABLE',
|
||
}
|
||
|
||
# States meaning a pipeline has finished and no longer needs polling.
|
||
_TERMINAL_WATCH_STATES = frozenset(
|
||
{'SUCCESS', 'FAILURE', 'ABORTED', 'UNSTABLE', 'CANCELLED'})
|
||
|
||
# Minimalist box for the `--watch` table: column dividers plus a single
|
||
# header rule, no outer frame (paired with `show_edge=False`). Each of the
|
||
# eight lines is four chars -- left edge, cell fill, column divider, right
|
||
# edge -- in the order Rich's `Box` expects (top, head, head-rule, mid, row,
|
||
# foot-rule, foot, bottom). Edge/foot lines are placeholders never drawn once
|
||
# the edge is hidden and the table has no footer.
|
||
_WATCH_TABLE_BOX = Box(' \n' # top
|
||
' │ \n' # head
|
||
' ── \n' # head rule
|
||
' │ \n' # mid
|
||
' │ \n' # row
|
||
' ── \n' # foot rule
|
||
' │ \n' # foot
|
||
' \n') # bottom
|
||
|
||
# Icon + rich style for each watch state, used to render the State column.
|
||
_WATCH_STATE_STYLE = {
|
||
'QUEUED': ('⏳', 'dim'),
|
||
'RUNNING': ('🔧', 'cyan'),
|
||
'SUCCESS': ('✔️', 'green'),
|
||
'FAILURE': ('🚨', 'bold red'),
|
||
'ABORTED': ('🛑', 'yellow'),
|
||
'CANCELLED': ('🚫', 'yellow'),
|
||
'UNSTABLE': ('⚠️', 'yellow'),
|
||
'UNKNOWN': ('🤔', 'dim'),
|
||
}
|
||
|
||
# A decorator to be shown for messages that the user should address before
|
||
# continuing.
|
||
ACTION_NEEDED_DECORATOR = '[bold yellow](action needed)[/]'
|
||
|
||
# The text for the body used on a GitHub issue for a version bump.
|
||
MINOR_VERSION_BUMP_ISSUE_TEMPLATE = """### Minor Chromium bump
|
||
|
||
{googlesource_log_link}
|
||
|
||
### QA tests
|
||
|
||
- Check branding items
|
||
- Check for version bump
|
||
|
||
### Minor Chromium bump
|
||
|
||
- No specific code changes in Brave (only line number changes in patches)
|
||
"""
|
||
|
||
|
||
def _get_current_branch_upstream_name() -> str | None:
|
||
"""Retrieves the name of the current branch's upstream.
|
||
"""
|
||
try:
|
||
return repository.brave.run_git('rev-parse', '--abbrev-ref',
|
||
'--symbolic-full-name', '@{upstream}')
|
||
except subprocess.CalledProcessError:
|
||
return None
|
||
|
||
|
||
def _ensure_chromium_tags(*refs: Version | str) -> None:
|
||
"""Ensures each Chromium reference resolves locally, fetching any that are
|
||
missing as tags from Googlesource.
|
||
|
||
Tags for freshly released Chromium versions are frequently absent from a
|
||
local checkout, so any operation that resolves a version to a commit (e.g.
|
||
the culprit pickaxes, `git show <tag>:<file>`) must fetch the tag first.
|
||
"""
|
||
missing = [
|
||
ref for ref in refs
|
||
if not repository.chromium.is_valid_git_reference(ref)
|
||
]
|
||
if not missing:
|
||
return
|
||
|
||
fetch_args = ['fetch', versioning.GOOGLESOURCE_LINK]
|
||
for ref in missing:
|
||
fetch_args += ['tag', ref]
|
||
repository.chromium.run_git(*fetch_args)
|
||
|
||
|
||
def _update_pinslist_timestamp() -> str:
|
||
"""Updates the pinslist timestamp in the input_file_parsers.cc file for the
|
||
version commit.
|
||
|
||
Returns:
|
||
The readable timestamp of the update into the file.
|
||
"""
|
||
content = repository.brave.read_file(PINSLIST_TIMESTAMP_FILE)
|
||
|
||
pattern = r"# Last updated:.*\nPinsListTimestamp\n[0-9]{10}\n"
|
||
match = re.search(pattern, content, flags=re.DOTALL)
|
||
if not match:
|
||
raise ValueError(
|
||
'Expected pattern for PinsListTimestamp block not found. '
|
||
'Aborting.')
|
||
|
||
# Update the timestamp
|
||
timestamp = int(datetime.now().timestamp())
|
||
readable_timestamp = datetime.fromtimestamp(timestamp).strftime(
|
||
'%a %b %d %H:%M:%S %Y')
|
||
updated_content = re.sub(
|
||
pattern,
|
||
(f'# Last updated: {readable_timestamp}\nPinsListTimestamp\n'
|
||
f'{timestamp}\n'),
|
||
content,
|
||
flags=re.DOTALL,
|
||
)
|
||
|
||
# Write back to the file
|
||
(repository.brave.root / PINSLIST_TIMESTAMP_FILE).write_text(
|
||
updated_content, encoding='utf-8', newline='')
|
||
|
||
updated = repository.brave.run_git('diff', PINSLIST_TIMESTAMP_FILE)
|
||
if updated == '':
|
||
raise ValueError('Pinslist timestamp failed to update.')
|
||
|
||
return readable_timestamp
|
||
|
||
|
||
def _is_gh_cli_logged_in():
|
||
"""Checks if the GitHub CLI is logged in.
|
||
"""
|
||
try:
|
||
result = terminal.run(['gh', 'auth', 'status']).stdout.strip()
|
||
if 'Logged in to github.com account' in result:
|
||
return True
|
||
except subprocess.CalledProcessError:
|
||
pass
|
||
|
||
return False
|
||
|
||
|
||
def _get_apply_patches_list() -> dict[Repository, list[Patchfile]] | None:
|
||
"""Retrieves the list of patches to be applied by running
|
||
`npm run apply_patches`, grouped by repository.
|
||
"""
|
||
|
||
try:
|
||
terminal.run_npm_command('apply_patches', '--',
|
||
'--print-patch-failures-in-json')
|
||
except subprocess.CalledProcessError as e:
|
||
# This is a regex to match the json output of the patches that failed
|
||
# to apply.
|
||
match = re.search(r'\[\s*{.*?}\s*\]', e.stdout, re.DOTALL)
|
||
if match is None:
|
||
return None
|
||
entries = json.loads(match.group(0))
|
||
patch_paths = [Path(entry['patchPath']) for entry in entries]
|
||
patch_files: dict[Repository, list[Patchfile]] = {}
|
||
for patch_path in patch_paths:
|
||
patchfile = Patchfile(path=patch_path)
|
||
patch_files.setdefault(patchfile.repository, []).append(patchfile)
|
||
return patch_files
|
||
|
||
return None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ApplyPatchesRecord:
|
||
"""A class to hold the continuation data for patches.
|
||
"""
|
||
|
||
# A dictionary of all patches with attempted `--3way`, grouped by
|
||
# repository.
|
||
patch_files: dict[Repository,
|
||
list[Patchfile]] = field(default_factory=dict)
|
||
|
||
# A list of patches that cannot be applied due to their source file being
|
||
# deleted.
|
||
patches_to_deleted_files: list[Patchfile] = field(default_factory=list)
|
||
|
||
# A list of files that require manual conflict resolution before continuing.
|
||
files_with_conflicts: list[Path] = field(default_factory=list)
|
||
|
||
# A list of patches that fail entirely when running apply with `--3way`.
|
||
broken_patches: list[Patchfile] = field(default_factory=list)
|
||
|
||
# A list of patches where the apply failure was resolved by plaster.
|
||
plaster_fixed_patches: list[Path] = field(default_factory=list)
|
||
|
||
# A list of patches where the apply failure could not be resolved by
|
||
# plaster.
|
||
plaster_broken_patches: list[Patchfile] = field(default_factory=list)
|
||
|
||
def all_conflict_resolved_patches(self) -> list[Patchfile]:
|
||
"""Returns a flattened list of all conflict-resolved candidates."""
|
||
return [p for patches in self.patch_files.values() for p in patches]
|
||
|
||
def requires_conflict_resolution(self):
|
||
"""Checks if there are any patches that require manual conflict
|
||
resolution.
|
||
|
||
This function is used to determine it is necessary to stop the process
|
||
to address any potential patch changes.
|
||
"""
|
||
return (self.files_with_conflicts or self.patches_to_deleted_files
|
||
or self.broken_patches or self.plaster_broken_patches)
|
||
|
||
def check_broken_plasters_fixed(self) -> None:
|
||
"""Verifies all previously-broken plasters are now resolved.
|
||
|
||
For each entry in plaster_broken_patches:
|
||
- If the plaster file still exists, runs a dry-run check to confirm
|
||
the plaster output is up to date.
|
||
- If the plaster file is gone, verifies the corresponding .patch
|
||
file has also been removed.
|
||
|
||
Raises InvalidInputException if any broken plaster is not yet resolved.
|
||
"""
|
||
for patchfile in self.plaster_broken_patches:
|
||
if patchfile.plaster.exists():
|
||
try:
|
||
PlasterFile(patchfile.plaster).apply(dry_run=True)
|
||
except PlasterFileNeedsRegen as e:
|
||
raise InvalidInputException(
|
||
'Plaster file has not been fixed and re-applied: '
|
||
f'{patchfile.plaster}') from e
|
||
else:
|
||
if (repository.brave.root / patchfile.path).exists():
|
||
raise InvalidInputException(
|
||
'Plaster file was deleted but patch still exists: '
|
||
f'{patchfile.path}')
|
||
|
||
def stage_all_patches(self):
|
||
"""Stages all patches that were applied, so they can be committed as
|
||
conflict-resolved patches.
|
||
|
||
Args:
|
||
ignore_deleted_files:
|
||
If set to True, deleted files will be ignored, and not staged.
|
||
"""
|
||
for _, patches in self.patch_files.items():
|
||
for patchfile in patches:
|
||
if not (repository.brave.root / patchfile.path).exists():
|
||
# Skip deleted files.
|
||
continue
|
||
|
||
repository.brave.run_git('add', patchfile.path)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ContinuationFile:
|
||
"""A class to hold the continuation data for the upgrade process.
|
||
"""
|
||
|
||
# The target version that brockit is aiming to upgrade brave to.
|
||
target_version: Version
|
||
|
||
# The version that was in the branch when the upgrade started (which can be
|
||
# different from the base version).
|
||
working_version: Version
|
||
|
||
# The base version for the upgrade process. This is saved as a reference
|
||
# like @previous cannot be relied on once we committed a change, moving the
|
||
# previous branch ahead.
|
||
base_version: Version
|
||
|
||
# This flag indicates that the prerun adivisories have been shown to the
|
||
# user.
|
||
has_shown_advisory: bool = False
|
||
|
||
# The continuation data for the patches.
|
||
apply_record: ApplyPatchesRecord | None = field(default=None)
|
||
|
||
@staticmethod
|
||
def load(target_version: Version,
|
||
working_version: Version | None = None,
|
||
check: bool = True) -> ContinuationFile | None:
|
||
"""Loads the continuation file.
|
||
|
||
This function loads the continuation file, and returns the instance of
|
||
the continuation file, or None if the file does not exist.
|
||
|
||
Args:
|
||
target_version:
|
||
The target version for the upgrade process. Used to validate
|
||
the continuation file being loaded.
|
||
working_version:
|
||
The working version in the branch, used to validate the
|
||
continuation file being loaded.
|
||
check:
|
||
If the function should raise an error if the file does not
|
||
exist.
|
||
"""
|
||
if not VERSION_UPGRADE_FILE.exists():
|
||
if check:
|
||
raise FileNotFoundError(
|
||
f'File {VERSION_UPGRADE_FILE} does not exist')
|
||
return None
|
||
|
||
continuation = pickle.loads(VERSION_UPGRADE_FILE.read_bytes())
|
||
|
||
if (continuation.target_version != target_version
|
||
or (working_version is not None
|
||
and continuation.working_version != working_version)):
|
||
if not check:
|
||
return None
|
||
|
||
# This validation is in place for something that shouldn't happen.
|
||
# If this is being hit, it means some wrong continuation file is in
|
||
# the tree, and the process should be started all over.
|
||
raise TypeError(
|
||
'Trying to load a continuation file from another run. Target '
|
||
f'verison:{continuation.target_version}, Working '
|
||
f'version:{continuation.working_version}')
|
||
|
||
return continuation
|
||
|
||
def save(self):
|
||
"""Saves the continuation file.
|
||
"""
|
||
VERSION_UPGRADE_FILE.write_bytes(pickle.dumps(self))
|
||
|
||
@staticmethod
|
||
def clear():
|
||
logging.debug('Clearing the continuation file.')
|
||
try:
|
||
Path(VERSION_UPGRADE_FILE).unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
|
||
class InvalidInputException(Exception):
|
||
"""Invalid input exceptions to be handled graciously and terminate."""
|
||
|
||
def __init__(self, message):
|
||
super().__init__(message)
|
||
logging.error(message)
|
||
|
||
|
||
class BadOutcomeException(Exception):
|
||
"""A failure along the way that results on task termination."""
|
||
|
||
def __init__(self, message):
|
||
super().__init__(message)
|
||
logging.warning(message)
|
||
|
||
|
||
class ActionNeededException(Exception):
|
||
"""An expected failure along the way that results on task termination."""
|
||
|
||
def __init__(self, message: str):
|
||
super().__init__(message)
|
||
console.log(message)
|
||
|
||
|
||
# Banners framing every Brockit task run, shared with `run_watching`.
|
||
_BROCKIT_START_BANNER = '[italic]🚀 Brockit!'
|
||
_BROCKIT_END_BANNER = '[bold]💥 Done!'
|
||
|
||
|
||
class Task(BaseTask):
|
||
"""Base class for all Brockit tasks.
|
||
|
||
Adds the Brockit banners around the generic `terminal.Task` run; the actual
|
||
behaviour (status spinner, `execute`/`status_message` contract) lives in the
|
||
base class. Subclasses provide `status_message`.
|
||
"""
|
||
|
||
# Abstract base: concrete subclasses implement `status_message`.
|
||
# pylint: disable=abstract-method
|
||
|
||
start_banner = _BROCKIT_START_BANNER
|
||
end_banner = _BROCKIT_END_BANNER
|
||
|
||
|
||
class Versioned(Task):
|
||
""" Base class for all versioned tasks.
|
||
|
||
Versioned tasks are tasks that have the concept of a base version and a
|
||
target version.
|
||
"""
|
||
|
||
def __init__(self,
|
||
base_version: Version,
|
||
target_version: Version | None = None):
|
||
# The version in `package.json` found in that upstream branch.
|
||
self.base_version = base_version
|
||
|
||
# The target of a given upgrade.
|
||
self.target_version = target_version
|
||
if self.target_version is None:
|
||
# When not provided we default for whatever is in the current
|
||
# branch because that's is possibly what the target version is for
|
||
# maintainance tasks.
|
||
self.target_version = Version.from_git('HEAD')
|
||
|
||
if self.target_version <= self.base_version:
|
||
raise InvalidInputException(
|
||
f'Target version {self.target_version} is not higher than base '
|
||
f'version {self.base_version}.')
|
||
|
||
def is_major(self) -> bool:
|
||
"""Returns True if this is a major version upgrade."""
|
||
return self.target_version.major > self.base_version.major
|
||
|
||
def _save_updated_patches(self):
|
||
"""Creates the updated patches change
|
||
|
||
This function creates the third commit in the order of the update, saving
|
||
all patches that might have been changed or deleted. Untracked patches are
|
||
excluded from addition at this stage.
|
||
"""
|
||
repository.brave.run_git('add', '-u', '*.patch')
|
||
|
||
repository.brave.git_commit(
|
||
f'Update patches from Chromium {self.base_version} '
|
||
f'to Chromium {self.target_version}.')
|
||
|
||
def _save_rebased_l10n(self):
|
||
"""Creates string rebase change
|
||
|
||
This function stages, and commits, all changed, updated, or deleted files
|
||
resulting from running npm run chromium_rebase_l10n.
|
||
"""
|
||
repository.brave.run_git('add', '*.grd', '*.grdp', '*.xtb')
|
||
repository.brave.git_commit(
|
||
f'Updated strings for Chromium {self.target_version}.')
|
||
|
||
def _save_gnrt_rerun(self, dry_run=False):
|
||
"""Creates the updated patches change
|
||
|
||
This function creates the third commit in the order of the update, saving
|
||
all patches that might have been changed or deleted. Untracked patches are
|
||
excluded from addition at this stage.
|
||
"""
|
||
terminal.run([VPYTHON3_PATH, './tools/crates/run_gnrt.py', 'vendor'],
|
||
cwd=repository.chromium.root)
|
||
terminal.run([VPYTHON3_PATH, './tools/crates/run_gnrt.py', 'gen'],
|
||
cwd=repository.chromium.root)
|
||
|
||
if dry_run:
|
||
return
|
||
|
||
repository.brave.run_git('add', '-u', 'third_party/rust/')
|
||
repository.brave.git_commit(
|
||
f'`gnrt` run for Chromium {self.target_version}.')
|
||
|
||
def status_message(self) -> str:
|
||
raise NotImplementedError
|
||
|
||
|
||
class Regen(Versioned):
|
||
"""Regenerates patches and strings for the current branch.
|
||
|
||
This task is used for cases where the user wants to regenerate patches and
|
||
strings. The purpose is to produce `Update patches` and `Updated strings`
|
||
where approrpriate.
|
||
"""
|
||
|
||
def status_message(self):
|
||
return "Updating patches and strings..."
|
||
|
||
def execute(self, dry_run=False) -> bool:
|
||
terminal.log_task(
|
||
f'Processing changes for Chromium {self.base_version} '
|
||
f'to Chromium {self.target_version}.')
|
||
|
||
terminal.run_npm_command('init')
|
||
|
||
terminal.run_npm_command('update_patches')
|
||
if not dry_run:
|
||
self._save_updated_patches()
|
||
|
||
terminal.run_npm_command('chromium_rebase_l10n')
|
||
if not dry_run:
|
||
self._save_rebased_l10n()
|
||
|
||
self._save_gnrt_rerun(dry_run=dry_run)
|
||
|
||
|
||
class GitHubIssue(Versioned):
|
||
"""Creates a GitHub issue for the upgrade.
|
||
|
||
This class offers ways to create or update the github issue for the
|
||
upgrade. Also, as this is its own task, it can be called on its own for
|
||
maintainance purposes.
|
||
"""
|
||
|
||
def status_message(self):
|
||
return "Creating/Updating GitHub issue for upgrade..."
|
||
|
||
def compose_issue_title(self):
|
||
"""Generates the title for the upgrade issue.
|
||
|
||
This function generates the title for the upgrade issue, based on the
|
||
base and target version. The title is generated in a way that it can be
|
||
used for both major and minor upgrades.
|
||
|
||
This title is the same used for the push request.
|
||
"""
|
||
title = 'Upgrade from Chromium {previous} to Chromium {to}'
|
||
if self.is_major():
|
||
# For major updates, the issue description doesn't have a precise
|
||
# version number.
|
||
title = title.format(previous=str(self.base_version.major),
|
||
to=str(self.target_version.major))
|
||
else:
|
||
title = title.format(previous=str(self.base_version),
|
||
to=str(self.target_version))
|
||
return title
|
||
|
||
def lookup_issue(self, title: str) -> list | None:
|
||
"""Looks up the issue for the upgrade.
|
||
|
||
This function checks if there's already an issue with the title
|
||
provided, and returns its details.
|
||
"""
|
||
results = json.loads(
|
||
terminal.run([
|
||
'gh', 'issue', 'list', '--repo', 'brave/brave-browser',
|
||
'--search', title, '--state', 'open', '--json',
|
||
'number,title,url,body'
|
||
]).stdout.strip())
|
||
return next((entry for entry in results if entry['title'] == title),
|
||
None)
|
||
|
||
def create_push_request(self, issue_url: str):
|
||
"""Creates a push request for the current branch.
|
||
|
||
This function creates a push request for the upgrade.
|
||
"""
|
||
current_branch = repository.brave.current_branch()
|
||
if current_branch == 'HEAD':
|
||
raise InvalidInputException(
|
||
'Cannot create a push request: Not in a branch')
|
||
|
||
# It is assumed that the upstream branch is the base branch for the PR.
|
||
upstream_branch = _get_current_branch_upstream_name()
|
||
if upstream_branch is None:
|
||
raise InvalidInputException(
|
||
'Cannot create a push request: Not in a branch with an upstream'
|
||
)
|
||
if '/' in upstream_branch:
|
||
# removing the remote portion.
|
||
upstream_branch = upstream_branch.split("/", 1)[-1]
|
||
|
||
results = json.loads(
|
||
terminal.run([
|
||
'gh', 'pr', 'list', '--head', current_branch,
|
||
'--json=number,url'
|
||
]).stdout.strip())
|
||
if results:
|
||
console.log(
|
||
f'The push request for {current_branch} is: {results[0]["url"]}'
|
||
)
|
||
return
|
||
|
||
# That's a naive way to think about this, but in general the uplift
|
||
# branches are upstreamed to 1.77.x, 1.78.x, etc.
|
||
is_uplift = (versioning.get_uplift_branch_name_from_package() ==
|
||
upstream_branch)
|
||
tag = f'[{upstream_branch}] ' if is_uplift else ''
|
||
cmd = [
|
||
'gh', 'pr', 'create', '--base', upstream_branch, '--head',
|
||
current_branch, '--title', f'{tag}{self.compose_issue_title()}',
|
||
'--body', f'Resolves {issue_url}', '--label',
|
||
'"CI/run-audit-deps"', '--label', '"CI/run-network-audit"',
|
||
'--assignee', 'cdesouza-chromium'
|
||
]
|
||
# Emerick and Alexey take care of even-numbered major versions, while
|
||
# Max and Sam do the odd ones.
|
||
if self.target_version.major % 2 == 0:
|
||
cmd += ['--assignee', 'emerick', '--assignee', 'AlexeyBarabash']
|
||
else:
|
||
cmd += ['--assignee', 'mkarolin', '--assignee', 'samartnik']
|
||
|
||
if self.is_major() or upstream_branch == 'master':
|
||
# It is not common for upstream test to be run on uplifts of minor
|
||
# upgrades.
|
||
cmd += ['--label', '"CI/run-upstream-tests"']
|
||
|
||
if self.is_major():
|
||
cmd += ['--label', '"CI/storybook-url"']
|
||
|
||
# Always create PR for major version upgrades as draft
|
||
cmd.append('--draft')
|
||
|
||
try:
|
||
pr_url = terminal.run(cmd).stdout.strip()
|
||
terminal.log_task(f'GitHub PR created for this bump: {pr_url}')
|
||
except subprocess.CalledProcessError as e:
|
||
raise BadOutcomeException(
|
||
f'Failed to create PR for {current_branch}: {e.stderr.strip()}'
|
||
) from e
|
||
|
||
if is_uplift:
|
||
# Only uplift branches set milestones.
|
||
results = json.loads(
|
||
terminal.run([
|
||
'gh', 'api', 'repos/brave/brave-core/milestones', '--jq',
|
||
'[.[] | {number, title}]'
|
||
]).stdout)
|
||
if not results:
|
||
raise BadOutcomeException(
|
||
'No milestones returned for brave-core')
|
||
|
||
milestone = next(
|
||
(entry["number"] for entry in results
|
||
if entry["title"].startswith(f'{upstream_branch} - ')), None)
|
||
if milestone is None:
|
||
raise BadOutcomeException(
|
||
f'Failed to find milestone for branch {upstream_branch}')
|
||
|
||
pr_number = pr_url.rsplit('/', 1)[-1]
|
||
terminal.run([
|
||
'gh', 'api', '-X', 'PATCH',
|
||
f'repos/brave/brave-core/issues/{pr_number}', '-F',
|
||
f'milestone={milestone}'
|
||
])
|
||
|
||
def create_or_update_version_issue(self, with_pr: bool):
|
||
"""Creates a github issue for the upgrade.
|
||
|
||
This function creates/updates the upgrade github issue.
|
||
"""
|
||
link = self.target_version.get_googlesource_diff_link(
|
||
from_version=str(self.base_version))
|
||
|
||
title = self.compose_issue_title()
|
||
issue = self.lookup_issue(title)
|
||
if issue is not None:
|
||
pattern = (
|
||
r"https://chromium\.googlesource\.com/chromium/src/\+log/[^\s]+"
|
||
)
|
||
body = re.sub(pattern, link, issue['body'])
|
||
if body == issue['body']:
|
||
console.log(
|
||
f'A Github issue with the title "{title}" is already '
|
||
f'created and up-to-date. {str(issue["url"])}')
|
||
else:
|
||
terminal.run([
|
||
'gh', 'issue', 'edit',
|
||
str(issue['number']), '--repo', 'brave/brave-browser',
|
||
'--body', f'{body}'
|
||
])
|
||
terminal.log_task(f'GitHub issue updated {str(issue["url"])}.')
|
||
if with_pr:
|
||
self.create_push_request(issue["url"])
|
||
return
|
||
|
||
body = MINOR_VERSION_BUMP_ISSUE_TEMPLATE.format(
|
||
googlesource_log_link=link)
|
||
issue_url = terminal.run([
|
||
'gh', 'issue', 'create', '--repo', 'brave/brave-browser',
|
||
'--title', title, '--body', f'{body}', '--label',
|
||
'"Chromium/upgrade minor"', '--label', '"OS/Android"', '--label',
|
||
'"OS/Desktop"', '--label', '"QA/Test-Plan-Specified"', '--label',
|
||
'"QA/Yes"', '--label', '"release-notes/include"', '--assignee',
|
||
'emerick', '--assignee', 'mkarolin', '--assignee',
|
||
'cdesouza-chromium'
|
||
]).stdout.strip()
|
||
terminal.log_task(f'GitHub Issue created for this bump: {issue_url}')
|
||
|
||
if with_pr:
|
||
self.create_push_request(issue_url)
|
||
|
||
def execute(self):
|
||
if not _is_gh_cli_logged_in():
|
||
raise BadOutcomeException('GitHub CLI is not logged in.')
|
||
|
||
self.create_or_update_version_issue(with_pr=True)
|
||
|
||
|
||
class ReUpgrade(Task):
|
||
"""Restarts the upgrade process.
|
||
|
||
This class is called when `--restart` is provided and it is useful when one
|
||
wants to restart fresh. It will reset the repository to where it was before
|
||
the update started.
|
||
"""
|
||
|
||
def __init__(self, target_version: Version):
|
||
# The target version passed to --to, which is used to make sure the
|
||
# restart is being done in the right place.
|
||
self.target_version = target_version
|
||
|
||
def status_message(self):
|
||
return "Restarting the upgrade process..."
|
||
|
||
def execute(self):
|
||
"""Restarts the upgrade process.
|
||
|
||
This function will reset the repository to the state it was before the
|
||
upgrade started. It will also clear the continuation file.
|
||
"""
|
||
working_version = Version.from_git('HEAD')
|
||
if self.target_version != working_version:
|
||
raise InvalidInputException(
|
||
f'Running with `--restart` but the target version does not '
|
||
f'match the current version. {self.target_version} '
|
||
f'vs {working_version}')
|
||
|
||
starting_change = repository.brave.last_changed(
|
||
PINSLIST_TIMESTAMP_FILE)
|
||
commit_message = repository.brave.get_commit_short_description(
|
||
starting_change)
|
||
if not commit_message.startswith(
|
||
'Update from Chromium ') or not commit_message.endswith(
|
||
f' to Chromium {self.target_version}.'):
|
||
raise InvalidInputException(
|
||
f'Running with `--restart` but the last change does not match '
|
||
f'the arguments provided. {starting_change} {commit_message}')
|
||
|
||
console.log('Discarding the following changes:')
|
||
console.log(
|
||
Padding(
|
||
'[dim]%s' % repository.brave.run_git(
|
||
'log', '--pretty=%h %s', f'HEAD...{starting_change}~1'),
|
||
(0, 4)))
|
||
|
||
ContinuationFile.clear()
|
||
repository.brave.run_git('reset', '--hard', f'{starting_change}~1')
|
||
|
||
|
||
class Upgrade(Versioned):
|
||
"""The upgrade process, holding the data related to the upgrade.
|
||
|
||
This class produces an object that is reponsible for keeping track of the
|
||
upgrade process step-by-step. It acquires all the common data necessary for
|
||
its completion.
|
||
"""
|
||
|
||
def __init__(self,
|
||
target_version: Version,
|
||
is_continuation: bool,
|
||
base_version: Version | None = None):
|
||
if ((base_version is None and not is_continuation)
|
||
or (base_version is not None and is_continuation)):
|
||
# either it is a new upgrade, and a base version is provided, or it
|
||
# is a continuation and no base version is provided as it gets read
|
||
# from disk.
|
||
raise NotImplementedError()
|
||
|
||
# Indicates that the upgrade is a continuation from a previous run.
|
||
self.is_continuation = is_continuation
|
||
|
||
# The last version the branch was in, that the update is being started
|
||
# from
|
||
self.working_version = None
|
||
if self.is_continuation:
|
||
version_on_head = Version.from_git('HEAD')
|
||
if target_version != version_on_head:
|
||
raise InvalidInputException(
|
||
f'Running with `--continue` on a branch with a different '
|
||
f'version what the target should be. {target_version} '
|
||
f'vs {version_on_head}')
|
||
|
||
# Loads the working version from the continuation file, because the
|
||
# current branch has already updated the working version to the
|
||
# target version.
|
||
try:
|
||
continuation = ContinuationFile.load(
|
||
target_version=target_version)
|
||
except FileNotFoundError as e:
|
||
raise InvalidInputException(
|
||
f'{VERSION_UPGRADE_FILE} continuation file does not exist. '
|
||
'(Are you sure you meant to pass [bold cyan]--continue[/]?)'
|
||
) from e
|
||
|
||
self.working_version = continuation.working_version
|
||
base_version = continuation.base_version
|
||
else:
|
||
self.working_version = Version.from_git('HEAD')
|
||
|
||
# The version currently set in the VERSION file.
|
||
self.chromium_src_version = versioning.read_chromium_version_file()
|
||
|
||
super().__init__(base_version, target_version)
|
||
|
||
def status_message(self):
|
||
return "Upgrading Chromium base version"
|
||
|
||
def apply_patches_3way(self) -> ApplyPatchesRecord:
|
||
"""Applies patches that have failed using the --3way option to allow for
|
||
manual conflict resolution.
|
||
|
||
This method will apply the patches and reset the state of applied
|
||
patches. Additionally, it will also produce a list of the files that
|
||
are waiting for conflict resolution.
|
||
|
||
A list of the patches applied will be produced as well.
|
||
|
||
When running brockit in a vscode terminal, this method will open any
|
||
files that need attention in the editor session.
|
||
"""
|
||
# This is a flat list of patches that cannot be applied due to their
|
||
# source file being deleted.
|
||
patches_to_deleted_files: list[Patchfile] = []
|
||
|
||
# A list of files that require manual conflict resolution before
|
||
# continuing.
|
||
files_with_conflicts: list[Path] = []
|
||
|
||
# These are patches that fail entirely when running apply with `--3way`.
|
||
broken_patches: list[Patchfile] = []
|
||
|
||
# Patches where the apply failure was resolved by plaster.
|
||
plaster_fixed_patches: list[Path] = []
|
||
|
||
# Patches where the apply failure could not be resolved by plaster.
|
||
plaster_broken_patches: list[Patchfile] = []
|
||
|
||
# A list of all patchfiles that failed to apply, grouped by repository.
|
||
patch_files = _get_apply_patches_list()
|
||
if patch_files is None:
|
||
raise ValueError('Apply patches had no failed patches.')
|
||
|
||
if patch_files:
|
||
terminal.log_task(
|
||
'[bold]Reapplying patch files with --3way:\n[/]%s' %
|
||
'\n'.join(f' * {patchfile.path}'
|
||
f'{" 🩹" if patchfile.has_plaster else ""}'
|
||
for patch_list in patch_files.values()
|
||
for patchfile in patch_list))
|
||
|
||
vscode_files: list[Path] = []
|
||
for repo, patches in patch_files.items():
|
||
for patchfile in patches:
|
||
status: Patchfile.ApplyStatus = patchfile.apply()
|
||
|
||
if status == Patchfile.ApplyStatus.CONFLICT:
|
||
files_with_conflicts.append(patchfile.source_from_brave())
|
||
elif status == Patchfile.ApplyStatus.BROKEN:
|
||
broken_patches.append(patchfile)
|
||
elif status == Patchfile.ApplyStatus.DELETED:
|
||
patches_to_deleted_files.append(patchfile)
|
||
elif status == Patchfile.ApplyStatus.PLASTER_FIXED:
|
||
plaster_fixed_patches.append(patchfile.path)
|
||
elif status == Patchfile.ApplyStatus.PLASTER_BROKEN:
|
||
plaster_broken_patches.append(patchfile)
|
||
|
||
for repo, patches in patch_files.items():
|
||
# Resetting any staged states from apply patches as that can cause
|
||
# issues when generating patches.
|
||
repo.unstage_all_changes()
|
||
|
||
if patches_to_deleted_files:
|
||
# The goal in this this section is print a report for listing every
|
||
# patch that cannot apply anymore because the source file is gone,
|
||
# fetching from git the commit and the reason why exactly the file
|
||
# is not there anymore (e.g. renamed, deleted).
|
||
|
||
terminal.log_task('[bold]Files that cannot be patched anymore '
|
||
f'{ACTION_NEEDED_DECORATOR}:[/]')
|
||
|
||
# This set will hold the information about the deleted patches
|
||
# in a way that they can be grouped around the chang that caused
|
||
# their removal.
|
||
deletion_report: dict[str, dict[Patchfile,
|
||
Patchfile.SourceStatus]] = {}
|
||
|
||
for patchfile in patches_to_deleted_files:
|
||
# Finding the culptrit commit hash.
|
||
commit = patchfile.get_last_commit_for_source()
|
||
deletion_report.setdefault(
|
||
commit,
|
||
{})[patchfile] = patchfile.get_source_removal_status(
|
||
commit)
|
||
|
||
for commit, patches in deletion_report.items():
|
||
for patchfile, status in patches.items():
|
||
if status.status == 'D':
|
||
console.log(
|
||
Padding(
|
||
f'✘ {patchfile.source} [red bold](deleted)',
|
||
(0, 4)))
|
||
vscode_files.append(patchfile.path)
|
||
elif status.status == 'R':
|
||
renamed_to = Path(patchfile.repository.from_brave() /
|
||
status.renamed_to)
|
||
console.log(
|
||
Padding(
|
||
f'✘ {patchfile.source_from_brave()}\n '
|
||
f'([yellow bold]renamed to[/] {renamed_to})',
|
||
(0, 4)))
|
||
vscode_files += [patchfile.path, renamed_to]
|
||
|
||
# Printing the commmit message for the grouped changes.
|
||
console.log(
|
||
Padding(
|
||
f'{next(iter(patches.items()))[1].commit_details}\n',
|
||
(0, 8),
|
||
style="dim"))
|
||
|
||
if broken_patches:
|
||
terminal.log_task(
|
||
'[bold]Broken patches that fail to apply entirely '
|
||
f'{ACTION_NEEDED_DECORATOR}:[/]')
|
||
|
||
for patchfile in broken_patches:
|
||
source = patchfile.source_from_brave()
|
||
console.log(Padding(f'✘ {patchfile.path} ➜ {source}', (0, 4)))
|
||
vscode_files += [patchfile.path, source]
|
||
|
||
if plaster_broken_patches:
|
||
terminal.log_task('[bold]Plaster failed to fix patches '
|
||
f'{ACTION_NEEDED_DECORATOR}:[/]')
|
||
|
||
for patchfile in plaster_broken_patches:
|
||
source = patchfile.source_from_brave()
|
||
console.log(
|
||
Padding(f'✘ {patchfile.plaster} ➜ {source}', (0, 4)))
|
||
vscode_files += [patchfile.plaster, source]
|
||
|
||
if files_with_conflicts:
|
||
vscode_files += files_with_conflicts
|
||
file_list = '\n'.join(f' ✘ {file}'
|
||
for file in files_with_conflicts)
|
||
terminal.log_task(f'[bold]Manually resolve conflicts for '
|
||
f'{ACTION_NEEDED_DECORATOR}:[/]\n{file_list}')
|
||
|
||
VsCodeIpcConnection().open_file(vscode_files)
|
||
|
||
# The continuation file is updated at the end of the process, in case
|
||
# the process has to be continued later.
|
||
apply_record = ApplyPatchesRecord(
|
||
patch_files=patch_files,
|
||
patches_to_deleted_files=patches_to_deleted_files,
|
||
files_with_conflicts=files_with_conflicts,
|
||
broken_patches=broken_patches,
|
||
plaster_fixed_patches=plaster_fixed_patches,
|
||
plaster_broken_patches=plaster_broken_patches)
|
||
|
||
replace(ContinuationFile.load(target_version=self.target_version),
|
||
apply_record=apply_record).save()
|
||
|
||
return apply_record
|
||
|
||
def _update_package_version(self):
|
||
"""Creates the change upgrading the Chromium version
|
||
|
||
This is for the creation of the first commit, which means updating
|
||
package.json to the target version provided, and commiting the change to
|
||
the repo
|
||
"""
|
||
package = versioning.load_package_file('HEAD')
|
||
package['config']['projects']['chrome']['tag'] = str(
|
||
self.target_version)
|
||
with Path(versioning.PACKAGE_FILE).open('w',
|
||
encoding='utf-8',
|
||
newline='') as package_file:
|
||
package_file.write(json.dumps(package, indent=2) + '\n')
|
||
|
||
repository.brave.run_git('add', versioning.PACKAGE_FILE)
|
||
|
||
# Pinlist timestamp update occurs with the package version update.
|
||
_update_pinslist_timestamp()
|
||
repository.brave.run_git('add', PINSLIST_TIMESTAMP_FILE)
|
||
repository.brave.git_commit(
|
||
f'Update from Chromium {self.base_version} '
|
||
f'to Chromium {self.target_version}.')
|
||
|
||
def _commit_pinned_patches_and_fixups(self,
|
||
patch_paths: set[str],
|
||
commit_message: str,
|
||
no_verify: bool = False) -> None:
|
||
"""Commits patch paths, routing dev-cycle patches as fixups.
|
||
|
||
For major version upgrades, inspects each patch's branch history. If
|
||
the most-recent branch commit touching a patch is not a brockit upgrade
|
||
commit, the patch is committed as a fixup! for that dev-cycle commit
|
||
instead. The remaining patches are committed together under
|
||
commit_message.
|
||
"""
|
||
if self.is_major():
|
||
up_to_git_ref = _solve_brave_ref('@previous-major')
|
||
|
||
fixup_groups: dict[str, list[str]] = {}
|
||
for patch_path in patch_paths:
|
||
commits = repository.brave.run_git('log', '--pretty=%H %s',
|
||
f'{up_to_git_ref}..HEAD',
|
||
'--', patch_path)
|
||
|
||
for line in commits.splitlines():
|
||
commit_hash, subject = line.split(' ', 1)
|
||
if not any(
|
||
subject.startswith(p)
|
||
for p in _UPGRADE_COMMIT_WITH_PATCHES_PREFIXES):
|
||
fixup_groups.setdefault(commit_hash,
|
||
[]).append(patch_path)
|
||
break
|
||
|
||
fixup_patches: set[str] = set()
|
||
for fixup_target, fixup_patch_paths in fixup_groups.items():
|
||
for patch_path in fixup_patch_paths:
|
||
repository.brave.run_git('add', patch_path)
|
||
fixup_patches.add(patch_path)
|
||
repository.brave.git_commit_fixup(fixup_target)
|
||
|
||
patch_paths -= fixup_patches
|
||
|
||
if patch_paths:
|
||
repository.brave.run_git('add', *patch_paths)
|
||
repository.brave.git_commit(commit_message, no_verify=no_verify)
|
||
|
||
def _commit_plaster_fixed_patches(self, apply_record: ApplyPatchesRecord):
|
||
"""Commits patches that were fixed by plaster."""
|
||
patch_paths = {
|
||
path.as_posix()
|
||
for path in apply_record.plaster_fixed_patches
|
||
}
|
||
# Adding no_verify to avoid issues if someone is using an old version
|
||
# of the commit-msg hook that has not included this name as an
|
||
# exception for the branch tag.
|
||
self._commit_pinned_patches_and_fixups(
|
||
patch_paths,
|
||
f'Apply-fixed 🩹 patches from Chromium {self.base_version} '
|
||
f'to Chromium {self.target_version}.',
|
||
no_verify=True)
|
||
|
||
def _run_update_patches(self) -> GitStatus:
|
||
"""Runs update_patches and returns the resulting GitStatus.
|
||
|
||
This function is usually preferred, as it checks if any patches are
|
||
deleted after running update_patches. Deleted patches should be
|
||
committed manually with a history of why the patching is not required
|
||
anymore.
|
||
|
||
return:
|
||
The GitStatus after running update_patches.
|
||
"""
|
||
terminal.run_npm_command('update_patches')
|
||
|
||
status = GitStatus()
|
||
if status.has_deleted_patch_files():
|
||
raise InvalidInputException(
|
||
'Deleted patches detected. These should be committed as their '
|
||
'own changes:\n%s' %
|
||
'\n'.join(status.staged.deleted + status.unstaged.deleted))
|
||
if status.has_untracked_patch_files():
|
||
raise InvalidInputException(
|
||
'Untracked patch files detected. These should be committed as '
|
||
'their own changes:\n%s' % '\n'.join(status.unstaged.added))
|
||
if status.has_staged_files():
|
||
raise InvalidInputException(
|
||
'Staged files detected after running update_patches. Please '
|
||
'make sure to commit or unstage any changes, to avoid '
|
||
'committing changes unintentionally.\n'
|
||
'Staged files:\n%s' %
|
||
'\n'.join(status.get_all_staged_entries()))
|
||
|
||
# The resulting updated patches should not be doing anything beyond
|
||
# what they were doing already, both for "Update patches" and
|
||
# "Conflict-resolved patches". Therefore, we check all the modified
|
||
# patches to make sure that the number of hunks in these patch files
|
||
# have not changed, as any significant change to a patchfile should be
|
||
# submitted as its own change, with a culprit for visibility.
|
||
all_modified = status.staged.modified + status.unstaged.modified
|
||
modified_patches = [
|
||
path for path in all_modified
|
||
if path.startswith('patches/') and path.endswith('.patch')
|
||
]
|
||
if not modified_patches:
|
||
return status
|
||
|
||
def count_hunks(contents: str) -> int:
|
||
return contents.count('\n@@ -')
|
||
|
||
patches_with_hunk_changes = []
|
||
for patch in modified_patches:
|
||
hunks_before = count_hunks(repository.brave.read_file(patch))
|
||
hunks_after = count_hunks(
|
||
(repository.brave.root / patch).read_bytes().decode('utf-8'))
|
||
if hunks_before != hunks_after:
|
||
patches_with_hunk_changes.append(
|
||
(patch, hunks_before, hunks_after))
|
||
|
||
if patches_with_hunk_changes:
|
||
list_str = '\n'.join([
|
||
f' * {patch}: {b} hunks before, {a} hunks after'
|
||
for patch, b, a in patches_with_hunk_changes
|
||
])
|
||
raise InvalidInputException(
|
||
'The following modified patches have changes in the number of '
|
||
'hunks, and are expected to be submitted separately as fixes '
|
||
'with Chromium culprits:\n'
|
||
f'{list_str}')
|
||
|
||
return status
|
||
|
||
def look_for_diffs(self, *files) -> str:
|
||
"""Return the diffs for the files provided for this upgrade.
|
||
|
||
Checks for diffs of the files provided in range of the changes for the
|
||
current upgrade. Returns an empty string if no diffs are found.
|
||
"""
|
||
return repository.chromium.run_git('diff', self.working_version,
|
||
self.target_version, '--', *files)
|
||
|
||
def get_assigned_value(self,
|
||
contents: str,
|
||
lookup: str,
|
||
added: bool = False,
|
||
removed: bool = False) -> str | None:
|
||
"""Uses a basic regex to extract the value being assinged into a key.
|
||
|
||
This function is useful to extract the value of a key from a file contents
|
||
that is being diffed or read in general.
|
||
|
||
Args:
|
||
contents:
|
||
The contents of the file to look for the key.
|
||
lookup:
|
||
The key to look for in the contents.
|
||
added:
|
||
If set to True, looks for the key starting with +.
|
||
removed:
|
||
If set to True, looks for the key starting with -.
|
||
Returns:
|
||
The value assigned to the key, or None if not found
|
||
"""
|
||
for line in contents.splitlines():
|
||
# Discad any comment that there may be in the line
|
||
line = line.split('#', 1)[0].strip()
|
||
|
||
if lookup not in line:
|
||
continue
|
||
if added and not line.startswith('+'):
|
||
continue
|
||
if removed and not line.startswith('-'):
|
||
continue
|
||
|
||
# remove the sign at the beggining of the line, if that's the case
|
||
# we are looking for.
|
||
if added or removed:
|
||
line = line[1:]
|
||
|
||
if '=' not in line:
|
||
continue
|
||
|
||
[key, value] = line.split('=', 1)
|
||
if key.strip().lstrip() == lookup:
|
||
return value.strip().lstrip().replace('"', '').replace("'", "")
|
||
return None
|
||
|
||
def _check_toolchain(self, file_path: str, key: str) -> dict | None:
|
||
""" Helper function to check for toolchain updates.
|
||
|
||
This helper is used to check for the MacOS SDK, Windows SDK to see if the
|
||
toolchain for their respective OSes need updateing. It looks for diff of a
|
||
given file to find if a certain key has been updade in-between the working
|
||
and target version.
|
||
|
||
Args:
|
||
file_path:
|
||
The file to look for the key in.
|
||
key:
|
||
The key to look for in the file.
|
||
Returns:
|
||
A dictionary with a summary of the SDK upgrade.
|
||
"""
|
||
toolchain_diff = self.look_for_diffs(file_path)
|
||
if key not in toolchain_diff:
|
||
return None
|
||
|
||
updated_value = self.get_assigned_value(toolchain_diff,
|
||
key,
|
||
added=True)
|
||
current_value = self.get_assigned_value(toolchain_diff,
|
||
key,
|
||
removed=True)
|
||
if not updated_value and not current_value:
|
||
return None
|
||
commit_log = repository.chromium.run_git(
|
||
'log', f'{self.working_version}..{self.target_version}', '-S',
|
||
updated_value, '--pretty=oneline', '-1', file_path)
|
||
commit_hash, commit_message = commit_log[:40], commit_log[41:]
|
||
|
||
return {
|
||
'current': current_value,
|
||
'target': updated_value,
|
||
'commit': {
|
||
'hash': commit_hash,
|
||
'message': commit_message
|
||
}
|
||
}
|
||
|
||
def _check_win_toolchain(self) -> dict | None:
|
||
"""Check for Windows toolchain updates.
|
||
|
||
This function returns an advisory record if the Windows SDK has been
|
||
updated, indicating a new toolchain is required.
|
||
"""
|
||
result = self._check_toolchain('build/vs_toolchain.py', 'SDK_VERSION')
|
||
if not result:
|
||
result = self._check_toolchain('build/vs_toolchain.py',
|
||
'TOOLCHAIN_HASH')
|
||
if result:
|
||
result['description'] = (
|
||
'Windows SDK has been updated. '
|
||
f'{result["current"]} ➜ {result["target"]}')
|
||
result['advice'] = (
|
||
'Contact DevOps regarding the new WinSDK for the hermetic '
|
||
'toolchain. Update `env.GYP_MSVS_HASH_*` in '
|
||
'build/commands/lib/config.js with correct hashes.')
|
||
return result
|
||
|
||
def _check_mac_toolchain(self) -> dict | None:
|
||
"""Check for MacOS toolchain updates.
|
||
|
||
This function returns an advisory record if the MacoOS SDK has been updated,
|
||
indicating a new toolchain is required.
|
||
"""
|
||
result = self._check_toolchain('build/config/mac/mac_sdk.gni',
|
||
'mac_sdk_official_version')
|
||
if result:
|
||
result['description'] = (
|
||
'MacOS SDK has been updated. '
|
||
f'{result["current"]} ➜ {result["target"]}')
|
||
result['advice'] = (
|
||
'Contact DevOps to ask for an updated MacOS toolchain node to '
|
||
'be used to generate a new toolchain, then generate the new '
|
||
'toolchain in https://ci.brave.com/view/toolchains/. Once the '
|
||
'new toolchain is published, call '
|
||
'`brockit.py update-xcode-toolchain --to=<chromium-ref>` to '
|
||
'repin it.')
|
||
return result
|
||
|
||
def _check_rust_toolchain(self) -> dict | None:
|
||
"""Check for Rust toolchain updates.
|
||
|
||
This function checks for any updates to the Rust toolchain, including the
|
||
validity of the rust toolchain URL for syncing.
|
||
|
||
Returns:
|
||
An advisory record if any updates occurred, and there's no toolchain in
|
||
our infra.
|
||
"""
|
||
toolchain_sources = [
|
||
'tools/rust/update_rust.py', 'tools/clang/scripts/update.py'
|
||
]
|
||
rust_diff = self.look_for_diffs(*toolchain_sources)
|
||
if not any(
|
||
key in rust_diff for key in
|
||
['-RUST_REVISION =', '-RUST_SUB_REVISION =', '-CLANG_REVISION =']):
|
||
return None
|
||
|
||
def get_rust_clang_revision(version: str) -> str:
|
||
rust_sources = repository.chromium.read_file(*toolchain_sources,
|
||
commit=version)
|
||
return '-'.join([
|
||
self.get_assigned_value(rust_sources, "RUST_REVISION"),
|
||
self.get_assigned_value(rust_sources, "RUST_SUB_REVISION"),
|
||
self.get_assigned_value(rust_sources, "CLANG_REVISION")
|
||
])
|
||
|
||
updated_version = get_rust_clang_revision(self.target_version)
|
||
rust_toolchain_url = RUST_TOOLCHAIN_URL.format(
|
||
revision=updated_version)
|
||
|
||
try:
|
||
response = requests.head(rust_toolchain_url,
|
||
allow_redirects=True,
|
||
timeout=5)
|
||
if response.status_code == 200:
|
||
return None
|
||
except requests.RequestException:
|
||
pass # Assume toolchain is not available if request fails
|
||
|
||
commit_log = repository.chromium.run_git(
|
||
'log', f'{self.working_version}..{self.target_version}',
|
||
'--pretty=oneline', '-1', 'tools/rust/update_rust.py',
|
||
'tools/clang/scripts/update.py')
|
||
commit_hash, commit_message = commit_log[:40], commit_log[41:]
|
||
|
||
return {
|
||
'current': get_rust_clang_revision(self.working_version),
|
||
'target': updated_version,
|
||
'description': 'The rust toolchain has been updated.',
|
||
'advice': ('Run the jobs in https://ci.brave.com/view/toolchains/ '
|
||
'to generate a new Rust toolchain.'),
|
||
'commit': {
|
||
'hash': commit_hash,
|
||
'message': commit_message
|
||
}
|
||
}
|
||
|
||
def _prerun_checks(self) -> bool:
|
||
"""Runs pre-run checks for the upgrade.
|
||
|
||
This function runs a series of checks to make sure the upgrade can proceed
|
||
without any issues. If any advisories have been found, this function will
|
||
print a summary that looks something like:
|
||
|
||
* Pre-run advisory (attention needed)
|
||
* The rust toolchain has been updated.
|
||
CL: Roll clang+rust llvmorg-21-init-1655-g7b473dfe-1 : llvmorg-2...
|
||
https://chromium.googlesource.com/chromium/src/+/f9fada98083846
|
||
Run the jobs in https://ci.brave.com/view/toolchains/ to generate
|
||
a new...
|
||
|
||
Returns:
|
||
True if all checks pass, and False otherwise.
|
||
"""
|
||
# Fetching the tags between the current version and the target to check
|
||
# for certain things that may have changed that require attention
|
||
_ensure_chromium_tags(self.working_version, self.target_version)
|
||
|
||
advisories = [
|
||
check for check in [
|
||
self._check_win_toolchain(),
|
||
self._check_mac_toolchain(),
|
||
self._check_rust_toolchain()
|
||
] if check is not None
|
||
]
|
||
|
||
if not advisories:
|
||
return True
|
||
|
||
terminal.log_task(
|
||
'[bold]Pre-run advisory ([bold yellow]attention needed[/])')
|
||
for advisory in advisories:
|
||
console.print(Padding(f'* {advisory["description"]}', (0, 15)))
|
||
console.print(
|
||
Padding(f'CL: [dim]{advisory["commit"]["message"]}', (0, 19)))
|
||
console.print(
|
||
Padding(
|
||
GOOGLESOURCE_COMMIT_LINK.format(
|
||
commit=advisory["commit"]["hash"]), (0, 23)))
|
||
console.print(Padding(f'{advisory["advice"]}', (0, 19)))
|
||
|
||
replace(ContinuationFile.load(target_version=self.target_version),
|
||
has_shown_advisory=True).save()
|
||
|
||
return False
|
||
|
||
def _continue(self,
|
||
no_conflict_continuation: bool = False,
|
||
apply_record: ApplyPatchesRecord | None = None):
|
||
"""Continues the upgrade process.
|
||
|
||
This function is responsible for continuing the upgrade process. It will
|
||
pick up from where the process left the last time.
|
||
|
||
This function handles resumption in a way that the user may have to call
|
||
brockit with `--continue` multiple times, which will result in this
|
||
function being called every time.
|
||
|
||
Files that are staged are considered as being meant for the
|
||
`conflict-resolved` change. Deleted files will cause this function to bail
|
||
out, so the user provide a commit message for the deletion.
|
||
|
||
Args:
|
||
no_conflict_continuation:
|
||
Indicates that a continuation does not produce a conflict-resolved
|
||
change.
|
||
"""
|
||
if not apply_record:
|
||
apply_record = (ContinuationFile.load(
|
||
self.target_version).apply_record)
|
||
|
||
if apply_record:
|
||
apply_record.check_broken_plasters_fixed()
|
||
|
||
update_status = self._run_update_patches()
|
||
|
||
# `apply_records` is not guaranteed to exist for every continuation. In
|
||
# some cases `_run_update_patches` is reponsible for pausing the lift
|
||
# process (e.g. cherry-pick shows up in upstream and causes patch
|
||
# deletions without 3way apply).
|
||
conflict_resolved_patches = None
|
||
if apply_record:
|
||
# A list of all "Conflict-resolved" candidates still waiting to be
|
||
# committed.
|
||
conflict_resolved_patches = {
|
||
patch.path.as_posix()
|
||
for patch in apply_record.all_conflict_resolved_patches()
|
||
if patch.path.as_posix() in update_status.unstaged.modified
|
||
}
|
||
|
||
if not conflict_resolved_patches and not no_conflict_continuation:
|
||
raise InvalidInputException(
|
||
'Nothing has been staged to commit conflict-resolved patches. '
|
||
'(Did you mean to pass [bold cyan]--no-conflict-change[/]?)')
|
||
|
||
if conflict_resolved_patches:
|
||
# For major upgrades, patches last touched by dev-cycle commits
|
||
# are routed as fixup! commits to avoid rebase conflicts when
|
||
# running rebase --squash-minor-bumps.
|
||
self._commit_pinned_patches_and_fixups(
|
||
conflict_resolved_patches,
|
||
f'Conflict-resolved patches from Chromium {self.base_version} '
|
||
f'to Chromium {self.target_version}.')
|
||
|
||
self._save_updated_patches()
|
||
# Run init again to make sure nothing is missing after updating
|
||
# patches.
|
||
terminal.run_npm_command('init')
|
||
|
||
terminal.run_npm_command('chromium_rebase_l10n')
|
||
self._save_rebased_l10n()
|
||
|
||
# With the continuation finished there's no need to keep the
|
||
# continuation file around.
|
||
ContinuationFile.clear()
|
||
|
||
def _start(self, ack_advisory: bool):
|
||
"""Starts the upgrade process.
|
||
|
||
This function is responsible for starting the upgrade process. It will
|
||
update the package version, run `npm run init`, and then run
|
||
`npm run update_patches`. If any patches fail to apply, it will run
|
||
`apply_patches_3way` to allow for manual conflict resolution.
|
||
|
||
For cases where no conflict resolution is required, the process will
|
||
will continue, concluding the whole four steps of the upgrade process.
|
||
|
||
Return:
|
||
Returns True if the process was successful, and False otherwise.
|
||
"""
|
||
if (self.working_version != self.chromium_src_version
|
||
and self.target_version != self.chromium_src_version):
|
||
logging.warning(
|
||
'Chrommium seems to be synced to a version entirely '
|
||
'unrelated. Brave %s ➜ Chromium %s', self.working_version,
|
||
self.chromium_src_version)
|
||
elif self.working_version != self.chromium_src_version:
|
||
logging.warning(
|
||
'Chromium is checked out with the target version. '
|
||
'Brave %s ➜ Chromium %s', self.working_version,
|
||
self.chromium_src_version)
|
||
|
||
if self.working_version != self.base_version:
|
||
terminal.log_task('Changes for this bump: %s' %
|
||
self.target_version.get_googlesource_diff_link(
|
||
self.working_version))
|
||
terminal.log_task(
|
||
'Changes since base version: %s' %
|
||
self.target_version.get_googlesource_diff_link(self.base_version))
|
||
|
||
if self.is_major():
|
||
# When doing a major lift, the branch name should indicate that
|
||
# (e.g. `cr149`).
|
||
expected_branch = f'cr{self.target_version.major}'
|
||
current_branch = repository.brave.current_branch()
|
||
if current_branch != expected_branch:
|
||
raise InvalidInputException(
|
||
f'Major version upgrades must be done on a branch named '
|
||
f'"{expected_branch}", but the current branch is '
|
||
f'"{current_branch}".')
|
||
|
||
if not ack_advisory and not self._prerun_checks():
|
||
raise ActionNeededException(
|
||
'👋 (Address advisories and then rerun with '
|
||
'[bold cyan]--ack-advisory[/])')
|
||
|
||
self._update_package_version()
|
||
|
||
try:
|
||
terminal.run_npm_command('init')
|
||
|
||
# When no conflicts come back, we can proceed with the
|
||
# update_patches.
|
||
self._run_update_patches()
|
||
except subprocess.CalledProcessError as e:
|
||
if ('There were some failures during git reset of specific '
|
||
'repo paths' in e.stderr):
|
||
logging.warning(
|
||
'[bold cyan]npm run init[/] is failing to reset some'
|
||
' paths. This could be happening because of a bad sync'
|
||
'state before starting the upgrade.')
|
||
|
||
if (e.returncode != 0
|
||
and 'Exiting as not all patches were successful!'
|
||
in e.stderr.splitlines()[-1]):
|
||
apply_record = self.apply_patches_3way()
|
||
if apply_record.plaster_fixed_patches:
|
||
self._commit_plaster_fixed_patches(apply_record)
|
||
if apply_record.requires_conflict_resolution():
|
||
# Manual resolution required.
|
||
raise ActionNeededException(
|
||
'👋 (Address all sections with '
|
||
f'{ACTION_NEEDED_DECORATOR} above, and then rerun '
|
||
'[italic]🚀Brockit![/] with [bold cyan]--continue[/])'
|
||
) from e
|
||
|
||
# With all conflicts resolved, it is necessary to close the
|
||
# upgrade with all the same steps produced when running an
|
||
# upgrade continuation, as recovering from a conflict-
|
||
# resolution failure.
|
||
self._continue(apply_record=apply_record)
|
||
return
|
||
if e.returncode != 0:
|
||
raise InvalidInputException(
|
||
f'Failures found when running npm run init\n{e.stderr}'
|
||
) from e
|
||
|
||
self._save_updated_patches()
|
||
|
||
terminal.run_npm_command('chromium_rebase_l10n')
|
||
self._save_rebased_l10n()
|
||
|
||
def execute(self, no_conflict_continuation: bool, with_github: bool,
|
||
ack_advisory: bool):
|
||
"""Executes the upgrade process.
|
||
|
||
Keep in this function all code that is common to both start and continue.
|
||
|
||
Args:
|
||
no_conflict_continuation:
|
||
Indicates that a continuation does not produce a conflict-resolved
|
||
change.
|
||
with_github:
|
||
Indicates the user wants to create or update the github issue for
|
||
the upgrade.
|
||
"""
|
||
if self.target_version == self.working_version:
|
||
raise InvalidInputException(
|
||
f'This branch is already in {self.target_version}. (Maybe you '
|
||
'meant to pass [bold cyan]--continue[/]?)')
|
||
|
||
if self.target_version < self.working_version:
|
||
raise InvalidInputException(
|
||
f'Cannot upgrade version from {self.target_version} '
|
||
f'to {self.working_version}')
|
||
|
||
if not self.is_continuation:
|
||
if ack_advisory:
|
||
# This check for the use of `--ack-advisory` for the actual
|
||
# case where advisory has been shown is to avoid accidental
|
||
# suppressions of advisories, and to prevent `--ack-advisory`
|
||
# becoming something similar to some `--force` flag.
|
||
continuation = ContinuationFile.load(
|
||
target_version=self.target_version,
|
||
working_version=self.working_version,
|
||
check=False)
|
||
if (continuation is not None
|
||
and not continuation.has_shown_advisory):
|
||
raise InvalidInputException(
|
||
'Use [bold cyna]--ack-advisory[/] just after being '
|
||
'shown advisories.')
|
||
# We initialise the continuation file here rather than in the
|
||
# constructor to avoid overwritting the file if the user made the
|
||
# mistake of calling brockit again without `--continue`.
|
||
ContinuationFile(target_version=self.target_version,
|
||
working_version=self.working_version,
|
||
base_version=self.base_version).save()
|
||
|
||
if with_github and not _is_gh_cli_logged_in():
|
||
# Fail early if gh cli is not logged in.
|
||
raise InvalidInputException('GitHub CLI is not logged in.')
|
||
|
||
if self.is_continuation:
|
||
if self.target_version != self.chromium_src_version:
|
||
raise InvalidInputException(
|
||
'To run with [bold cyan]--continue[/] the Chromium '
|
||
'version has to be in Sync with Brave. Brave '
|
||
f'{self.target_version} ➜ '
|
||
f'Chromium {self.chromium_src_version}')
|
||
|
||
self._continue(no_conflict_continuation=no_conflict_continuation)
|
||
else:
|
||
self._start(ack_advisory=ack_advisory)
|
||
|
||
if with_github:
|
||
GitHubIssue(base_version=self.base_version,
|
||
target_version=self.target_version
|
||
).create_or_update_version_issue(with_pr=False)
|
||
|
||
try:
|
||
terminal.run([VPYTHON3_PATH, plaster.__file__, 'check'])
|
||
except subprocess.CalledProcessError:
|
||
terminal.log_task(
|
||
'[bold]❌[/] Plaster check. Please investigate it.')
|
||
|
||
try:
|
||
self._save_gnrt_rerun()
|
||
except subprocess.CalledProcessError:
|
||
terminal.log_task('[bold]❌[/] GNRT rerun. Please investigate it.')
|
||
|
||
|
||
def _solve_brave_ref(from_ref: str | None) -> str:
|
||
"""Solves the git reference.
|
||
|
||
This function is used to resolve the git reference provided by the user.
|
||
This reference can be either a branch name, a commit hash, or a special
|
||
label used by brockit to find specific changes.
|
||
|
||
Args:
|
||
from_ref:
|
||
The git reference to resolve. If not provided, it defaults
|
||
`@upstream`.
|
||
|
||
Returns:
|
||
The resolved git reference, if the reference is valid, or the
|
||
reference for the solved special label:
|
||
|
||
+-----------------+--------------------------------------------------+
|
||
| Label | Description |
|
||
+-----------------+--------------------------------------------------+
|
||
| @upstream | Upstream branch of the current branch |
|
||
| @previous | Commit just before the last version bump |
|
||
| @previous-major | Commit just before the last major version bump |
|
||
+-----------------+--------------------------------------------------+
|
||
"""
|
||
if not from_ref:
|
||
from_ref = '@upstream'
|
||
|
||
if from_ref and from_ref[0] != '@':
|
||
# No special handling needed
|
||
if not repository.brave.is_valid_git_reference(from_ref):
|
||
raise InvalidInputException(
|
||
'Value provided to [bold cyan]--from-ref[/] is not a valid '
|
||
f'git ref: {from_ref}')
|
||
return from_ref
|
||
|
||
if from_ref == "@upstream":
|
||
upstream_branch = _get_current_branch_upstream_name()
|
||
if upstream_branch is None:
|
||
raise InvalidInputException(
|
||
'Could not determine the upstream branch. (Maybe set '
|
||
'[bold cyan]--set-upstream-to[/] in your branch?)')
|
||
return upstream_branch
|
||
|
||
def find_previous_version_hash(is_major: bool = False) -> str:
|
||
starting_version = Version.from_git('HEAD')
|
||
base_version = starting_version
|
||
last_changed = repository.brave.last_changed(versioning.PACKAGE_FILE)
|
||
while True:
|
||
base_version = Version.from_git(f'{last_changed}~1')
|
||
if (is_major and base_version.major != starting_version.major):
|
||
break
|
||
if (not is_major and base_version != starting_version):
|
||
break
|
||
# Prefer to look for the PACKAGE_FILE here, because this has to
|
||
# resolve even when the upgrade was done manually, so don't assume
|
||
# the presence of pinslist timestamp changes.
|
||
last_changed = repository.brave.last_changed(
|
||
versioning.PACKAGE_FILE, f'{last_changed}~1')
|
||
|
||
return f'{last_changed}~1'
|
||
|
||
if from_ref == "@previous":
|
||
return find_previous_version_hash()
|
||
|
||
if from_ref == "@previous-major":
|
||
return find_previous_version_hash(is_major=True)
|
||
|
||
raise NotImplementedError(
|
||
f'Unknown value for [bold cyan]--from-ref[/]: {from_ref}. '
|
||
'Valid values are: @upstream, @previous, @previous-major')
|
||
|
||
|
||
class Rebase(Task):
|
||
"""Regenerates patches and strings for the current branch.
|
||
|
||
This task is used for cases where the user wants to regenerate patches and
|
||
strings. The purpose is to produce `Update patches` and `Updated strings`
|
||
where approrpriate.
|
||
"""
|
||
|
||
def status_message(self):
|
||
return "Rebasing current branch..."
|
||
|
||
@staticmethod
|
||
def discard_regen_changes_from_rebase_plan(todo_file: Path):
|
||
"""Removes regen changes from the rebase plan.
|
||
|
||
This function removes all lines that contain the string `Update patches`
|
||
or `Updated strings` from the rebase plan file, and saves the changes to
|
||
the file.
|
||
"""
|
||
lines = todo_file.read_bytes().decode('utf-8').splitlines(
|
||
keepends=True)
|
||
todo_file.write_text(
|
||
''.join(line for line in lines
|
||
if 'Update patches from Chromium ' not in line
|
||
and 'Updated strings for Chromium ' not in line),
|
||
encoding='utf-8',
|
||
newline='')
|
||
|
||
@staticmethod
|
||
def recommit_in_rebase_plan(todo_file: Path):
|
||
"""Recommits the first commit in the rebase plan.
|
||
|
||
This function replaces the first `pick` in the rebase plan with `edit`,
|
||
which forces the first commit to be recommitted.
|
||
"""
|
||
contents = todo_file.read_bytes().decode('utf-8')
|
||
todo_file.write_text(contents.replace('pick', 'edit', 1),
|
||
encoding='utf-8',
|
||
newline='')
|
||
|
||
@staticmethod
|
||
def squash_minor_bumps_from_rebase_plan(todo_file: Path):
|
||
"""Squashes minor bumps from the rebase plan.
|
||
|
||
This function groups all commmit lines that start with "Update from
|
||
Chromium" into a single commit on top, and does the same for commits
|
||
that start with "Conflict-resolved patches from Chromium"
|
||
"""
|
||
lines = todo_file.read_bytes().decode('utf-8').splitlines(
|
||
keepends=True)
|
||
|
||
version = []
|
||
conflict = []
|
||
gnrt = []
|
||
iwyu = []
|
||
others = []
|
||
plaster_reruns = []
|
||
reassign = []
|
||
for line in lines:
|
||
# Empty lines and comment lines are not interesting when doing the
|
||
# rebase todo file.
|
||
if line.startswith('#') or line.strip() == '':
|
||
continue
|
||
|
||
# We are breaking down the lines into command|commit columns. The
|
||
# split below works on lines that are supposed to look like this:
|
||
#
|
||
# pick 7aab77cbd1a # [cr148] Args plumbed into context menu
|
||
# pick 7765fb6198b # [cr148] `TabCardView` created
|
||
# pick d97c4b4ee81 # [cr148] `AtomicString::LowerASCII` deleted
|
||
# pick 1b7e261baed # [cr148] Tab strip model shadow files moved
|
||
#
|
||
# The left side of before the (#) comment marker represent the
|
||
# rebase interactive command, while the right side has the commit
|
||
# description. As the right side is obviously a comment, it goes
|
||
# without saying that the right side is not necessarily required by
|
||
# `git rebase --interactive` to work, and one shouldn't rely on it,
|
||
# however for the way brockit works, we can always rely on these
|
||
# comments to be present and providing accurate data for us to
|
||
# scrape. This makes the split below safe to do.
|
||
_, comment = [part.lstrip() for part in line.split('#', 1)]
|
||
|
||
if comment.startswith('Update from Chromium '):
|
||
version.append(
|
||
line if not version else line.replace('pick', 'squash'))
|
||
elif comment.startswith('Apply-fixed 🩹 patches from Chromium '):
|
||
plaster_reruns.append(line if not plaster_reruns else line.
|
||
replace('pick', 'squash'))
|
||
elif comment.startswith(
|
||
'Conflict-resolved patches from Chromium '):
|
||
conflict.append(
|
||
line if not conflict else line.replace('pick', 'squash'))
|
||
elif comment.startswith(REASSIGN_COMMIT_MSG_PREFIX):
|
||
reassign.append(line)
|
||
elif '`gnrt` run for Chromium ' in comment:
|
||
gnrt.append(
|
||
line if not gnrt else line.replace('pick', 'squash'))
|
||
elif '] IWYU fixes.' in comment:
|
||
iwyu.append(
|
||
line if not iwyu else line.replace('pick', 'squash'))
|
||
else:
|
||
others.append(line)
|
||
|
||
# Merging all categories in the desired order.
|
||
new_todo_file = (version + plaster_reruns + conflict + gnrt + iwyu +
|
||
others)
|
||
|
||
# Looking for every occurrence of lines starting with `reassign! `,
|
||
# taking the commit message after it, and looking for the the first
|
||
# line starting with `pick ` that matches the commit message, then
|
||
# moving the reassign commit above it, and changing the pick line under
|
||
# it to a `squash` line.
|
||
# Orphaned `reassign!` commits get intentionally silently dropped at
|
||
# this point.
|
||
for reassign_line in reassign:
|
||
command, comment = [
|
||
part.lstrip() for part in reassign_line.split('#', 1)
|
||
]
|
||
|
||
# The format of the commit reassign commit will always be:
|
||
#
|
||
# reassign!ae3724d6603! [brockit] Authorship reassignment
|
||
#
|
||
# The first part can be ignored. The hash is the second part, and
|
||
# the remainder is the commit message.
|
||
_, commit, reassign_message = comment.strip().split('!', 2)
|
||
# the commit message has a trailing comment for empty commits.
|
||
reassign_message = (
|
||
reassign_message.strip().removesuffix(' # empty'))
|
||
|
||
# Looking first for a line with the exact hash mentioned in the
|
||
# reassign message. (This should be enough, however a hash is not
|
||
# guaranteed to match the one in the reassignment commit, if any
|
||
# rebase took place that didn't carry out this process first, for
|
||
# by a previous call of `brockit rebase` without
|
||
# `--squash-minor-bumps`.)
|
||
target_index = None
|
||
for i, line in enumerate(new_todo_file):
|
||
if line.startswith(f'pick {commit}'):
|
||
target_index = i
|
||
break
|
||
|
||
# if no line for the provided hash is found, then falling back to
|
||
# looking for a line with the same commit message.
|
||
if target_index is None:
|
||
for i, line in enumerate(new_todo_file):
|
||
if line.strip().endswith(f' # {reassign_message}'
|
||
) and line.startswith('pick '):
|
||
target_index = i
|
||
break
|
||
|
||
if target_index is not None:
|
||
new_todo_file.insert(target_index, f'{command} # {comment}')
|
||
new_todo_file[target_index +
|
||
1] = new_todo_file[target_index + 1].replace(
|
||
'pick', 'squash', 1)
|
||
|
||
todo_file.write_text(''.join(new_todo_file),
|
||
encoding='utf-8',
|
||
newline='')
|
||
|
||
@staticmethod
|
||
def fix_squash_commit_messages(todo_file: Path):
|
||
"""Fixes the commit messages during rebase squash.
|
||
|
||
This function handles rebase squashes to correct the commit messages. It
|
||
does two things:
|
||
|
||
For authorship reassignments, it removes the `reassign!` line, and restore
|
||
the original commit message, resulting in the original author being swapped
|
||
with the author of the reassignment.
|
||
|
||
For minor bumps, it keeps the message of the last minor bump, which is the
|
||
one that should be retained.
|
||
"""
|
||
lines = todo_file.read_bytes().decode('utf-8').splitlines(
|
||
keepends=True)
|
||
|
||
# Filter out empty lines and comments once to make searching easier
|
||
# We keep the original index to reference back to 'lines' if needed
|
||
valid_entries = [(i, line) for i, line in enumerate(lines)
|
||
if line.strip() and not line.lstrip().startswith('#')]
|
||
|
||
if not valid_entries:
|
||
sys.exit("Error: No valid content found in commit message.")
|
||
|
||
_, first_line = valid_entries[0]
|
||
|
||
# Handle "reassign!" logic
|
||
if first_line.startswith(REASSIGN_COMMIT_MSG_PREFIX):
|
||
if len(valid_entries) > 1:
|
||
# Get the index of the very next valid line
|
||
next_valid_idx = valid_entries[1][0]
|
||
content_to_write = lines[next_valid_idx:]
|
||
else:
|
||
sys.exit("Error: No valid content found in reassign squash.")
|
||
|
||
else:
|
||
# Standard squash: Keep only the last valid line
|
||
_, last_line = valid_entries[-1]
|
||
content_to_write = [last_line]
|
||
|
||
todo_file.write_text(''.join(content_to_write),
|
||
encoding='utf-8',
|
||
newline='')
|
||
|
||
def execute(self,
|
||
from_ref: str | None,
|
||
to_ref: str | None,
|
||
recommit: bool,
|
||
discard_regen_changes: bool,
|
||
squash_minor_bumps: bool,
|
||
v2: bool = False) -> bool:
|
||
"""Rebases the current branch onto the provided ref.
|
||
|
||
This function rebases the current branch onto the provided branch. It is
|
||
the same as calling `git rebase --i --autosquash`.
|
||
|
||
Args:
|
||
from_ref:
|
||
The reference to start rebasing from. This refers to the first
|
||
change in the branch we want to pick up when rebasing. When null,
|
||
it will either default to `@previous-major` when `to_ref` is in a
|
||
different major version, or to `@previous` when `to_ref` is in the
|
||
same version.
|
||
to_ref:
|
||
This is the git reference that we are rebasing onto. This will
|
||
default to `@upstream` if not provided.
|
||
recommit:
|
||
Indicates that the first commit should be recommitted to force all
|
||
the other commits to be recommitted as well.
|
||
discard_regen_changes:
|
||
Indicates that the changes that are automatically regenerated
|
||
should be discarded.
|
||
|
||
Returns:
|
||
True if the rebase was successful, and False otherwise.
|
||
"""
|
||
to_ref = _solve_brave_ref(to_ref)
|
||
|
||
if from_ref is None:
|
||
if Version.from_git(to_ref).major != Version.from_git(
|
||
'HEAD').major:
|
||
# If the major version is different, we default to the
|
||
# previous major version.
|
||
from_ref = _solve_brave_ref(from_ref or '@previous-major')
|
||
else:
|
||
# If the major version is the same, we default to the
|
||
# previous version.
|
||
from_ref = _solve_brave_ref(from_ref or '@previous')
|
||
|
||
from_ref = _solve_brave_ref(from_ref)
|
||
|
||
current_branch = repository.brave.current_branch()
|
||
terminal.log_task(
|
||
f'Rebasing {current_branch} onto {to_ref} starting from {from_ref}'
|
||
)
|
||
|
||
# We have to receive the rebase plan from git, and then modify it
|
||
# if that's desired. That's done by calling this script again with
|
||
# special internal flags.
|
||
env = os.environ.copy()
|
||
# Capture the user's preferred editors BEFORE we override
|
||
# `GIT_SEQUENCE_EDITOR` / `GIT_EDITOR` below -- v2's editor
|
||
# fallback flows through `--internal-rebase-crash-*-editor` and
|
||
# needs to know what the user originally configured.
|
||
crash_seq_editor = rebase_v2.get_git_editor('GIT_SEQUENCE_EDITOR')
|
||
crash_msg_editor = rebase_v2.get_git_editor()
|
||
editor = [str(VPYTHON3_PATH), __file__]
|
||
# v2 plan-rewriting flags share the `--internal-rebase-v2-plan-`
|
||
# prefix so the dispatch can detect them with one check and
|
||
# consolidate both into a single `rewrite_plan` call.
|
||
discard_flag = ('--internal-rebase-v2-plan-discard-recyclable'
|
||
if v2 else '--internal-rebase-remove-regen-changes')
|
||
squash_plan_flag = ('--internal-rebase-v2-plan-squash-pinned'
|
||
if v2 else '--internal-rebase-squash-minor-bumps')
|
||
squash_msg_flag = ('--internal-rebase-v2-fix-message' if v2 else
|
||
'--internal-rebase-squash-commit-message')
|
||
if discard_regen_changes:
|
||
editor.append(discard_flag)
|
||
if recommit:
|
||
editor.append('--internal-rebase-recommit')
|
||
if squash_minor_bumps:
|
||
editor.append(squash_plan_flag)
|
||
|
||
# Squashes will cause `GIT_EDITOR` also to open for the commit
|
||
# message, so we need to handle those too.
|
||
msg_editor_cmd = (
|
||
f'{str(VPYTHON3_PATH)} {__file__} {squash_msg_flag}')
|
||
if v2:
|
||
msg_editor_cmd += (
|
||
f' --internal-rebase-crash-msg-editor={crash_msg_editor}')
|
||
env["GIT_EDITOR"] = msg_editor_cmd
|
||
|
||
if v2 and len(editor) > 2:
|
||
# Pass the captured sequence-editor fallback alongside the
|
||
# v2 plan flags so the subprocess can hand off to it when
|
||
# `EditorRecoverableFailure` fires.
|
||
editor.append(
|
||
f'--internal-rebase-crash-sequence-editor={crash_seq_editor}')
|
||
|
||
if len(editor) > 2:
|
||
env["GIT_SEQUENCE_EDITOR"] = " ".join(editor)
|
||
else:
|
||
# If there are no internal operation, we can just return always
|
||
# true to whatever plan git gives us.
|
||
env["GIT_SEQUENCE_EDITOR"] = 'cmd /c "exit 0"' if platform.system(
|
||
) == 'Windows' else 'true'
|
||
|
||
try:
|
||
# `interactive=True` as we may need to open an editor if
|
||
# `EditorRecoverableFailure` is raised, so we can capture the pipes.
|
||
terminal.run([
|
||
'git', 'rebase', '--interactive', '--autosquash',
|
||
'--empty=drop', '--onto', to_ref, from_ref, current_branch
|
||
],
|
||
env=env,
|
||
interactive=True)
|
||
if recommit:
|
||
repository.brave.run_git('commit', '--amend', '--no-edit')
|
||
repository.brave.run_git('rebase', '--continue')
|
||
except subprocess.CalledProcessError as e:
|
||
raise InvalidInputException('Rebase failed.') from e
|
||
|
||
|
||
class _MarkChangeTask(Task):
|
||
"""Base for tasks that mark a change with an empty `<prefix><hash>!` commit.
|
||
|
||
This class creates an empty commit with a subcommand that is understood by
|
||
brockit's `rebase` command to mark a change in the branch.
|
||
"""
|
||
|
||
# Abstract base: instantiated only through its concrete subclasses.
|
||
# pylint: disable=abstract-method
|
||
|
||
def __init__(self, prefix: str):
|
||
# The `<prefix>` placed before the target hash (e.g. `reassign!`).
|
||
self._prefix = prefix
|
||
|
||
def status_message(self):
|
||
# Derive the action word from the prefix, e.g. `drop!` → "drop".
|
||
return f"Creating {self._prefix.rstrip('!')} commit..."
|
||
|
||
def execute(self, change: str):
|
||
"""Creates the empty `<prefix><hash>! <subject>` commit.
|
||
|
||
Args:
|
||
change:
|
||
The change to mark. This is any valid git reference that resolves
|
||
to a single commit.
|
||
"""
|
||
status = GitStatus()
|
||
if status.has_staged_files():
|
||
raise InvalidInputException(
|
||
'Staged files detected. Please commit or unstage changes '
|
||
'before marking the change:\n%s' %
|
||
'\n'.join(status.get_all_staged_entries()))
|
||
|
||
commit, message = repository.brave.run_git('log', '-1', change, '-s',
|
||
'--format=%h %s').split(
|
||
' ', 1)
|
||
repository.brave.git_commit(f"{self._prefix}{commit}! {message}",
|
||
allows_empty=True,
|
||
no_verify=True)
|
||
|
||
|
||
class Reassign(_MarkChangeTask):
|
||
"""Creates a reassignment commit for a given change in the branch.
|
||
|
||
This task is used for cases where the authorship of a given change should be
|
||
reassigned to a different author. The empty `reassign!` commit it creates is
|
||
picked up by brockit's `rebase` command and squashed as the base commit for
|
||
the original change, resulting in a change of authorship.
|
||
"""
|
||
|
||
def __init__(self):
|
||
super().__init__(REASSIGN_COMMIT_MSG_PREFIX)
|
||
|
||
|
||
class Drop(_MarkChangeTask):
|
||
"""Creates a drop commit for a given change in the branch.
|
||
|
||
This task is used to create a `drop!` commit, which gets picked up by the
|
||
rebase engine, and causes the target change to be dropped during rebase.
|
||
"""
|
||
|
||
def __init__(self):
|
||
super().__init__(DROP_COMMIT_MSG_PREFIX)
|
||
|
||
|
||
class UpdateXcodeToolchain(Task):
|
||
"""Pins `build/mac/download_hermetic_xcode.py` to a published toolchain.
|
||
|
||
This class produces a Xcode toolchain update commit, anchored on the
|
||
Chromium tag pointing at the new toolchain.
|
||
"""
|
||
|
||
# Matches the provenance comment above the pinned constants in
|
||
# `HERMETIC_XCODE_SCRIPT` so it can be regenerated from the freshly resolved
|
||
# toolchain. Spans the opening line and any contiguous comment lines that
|
||
# follow, stopping at the first non-comment line (the constants).
|
||
_PROVENANCE_COMMENT_RE = re.compile(
|
||
r'^# This contains binaries from Xcode\b.*\n(?:#.*\n)*', re.MULTILINE)
|
||
|
||
# Matches the `MAC_MINIMUM_OS_VERSION` block -- its leading comment lines
|
||
# plus the assignment -- in both Chromium's `build/mac_toolchain.py` and the
|
||
# hermetic downloader. `update-xcode-toolchain` lifts this block from
|
||
# upstream and drops it into the downloader verbatim. Each block is preceded
|
||
# by a blank line, so the contiguous comment run never reaches further up.
|
||
_MIN_OS_VERSION_BLOCK_RE = re.compile(
|
||
r'(?:^#.*\n)*^MAC_MINIMUM_OS_VERSION = \[[^\]]*\]\n', re.MULTILINE)
|
||
|
||
def status_message(self):
|
||
return "Updating Apple toolchain..."
|
||
|
||
@staticmethod
|
||
def _provenance_comment(sdk_info: build_xcode_toolchain.MacSdkInfo,
|
||
index: dict) -> str:
|
||
"""Render the wrapped provenance comment for the resolved toolchain.
|
||
|
||
Records, in prose, the Xcode version/build and Metal build the index
|
||
reports alongside the macOS SDK version/build, so the in-tree
|
||
downloader keeps documenting exactly what its archive contains now that
|
||
it no longer carries an `XCODE_VERSION` constant.
|
||
"""
|
||
metal_build = index.get('metal_build') or 'unknown'
|
||
sentence = (
|
||
f"This contains binaries from Xcode {index['xcode_version']} "
|
||
f"({index['xcode_build']}) along with the macOS "
|
||
f"{sdk_info.sdk_version} SDK ({sdk_info.product_build_version}) "
|
||
f"and the Metal toolchain ({metal_build}).")
|
||
return textwrap.fill(
|
||
sentence, width=79, initial_indent='# ',
|
||
subsequent_indent='# ') + '\n'
|
||
|
||
def _rewrite_hermetic_xcode_script(
|
||
self, sdk_info: build_xcode_toolchain.MacSdkInfo, index: dict,
|
||
mac_toolchain_py: str) -> bool:
|
||
"""Pins the archive hash and SDK constants, refreshes the provenance
|
||
comment, and mirrors Chromium's `MAC_MINIMUM_OS_VERSION` block.
|
||
|
||
`mac_toolchain_py` is the upstream `build/mac_toolchain.py` source the
|
||
minimum-OS block is lifted from.
|
||
|
||
Returns True if the on-disk file actually changed, False when it was
|
||
already pinned to these exact values (so the caller can skip
|
||
committing).
|
||
"""
|
||
script_path = repository.brave.root / HERMETIC_XCODE_SCRIPT
|
||
original = script_path.read_bytes().decode('utf-8')
|
||
sdk_version = sdk_info.sdk_version
|
||
build_version = sdk_info.product_build_version
|
||
|
||
content = re.sub(r"MAC_BINARIES_HASH = '[^']*'",
|
||
f"MAC_BINARIES_HASH = '{index['sha256sum']}'",
|
||
original,
|
||
count=1)
|
||
content = re.sub(r"MAC_SDK_OFFICIAL_VERSION = '[^']*'",
|
||
f"MAC_SDK_OFFICIAL_VERSION = '{sdk_version}'",
|
||
content,
|
||
count=1)
|
||
content = re.sub(r"MAC_SDK_OFFICIAL_BUILD_VERSION = '[^']*'",
|
||
f"MAC_SDK_OFFICIAL_BUILD_VERSION = '{build_version}'",
|
||
content,
|
||
count=1)
|
||
content = self._PROVENANCE_COMMENT_RE.sub(
|
||
lambda _: self._provenance_comment(sdk_info, index),
|
||
content,
|
||
count=1)
|
||
|
||
# Lift the minimum-OS gate (and its comment) verbatim from upstream.
|
||
upstream_min_os = self._MIN_OS_VERSION_BLOCK_RE.search(
|
||
mac_toolchain_py)
|
||
if upstream_min_os is None:
|
||
raise InvalidInputException(
|
||
'Could not find the MAC_MINIMUM_OS_VERSION block in '
|
||
f'{CHROMIUM_MAC_TOOLCHAIN_PY}.')
|
||
content = self._MIN_OS_VERSION_BLOCK_RE.sub(
|
||
lambda _: upstream_min_os.group(0), content, count=1)
|
||
|
||
if content == original:
|
||
return False
|
||
|
||
script_path.write_text(content, encoding='utf-8', newline='')
|
||
return True
|
||
|
||
def _resolve_chromium_culprit(self,
|
||
sdk_info: build_xcode_toolchain.MacSdkInfo,
|
||
ref: str) -> str:
|
||
"""Finds the Chromium commit that pinned this macOS SDK.
|
||
|
||
Pickaxes `build/config/mac/mac_sdk.gni` up to `ref` for the commit that
|
||
last set either the SDK version or its build number, mirroring
|
||
`UpdateRustWasmToolchain._resolve_chromium_culprit`.
|
||
"""
|
||
_ensure_chromium_tags(ref)
|
||
version = re.escape(sdk_info.sdk_version)
|
||
build = re.escape(sdk_info.product_build_version)
|
||
regex = (f'mac_sdk_official_version = "{version}"'
|
||
f'|mac_sdk_official_build_version = "{build}"')
|
||
commit_hash = repository.chromium.run_git('log', '--extended-regexp',
|
||
'-G', regex, '--pretty=%H',
|
||
'-1', ref, '--',
|
||
CHROMIUM_MAC_SDK_GNI)
|
||
if not commit_hash:
|
||
raise InvalidInputException(
|
||
'Could not find a Chromium commit setting '
|
||
f'`mac_sdk_official_version = "{sdk_info.sdk_version}"` or '
|
||
'`mac_sdk_official_build_version = '
|
||
f'"{sdk_info.product_build_version}"` in '
|
||
f'{CHROMIUM_MAC_SDK_GNI} at {ref}. Pass '
|
||
'[bold cyan]--culprit[/] to point at it explicitly.')
|
||
return commit_hash
|
||
|
||
def execute(self, chromium_ref: str, culprit: str | None):
|
||
status = GitStatus()
|
||
if status.has_staged_files():
|
||
raise InvalidInputException(
|
||
'Staged files detected. Please commit or unstage changes '
|
||
'before generating a toolchain update:\n%s' %
|
||
'\n'.join(status.get_all_staged_entries()))
|
||
|
||
# Resolve to a concrete Chromium tag (supports the @latest-* labels),
|
||
# make sure the tag is available locally, then read the SDK pin and the
|
||
# minimum-OS gate from Chromium at that ref.
|
||
version = _fetch_chromium_tag(chromium_ref)
|
||
ref = str(version)
|
||
_ensure_chromium_tags(ref)
|
||
mac_sdk_gni = repository.chromium.read_file(CHROMIUM_MAC_SDK_GNI,
|
||
commit=ref)
|
||
mac_toolchain_py = repository.chromium.read_file(
|
||
CHROMIUM_MAC_TOOLCHAIN_PY, commit=ref)
|
||
try:
|
||
sdk_info = build_xcode_toolchain.MacSdkInfo.from_gni(mac_sdk_gni)
|
||
except RuntimeError as e:
|
||
raise BadOutcomeException(str(e)) from e
|
||
|
||
try:
|
||
index = build_xcode_toolchain.fetch_published_index(sdk_info)
|
||
except RuntimeError as e:
|
||
raise BadOutcomeException(str(e)) from e
|
||
|
||
if not self._rewrite_hermetic_xcode_script(sdk_info, index,
|
||
mac_toolchain_py):
|
||
raise InvalidInputException(
|
||
f'{HERMETIC_XCODE_SCRIPT} is already pinned to these values; '
|
||
'nothing to commit.')
|
||
|
||
commit_hash = culprit or self._resolve_chromium_culprit(sdk_info, ref)
|
||
|
||
title = (f'Switch to Xcode {index["xcode_version"]} '
|
||
f'{index["xcode_build"]}')
|
||
repository.brave.run_git('add', HERMETIC_XCODE_SCRIPT)
|
||
repository.brave.git_commit(title,
|
||
env={
|
||
**os.environ,
|
||
'tags': 'toolchain',
|
||
'culprit': commit_hash,
|
||
})
|
||
|
||
|
||
@dataclass
|
||
class _WatchedJob:
|
||
"""Mutable per-pipeline state tracked while `--watch` polls Jenkins."""
|
||
|
||
# The Jenkins job name.
|
||
job: str
|
||
|
||
# The transient queue-item URL from the trigger's `Location` header.
|
||
queue_url: str
|
||
|
||
# The build URL, resolved once an executor dequeues the job.
|
||
build_url: str | None = None
|
||
|
||
# Canonical state: QUEUED / RUNNING / SUCCESS / FAILURE / ABORTED /
|
||
# UNSTABLE / CANCELLED / UNKNOWN.
|
||
state: str = 'QUEUED'
|
||
|
||
# Human-readable current stage (or the queue reason while QUEUED).
|
||
stage: str = ''
|
||
|
||
# Pre-formatted elapsed build time (e.g. "12m04s"), as of the last poll.
|
||
# Used as-is for non-running rows; RUNNING rows tick forward from the two
|
||
# fields below instead (see `_ElapsedClock`).
|
||
elapsed: str = ''
|
||
|
||
# The raw server-reported build duration (ms) at the last poll, and the
|
||
# monotonic clock read at that same instant. Together they anchor a
|
||
# RUNNING row's locally-ticking elapsed counter. Both None until a build
|
||
# is resolved.
|
||
duration_millis: int | None = None
|
||
elapsed_anchor: float | None = None
|
||
|
||
# The pipeline's configured `display-name`, resolved once when watching
|
||
# starts. None until resolved, or when the pipeline sets no display name.
|
||
display_name: str | None = None
|
||
|
||
@property
|
||
def bot(self) -> str:
|
||
"""Label for the table's Bot column.
|
||
|
||
The pipeline's configured `display-name` when it has one, otherwise
|
||
its Jenkins job name.
|
||
"""
|
||
return self.display_name or self.job
|
||
|
||
@property
|
||
def is_terminal(self) -> bool:
|
||
"""Whether the pipeline has finished and no longer needs polling."""
|
||
return self.state in _TERMINAL_WATCH_STATES
|
||
|
||
def link(self, base_url: str) -> str:
|
||
"""The best link available: the build URL, else the job page."""
|
||
return self.build_url or f'{base_url}/job/{self.job}/'
|
||
|
||
|
||
class _ElapsedClock:
|
||
"""Renderable that advances a RUNNING job's elapsed time between polls.
|
||
|
||
Rich re-renders the `Live` tree on every refresh -- the same mechanism
|
||
that animates the RUNNING spinner -- so recomputing the value here lets
|
||
the counter tick smoothly at the Live refresh rate instead of jumping once
|
||
per poll. `base_millis` is the duration the server reported at the last
|
||
poll and `anchor` is the monotonic clock read at that same instant; the
|
||
rendered value is `base + (now - anchor)`. Every poll re-anchors both, so
|
||
the local ticking stays corrected by the authoritative server value and
|
||
settles on it once the build leaves RUNNING.
|
||
"""
|
||
|
||
def __init__(self, base_millis: int, anchor: float) -> None:
|
||
self._base_millis = base_millis
|
||
self._anchor = anchor
|
||
|
||
def __rich__(self) -> Text:
|
||
elapsed_millis = (self._base_millis +
|
||
(time.monotonic() - self._anchor) * 1000)
|
||
return Text(GenRustToolchain._format_duration(elapsed_millis),
|
||
justify='right')
|
||
|
||
|
||
class GenRustToolchain(Task):
|
||
"""Triggers the Rust/WASM toolchain Jenkins pipelines for a Chromium tag.
|
||
|
||
There is one pipeline per platform (see `RUST_TOOLCHAIN_JOBS`), and each
|
||
takes a single `CHROMIUM_TAG` build parameter. This task resolves the
|
||
requested version (accepting the same labels as `lift --to`, e.g.
|
||
`@latest-canary`), reads the Jenkins credentials from `~/.jenkins.json`,
|
||
and kicks off a parameterized build for every pipeline.
|
||
|
||
The expected `~/.jenkins.json` schema is:
|
||
|
||
{
|
||
"url": "https://ci.brave.com",
|
||
"username": "<ldap-user>",
|
||
"token": "<jenkins-api-token>"
|
||
}
|
||
"""
|
||
|
||
def status_message(self):
|
||
return "Triggering Rust toolchain builds..."
|
||
|
||
def run_watching(self, tag: str) -> None:
|
||
"""Entry point for `--watch` that bypasses `Task.run`.
|
||
|
||
The watch table is a `rich.live.Live` display, and rich permits only
|
||
one live display at a time. `Task.run` wraps `execute` in the shared
|
||
status spinner -- itself a live display -- so the watch flow cannot go
|
||
through it. This mirrors `run`'s banner framing but drives `execute`
|
||
directly, without the spinner.
|
||
"""
|
||
console.log(self.start_banner)
|
||
self.execute(tag=tag, watch=True)
|
||
console.log(self.end_banner)
|
||
|
||
@staticmethod
|
||
def _load_jenkins_config() -> tuple[str, str, str]:
|
||
"""Reads the Jenkins base URL and credentials from `~/.jenkins.json`.
|
||
|
||
Returns:
|
||
A `(base_url, username, token)` tuple, with any trailing slash
|
||
stripped from the base URL.
|
||
"""
|
||
if not JENKINS_CONFIG_FILE.is_file():
|
||
raise InvalidInputException(
|
||
f'Jenkins config not found at {JENKINS_CONFIG_FILE}. Create it '
|
||
'with [bold cyan]url[/], [bold cyan]username[/], and '
|
||
'[bold cyan]token[/] fields.')
|
||
|
||
try:
|
||
config = json.loads(
|
||
JENKINS_CONFIG_FILE.read_bytes().decode('utf-8'))
|
||
except json.JSONDecodeError as e:
|
||
raise InvalidInputException(
|
||
f'Failed to parse {JENKINS_CONFIG_FILE}: {e}') from e
|
||
|
||
missing = [
|
||
key for key in ('url', 'username', 'token') if not config.get(key)
|
||
]
|
||
if missing:
|
||
raise InvalidInputException(
|
||
f'{JENKINS_CONFIG_FILE} is missing required field(s): '
|
||
f'{", ".join(missing)}.')
|
||
|
||
return config['url'].rstrip('/'), config['username'], config['token']
|
||
|
||
@staticmethod
|
||
def _get_crumb(session: requests.Session, base_url: str) -> dict[str, str]:
|
||
"""Fetches a Jenkins CSRF crumb as a ready-to-merge header dict.
|
||
|
||
Returns an empty dict when the crumb issuer is unavailable. API-token
|
||
auth is usually crumb-exempt, so a missing issuer is treated as "no
|
||
crumb needed" rather than a hard failure.
|
||
"""
|
||
try:
|
||
response = session.get(f'{base_url}/crumbIssuer/api/json',
|
||
timeout=15)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
return {data['crumbRequestField']: data['crumb']}
|
||
except (requests.RequestException, KeyError, ValueError):
|
||
return {}
|
||
|
||
def execute(self, tag: str, watch: bool = False):
|
||
# Resolve the version first so a bad tag/label fails before we touch
|
||
# any Jenkins state.
|
||
version = _fetch_chromium_tag(tag)
|
||
|
||
base_url, username, token = self._load_jenkins_config()
|
||
|
||
session = requests.Session()
|
||
session.auth = (username, token)
|
||
crumb = self._get_crumb(session, base_url)
|
||
|
||
terminal.log_task(
|
||
f'Triggering Rust toolchain pipelines for Chromium {version}:')
|
||
|
||
watched: list[_WatchedJob] = []
|
||
failures: list[str] = []
|
||
for job in RUST_TOOLCHAIN_JOBS:
|
||
try:
|
||
response = session.post(
|
||
f'{base_url}/job/{job}/buildWithParameters',
|
||
params={'CHROMIUM_TAG': str(version)},
|
||
headers=crumb,
|
||
timeout=30)
|
||
response.raise_for_status()
|
||
except requests.RequestException as e:
|
||
failures.append(job)
|
||
console.log(Padding(f'✘ {job}: {e}', (0, 4)))
|
||
continue
|
||
|
||
# Jenkins' 201 Location header points at the transient queue item,
|
||
# not the build: the build number isn't assigned until an executor
|
||
# picks the job up, and the queue URL only serves JSON.
|
||
queue_url = response.headers.get('Location', '')
|
||
watched.append(_WatchedJob(job=job, queue_url=queue_url))
|
||
if not watch:
|
||
# Link the job page, which always resolves and surfaces the
|
||
# queued/running build.
|
||
terminal.log_task(f'[bold]✔️ [/]{job} ➜ {base_url}/job/{job}/')
|
||
|
||
if failures:
|
||
raise BadOutcomeException(
|
||
'Failed to trigger the following Rust toolchain pipelines:\n%s'
|
||
% '\n'.join(f' * {job}' for job in failures))
|
||
|
||
if watch:
|
||
self._watch(session, base_url, version, watched)
|
||
|
||
@staticmethod
|
||
def _get_json(session: requests.Session, url: str) -> dict | None:
|
||
"""GETs `url` and returns the parsed JSON, or None on any failure."""
|
||
try:
|
||
response = session.get(url, timeout=15)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
except (requests.RequestException, ValueError):
|
||
return None
|
||
|
||
def _resolve_display_name(self, session: requests.Session, base_url: str,
|
||
job: _WatchedJob) -> None:
|
||
"""Records a pipeline's `display-name` for the Bot column, if set.
|
||
|
||
Jenkins returns the job name as `displayName` when no display name is
|
||
configured, so a value equal to the job name is treated as "unset" and
|
||
left as None -- `_WatchedJob.bot` then falls back to the job name.
|
||
"""
|
||
info = self._get_json(
|
||
session,
|
||
f'{base_url}/job/{job.job}/api/json?tree=displayName,name')
|
||
if info is None:
|
||
return
|
||
display_name = info.get('displayName')
|
||
if display_name and display_name != info.get('name'):
|
||
job.display_name = display_name
|
||
|
||
@staticmethod
|
||
def _format_duration(millis) -> str:
|
||
"""Formats a Jenkins millisecond duration as e.g. "12m04s"."""
|
||
if not millis:
|
||
return ''
|
||
total_seconds = int(millis) // 1000
|
||
minutes, seconds = divmod(total_seconds, 60)
|
||
if minutes:
|
||
return f'{minutes}m{seconds:02d}s'
|
||
return f'{seconds}s'
|
||
|
||
def _poll_job(self, session: requests.Session, job: _WatchedJob) -> None:
|
||
"""Refreshes one pipeline's state in place.
|
||
|
||
Resolves the queue item to a build the first time an executor picks it
|
||
up, then tracks the running build's stage and result via the Pipeline
|
||
Stage View API (falling back to the plain build API).
|
||
"""
|
||
if job.is_terminal:
|
||
return
|
||
|
||
if job.build_url is None:
|
||
if not job.queue_url:
|
||
job.state = 'UNKNOWN'
|
||
job.stage = 'no queue item returned'
|
||
return
|
||
item = self._get_json(session, f'{job.queue_url}api/json')
|
||
if item is None:
|
||
return # Transient; retry on the next poll.
|
||
if item.get('cancelled'):
|
||
job.state = 'CANCELLED'
|
||
job.stage = 'queue item cancelled'
|
||
return
|
||
executable = item.get('executable')
|
||
if not executable:
|
||
# Still queued; surface Jenkins' reason (e.g. "Waiting for
|
||
# next available executor").
|
||
job.state = 'QUEUED'
|
||
job.stage = (item.get('why') or 'waiting').strip()
|
||
return
|
||
job.build_url = executable.get('url') or ''
|
||
job.state = 'RUNNING'
|
||
|
||
self._refresh_build_state(session, job)
|
||
|
||
def _record_elapsed(self, job: _WatchedJob, millis) -> None:
|
||
"""Anchors a job's elapsed time to the latest server-reported duration.
|
||
|
||
Stores the raw duration and a monotonic timestamp so a RUNNING row can
|
||
tick forward between polls (see `_ElapsedClock`), and keeps the
|
||
formatted string used to render every non-running row.
|
||
"""
|
||
job.duration_millis = int(millis) if millis else None
|
||
job.elapsed_anchor = time.monotonic()
|
||
job.elapsed = self._format_duration(millis)
|
||
|
||
def _refresh_build_state(self, session: requests.Session,
|
||
job: _WatchedJob) -> None:
|
||
"""Updates state/stage/elapsed for a job that already has a build."""
|
||
describe = self._get_json(session, f'{job.build_url}wfapi/describe')
|
||
if describe is not None:
|
||
job.state = _WFAPI_STATUS_TO_STATE.get(describe.get('status', ''),
|
||
job.state)
|
||
self._record_elapsed(job, describe.get('durationMillis'))
|
||
stages = describe.get('stages') or []
|
||
running = [s for s in stages if s.get('status') == 'IN_PROGRESS']
|
||
if running:
|
||
# The deepest reported in-progress stage is the most specific.
|
||
job.stage = running[-1].get('name', '')
|
||
elif job.is_terminal:
|
||
job.stage = '(done)'
|
||
elif stages:
|
||
job.stage = stages[-1].get('name', '')
|
||
return
|
||
|
||
# Fallback for jobs without the Stage View plugin: the plain build API
|
||
# gives building/result but no per-stage detail.
|
||
info = self._get_json(session, f'{job.build_url}api/json')
|
||
if info is None:
|
||
return
|
||
self._record_elapsed(job, info.get('duration'))
|
||
if info.get('building'):
|
||
job.state = 'RUNNING'
|
||
job.stage = 'building'
|
||
else:
|
||
job.state = info.get('result') or 'UNKNOWN'
|
||
job.stage = '(done)'
|
||
|
||
@staticmethod
|
||
def _state_cell(state: str) -> str | Spinner:
|
||
"""Renders the State column.
|
||
|
||
RUNNING gets an animated throbber (the `Live` display drives the
|
||
animation); every other state is a static icon + label.
|
||
"""
|
||
if state == 'RUNNING':
|
||
return Spinner('dots', text='RUNNING', style='cyan')
|
||
icon, style = _WATCH_STATE_STYLE.get(state, ('?', 'dim'))
|
||
return f'[{style}]{icon} {state}[/]'
|
||
|
||
@staticmethod
|
||
def _elapsed_cell(job: _WatchedJob) -> str | _ElapsedClock:
|
||
"""Renders the Elapsed column.
|
||
|
||
A RUNNING build with a resolved duration ticks forward between polls
|
||
via `_ElapsedClock`; every other state shows the static,
|
||
server-accurate value captured at the last poll.
|
||
"""
|
||
if (job.state == 'RUNNING' and job.duration_millis is not None
|
||
and job.elapsed_anchor is not None):
|
||
return _ElapsedClock(job.duration_millis, job.elapsed_anchor)
|
||
return job.elapsed or '—'
|
||
|
||
@staticmethod
|
||
def _link_cell(url: str) -> Text:
|
||
"""Renders a URL as a styled, clickable hyperlink for the Build column.
|
||
|
||
Table cells skip the `ReprHighlighter` that `console.log`/`print` run
|
||
over their output, so a bare URL string renders as plain text. Wrapping
|
||
it in a `Text` with a `link` style emits the OSC 8 hyperlink escape
|
||
(clickable in capable terminals) and the blue underline mirrors the
|
||
look Rich gives auto-detected URLs elsewhere in brockit.
|
||
"""
|
||
return Text(url, style=f'underline blue link {url}')
|
||
|
||
@staticmethod
|
||
def _dim_cell(renderable: str | Text, dim: bool) -> str | Text:
|
||
"""Dims a non-state cell for finished, non-successful rows.
|
||
|
||
Returns the renderable untouched when `dim` is False. Otherwise wraps
|
||
it in Rich's `dim` style (preserving any existing style, such as the
|
||
Build column's hyperlink) so the whole row reads as muted -- except the
|
||
State cell, which the caller leaves coloured so the outcome stands out.
|
||
"""
|
||
if not dim:
|
||
return renderable
|
||
text = renderable if isinstance(renderable, Text) else Text(
|
||
str(renderable))
|
||
text.stylize('dim')
|
||
return text
|
||
|
||
def _render_table(self, version: Version, base_url: str,
|
||
jobs: list[_WatchedJob]) -> Table:
|
||
"""Builds the per-pipeline progress table rendered in place by Live."""
|
||
table = Table(title=f'Rust toolchain · Chromium {version}',
|
||
title_justify='left',
|
||
box=_WATCH_TABLE_BOX,
|
||
show_edge=False,
|
||
expand=True)
|
||
table.add_column('Bot', no_wrap=True)
|
||
table.add_column('State', no_wrap=True)
|
||
table.add_column('Stage', no_wrap=True)
|
||
table.add_column('Elapsed', justify='right', no_wrap=True)
|
||
table.add_column('Build', overflow='fold')
|
||
for job in jobs:
|
||
# A job that has finished as anything other than SUCCESS is dimmed
|
||
# across the row, except its State cell, which keeps its colour so
|
||
# the failure still stands out.
|
||
dim = job.is_terminal and job.state != 'SUCCESS'
|
||
table.add_row(
|
||
self._dim_cell(job.bot, dim), self._state_cell(job.state),
|
||
self._dim_cell(job.stage or '—', dim),
|
||
self._dim_cell(self._elapsed_cell(job), dim),
|
||
self._dim_cell(self._link_cell(job.link(base_url)), dim))
|
||
return table
|
||
|
||
def _watch(self, session: requests.Session, base_url: str,
|
||
version: Version, jobs: list[_WatchedJob]) -> None:
|
||
"""Polls the triggered pipelines, updating an in-place table until all
|
||
finish or the user interrupts with Ctrl+C (which detaches and leaves
|
||
the builds running).
|
||
"""
|
||
terminal.log_task('Watching pipelines — press [bold cyan]Ctrl+C[/] to '
|
||
'stop watching (builds keep running).')
|
||
# Resolve each pipeline's display name once up front so the Bot column
|
||
# shows it from the very first render.
|
||
for job in jobs:
|
||
self._resolve_display_name(session, base_url, job)
|
||
try:
|
||
# The throbber on RUNNING rows animates off the Live's own refresh
|
||
# (~12.5fps matches the `dots` spinner cadence); the data itself is
|
||
# only re-polled every WATCH_POLL_INTERVAL_SECONDS.
|
||
with Live(self._render_table(version, base_url, jobs),
|
||
console=console,
|
||
refresh_per_second=12.5) as live:
|
||
while True:
|
||
for job in jobs:
|
||
self._poll_job(session, job)
|
||
live.update(self._render_table(version, base_url, jobs))
|
||
if all(job.is_terminal for job in jobs):
|
||
break
|
||
time.sleep(WATCH_POLL_INTERVAL_SECONDS)
|
||
except KeyboardInterrupt:
|
||
# Detach cleanly: the builds keep running on CI.
|
||
console.log('[yellow]Stopped watching. Builds continue on CI:[/]')
|
||
for job in jobs:
|
||
console.log(Padding(f'{job.bot}: {job.link(base_url)}',
|
||
(0, 4)))
|
||
|
||
|
||
# Installer whose `EXTRA_DEPS` rust-toolchain entry is repinned by
|
||
# `update-rust-wasm-toolchain` (relative to repository.brave.root).
|
||
INSTALL_EXTRA_DEPS_FILE = Path('tools/cr/toolchains/install_extra_deps.py')
|
||
|
||
# Chromium-side scripts that pin the Rust/Clang revision the toolchain is built
|
||
# from, and the constants in them that identify it. `update-rust-wasm-toolchain`
|
||
# pickaxes these scripts for the commit that introduced the current values to
|
||
# attribute the toolchain update (the same constants `_check_rust_toolchain`
|
||
# reads).
|
||
RUST_REVISION_FILES = ('tools/rust/update_rust.py',
|
||
'tools/clang/scripts/update.py')
|
||
RUST_REVISION_KEYS = ('RUST_REVISION', 'RUST_SUB_REVISION', 'CLANG_REVISION')
|
||
|
||
|
||
def _render_py_literal(value: object, indent: int = 0) -> str:
|
||
"""Render a str/bool/dict/list literal as multi-line Python source.
|
||
|
||
Matches the layout `install_extra_deps.py` already uses -- 4-space
|
||
indent steps, single-quoted strings, trailing commas -- so re-rendering
|
||
`EXTRA_DEPS` round-trips through yapf unchanged.
|
||
"""
|
||
pad = ' ' * indent
|
||
child = ' ' * (indent + 4)
|
||
if isinstance(value, dict):
|
||
if not value:
|
||
return '{}'
|
||
items = ''.join(f'{child}{_render_py_literal(key)}: '
|
||
f'{_render_py_literal(val, indent + 4)},\n'
|
||
for key, val in value.items())
|
||
return f'{{\n{items}{pad}}}'
|
||
if isinstance(value, list):
|
||
if not value:
|
||
return '[]'
|
||
items = ''.join(f'{child}{_render_py_literal(item, indent + 4)},\n'
|
||
for item in value)
|
||
return f'[\n{items}{pad}]'
|
||
if isinstance(value, bool):
|
||
return 'True' if value else 'False'
|
||
if isinstance(value, str):
|
||
# Single-quoted to match the file. Values here never contain a single
|
||
# quote or backslash; fall back to repr only defensively.
|
||
if "'" in value or '\\' in value:
|
||
return repr(value)
|
||
return f"'{value}'"
|
||
return repr(value)
|
||
|
||
|
||
class UpdateRustWasmToolchain(Task):
|
||
"""Updates the rust WASM entry in `install_extra_deps.py`.
|
||
|
||
This task is used to update the Rust/WASM toolchain pins to be used during
|
||
checkout of Brave. This is a very simple helper, and all it does is take
|
||
care to retrieve the details for the latest published Rust/WASM toolchain
|
||
matching the version currently selected in Chromium, and with that it
|
||
rewrites the `EXTRA_DEPS` details for the rust-toolchain entry in
|
||
`install_extra_deps.py`.
|
||
|
||
After the file for the deps is updated, the tasks also commits a change with
|
||
a culprit.
|
||
"""
|
||
|
||
def status_message(self):
|
||
return "Updating Rust/WASM toolchain pins..."
|
||
|
||
@staticmethod
|
||
def _load_extra_deps(source: str) -> tuple[ast.Assign, dict]:
|
||
"""Return the `EXTRA_DEPS = {...}` assignment node and its dict value.
|
||
|
||
The value is read with `ast.literal_eval`, so the installer is parsed
|
||
as data rather than imported (importing it pulls in gclient).
|
||
"""
|
||
for node in ast.parse(source).body:
|
||
if (isinstance(node, ast.Assign) and len(node.targets) == 1
|
||
and isinstance(node.targets[0], ast.Name)
|
||
and node.targets[0].id == 'EXTRA_DEPS'):
|
||
return node, ast.literal_eval(node.value)
|
||
raise InvalidInputException(
|
||
f'No EXTRA_DEPS assignment found in {INSTALL_EXTRA_DEPS_FILE}.')
|
||
|
||
@staticmethod
|
||
def _upstream_stem(text: str) -> str:
|
||
"""Build the upstream toolchain package stem from the revision scripts.
|
||
|
||
Matches `package_rust.RUST_TOOLCHAIN_PACKAGE_NAME` minus the `.tar.xz`
|
||
suffix --
|
||
`rust-toolchain-<RUST_REVISION>-<RUST_SUB_REVISION>-<CLANG_REVISION>` --
|
||
built from the scripts' source so brockit needn't import `package_rust`
|
||
(which pulls in gclient). Value patterns mirror
|
||
`tools/clang/scripts/upload_revision.py`: quoted strings, bare-integer
|
||
sub-revision.
|
||
"""
|
||
|
||
def read(key: str, value: str) -> str:
|
||
match = re.search(rf'{key} = {value}', text)
|
||
if not match:
|
||
raise InvalidInputException(
|
||
f'{key} not found in {", ".join(RUST_REVISION_FILES)}.')
|
||
return match.group(1)
|
||
|
||
quoted, integer = r"'([0-9a-z-]+)'", r'([0-9]+)'
|
||
return ('rust-toolchain-'
|
||
f'{read("RUST_REVISION", quoted)}-'
|
||
f'{read("RUST_SUB_REVISION", integer)}-'
|
||
f'{read("CLANG_REVISION", quoted)}')
|
||
|
||
@staticmethod
|
||
def _resolve_chromium_culprit(text: str, ref: str) -> str:
|
||
"""Finds the commit that updated the Rust toolchain in Chromium.
|
||
|
||
This function determines which commit is reponsible for the current
|
||
rust toolchain versionin in Chromium, using `ref` as a starting point..
|
||
"""
|
||
_ensure_chromium_tags(ref)
|
||
|
||
def assignment_pattern(name: str) -> str:
|
||
match = re.search(rf'(?m)^{name} = (.+)$', text)
|
||
if not match:
|
||
raise InvalidInputException(
|
||
f'Could not read {name} from '
|
||
f'{", ".join(RUST_REVISION_FILES)}.')
|
||
value = match.group(1).split('#', 1)[0].strip()
|
||
return f'{name} = {re.escape(value)}'
|
||
|
||
regex = '|'.join(
|
||
assignment_pattern(name) for name in RUST_REVISION_KEYS)
|
||
commit_hash = repository.chromium.run_git('log', '--extended-regexp',
|
||
'-G', regex, '--pretty=%H',
|
||
'-1', ref, '--',
|
||
*RUST_REVISION_FILES)
|
||
if not commit_hash:
|
||
raise InvalidInputException(
|
||
'Could not find a Chromium commit pinning the Rust/Clang '
|
||
f'revision at {ref}. Pass [bold cyan]--culprit[/] to point '
|
||
'at it explicitly.')
|
||
return commit_hash
|
||
|
||
@staticmethod
|
||
def _commit_title(new_entry: dict) -> str:
|
||
"""Build a commit subject naming the exact toolchain build being pinned.
|
||
|
||
We build the title to show the new toolchain's relevant details, being
|
||
the rust version, and the clang revision it was built with. The
|
||
trailing `sub` is the upstream rust sub revision (RUST_SUB_REVISION),
|
||
not Brave's respin counter. The result is something like this:
|
||
|
||
Rust/WASM toolchain (4c4205163abc-5, llvmorg-23-init-10931-g20b6ec77, sub 5)
|
||
|
||
The values in the title are extracted form the tarball's name.
|
||
"""
|
||
object_name = next(iter(
|
||
new_entry.values()))['objects'][0]['object_name']
|
||
name = object_name.removesuffix('.tar.xz')
|
||
match = re.search(
|
||
r'rust-toolchain-(?P<rust>[0-9a-f]+)-(?P<sub>\d+)-'
|
||
r'(?P<clang>llvmorg-.+)-(?P<brave>\d+)$', name)
|
||
if not match:
|
||
return f'Rust/WASM toolchain {name}'
|
||
return (f'Rust/WASM toolchain ({match["rust"][:12]}-{match["sub"]}, '
|
||
f'{match["clang"]}, sub {match["sub"]})')
|
||
|
||
def execute(self, chromium_ref: str, culprit: str | None):
|
||
status = GitStatus()
|
||
if status.has_staged_files():
|
||
raise InvalidInputException(
|
||
'Staged files detected. Please commit or unstage changes '
|
||
'before generating a toolchain update:\n%s' %
|
||
'\n'.join(status.get_all_staged_entries()))
|
||
|
||
# Resolve to a concrete Chromium tag (supports the @latest-* labels),
|
||
# make sure the tag is available locally, then read both revision
|
||
# scripts at that ref in a single `git show`.
|
||
version = _fetch_chromium_tag(chromium_ref)
|
||
ref = str(version)
|
||
_ensure_chromium_tags(ref)
|
||
revision_text = repository.chromium.read_file(*RUST_REVISION_FILES,
|
||
commit=ref)
|
||
upstream_stem = self._upstream_stem(revision_text)
|
||
|
||
try:
|
||
new_entry = build_rust_toolchain.rust_toolchain_extra_dep(
|
||
upstream_stem)
|
||
except RuntimeError as e:
|
||
raise BadOutcomeException(str(e)) from e
|
||
|
||
path = repository.brave.root / INSTALL_EXTRA_DEPS_FILE
|
||
source = path.read_bytes().decode('utf-8')
|
||
node, extra_deps = self._load_extra_deps(source)
|
||
|
||
# Replace the rust-toolchain entry (in place, preserving key order),
|
||
# then re-render the whole EXTRA_DEPS assignment.
|
||
extra_deps.update(new_entry)
|
||
rendered = f'EXTRA_DEPS = {_render_py_literal(extra_deps)}\n'
|
||
lines = source.splitlines(keepends=True)
|
||
new_source = (''.join(lines[:node.lineno - 1]) + rendered +
|
||
''.join(lines[node.end_lineno:]))
|
||
|
||
if new_source == source:
|
||
terminal.log_task(
|
||
f'{INSTALL_EXTRA_DEPS_FILE} is already up to date; '
|
||
'nothing to commit.')
|
||
return
|
||
|
||
path.write_text(new_source, encoding='utf-8', newline='')
|
||
|
||
commit_hash = culprit or self._resolve_chromium_culprit(
|
||
revision_text, ref)
|
||
repository.brave.run_git('add', INSTALL_EXTRA_DEPS_FILE)
|
||
repository.brave.git_commit(self._commit_title(new_entry),
|
||
env={
|
||
**os.environ,
|
||
'tags': 'toolchain',
|
||
'culprit': commit_hash,
|
||
})
|
||
|
||
|
||
def fetch_chromium_dash_version(channel: str) -> Version:
|
||
"""Fetches the highest latest version across all platforms for a channel.
|
||
|
||
For the canary channel, the ASAN variant on Windows is also considered.
|
||
"""
|
||
|
||
def _fetch(channel: str, target_platform: str) -> Version:
|
||
response = requests.get(CHROMIUMDASH_LATEST_RELEASE.format(
|
||
channel=channel, platform=target_platform),
|
||
timeout=10)
|
||
return Version(response.json()[0].get('version'))
|
||
|
||
platforms = ('Windows', 'Linux', 'Android', 'Mac', 'ios')
|
||
versions = [_fetch(channel, platform) for platform in platforms]
|
||
if channel == 'canary':
|
||
versions.append(_fetch('canary_asan', target_platform='Windows'))
|
||
return max(versions)
|
||
|
||
|
||
def _fetch_chromium_tag(to: str) -> Version:
|
||
"""Resolves the --to flag value to a Version.
|
||
|
||
+---------------------------+---------------------------------------------+
|
||
| Labels | Description |
|
||
+---------------------------+---------------------------------------------+
|
||
| @latest-tag | Latest tag in the Chromium repo |
|
||
| @latest-m{MAJOR} | Latest tag for the given major version |
|
||
| @latest-for-branch | Latest tag for the major inferred from the |
|
||
| | current branch name ( format cr{MAJOR}) |
|
||
| @latest-canary | Latest canary release from ChromiumDash |
|
||
| @latest-beta | Latest beta release from ChromiumDash |
|
||
| @latest-dev | Latest dev release from ChromiumDash |
|
||
| @latest-stable | Latest stable release from ChromiumDash |
|
||
+---------------------------+---------------------------------------------+
|
||
"""
|
||
|
||
# If not a label, then this value is expected to be a valid Chromium
|
||
# version.
|
||
if not to.startswith('@'):
|
||
return Version(to)
|
||
|
||
if to == '@latest-for-branch':
|
||
branch = repository.brave.current_branch()
|
||
match = re.fullmatch(r'cr(\d+)', branch)
|
||
if not match:
|
||
raise InvalidInputException(
|
||
'@latest-for-branch requires the current branch to be named '
|
||
f'cr{{MAJOR}} (e.g. cr135), but the current branch is '
|
||
f'"{branch}".')
|
||
return _fetch_chromium_tag(f'@latest-m{match.group(1)}')
|
||
|
||
if to == '@latest-tag':
|
||
version = Version.get_latest_googlesource_tag_version()
|
||
if version is None:
|
||
raise InvalidInputException(
|
||
'Could not fetch latest Googlesource tag.')
|
||
return version
|
||
if to.startswith('@latest-m'):
|
||
major_str = to[len('@latest-m'):]
|
||
if not major_str.isdigit():
|
||
raise InvalidInputException(
|
||
f'Invalid major version in "{to}": '
|
||
f'"{major_str}" is not a valid integer.')
|
||
version = Version.get_latest_googlesource_tag_version(
|
||
major=int(major_str))
|
||
if version is None:
|
||
raise InvalidInputException(
|
||
'Could not find a Googlesource tag for major version '
|
||
f'{major_str}.')
|
||
return version
|
||
if to.startswith('@latest-'):
|
||
[_, channel] = to.split('-', 1)
|
||
if channel not in ('canary', 'beta', 'dev', 'stable'):
|
||
raise InvalidInputException(
|
||
f'Invalid @latest channel: "{channel}". '
|
||
'Valid options: canary, beta, dev, stable.')
|
||
|
||
return fetch_chromium_dash_version(channel)
|
||
|
||
raise InvalidInputException(
|
||
f'Unknown label: "{to}". '
|
||
'Valid labels: @latest-tag, @latest-m{MAJOR}, @latest-for-branch, '
|
||
'@latest-canary, @latest-beta, @latest-dev, @latest-stable.')
|
||
|
||
|
||
def show(args: argparse.Namespace):
|
||
"""Prints various insights about brave-core.
|
||
|
||
This is a helper command line that allows us to inspect a few things about
|
||
brave-core and how brockit process things.
|
||
"""
|
||
if args.package_version:
|
||
console.print(f'upstream version: {Version.from_git("HEAD")}')
|
||
|
||
if args.from_ref_value is not None:
|
||
from_ref_value = Version.from_git(_solve_brave_ref(
|
||
args.from_ref_value))
|
||
if from_ref_value is not None:
|
||
console.print(f'base version: {from_ref_value}')
|
||
|
||
if args.log_link:
|
||
console.print('googlesource link: %s' %
|
||
Version.from_git('HEAD').get_googlesource_diff_link(
|
||
Version.from_git(_solve_brave_ref('@previous'))))
|
||
|
||
if args.chromium_version_label is not None:
|
||
console.print('version: %s' %
|
||
_fetch_chromium_tag(args.chromium_version_label))
|
||
|
||
|
||
def main():
|
||
# This is a global parser with arguments that apply to every function.
|
||
global_parser = argparse.ArgumentParser(add_help=False)
|
||
global_parser.add_argument(
|
||
'--verbose',
|
||
action='store_true',
|
||
help='Produces verbose logs (full command lines being executed, etc).')
|
||
global_parser.add_argument(
|
||
'--infra-mode',
|
||
action='store_true',
|
||
help=
|
||
('Indicates that the script is being run in the infra environment. '
|
||
'This changes the script output, specially providing feedback for the '
|
||
'CI to be kept alive.'),
|
||
dest='infra_mode')
|
||
|
||
# The `--from-ref` parse is used by multiple operations.
|
||
base_version_parser = argparse.ArgumentParser(
|
||
add_help=False, formatter_class=argparse.RawTextHelpFormatter)
|
||
base_version_parser.add_argument(
|
||
'--from-ref',
|
||
help=
|
||
('A brave-core git reference for the Chromium version to upgrade\n'
|
||
'from (branch, commit hash, tag, etc.), or one of these labels:\n'
|
||
' @upstream Upstream branch of the current branch (default)\n'
|
||
' @previous Commit just before the last version bump\n'
|
||
' @previous-major Commit just before the last major version bump'),
|
||
default=None)
|
||
|
||
parser = argparse.ArgumentParser()
|
||
|
||
subparsers = parser.add_subparsers(dest='command', required=True)
|
||
lift_parser = subparsers.add_parser(
|
||
'lift',
|
||
parents=[global_parser, base_version_parser],
|
||
formatter_class=argparse.RawTextHelpFormatter,
|
||
help='Upgrade the chromium base version. Special tags: '
|
||
'@latest-[beta|dev|canary] pulls the version from chromium dash; '
|
||
'@latest-tag pulls the latest tag from Googlesource.')
|
||
lift_parser.add_argument(
|
||
'--to',
|
||
required=True,
|
||
help=(
|
||
'The Chromium version to upgrade to (e.g. 147.0.7727.117),\n'
|
||
'or one of the following labels:\n'
|
||
' @latest-tag Latest tag from the Chromium Googlesource'
|
||
' repo\n'
|
||
' @latest-m{MAJOR} Latest Googlesource tag for the given major'
|
||
' version\n'
|
||
' @latest-for-branch Latest tag for the major inferred from the\n'
|
||
' current branch name (must be named'
|
||
' cr{MAJOR})\n'
|
||
' @latest-canary Latest canary release from ChromiumDash\n'
|
||
' @latest-beta Latest beta release from ChromiumDash\n'
|
||
' @latest-dev Latest dev release from ChromiumDash\n'
|
||
' @latest-stable Latest stable release from ChromiumDash'),
|
||
)
|
||
lift_parser.add_argument(
|
||
'--continue',
|
||
action='store_true',
|
||
help='Resumes from manual patch conflict resolution.',
|
||
dest='is_continuation')
|
||
lift_parser.add_argument(
|
||
'--ack-advisory',
|
||
action='store_true',
|
||
help=
|
||
'Added to indicate that pre-run check advisory has been acknowledged.')
|
||
lift_parser.add_argument(
|
||
'--restart',
|
||
action='store_true',
|
||
help='Resumes from manual patch conflict resolution.')
|
||
lift_parser.add_argument(
|
||
'--with-github',
|
||
action='store_true',
|
||
help='Creates or updates the github for this branch.',
|
||
dest='with_github')
|
||
lift_parser.add_argument(
|
||
'--no-conflict-change',
|
||
action='store_true',
|
||
help='Indicates that a continuation does not have conflict patches to '
|
||
'commit any longer.',
|
||
dest='no_conflict')
|
||
|
||
regen_parser = subparsers.add_parser(
|
||
'regen',
|
||
parents=[global_parser, base_version_parser],
|
||
help='Regenerates all patches and strings for the current branch.')
|
||
regen_parser.add_argument(
|
||
'--dry-run',
|
||
action='store_true',
|
||
help='Nothing is commited to git with this flag.')
|
||
|
||
rebase_parser = subparsers.add_parser(
|
||
'rebase',
|
||
parents=[global_parser, base_version_parser],
|
||
help='Rebases the current branch.')
|
||
rebase_parser.add_argument(
|
||
'--to-ref',
|
||
default=None,
|
||
help='The branch you are rebasing to. Defaults to the upstream branch.'
|
||
)
|
||
rebase_parser.add_argument(
|
||
'--recommit',
|
||
action='store_true',
|
||
help=
|
||
'Even if there is nothing to rebase, do a rebase to recommit changes.')
|
||
rebase_parser.add_argument(
|
||
'--discard-regen-changes',
|
||
action='store_true',
|
||
help=
|
||
'Discard patches like "Update patches" and "Updated strings" that can '
|
||
'be regenerated.')
|
||
rebase_parser.add_argument(
|
||
'--squash-minor-bumps',
|
||
action='store_true',
|
||
help=
|
||
'Squashes all the minor bumps in-between the the last version and the '
|
||
'previous upstream ref.')
|
||
rebase_parser.add_argument(
|
||
'--v2',
|
||
action='store_true',
|
||
help='Use the Rebase v2 code path (rebase_v2.py).')
|
||
|
||
subparsers.add_parser(
|
||
'update-version-issue',
|
||
parents=[global_parser, base_version_parser],
|
||
help='Creates or updates the GitHub issue for the corrent branch.')
|
||
|
||
show_parser = subparsers.add_parser(
|
||
'show', help='Prints various insights about brave-core.')
|
||
show_parser.add_argument(
|
||
'--package-version',
|
||
action='store_true',
|
||
help='Shows the current Chromium version in package.')
|
||
show_parser.add_argument(
|
||
'--from-ref-value',
|
||
help='Shows the Chromium version from a git reference.',
|
||
default=None,
|
||
dest='from_ref_value')
|
||
show_parser.add_argument('--log-link',
|
||
action='store_true',
|
||
help='Prints the git log links to googlesource.')
|
||
show_parser.add_argument(
|
||
'--chromium-version-label',
|
||
default=None,
|
||
help='Prints the version for the given label (e.g. @latest-canary).')
|
||
|
||
reassign_parser = subparsers.add_parser(
|
||
'reassign',
|
||
parents=[global_parser],
|
||
help=(
|
||
f'Creates a {REASSIGN_COMMIT_MSG_PREFIX} commit for a given change '
|
||
'to change authorship to current user.'))
|
||
reassign_parser.add_argument(
|
||
'change',
|
||
help='The commit reference to reassign (hash, HEAD~N, etc.).')
|
||
|
||
drop_parser = subparsers.add_parser(
|
||
'drop',
|
||
parents=[global_parser],
|
||
help=(f'Creates a {DROP_COMMIT_MSG_PREFIX} commit marking a given '
|
||
'change to be dropped during rebase.'))
|
||
drop_parser.add_argument(
|
||
'change', help='The commit reference to drop (hash, HEAD~N, etc.).')
|
||
|
||
update_xcode_parser = subparsers.add_parser(
|
||
'update-xcode-toolchain',
|
||
parents=[global_parser],
|
||
formatter_class=argparse.RawTextHelpFormatter,
|
||
help='Pins build/mac/download_hermetic_xcode.py to the published '
|
||
'hermetic Xcode toolchain for a Chromium tag\'s macOS SDK, and commits '
|
||
'the change.')
|
||
update_xcode_parser.add_argument(
|
||
'--to',
|
||
required=True,
|
||
dest='to',
|
||
help=(
|
||
'The Chromium version whose pinned macOS SDK to repin against\n'
|
||
'(e.g. 150.0.7850.1), or one of the @latest-* labels accepted by\n'
|
||
'`lift --to` (e.g. @latest-canary, @latest-m150,\n'
|
||
'@latest-for-branch, @latest-tag).'))
|
||
update_xcode_parser.add_argument(
|
||
'--culprit',
|
||
default=None,
|
||
help='Chromium commit hash to reference in the commit body. Defaults '
|
||
'to auto-detecting the commit that pinned the macOS SDK in '
|
||
f'{CHROMIUM_MAC_SDK_GNI}.',
|
||
dest='culprit')
|
||
|
||
gen_rust_parser = subparsers.add_parser(
|
||
'gen-rust-toolchain',
|
||
parents=[global_parser],
|
||
formatter_class=argparse.RawTextHelpFormatter,
|
||
help='Triggers the Rust/WASM toolchain Jenkins pipelines for a '
|
||
'Chromium tag. Requires credentials in ~/.jenkins.json.')
|
||
gen_rust_parser.add_argument(
|
||
'tag',
|
||
help=('The Chromium version to build the toolchain for (e.g.\n'
|
||
'150.0.7850.1), or one of the @latest-* labels accepted by\n'
|
||
'`lift --to` (e.g. @latest-canary, @latest-m150,\n'
|
||
'@latest-for-branch, @latest-tag).'))
|
||
gen_rust_parser.add_argument(
|
||
'--watch',
|
||
action='store_true',
|
||
help='After triggering, show a live-updating table of each '
|
||
"pipeline's\nstage and status until all finish. Press Ctrl+C to stop "
|
||
'watching;\nthe builds keep running.')
|
||
|
||
update_rust_parser = subparsers.add_parser(
|
||
'update-rust-wasm-toolchain',
|
||
parents=[global_parser],
|
||
formatter_class=argparse.RawTextHelpFormatter,
|
||
help='Repins the Rust/WASM toolchain objects in '
|
||
'tools/cr/toolchains/install_extra_deps.py to the latest published '
|
||
'archives for a Chromium tag\'s Rust+Clang revision, and commits the '
|
||
'change.')
|
||
update_rust_parser.add_argument(
|
||
'--to',
|
||
required=True,
|
||
dest='to',
|
||
help=(
|
||
'The Chromium version whose Rust+Clang revision to repin against\n'
|
||
'(e.g. 150.0.7850.1), or one of the @latest-* labels accepted by\n'
|
||
'`lift --to` (e.g. @latest-canary, @latest-m150,\n'
|
||
'@latest-for-branch, @latest-tag).'))
|
||
update_rust_parser.add_argument(
|
||
'--culprit',
|
||
default=None,
|
||
help='Chromium commit hash to reference in the commit body. Defaults '
|
||
'to the last Chromium commit touching the Rust/Clang revision '
|
||
'(tools/rust/update_rust.py, tools/clang/scripts/update.py).',
|
||
dest='culprit')
|
||
|
||
subparsers.add_parser('reference',
|
||
help='Detailed documentation for this tool.')
|
||
args = parser.parse_args()
|
||
|
||
logging.basicConfig(level=logging.DEBUG if is_verbose() else logging.INFO,
|
||
format='%(message)s',
|
||
handlers=[IncendiaryErrorHandler()],
|
||
force=True)
|
||
|
||
if hasattr(args, 'infra_mode') and args.infra_mode:
|
||
terminal.set_infra_mode()
|
||
|
||
if hasattr(args, 'from_ref'):
|
||
if args.command == 'lift' and args.is_continuation:
|
||
if args.from_ref is not None:
|
||
parser.error(
|
||
'Switch --from-ref not supported with --continue.')
|
||
|
||
def resolve_version_with_from_ref_arg() -> Version:
|
||
return Version.from_git(_solve_brave_ref(args.from_ref))
|
||
|
||
if args.command == 'lift' and args.no_conflict and not args.is_continuation:
|
||
parser.error('--no-conflict-change can only be used with --continue')
|
||
if args.command == 'lift' and args.restart and args.is_continuation:
|
||
parser.error('--restart does not support --continue')
|
||
if args.command == 'lift' and args.ack_advisory and args.is_continuation:
|
||
parser.error('--ack-advisory does not support --continue')
|
||
try:
|
||
if args.command == 'lift':
|
||
target = _fetch_chromium_tag(args.to)
|
||
if args.restart:
|
||
ReUpgrade(target).run()
|
||
|
||
if not args.is_continuation:
|
||
upgrade = Upgrade(target, args.is_continuation,
|
||
resolve_version_with_from_ref_arg())
|
||
else:
|
||
upgrade = Upgrade(_fetch_chromium_tag(args.to),
|
||
args.is_continuation)
|
||
|
||
upgrade.run(no_conflict_continuation=args.no_conflict,
|
||
with_github=args.with_github,
|
||
ack_advisory=args.ack_advisory)
|
||
if args.command == 'rebase':
|
||
Rebase().run(from_ref=args.from_ref,
|
||
to_ref=args.to_ref,
|
||
recommit=args.recommit,
|
||
discard_regen_changes=args.discard_regen_changes,
|
||
squash_minor_bumps=args.squash_minor_bumps,
|
||
v2=args.v2)
|
||
if args.command == 'regen':
|
||
Regen(
|
||
resolve_version_with_from_ref_arg()).run(dry_run=args.dry_run)
|
||
if args.command == 'update-version-issue':
|
||
GitHubIssue(resolve_version_with_from_ref_arg()).run()
|
||
if args.command == 'reassign':
|
||
Reassign().run(change=args.change)
|
||
if args.command == 'drop':
|
||
Drop().run(change=args.change)
|
||
if args.command == 'update-xcode-toolchain':
|
||
UpdateXcodeToolchain().run(chromium_ref=args.to,
|
||
culprit=args.culprit)
|
||
if args.command == 'gen-rust-toolchain':
|
||
task = GenRustToolchain()
|
||
if args.watch:
|
||
# `--watch` provdes live updates, therefore it doesn't use the
|
||
# spinner provided by `Task.run`.
|
||
task.run_watching(tag=args.tag)
|
||
else:
|
||
task.run(tag=args.tag)
|
||
if args.command == 'update-rust-wasm-toolchain':
|
||
UpdateRustWasmToolchain().run(chromium_ref=args.to,
|
||
culprit=args.culprit)
|
||
if args.command == 'reference':
|
||
console.print(Markdown(__doc__))
|
||
if args.command == 'show':
|
||
show(args)
|
||
except (InvalidInputException, BadOutcomeException, ActionNeededException):
|
||
return 1
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
if any(arg.startswith("--internal-rebase") for arg in sys.argv):
|
||
# Special flags used to carry out some of the rebase tasks fed by git
|
||
# during rebase --interactive mode.
|
||
if '--internal-rebase-remove-regen-changes' in sys.argv:
|
||
Rebase.discard_regen_changes_from_rebase_plan(Path(sys.argv[-1]))
|
||
if '--internal-rebase-recommit' in sys.argv:
|
||
Rebase.recommit_in_rebase_plan(Path(sys.argv[-1]))
|
||
if '--internal-rebase-squash-minor-bumps' in sys.argv:
|
||
Rebase.squash_minor_bumps_from_rebase_plan(Path(sys.argv[-1]))
|
||
if '--internal-rebase-squash-commit-message' in sys.argv:
|
||
Rebase.fix_squash_commit_messages(Path(sys.argv[-1]))
|
||
|
||
def _crash_editor_from_argv(flag_prefix: str) -> str:
|
||
"""Pulls a `--<flag_prefix>=<editor>` value out of `sys.argv`.
|
||
|
||
Raises `NotImplementedError` when the flag is absent or its
|
||
value is empty -- `Rebase.execute` appends this flag
|
||
unconditionally for v2 rebase steps, so a missing value
|
||
means a wiring bug rather than something recoverable at
|
||
runtime.
|
||
"""
|
||
prefix = f'{flag_prefix}='
|
||
for arg in sys.argv:
|
||
if arg.startswith(prefix):
|
||
value = arg[len(prefix):]
|
||
if value:
|
||
return value
|
||
break
|
||
raise NotImplementedError(
|
||
f'Expected --{flag_prefix}=<editor> in sys.argv but '
|
||
f'none was found (or it was empty). `Rebase.execute` '
|
||
f'should have appended it for this v2 dispatch.')
|
||
|
||
if any(a.startswith('--internal-rebase-v2-plan-') for a in sys.argv):
|
||
editor_path = Path(sys.argv[-1])
|
||
crash_editor = _crash_editor_from_argv(
|
||
'--internal-rebase-crash-sequence-editor')
|
||
try:
|
||
rebase_v2.rewrite_plan(
|
||
todo_file=editor_path,
|
||
discard_recyclable=(
|
||
'--internal-rebase-v2-plan-discard-recyclable'
|
||
in sys.argv),
|
||
pinned_squashed=('--internal-rebase-v2-plan-squash-pinned'
|
||
in sys.argv))
|
||
except rebase_v2.EditorRecoverableFailure as e:
|
||
rebase_v2.hand_off_to_editor(editor_path,
|
||
reason=str(e),
|
||
editor=crash_editor)
|
||
if '--internal-rebase-v2-fix-message' in sys.argv:
|
||
editor_path = Path(sys.argv[-1])
|
||
crash_editor = _crash_editor_from_argv(
|
||
'--internal-rebase-crash-msg-editor')
|
||
try:
|
||
writer = rebase_v2.MessageWriter.parse(editor_path)
|
||
writer.rewrite_with_last_message()
|
||
except rebase_v2.EditorRecoverableFailure as e:
|
||
rebase_v2.hand_off_to_editor(editor_path,
|
||
reason=str(e),
|
||
editor=crash_editor)
|
||
sys.exit(0)
|
||
|
||
sys.exit(main())
|