[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:
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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())
|
||||
Executable
+98
@@ -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())
|
||||
Reference in New Issue
Block a user