[toolchain] Xcode license accept tools (#36672)

This PR adds a python script that can be setup to accept the License for
us, in an approach similar to the one used by Chromium infra's puppet.
This commit is contained in:
cdesouza-chromium
2026-05-25 13:46:55 +01:00
committed by GitHub
parent 42b408a234
commit 0a9855f7f4
5 changed files with 479 additions and 33 deletions
+49 -23
View File
@@ -11,7 +11,7 @@ port cleanly. Brave-specific differences:
* Downloads a Brave-internal tarball rather than a CIPD package.
* Gated on USE_BRAVE_HERMETIC_TOOLCHAIN=1 in place of upstream's
should_use_hermetic_xcode.py check.
* Compares Xcode versions via pkg_resources.parse_version (semver-aware)
* Compares Xcode versions via packaging.version.parse (semver-aware)
instead of upstream's string-split lexicographic compare.
"""
@@ -26,10 +26,31 @@ import sys
from pathlib import Path
from urllib.error import URLError # pylint: disable=no-name-in-module,import-error
import pkg_resources
from packaging.version import parse as parse_version
import deps
from deps_config import DEPS_PACKAGES_INTERNAL_URL, MAC_TOOLCHAIN_ROOT
# Importing script explicitly, so we can call this script from the terminal
# without needing to set up the PYTHONPATH.
sys.path.append(str(Path(__file__).resolve().parents[2] / 'script'))
import deps # pylint: disable=wrong-import-position
# This contains binaries from Xcode 26.4 (17E202) along with the macOS 26.4 SDK
# (25E251) and the Metal toolchain (17E188).
XCODE_VERSION = '26.4.1'
XCODE_TOOLCHAIN_DOWNLOAD_URL = (
f'https://vhemnu34de4lf5cj6bx2wwshyy0egdxk.lambda-url.us-west-2.on.aws'
f'/xcode-hermetic-toolchain/xcode-hermetic-toolchain-{XCODE_VERSION}.tar.gz'
)
# The toolchain will not be downloaded if the minimum OS version is not met. 19
# is the Darwin major version number for macOS 10.15. Xcode 26.0 17A324 only
# runs on macOS 15.6 and newer, but some bots are still running older OS
# versions. macOS 10.15.4, the OS minimum through Xcode 12.4, still seems to
# work.
MAC_MINIMUM_OS_VERSION = [19, 4]
MAC_TOOLCHAIN_ROOT = Path(
__file__).resolve().parents[3] / 'build' / 'mac_files'
def LoadPList(path: Path) -> dict:
@@ -37,20 +58,6 @@ def LoadPList(path: Path) -> dict:
return plistlib.loads(path.read_bytes())
# This contains binaries from Xcode 26.4.1, along with the macOS 26.4 SDK
XCODE_VERSION = '26.4.1'
HERMETIC_XCODE_BINARY = (
DEPS_PACKAGES_INTERNAL_URL +
'/xcode-hermetic-toolchain/xcode-hermetic-toolchain-' + XCODE_VERSION +
'.tar.gz')
# The toolchain will not be downloaded if the minimum OS version is not met. 19
# is the Darwin major version number for macOS 10.15.
MAC_MINIMUM_OS_VERSION = [19, 4]
TOOLCHAIN_BUILD_DIR = Path(MAC_TOOLCHAIN_ROOT) / 'Xcode.app'
def PlatformMeetsHermeticXcodeRequirements() -> bool:
if sys.platform == 'darwin':
needed = MAC_MINIMUM_OS_VERSION
@@ -75,14 +82,14 @@ def GetHermeticXcodeVersion(binaries_root: Path) -> str:
def InstallXcodeBinaries() -> int:
"""Installs the Xcode binaries needed to build Brave and accepts the
license."""
binaries_root = Path(MAC_TOOLCHAIN_ROOT) / 'xcode_binaries'
binaries_root = MAC_TOOLCHAIN_ROOT / 'xcode_binaries'
# Tarball extraction or not, if we have a hermetic toolchain,we still want
# to process the license if the version is newer than the currently
# accepted one.
if (XCODE_VERSION != GetHermeticXcodeVersion(binaries_root)
or binaries_root.is_symlink()):
url = HERMETIC_XCODE_BINARY
url = XCODE_TOOLCHAIN_DOWNLOAD_URL
print(f"Downloading hermetic Xcode: {url}")
try:
deps.DownloadAndUnpack(url, binaries_root)
@@ -93,6 +100,26 @@ def InstallXcodeBinaries() -> int:
else:
print(f"Hermetic Xcode {XCODE_VERSION} already installed")
on_disk_version = GetHermeticXcodeVersion(binaries_root)
license_info_path = (binaries_root /
'Contents/Resources/LicenseInfo.plist')
on_disk_license = (LoadPList(license_info_path).get(
'licenseID', '(missing)') if license_info_path.exists() else
'(LicenseInfo.plist not present)')
print(f" on-disk hermetic version: {on_disk_version}")
print(f" on-disk hermetic licenseID: {on_disk_license}")
current_license_path = Path(
'/Library/Preferences/com.apple.dt.Xcode.plist')
if current_license_path.exists():
sys_plist = LoadPList(current_license_path)
sys_version = sys_plist.get('IDEXcodeVersionForAgreedToGMLicense',
'(missing)')
sys_license = sys_plist.get('IDELastGMLicenseAgreedTo', '(missing)')
else:
sys_version = sys_license = '(plist not present)'
print(f" system recorded version: {sys_version}")
print(f" system recorded licenseID: {sys_license}")
if sys.platform != 'darwin':
return 0
@@ -115,9 +142,8 @@ def InstallXcodeBinaries() -> int:
current_license_plist = LoadPList(current_license_path)
xcode_version = current_license_plist.get(
'IDEXcodeVersionForAgreedToGMLicense')
if (xcode_version is not None
and pkg_resources.parse_version(xcode_version)
>= pkg_resources.parse_version(hermetic_xcode_version)):
if (xcode_version is not None and parse_version(xcode_version)
>= parse_version(hermetic_xcode_version)):
should_overwrite_license = False
if not should_overwrite_license:
-6
View File
@@ -5,12 +5,6 @@
# 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/.
import os
# Version number and URL for pre-configured rust dependency package
# e.g. rust_deps_mac_0.1.0.gz
DEPS_PACKAGES_URL = "https://brave-build-deps-public.s3.brave.com"
DEPS_PACKAGES_INTERNAL_URL = "https://vhemnu34de4lf5cj6bx2wwshyy0egdxk.lambda-url.us-west-2.on.aws" # pylint: disable=line-too-long
MAC_TOOLCHAIN_ROOT = os.path.join(os.path.dirname(os.path.dirname(
os.path.dirname(__file__))),
'build', 'mac_files')
+60 -4
View File
@@ -1,15 +1,14 @@
# Browser Toolchain Scripts
This directory contains scripts for generating browser toolchains (compilers,
This directory contains scripts for relating to the browser toolchains (compilers,
linkers, standard libraries, etc.) used by the Brave build.
## Design rules
Scripts here are designed to be launched with a single `curl` one-liner,
so they must be **self-contained in a single source file** with **no
dependencies outside the Python standard library** (or the platform's
default tooling such as `git`). Do not split logic across helper modules or
add third-party imports.
dependencies outside the Python standard library**. Do not split logic across
helper modules or add third-party imports.
Example invocation pattern:
@@ -36,3 +35,60 @@ curl -sL \
```
Pass `--help` for the full list of options.
### `build_xcode_toolchain.py`
macOS-only. Builds a hermetic, reproducible Xcode toolchain archive from
the local Xcode.app installation. The archive contains the subset of
files listed in Chromium's `build/xcode_binaries.yaml` plus the on-demand
Metal toolchain, with all archive metadata zeroed for reproducible builds.
```sh
curl -sL \
https://raw.githubusercontent.com/brave/brave-core/refs/heads/master/tools/cr/toolchain/build_xcode_toolchain.py \
| python3 - \
--out-dir=./out/ \
--chromium-tag=150.0.7841.1
```
The output filename encodes the Xcode and SDK versions and is consumed by
`brockit update-xcode-toolchain` to pin Brave's
`build/mac/download_hermetic_xcode.py`. Pass `--help` for the full list of
options; see the script's module docstring for the archive filename format.
### `xcode_accept_license.py`
macOS-only. Writes the two preference keys Apple's command-line tools
check for license acceptance
(`IDEXcodeVersionForAgreedToGMLicense` and `IDELastGMLicenseAgreedTo`)
into `/Library/Preferences/com.apple.dt.Xcode.plist`. Designed to be
installed at `/usr/local/bin/` and invoked under a narrowly-scoped
NOPASSWD sudoers grant so build hooks
(`brave/build/mac/download_hermetic_xcode.py`) can accept the license
without a password prompt.
The script's own docstring covers manual install, the corresponding
sudoers entry, and the security model. Run with `--help` for the CLI.
### `install_xcode_accept_license.py`
Installer/verifier for the helper above. Lays down both the script
(`/usr/local/bin/xcode_accept_license.py`, mode 0755 owned by
`root:wheel`) and the matching sudoers drop-in
(`/etc/sudoers.d/xcode_accept_license`, mode 0440 owned by root,
validated via `visudo -c`), then runs a non-destructive smoke test using
`sudo -n -l` to confirm the grant works without ever invoking the helper.
```sh
# install + verify
python3 install_xcode_accept_license.py --username $USER
# verify an existing install
python3 install_xcode_accept_license.py --check-only --username $USER
# tear down
python3 install_xcode_accept_license.py --uninstall
```
The script re-execs under sudo automatically if not invoked as root, so
there's no need to remember the `sudo` prefix.
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
# Copyright (c) 2026 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/.
"""install_xcode_accept_license.py
Installs the `xcode_accept_license.py` helper and its sudoers drop-in on a
macOS. Also doubles as a local verifier: the smoke test uses `sudo -n -l`,
which asks sudo whether the policy would permit the invocation without actually
executing the script, so it never touches
/Library/Preferences/com.apple.dt.Xcode.plist.
USAGE
# install + verify
python3 install_xcode_accept_license.py --username $USER
# check existing install only
python3 install_xcode_accept_license.py --check-only \\
--username $USER
# tear down
python3 install_xcode_accept_license.py --uninstall
The script re-exec's itself under sudo if not already root, so a single
password prompt is enough. Re-running is safe (idempotent overwrite).
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
SCRIPT_NAME = 'xcode_accept_license.py'
DEFAULT_TARGET = Path('/usr/local/bin') / SCRIPT_NAME
DEFAULT_SUDOERS_PATH = Path('/etc/sudoers.d/xcode_accept_license')
VISUDO = '/usr/sbin/visudo'
SUDO = '/usr/bin/sudo'
# Args used by the smoke test. Both pass the sudoers glob pin and the
# helper's regex check, but because `sudo -n -l` lists the matching policy
# without executing the command, the helper itself is never run and the
# Xcode license plist is not touched.
SMOKE_TEST_ARGS = ['0', '0']
def sudoers_entry(username: str, target: Path) -> str:
"""The single line we write into /etc/sudoers.d/.
The `[0-9]*` / `[A-Za-z0-9_.-]*` patterns are sudoers globs (not regex)
pinning the leading character of each argument. Precise input
validation still lives in the helper script's own regex check.
"""
return (f'{username} ALL=(root) NOPASSWD: '
f'{target} [0-9]* [A-Za-z0-9_.-]*\n')
def require_root() -> None:
if os.geteuid() == 0:
return
# Re-exec under sudo. This process is replaced; sudo prompts for a
# password (or uses cached credentials), then runs us again with euid 0
# and $SUDO_USER set to the invoking account, so the --username
# default keeps working transparently.
print('Elevating via sudo (you may be prompted for your password)...',
file=sys.stderr)
os.execv('/usr/bin/sudo', ['/usr/bin/sudo', sys.executable, *sys.argv])
def require_macos() -> None:
if sys.platform != 'darwin':
sys.exit(f'Only supported on macOS; got {sys.platform}.')
def find_source(explicit: Path | None) -> Path:
if explicit is not None:
if not explicit.is_file():
sys.exit(f'--source does not exist: {explicit}')
return explicit.resolve()
here = Path(__file__).resolve().parent
candidate = here / SCRIPT_NAME
if not candidate.is_file():
sys.exit(f'Cannot locate {SCRIPT_NAME} alongside this installer '
f'({candidate}). Pass --source explicitly.')
return candidate
def install_script(source: Path, target: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(source, target)
# gid 0 == `wheel` on macOS, matching the docs' `install -o root -g wheel`.
os.chown(target, 0, 0)
target.chmod(0o755)
print(f' installed {target} (root:wheel mode 0755)')
def install_sudoers(path: Path, contents: str) -> None:
"""Write the sudoers drop-in atomically.
Stages the file as `<path>.tmp` so that even if validation fails the
half-written file is never picked up by sudo. Files under /etc/sudoers.d/
whose name contains `.` are skipped by sudo's includedir loader, so the
staging file is invisible to policy while it exists.
"""
tmp = path.with_suffix(path.suffix + '.tmp')
tmp.write_bytes(contents.encode('utf-8'))
os.chown(tmp, 0, 0)
tmp.chmod(0o440)
try:
subprocess.run([VISUDO, '-c', '-f', str(tmp)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
tmp.unlink(missing_ok=True)
sys.exit('visudo rejected the generated sudoers entry:\n' +
e.stdout.decode('utf-8', errors='replace'))
tmp.replace(path)
print(f' installed {path} (root:wheel mode 0440, visudo -c passed)')
def verify_target(target: Path) -> None:
if not target.is_file():
sys.exit(f' MISSING: {target}')
st = target.stat()
mode = st.st_mode & 0o777
if (st.st_uid, mode) != (0, 0o755):
sys.exit(f' WRONG ownership/mode for {target}: '
f'uid={st.st_uid} mode={oct(mode)} (want uid=0 mode=0o755)')
print(f' ok: {target} (uid={st.st_uid} mode={oct(mode)})')
def verify_sudoers(path: Path) -> None:
if not path.is_file():
sys.exit(f' MISSING: {path}')
st = path.stat()
mode = st.st_mode & 0o777
if (st.st_uid, mode) != (0, 0o440):
sys.exit(f' WRONG ownership/mode for {path}: '
f'uid={st.st_uid} mode={oct(mode)} (want uid=0 mode=0o440)')
subprocess.run([VISUDO, '-c', '-f', str(path)],
check=True,
stdout=subprocess.DEVNULL)
print(f' ok: {path} (uid={st.st_uid} mode={oct(mode)}, '
f'visudo -c passed)')
def smoke_test(username: str, target: Path) -> None:
"""Verify the sudoers grant permits <username> to run the helper.
`sudo -n -l <cmd>` only asks sudo's policy engine whether the invocation
would be permitted; it never executes <cmd>. That keeps this check
non-destructive — no plist write happens — while still exercising every
layer: the user identity, the NOPASSWD bit, the absolute path match, and
the sudoers argument globs.
"""
args = [
SUDO, '-u', username, SUDO, '-n', '-l',
str(target), *SMOKE_TEST_ARGS
]
try:
result = subprocess.run(args,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
sys.exit(
f' FAILED: sudo would not let {username} run {target} '
'without a password.\n'
f' stderr: {e.stderr.decode("utf-8", errors="replace").strip()}'
)
permitted = result.stdout.decode('utf-8', errors='replace').strip()
print(f' ok: sudo policy permits {username!r} to run:')
print(f' {permitted}')
def do_install(args: argparse.Namespace) -> int:
source = find_source(args.source)
entry = sudoers_entry(args.username, args.target)
print('Installing:')
print(f' source {source}')
print(f' target {args.target}')
print(f' sudoers file {args.sudoers_file}')
print(f' username {args.username}')
print(f' sudoers entry {entry.strip()}')
print()
install_script(source, args.target)
install_sudoers(args.sudoers_file, entry)
print()
print('Verifying:')
verify_target(args.target)
verify_sudoers(args.sudoers_file)
smoke_test(args.username, args.target)
print()
print('Done.')
return 0
def do_uninstall(args: argparse.Namespace) -> int:
print('Removing:')
for path in (args.sudoers_file, args.target):
if path.exists():
path.unlink()
print(f' removed {path}')
else:
print(f' not present: {path}')
return 0
def do_check_only(args: argparse.Namespace) -> int:
print('Verifying:')
verify_target(args.target)
verify_sudoers(args.sudoers_file)
smoke_test(args.username, args.target)
print()
print('Done.')
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description='Install / verify / remove the xcode_accept_license.py '
'helper and its sudoers drop-in on macOS.',
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
'--username',
default=os.environ.get('SUDO_USER'),
help='Account that gets the NOPASSWD grant. Defaults to $SUDO_USER '
'(the account that invoked sudo). Required for install and '
'--check-only.')
parser.add_argument('--source',
type=Path,
help=f'Path to {SCRIPT_NAME} to install. Defaults to '
'the file alongside this installer.')
parser.add_argument('--target',
type=Path,
default=DEFAULT_TARGET,
help=f'Install target (default: {DEFAULT_TARGET}).')
parser.add_argument(
'--sudoers-file',
type=Path,
default=DEFAULT_SUDOERS_PATH,
help=f'sudoers drop-in path (default: {DEFAULT_SUDOERS_PATH}).')
mode = parser.add_mutually_exclusive_group()
mode.add_argument('--uninstall',
action='store_true',
help='Remove the helper and the sudoers entry.')
mode.add_argument(
'--check-only',
action='store_true',
help='Verify an existing install; do not modify anything.')
args = parser.parse_args()
require_macos()
require_root()
if args.uninstall:
return do_uninstall(args)
if not args.username:
sys.exit('--username is required (no $SUDO_USER fallback '
'available).')
if args.check_only:
return do_check_only(args)
return do_install(args)
if __name__ == '__main__':
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
# Copyright (c) 2026 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/.
"""xcode_accept_license.py
Accepts the Xcode GM license on a macOS machine without prompting for a
password. Mirrors the approach used in Chromium's infra puppet.
USAGE
sudo /usr/local/bin/xcode_accept_license.py <xcode-version> <license-version>
Example:
sudo /usr/local/bin/xcode_accept_license.py 26.3 EA1647
Both values come from the hermetic Xcode package:
<xcode-version> = Contents/version.plist : CFBundleShortVersionString
<license-version> = Contents/Resources/LicenseInfo.plist : licenseID
SETTING UP MANUALLY
Replace `username` with the account name.
sudo install -o root -g wheel -m 0755 \\
xcode_accept_license.py /usr/local/bin/xcode_accept_license.py
echo 'username ALL=(root) NOPASSWD: /usr/local/bin/xcode_accept_license.py [0-9]* [A-Za-z0-9_.-]*' \\
| sudo tee /etc/sudoers.d/xcode_accept_license
sudo chown root:wheel /etc/sudoers.d/xcode_accept_license
sudo chmod 0440 /etc/sudoers.d/xcode_accept_license
sudo visudo -c -f /etc/sudoers.d/xcode_accept_license
sudo -n /usr/local/bin/xcode_accept_license.py 26.3 EA1647
SETTING UP WITH install_xcode_accept_license.py
Same outcome with atomic sudoers placement and a non-destructive smoke
test. See that script's own docstring for `--check-only` / `--uninstall`.
python3 install_xcode_accept_license.py --username $USER
SECURITY NOTES
- The script writes only to /Library/Preferences/com.apple.dt.Xcode.plist
and performs no network I/O.
- Arguments are regex-validated before being passed to `defaults` so the
NOPASSWD path cannot be used to set arbitrary preference keys.
- Subprocess calls use list-form `subprocess.check_call`
(never `shell=True`), so each argument lands in argv as a single opaque
element. Shell-style injection through quoting bugs is not reachable by
construction.
- Absolute paths to /usr/bin/defaults and /usr/bin/plutil are hard-coded so
a manipulated PATH cannot redirect either tool.
"""
import argparse
import re
import subprocess
import sys
PLIST = '/Library/Preferences/com.apple.dt.Xcode.plist'
DEFAULTS = '/usr/bin/defaults'
PLUTIL = '/usr/bin/plutil'
# Xcode short versions look like "26.3" or "26.3.1"; license IDs are short
# alphanumeric tokens such as "EA1647".
XCODE_VERSION_RE = re.compile(r'\d+(\.\d+)*')
LICENSE_VERSION_RE = re.compile(r'[A-Za-z0-9_.-]+')
def main() -> int:
parser = argparse.ArgumentParser(
description='Accept the Xcode GM license on behalf of all users.')
parser.add_argument('xcode_version',
help='CFBundleShortVersionString from the hermetic '
'Xcode bundle, e.g. 26.3')
parser.add_argument('license_version',
help='licenseID from LicenseInfo.plist, e.g. EA1647')
args = parser.parse_args()
if not XCODE_VERSION_RE.fullmatch(args.xcode_version):
print(f'invalid xcode-version: {args.xcode_version}', file=sys.stderr)
return 64
if not LICENSE_VERSION_RE.fullmatch(args.license_version):
print(f'invalid license-version: {args.license_version}',
file=sys.stderr)
return 64
subprocess.check_call([
DEFAULTS, 'write', PLIST, 'IDEXcodeVersionForAgreedToGMLicense',
'-string', args.xcode_version
])
subprocess.check_call([
DEFAULTS, 'write', PLIST, 'IDELastGMLicenseAgreedTo', '-string',
args.license_version
])
subprocess.check_call([PLUTIL, '-convert', 'xml1', PLIST])
return 0
if __name__ == '__main__':
sys.exit(main())