brizental pushed to branch tor-browser-153.5.0esr-16.0-1 at The Tor Project / Applications / Tor Browser Commits: 230b2e7d by Beatriz Rizental at 2026-10-09T06:15:16-03:00 BB 45308: Implement ./mach yeet command ./mach yeet is the Tor Project version of try -- but much simpler. - - - - - 6b009dd1 by Beatriz Rizental at 2026-10-09T06:15:17-03:00 fixup! BB 45308: Implement ./mach yeet command Bug 45308: Implement ./mach yeet auth - - - - - fcf7cd1e by Beatriz Rizental at 2026-10-09T06:15:17-03:00 fixup! BB 45308: Implement ./mach yeet command Bug 45308: Implement ./mach yeet's upload step Uploads a local file to the configured build server's public_html over scp. - - - - - 014bb2ac by Beatriz Rizental at 2026-10-09T06:15:18-03:00 fixup! BB 45308: Implement ./mach yeet command Bug 45308: Implement ./mach yeet remote's CI trigger - - - - - 7cd8a546 by Beatriz Rizental at 2026-10-09T06:15:19-03:00 fixup! BB 45308: Implement ./mach yeet command Address review comments - - - - - 0cece149 by Beatriz Rizental at 2026-10-09T06:16:59-03:00 fixup! BB 45308: Implement ./mach yeet command Attend more review comments. - - - - - 15 changed files: - .gitignore - python/mach/mach/command_util.py - tools/moz.build - + tools/yeet/__init__.py - + tools/yeet/auth.py - + tools/yeet/common.py - + tools/yeet/mach_commands.py - + tools/yeet/remote.py - + tools/yeet/tests/python/conftest.py - + tools/yeet/tests/python/python.toml - + tools/yeet/tests/python/test_auth.py - + tools/yeet/tests/python/test_common.py - + tools/yeet/tests/python/test_remote.py - + tools/yeet/tests/python/test_upload.py - + tools/yeet/upload.py Changes: ===================================== .gitignore ===================================== @@ -426,8 +426,10 @@ CLAUDE.local.md # Other files modified at build time mobile/android/.experimenter.json +# GitLab project and token saved by `mach yeet auth` +/.yeet.json + # Tor libraries for local builds mobile/android/fenix/tools/nimbus-fml mobile/android/fenix/app/tor-expert-bundle.aar mobile/android/fenix/app/src/main/assets/extensions/{73a6fe31-595d-460b-a920-fcc0f8843232}.xpi - ===================================== python/mach/mach/command_util.py ===================================== @@ -291,6 +291,7 @@ MACH_COMMANDS = { "xpcshell-test": MachCommandReference( "testing/xpcshell/mach_commands.py", ["test"] ), + "yeet": MachCommandReference("tools/yeet/mach_commands.py"), } ===================================== tools/moz.build ===================================== @@ -75,4 +75,5 @@ PYTHON_UNITTEST_MANIFESTS += [ "lint/test/python.toml", "tryselect/test/python.toml", "update-packaging/test/python.toml", + "yeet/tests/python/python.toml", ] ===================================== tools/yeet/__init__.py ===================================== @@ -0,0 +1,3 @@ +# 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 http://mozilla.org/MPL/2.0/. ===================================== tools/yeet/auth.py ===================================== @@ -0,0 +1,264 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import json +import os +import subprocess +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from yeet.common import ( + CANONICAL_GITLAB_PROJECT, + CANONICAL_GITLAB_REPO_NAME, + DEFAULT_SSH_HOST, + GITLAB_API_BASE, + TERM, + YEET_CONFIG_FILENAME, + prompt, +) + + +def _resolve_project(raw_username): + """Build a fork project path out of a GitLab username. Raises ValueError + if it's empty or the canonical repo's own namespace. + """ + username = raw_username.strip() + if not username: + raise ValueError("A GitLab username is required.") + + project = f"{username}/{CANONICAL_GITLAB_REPO_NAME}" + if project.lower() == CANONICAL_GITLAB_PROJECT.lower(): + raise ValueError( + "That's the canonical repo's namespace, not a fork. Use your " + "own GitLab username instead." + ) + + return project + + +def _prompt_fork(): + print() + print( + TERM.yellow( + f"""`mach yeet remote` runs against your own fork of +{CANONICAL_GITLAB_PROJECT}, never against the canonical repo +directly -- that keeps CI runs off the shared project. + +If you haven't forked it yet, fork it first at: + https://gitlab.torproject.org/{CANONICAL_GITLAB_PROJECT}/-/forks/new + +Keep your fork up to date too. An outdated fork's .gitlab-ci.yml may be missing +pieces this command relies on, so pipelines could fail or come back empty.""" + ) + ) + print() + + while True: + try: + return _resolve_project(prompt("Your GitLab username: ")) + except ValueError as e: + print(TERM.red(f"ERROR! {e}")) + + +def _gitlab_token_instructions(project): + return f""" +Now you need a pipeline trigger token for your fork: + {project} + +This is NOT a personal access token -- it's specific to this one project, and +it's the only kind that can actually run CI jobs here (their rules require it): + + 1. Go to https://gitlab.torproject.org/{project}/-/settings/ci_cd#js-pipeline-triggers + 2. Expand "Pipeline trigger tokens" and add a new one. + 3. Paste the token below. + +The project and token will be saved to {YEET_CONFIG_FILENAME} at the root of the repository +and are not committed to it. +""".strip() + + +def _print_ssh_instructions(): + print() + print( + TERM.yellow( + f"""`mach yeet` also uploads to and runs commands on a build server over SSH +({DEFAULT_SSH_HOST} by default). Uploaded files are served from your +account's public_html there, at: + https://<host>/~<username> + +Anything special about the connection -- port, jump host, a hardware key's +PKCS11 provider, agent forwarding -- belongs in your ~/.ssh/config, not here: +we just shell out to `ssh`. Whatever already works on your command line works +for `mach yeet` too.""" + ) + ) + print() + + +def _prompt_ssh_host(): + return prompt(f"SSH host [{DEFAULT_SSH_HOST}]: ").strip() or DEFAULT_SSH_HOST + + +def _prompt_ssh_username(ssh_host): + while True: + username = prompt(f"Your username on {ssh_host}: ").strip() + if username: + return username + print(TERM.red("ERROR! Username cannot be empty.")) + + +def _prompt_token(): + while True: + token = prompt("GitLab token: ", secret=True).strip() + if token: + return token + print(TERM.red("ERROR! Token cannot be empty.")) + + +def _verify_ssh(target): + """Try an actual SSH connection. stdin is /dev/null so ssh can't swallow + input buffered for us; its touch/PIN/passphrase prompts go through + /dev/tty and still reach the user. + """ + try: + result = subprocess.run( + ["ssh", target, "true"], + stdin=subprocess.DEVNULL, + check=False, + ) + except FileNotFoundError: + print() + print(TERM.red("ERROR! No `ssh` binary found on PATH.")) + return False + return result.returncode == 0 + + +def _verify_auth(project, token): + """Check that `project`/`token` are accepted by GitLab, without + actually triggering a pipeline. + + GitLab authenticates the trigger token before validating the rest of + the request, so posting to the trigger endpoint without the required + `ref` param never queues a pipeline: a valid token/project combination + gets rejected with 400 (missing ref) instead, while an invalid token + or unknown project is rejected with 401/404 before `ref` is even + looked at. + """ + url = f"{GITLAB_API_BASE}/projects/{urllib.parse.quote(project, safe='')}/trigger/pipeline" + data = urllib.parse.urlencode({"token": token}).encode() + request = urllib.request.Request(url, data=data, method="POST") + try: + with urllib.request.urlopen(request): + pass + except urllib.error.HTTPError as e: + return e.code == 400 + else: + return True + + +def run(command_context, **kwargs): + gitlab_username = kwargs.get("gitlab_username") + if gitlab_username is not None: + try: + project = _resolve_project(gitlab_username) + except ValueError as e: + print(TERM.red(f"ERROR! --gitlab-username: {e}")) + return 1 + else: + project = _prompt_fork() + + gitlab_token = kwargs.get("gitlab_token") + if gitlab_token is not None: + token = gitlab_token.strip() + if not token: + print() + print(TERM.red("ERROR! --gitlab-token was empty.")) + return 1 + else: + print() + print(_gitlab_token_instructions(project)) + print() + token = _prompt_token() + + verify = kwargs.get("verify", True) + if verify: + print() + print("Verifying credentials with GitLab...") + try: + verified = _verify_auth(project, token) + except urllib.error.URLError as e: + print() + print(TERM.red(f"ERROR! Could not reach GitLab: {e}")) + return 1 + + if not verified: + print() + print( + TERM.red( + "ERROR! GitLab rejected that project/token combination. " + "Double-check the fork name and the trigger token." + ) + ) + return 1 + print(TERM.green("Credentials verified.")) + + ssh_host = kwargs.get("ssh_host") + ssh_username = kwargs.get("ssh_username") + if ssh_host is None or ssh_username is None: + _print_ssh_instructions() + + if ssh_host is not None: + ssh_host = ssh_host.strip() + if not ssh_host: + print() + print(TERM.red("ERROR! --ssh-host was empty.")) + return 1 + else: + ssh_host = _prompt_ssh_host() + + if ssh_username is not None: + ssh_username = ssh_username.strip() + if not ssh_username: + print() + print(TERM.red("ERROR! --ssh-user was empty.")) + return 1 + else: + ssh_username = _prompt_ssh_username(ssh_host) + + if verify: + ssh_target = f"{ssh_username}@{ssh_host}" + print() + print(f"Verifying SSH access to {ssh_target}...") + if not _verify_ssh(ssh_target): + print() + print( + TERM.red( + f"ERROR! Could not SSH into {ssh_target}. Make sure you " + "can `ssh` there yourself first." + ) + ) + return 1 + print(TERM.green("SSH access verified.")) + + tokens_path = Path(command_context.topsrcdir) / YEET_CONFIG_FILENAME + file_desc = os.open( + tokens_path, flags=os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode=0o600 + ) + with open(file_desc, "w") as f: + json.dump( + { + "project": project, + "token": token, + "ssh_host": ssh_host, + "ssh_user": ssh_username, + }, + f, + ) + os.chmod(tokens_path, 0o600) + + print() + print(TERM.green(f"Saved to {tokens_path}")) + return 0 ===================================== tools/yeet/common.py ===================================== @@ -0,0 +1,66 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import getpass +import json +import sys +from pathlib import Path + +from mozterm import Terminal + +TERM = Terminal() + +GITLAB_API_BASE = "https://gitlab.torproject.org/api/v4" + +# The canonical repo. `mach yeet remote` must never target this directly -- +# only a fork of it, so CI runs don't pile up on the shared project. +CANONICAL_GITLAB_NAMESPACE = "tpo/applications" +CANONICAL_GITLAB_REPO_NAME = "tor-browser-bundle-testsuite" +CANONICAL_GITLAB_PROJECT = f"{CANONICAL_GITLAB_NAMESPACE}/{CANONICAL_GITLAB_REPO_NAME}" + +# Project and token saved by `mach yeet auth`. +YEET_CONFIG_FILENAME = ".yeet.json" + +# The default server `mach yeet` uploads to and runs commands on over SSH. +# Uploaded files are served from the user's public_html on it. +DEFAULT_SSH_HOST = "tb-build-03.torproject.org" + + +def prompt(message, secret=False): + """Read a line of user input, first discarding anything typed before the + prompt appeared so stray keystrokes aren't taken as the answer. + """ + if sys.stdin.isatty(): + try: + import termios + + termios.tcflush(sys.stdin, termios.TCIFLUSH) + except ImportError: + pass + if secret: + return getpass.getpass(message) + return input(message) + + +def confirm(message): + """Ask a yes/no question, defaulting to no, until a valid answer is given.""" + while True: + answer = prompt(f"{message} [y/N]: ").strip().lower() + if answer in ("y", "yes"): + return True + if answer in ("", "n", "no"): + return False + print(TERM.red("ERROR! Please answer 'y' or 'n'.")) + + +def read_config(topsrcdir): + """Read the project/token/ssh_* config saved by `mach yeet auth`, or + None if it hasn't been run yet. + """ + config_path = Path(topsrcdir) / YEET_CONFIG_FILENAME + if not config_path.is_file(): + return None + + with open(config_path) as f: + return json.load(f) ===================================== tools/yeet/mach_commands.py ===================================== @@ -0,0 +1,141 @@ +# 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 http://mozilla.org/MPL/2.0/. + +from mach.decorators import Command, CommandArgument, SubCommand # noqa: I001 + +from yeet import remote + + +@Command( + "yeet", + category="ci", + description="TODO: build / test remotely from the current state of the repo.", +) +def yeet(command_context): + # TODO: this will be the main "yeet" command -- push, build and (maybe) + # test remotely from the current state of the local repo, rather than + # requiring an already-published build like `yeet remote` does. + pass + + +@SubCommand( + "yeet", + "remote", + description="Trigger a tor-browser-bundle-testsuite CI job against an " + "already-published build, for a single platform.", +) +@CommandArgument( + "--platform", + default=None, + choices=remote.PLATFORMS, + help="Platform to test. Prompted for if not provided.", +) +@CommandArgument( + "--channel", + default=None, + choices=remote.ANDROID_CHANNELS, + help="Android build channel/flavor to install (only used with " + "--platform android_x86_64). Prompted for if not provided.", +) +@CommandArgument( + "installer_url", + help="URL of the installer to test, or a local file path to upload first.", +) +@CommandArgument( + "--sha256sums-url", default=None, help="URL of the build's sha256sums file." +) +@CommandArgument( + "--artifacts-url", + default=None, + help="URL of the build's artifacts directory. Inferred from the " + "in-tree browser version and --platform if not provided.", +) +@CommandArgument( + "--mozharness-url", + default=None, + help="URL of the mozharness.zip to use. Inferred from the in-tree " + "browser version and --platform if not provided.", +) +@CommandArgument( + "--version", + default=None, + help="Browser version to use when inferring --artifacts-url and " + "--mozharness-url. Defaults to the in-tree version " + "(browser/config/version.txt).", +) +@CommandArgument( + "--ref", + default="main", + help="tor-browser-bundle-testsuite ref to run on. Must have a " + ".gitlab-ci.yml, which the default branch may not.", +) +@CommandArgument( + "--tag", + dest="tags", + action="append", + default=None, + help="Test tag to filter tests by (can be passed multiple times, e.g. " + "--tag tor --tag base-browser). Defaults to '--tag tor --tag base-browser'.", +) +@CommandArgument( + "--dry-run", + action="store_true", + default=False, + help="Print the curl command instead of triggering the pipeline.", +) +@CommandArgument( + "--yes", + "-y", + dest="assume_yes", + action="store_true", + default=False, + help="Upload a local installer without asking for confirmation.", +) +def yeet_remote(command_context, **kwargs): + # With action="append", argparse appends to a non-None default instead of + # replacing it, so the default tags are applied here only if none were given. + if kwargs["tags"] is None: + kwargs["tags"] = ["tor", "base-browser"] + return remote.run(command_context, **kwargs) + + +@SubCommand( + "yeet", + "auth", + description="Save a GitLab project and access token for running CI jobs.", +) +@CommandArgument( + "--no-verify", + dest="verify", + action="store_false", + help="Skip checking the GitLab and SSH credentials before saving them.", +) +@CommandArgument( + "--gitlab-username", + default=None, + help="GitLab username to use, skipping that prompt.", +) +@CommandArgument( + "--gitlab-token", + default=None, + help=( + "GitLab pipeline trigger token to use, skipping that prompt. Note " + "this may end up in your shell history." + ), +) +@CommandArgument( + "--ssh-user", + dest="ssh_username", + default=None, + help="Username on the build server, skipping that prompt.", +) +@CommandArgument( + "--ssh-host", + default=None, + help="Build server to upload to and run commands on over SSH, skipping that prompt.", +) +def yeet_auth(command_context, **kwargs): + from yeet import auth + + return auth.run(command_context, **kwargs) ===================================== tools/yeet/remote.py ===================================== @@ -0,0 +1,228 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import json +import os +import shlex +import urllib.error +import urllib.parse +import urllib.request + +from yeet import upload +from yeet.common import ( + GITLAB_API_BASE, + TERM, + prompt, + read_config, +) + +PLATFORM_TOOLCHAIN_DIR = { + "debian_x86_64": "linux-x86_64", + "windows_x86_64": "windows-x86_64", + "macos_x86_64": "macos-x86_64", + "android_x86_64": "android-x86_64", +} + +PLATFORMS = list(PLATFORM_TOOLCHAIN_DIR.keys()) + +GITLAB_REQUEST_TIMEOUT_SECONDS = 30 + +TOOLCHAINS_URL_BASE = "https://nightlies.tbb.torproject.org/nightly-builds/toolchains" + +# Each Android build channel/flavor ships under its own package name, so the +# pipeline needs to be told which one to install. +ANDROID_CHANNEL_PACKAGE_NAMES = { + "debug": "org.torproject.torbrowser_debug", + "nightly": "org.torproject.torbrowser_nightly", + "alpha": "org.torproject.torbrowser_alpha", + "release": "org.torproject.torbrowser", +} +ANDROID_CHANNELS = list(ANDROID_CHANNEL_PACKAGE_NAMES) + + +def _read_browser_version(topsrcdir): + version_path = os.path.join(topsrcdir, "browser", "config", "version.txt") + with open(version_path) as f: + return f.read().strip() + + +def _resolve_version(topsrcdir, version): + if version: + return version + + version = _read_browser_version(topsrcdir) + print( + TERM.yellow( + f"""Inferred platform version {version} from browser/config/version.txt. +Pass --version if this isn't the version you want to test.""" + ) + ) + return version + + +def _artifacts_url(version, platform): + return f"{TOOLCHAINS_URL_BASE}/{version}/{PLATFORM_TOOLCHAIN_DIR[platform]}/" + + +def _mozharness_url(version, platform): + return f"{_artifacts_url(version, platform)}mozharness.zip" + + +def _curl_command(url, headers, data): + args = ["curl", "--request", "POST"] + for name, value in headers.items(): + args += ["--header", f"{name}: {value}"] + args += ["--data", data.decode("utf-8"), url] + return shlex.join(args) + + +def _resolve_installer_url(topsrcdir, installer, platform, assume_yes, dry_run): + """Accept either an installer URL directly, or a local file path to + upload first. On a dry run a local file is not uploaded, and a + placeholder URL is returned instead. Raises whatever + `upload.run` raises on failure. + """ + if urllib.parse.urlparse(installer).scheme in ("http", "https"): + return installer + if dry_run: + file_path = upload.resolve_file(installer) + print(TERM.yellow(f"Dry run: not uploading {file_path}.")) + return f"<URL of uploaded {file_path.name}>" + return upload.run(topsrcdir, installer, platform, assume_yes=assume_yes) + + +def _prompt_platform(): + print() + print( + TERM.bold( + "Select the platform you are testing for (if not listed, tests are not supported yet):" + ) + ) + for i, platform in enumerate(PLATFORMS, 1): + print(f" {TERM.cyan(str(i))}) {platform}") + print() + + while True: + choice = prompt(f"Platform [1-{len(PLATFORMS)}]: ").strip() + if choice.isdigit() and 1 <= int(choice) <= len(PLATFORMS): + print() + return PLATFORMS[int(choice) - 1] + print(TERM.red(f"ERROR! Please enter a number between 1 and {len(PLATFORMS)}.")) + + +def _prompt_channel(): + print() + print(TERM.bold("Select the Android build channel/flavor you are testing:")) + for i, channel in enumerate(ANDROID_CHANNELS, 1): + print(f" {TERM.cyan(str(i))}) {channel}") + print() + + while True: + choice = prompt(f"Channel [1-{len(ANDROID_CHANNELS)}]: ").strip() + if choice.isdigit() and 1 <= int(choice) <= len(ANDROID_CHANNELS): + print() + return ANDROID_CHANNELS[int(choice) - 1] + print( + TERM.red( + f"ERROR! Please enter a number between 1 and {len(ANDROID_CHANNELS)}." + ) + ) + + +def run(command_context, **kwargs): + # TODO(tor-browser-bundle-testsuite#40120): this runs whatever the pipeline + # runs, indiscriminately -- fine for now since marionette is the only suite + # it has, but once others (xpcshell, cppunittest, mochitest, ...) exist, add + # a --suite argument here and thread it into `inputs` so a run can be scoped + # to just one. + config = read_config(command_context.topsrcdir) + if config is None: + print(TERM.red("No GitLab fork/token found, run `mach yeet auth` first.")) + return 1 + + installer_url = kwargs["installer_url"].strip() + if not installer_url: + print(TERM.red("ERROR! The installer URL or path was empty.")) + return 1 + + platform = kwargs.get("platform") or _prompt_platform() + dry_run = kwargs.get("dry_run", False) + + try: + installer_url = _resolve_installer_url( + command_context.topsrcdir, + installer_url, + platform, + kwargs.get("assume_yes", False), + dry_run, + ) + except (FileNotFoundError, RuntimeError) as e: + print() + print(TERM.red(f"ERROR! {e}")) + return 1 + + package_name = None + if platform == "android_x86_64": + channel = kwargs.get("channel") or _prompt_channel() + if channel not in ANDROID_CHANNEL_PACKAGE_NAMES: + print(TERM.red(f"ERROR! Unknown channel {channel!r}.")) + return 1 + package_name = ANDROID_CHANNEL_PACKAGE_NAMES[channel] + + artifacts_url = kwargs.get("artifacts_url") + mozharness_url = kwargs.get("mozharness_url") + if not artifacts_url or not mozharness_url: + version = _resolve_version(command_context.topsrcdir, kwargs.get("version")) + if not artifacts_url: + artifacts_url = _artifacts_url(version, platform) + if not mozharness_url: + mozharness_url = _mozharness_url(version, platform) + + inputs = { + "mozharness_url": mozharness_url, + f"{platform}_installer_url": installer_url, + f"{platform}_artifacts_url": artifacts_url, + } + if package_name: + inputs[f"{platform}_package_name"] = package_name + if sha256sums_url := kwargs.get("sha256sums_url"): + inputs[f"{platform}_sha256sums_url"] = sha256sums_url + if tags := kwargs.get("tags"): + inputs["tags"] = shlex.join(arg for tag in tags for arg in ("--tag", tag)) + + project_id = urllib.parse.quote(config["project"], safe="") + url = f"{GITLAB_API_BASE}/projects/{project_id}/trigger/pipeline" + headers = {"Content-Type": "application/json"} + data = json.dumps({ + "token": config["token"], + "ref": kwargs.get("ref", "main"), + "inputs": inputs, + }).encode("utf-8") + + if dry_run: + print() + print(TERM.cyan(_curl_command(url, headers, data))) + print() + return 0 + + request = urllib.request.Request(url, data=data, headers=headers, method="POST") + + try: + with urllib.request.urlopen( + request, timeout=GITLAB_REQUEST_TIMEOUT_SECONDS + ) as response: + result = json.load(response) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") + print( + TERM.red(f"ERROR! Failed to trigger pipeline: {e.code} {e.reason}\n{body}") + ) + return 1 + except urllib.error.URLError as e: + print(TERM.red(f"ERROR! Could not reach GitLab: {e}")) + return 1 + + print() + print(TERM.green(f"Pipeline triggered: {result.get('web_url')}")) + return 0 ===================================== tools/yeet/tests/python/conftest.py ===================================== @@ -0,0 +1,23 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import json + +from yeet import common # noqa: E402 + + +def write_config(tmp_path, **overrides): + """Write a `.yeet.json` under `tmp_path`, as `mach yeet auth` would, + for tests that need one already in place. + """ + config = { + "project": "bea/tor-browser-bundle-testsuite", + "token": "s3cr3t", + "ssh_host": common.DEFAULT_SSH_HOST, + "ssh_user": "bea", + } + config.update(overrides) + with open(tmp_path / common.YEET_CONFIG_FILENAME, "w") as f: + json.dump(config, f) + return config ===================================== tools/yeet/tests/python/python.toml ===================================== @@ -0,0 +1,10 @@ +[DEFAULT] +subsuite = "base-browser" + +["test_auth.py"] + +["test_upload.py"] + +["test_remote.py"] + +["test_common.py"] ===================================== tools/yeet/tests/python/test_auth.py ===================================== @@ -0,0 +1,435 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import json +import urllib.error +from types import SimpleNamespace + +import mozunit +import pytest +from yeet import auth, common + + +def _set_inputs(monkeypatch, *values): + values = iter(values) + monkeypatch.setattr("builtins.input", lambda prompt="": next(values)) + + +def _forbid_prompts(monkeypatch): + """Make input()/getpass() blow up instead of hanging, so a test can + assert that a fully non-interactive call never prompts.""" + + def _fail(prompt=""): + raise AssertionError(f"unexpected prompt: {prompt!r}") + + monkeypatch.setattr("builtins.input", _fail) + monkeypatch.setattr(common.getpass, "getpass", _fail) + + +@pytest.mark.parametrize("value", ["bea", " bea ", "bea.b", "bea_b-1"]) +def test_resolve_project_accepts_username(value): + project = auth._resolve_project(value) + + assert project == f"{value.strip()}/{common.CANONICAL_GITLAB_REPO_NAME}" + + +@pytest.mark.parametrize( + "value", + [ + common.CANONICAL_GITLAB_NAMESPACE, + common.CANONICAL_GITLAB_NAMESPACE.upper(), + f" {common.CANONICAL_GITLAB_NAMESPACE} ", + ], +) +def test_resolve_project_rejects_canonical_namespace(value): + with pytest.raises(ValueError, match="canonical"): + auth._resolve_project(value) + + +def test_resolve_project_accepts_top_level_group_of_canonical_namespace(): + group = common.CANONICAL_GITLAB_NAMESPACE.split("/", 1)[0] + + assert auth._resolve_project(group) == ( + f"{group}/{common.CANONICAL_GITLAB_REPO_NAME}" + ) + + +def test_prompt_fork_rejects_canonical_namespace(monkeypatch, capsys): + canonical_namespace = common.CANONICAL_GITLAB_NAMESPACE + _set_inputs(monkeypatch, canonical_namespace, "bea") + + project = auth._prompt_fork() + + assert project == f"bea/{common.CANONICAL_GITLAB_REPO_NAME}" + assert "not a" in capsys.readouterr().out.lower() + + +@pytest.mark.parametrize("value", ["", " ", "\t\n"]) +def test_resolve_project_rejects_blank_username(value): + with pytest.raises(ValueError): + auth._resolve_project(value) + + +def test_prompt_fork_rejects_empty_username(monkeypatch): + _set_inputs(monkeypatch, "", " \t ", "bea") + + project = auth._prompt_fork() + + assert project == f"bea/{common.CANONICAL_GITLAB_REPO_NAME}" + + +@pytest.mark.parametrize("status", [400]) +def test_verify_auth_accepts_valid_token(monkeypatch, status): + def fake_urlopen(request, *args, **kwargs): + raise urllib.error.HTTPError("url", status, "msg", None, None) + + monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen) + + assert auth._verify_auth("bea/tor-browser-bundle-testsuite", "tok") is True + + +@pytest.mark.parametrize("status", [401, 404]) +def test_verify_auth_rejects_bad_token_or_project(monkeypatch, status): + def fake_urlopen(request, *args, **kwargs): + raise urllib.error.HTTPError("url", status, "msg", None, None) + + monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen) + + assert auth._verify_auth("bea/tor-browser-bundle-testsuite", "tok") is False + + +def test_verify_auth_propagates_network_errors(monkeypatch): + def fake_urlopen(request, *args, **kwargs): + raise urllib.error.URLError("unreachable") + + monkeypatch.setattr(auth.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(urllib.error.URLError): + auth._verify_auth("bea/tor-browser-bundle-testsuite", "tok") + + +def test_verify_ssh_success(monkeypatch): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(auth.subprocess, "run", fake_run) + + assert auth._verify_ssh("bea@tb-build-03.torproject.org") is True + cmd, kwargs = calls[0] + assert cmd[-2:] == ["bea@tb-build-03.torproject.org", "true"] + assert kwargs["stdin"] is auth.subprocess.DEVNULL + + +def test_verify_ssh_failure(monkeypatch): + monkeypatch.setattr( + auth.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=255), + ) + + assert auth._verify_ssh("bea@tb-build-03.torproject.org") is False + + +def test_verify_ssh_missing_binary(monkeypatch, capsys): + def fake_run(*args, **kwargs): + raise FileNotFoundError() + + monkeypatch.setattr(auth.subprocess, "run", fake_run) + + assert auth._verify_ssh("bea@tb-build-03.torproject.org") is False + assert "no `ssh` binary" in capsys.readouterr().out.lower() + + +def test_run_saves_config_and_skips_verification(tmp_path, monkeypatch): + _set_inputs(monkeypatch, "bea", "", "bea") + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t") + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=False) + + assert result == 0 + config_path = tmp_path / common.YEET_CONFIG_FILENAME + with open(config_path) as f: + saved = json.load(f) + assert saved == { + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}", + "token": "s3cr3t", + "ssh_host": common.DEFAULT_SSH_HOST, + "ssh_user": "bea", + } + assert (config_path.stat().st_mode & 0o777) == 0o600 + + +def test_prompt_ssh_username_reprompts_until_non_empty(monkeypatch, capsys): + _set_inputs(monkeypatch, "", " ", "bea") + + assert auth._prompt_ssh_username(common.DEFAULT_SSH_HOST) == "bea" + assert capsys.readouterr().out.count("ERROR!") == 2 + + +def test_prompt_ssh_host_defaults_when_empty(monkeypatch): + _set_inputs(monkeypatch, " ") + + assert auth._prompt_ssh_host() == common.DEFAULT_SSH_HOST + + +def test_run_prompts_for_ssh_host(tmp_path, monkeypatch): + _set_inputs(monkeypatch, "build.example.com", "bea") + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run( + command_context, verify=False, gitlab_username="bea", gitlab_token="s3cr3t" + ) + + assert result == 0 + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + saved = json.load(f) + assert saved["ssh_host"] == "build.example.com" + assert saved["ssh_user"] == "bea" + + +def test_prompt_token_reprompts_until_non_empty(monkeypatch, capsys): + values = iter(["", " ", "s3cr3t"]) + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": next(values)) + + assert auth._prompt_token() == "s3cr3t" + assert capsys.readouterr().out.count("ERROR!") == 2 + + +def test_run_aborts_when_gitlab_verification_fails(tmp_path, monkeypatch, capsys): + _set_inputs(monkeypatch, "bea", "", "bea") + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t") + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: False) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=True) + + assert result == 1 + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists() + assert ( + "ERROR! GitLab rejected that project/token combination." + in capsys.readouterr().out + ) + + +def test_run_aborts_when_gitlab_unreachable(tmp_path, monkeypatch, capsys): + _set_inputs(monkeypatch, "bea", "", "bea") + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t") + + def raise_url_error(project, token): + raise urllib.error.URLError("unreachable") + + monkeypatch.setattr(auth, "_verify_auth", raise_url_error) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=True) + + assert result == 1 + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists() + assert ( + "ERROR! Could not reach GitLab: <urlopen error unreachable>" + in capsys.readouterr().out + ) + + +def test_run_aborts_when_ssh_verification_fails(tmp_path, monkeypatch, capsys): + _set_inputs(monkeypatch, "bea", "", "bea") + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t") + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True) + monkeypatch.setattr(auth, "_verify_ssh", lambda target: False) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=True) + + assert result == 1 + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists() + assert ( + f"ERROR! Could not SSH into bea@{common.DEFAULT_SSH_HOST}." + in capsys.readouterr().out + ) + + +def test_run_saves_config_when_verification_succeeds(tmp_path, monkeypatch): + _set_inputs(monkeypatch, "bea", "", "bea") + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t") + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True) + monkeypatch.setattr(auth, "_verify_ssh", lambda target: True) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=True) + + assert result == 0 + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + assert json.load(f) == { + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}", + "token": "s3cr3t", + "ssh_host": common.DEFAULT_SSH_HOST, + "ssh_user": "bea", + } + + +def test_run_uses_cli_gitlab_username_and_still_prompts_for_rest(tmp_path, monkeypatch): + # ssh host and username aren't given via CLI, so they're the only things + # prompted for. + _set_inputs(monkeypatch, "", "bea") + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": "s3cr3t") + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=False, gitlab_username="bea") + + assert result == 0 + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + saved = json.load(f) + assert saved["project"] == f"bea/{common.CANONICAL_GITLAB_REPO_NAME}" + assert saved["ssh_user"] == "bea" + + +def test_run_rejects_invalid_cli_gitlab_username(tmp_path, monkeypatch, capsys): + _forbid_prompts(monkeypatch) + canonical_namespace = common.CANONICAL_GITLAB_NAMESPACE + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run( + command_context, verify=False, gitlab_username=canonical_namespace + ) + + assert result == 1 + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists() + assert "not a" in capsys.readouterr().out.lower() + + +def test_run_fully_noninteractive_with_all_cli_args(tmp_path, monkeypatch): + _forbid_prompts(monkeypatch) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run( + command_context, + verify=False, + gitlab_username="bea", + gitlab_token="s3cr3t", + ssh_username="bea", + ssh_host=common.DEFAULT_SSH_HOST, + ) + + assert result == 0 + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + saved = json.load(f) + assert saved == { + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}", + "token": "s3cr3t", + "ssh_host": common.DEFAULT_SSH_HOST, + "ssh_user": "bea", + } + + +def test_run_saves_cli_ssh_host(tmp_path, monkeypatch): + _forbid_prompts(monkeypatch) + targets = [] + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True) + monkeypatch.setattr( + auth, "_verify_ssh", lambda target: targets.append(target) or True + ) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run( + command_context, + verify=True, + gitlab_username="bea", + gitlab_token="s3cr3t", + ssh_username="bea", + ssh_host="build.example.com", + ) + + assert result == 0 + assert targets == ["bea@build.example.com"] + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + assert json.load(f)["ssh_host"] == "build.example.com" + + +def test_run_fully_noninteractive_with_verification(tmp_path, monkeypatch): + _forbid_prompts(monkeypatch) + monkeypatch.setattr(auth, "_verify_auth", lambda project, token: True) + monkeypatch.setattr(auth, "_verify_ssh", lambda target: True) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run( + command_context, + verify=True, + gitlab_username="bea", + gitlab_token="s3cr3t", + ssh_username="bea", + ssh_host=common.DEFAULT_SSH_HOST, + ) + + assert result == 0 + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + assert json.load(f) == { + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}", + "token": "s3cr3t", + "ssh_host": common.DEFAULT_SSH_HOST, + "ssh_user": "bea", + } + + +_CLI_ARGS = { + "gitlab_username": "bea", + "gitlab_token": "s3cr3t", + "ssh_username": "bea", + "ssh_host": "build.example.com", +} + +_SAVED_CONFIG = { + "project": f"bea/{common.CANONICAL_GITLAB_REPO_NAME}", + "token": "s3cr3t", + "ssh_host": "build.example.com", + "ssh_user": "bea", +} + + +@pytest.mark.parametrize("padding", [" ", "\t", "\n", " \t\n "]) +def test_run_trims_cli_args(tmp_path, monkeypatch, padding): + _forbid_prompts(monkeypatch) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run( + command_context, + verify=False, + **{name: f"{padding}{value}{padding}" for name, value in _CLI_ARGS.items()}, + ) + + assert result == 0 + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + assert json.load(f) == _SAVED_CONFIG + + +def test_run_trims_prompted_values(tmp_path, monkeypatch): + _set_inputs(monkeypatch, " bea\t", " build.example.com\n", "\tbea ") + monkeypatch.setattr(common.getpass, "getpass", lambda prompt="": " s3cr3t\t") + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=False) + + assert result == 0 + with open(tmp_path / common.YEET_CONFIG_FILENAME) as f: + assert json.load(f) == _SAVED_CONFIG + + +@pytest.mark.parametrize("arg", list(_CLI_ARGS)) +@pytest.mark.parametrize("blank", ["", " ", "\t\n"]) +def test_run_rejects_blank_cli_arg(tmp_path, monkeypatch, capsys, arg, blank): + _forbid_prompts(monkeypatch) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = auth.run(command_context, verify=False, **{**_CLI_ARGS, arg: blank}) + + assert result == 1 + assert not (tmp_path / common.YEET_CONFIG_FILENAME).exists() + assert "ERROR!" in capsys.readouterr().out + + +if __name__ == "__main__": + mozunit.main() ===================================== tools/yeet/tests/python/test_common.py ===================================== @@ -0,0 +1,80 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import sys +from types import SimpleNamespace + +import mozunit +import pytest +from yeet import common + + +@pytest.mark.parametrize("secret", [False, True]) +def test_prompt_flushes_pending_tty_input(monkeypatch, secret): + termios = pytest.importorskip("termios") + events = [] + monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: True)) + monkeypatch.setattr( + termios, "tcflush", lambda fd, queue: events.append(("flush", queue)) + ) + monkeypatch.setattr( + "builtins.input", lambda message="": events.append("input") or "plain" + ) + monkeypatch.setattr( + common.getpass, + "getpass", + lambda message="": events.append("getpass") or "hidden", + ) + + answer = common.prompt("Q: ", secret=secret) + + assert answer == ("hidden" if secret else "plain") + assert events == [ + ("flush", termios.TCIFLUSH), + "getpass" if secret else "input", + ] + + +def test_prompt_skips_flush_when_not_a_tty(monkeypatch): + termios = pytest.importorskip("termios") + monkeypatch.setattr(sys, "stdin", SimpleNamespace(isatty=lambda: False)) + monkeypatch.setattr( + termios, + "tcflush", + lambda *args: pytest.fail("tcflush called on non-tty stdin"), + ) + monkeypatch.setattr("builtins.input", lambda message="": "piped") + + assert common.prompt("Q: ") == "piped" + + +@pytest.mark.parametrize( + "answers,expected", + [ + (["y"], True), + (["YES"], True), + ([" yes "], True), + ([""], False), + (["n"], False), + (["No"], False), + (["maybe", "y"], True), + ], +) +def test_confirm(monkeypatch, answers, expected): + answers = iter(answers) + monkeypatch.setattr("builtins.input", lambda message="": next(answers)) + + assert common.confirm("Go?") is expected + + +def test_confirm_reprompts_on_invalid_answer(monkeypatch, capsys): + answers = iter(["maybe", "sure", "n"]) + monkeypatch.setattr("builtins.input", lambda message="": next(answers)) + + assert common.confirm("Go?") is False + assert capsys.readouterr().out.count("ERROR!") == 2 + + +if __name__ == "__main__": + mozunit.main() ===================================== tools/yeet/tests/python/test_remote.py ===================================== @@ -0,0 +1,636 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import json +import shlex +import urllib.error +from types import SimpleNamespace + +import mozunit +import pytest +from conftest import write_config +from yeet import remote + + +class _FakeResponse: + def __init__(self, body): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return self._body + + +def _write_version(tmp_path, version="153.2.0"): + version_dir = tmp_path / "browser" / "config" + version_dir.mkdir(parents=True) + (version_dir / "version.txt").write_text(f"{version}\n") + + +def test_read_browser_version(tmp_path): + _write_version(tmp_path, "1.2.3") + + assert remote._read_browser_version(tmp_path) == "1.2.3" + + +def test_resolve_version_prefers_given_value(tmp_path): + + assert remote._resolve_version(tmp_path, "9.9.9") == "9.9.9" + + +def test_resolve_version_falls_back_to_in_tree_version(tmp_path, capsys): + _write_version(tmp_path, "1.2.3") + + assert remote._resolve_version(tmp_path, None) == "1.2.3" + assert "1.2.3" in capsys.readouterr().out + + +def test_artifacts_and_mozharness_urls(): + artifacts = remote._artifacts_url("1.2.3", "android_x86_64") + + assert artifacts == f"{remote.TOOLCHAINS_URL_BASE}/1.2.3/android-x86_64/" + assert remote._mozharness_url("1.2.3", "android_x86_64") == ( + artifacts + "mozharness.zip" + ) + + +def test_curl_command_includes_header_data_and_url(): + command = remote._curl_command( + "https://example.org/trigger", + {"Content-Type": "application/json"}, + b'{"a": 1}', + ) + + assert shlex.split(command) == [ + "curl", + "--request", + "POST", + "--header", + "Content-Type: application/json", + "--data", + '{"a": 1}', + "https://example.org/trigger", + ] + + +def _failing_upload_run(topsrcdir, file, platform, assume_yes): + raise RuntimeError("boom") + + +def test_resolve_installer_url_passes_through_a_url(tmp_path): + + url = remote._resolve_installer_url( + tmp_path, + "https://example.org/app.apk", + "android_x86_64", + assume_yes=False, + dry_run=False, + ) + + assert url == "https://example.org/app.apk" + + +def test_resolve_installer_url_uploads_a_local_path(tmp_path, monkeypatch): + calls = [] + + def fake_upload_run(topsrcdir, file, platform, assume_yes): + calls.append((topsrcdir, file, platform, assume_yes)) + return "https://tb-build-03.torproject.org/~bea/yeet/ts.apk" + + monkeypatch.setattr(remote.upload, "run", fake_upload_run) + + url = remote._resolve_installer_url( + tmp_path, + "/tmp/app.apk", + "android_x86_64", + assume_yes=True, + dry_run=False, + ) + + assert url == "https://tb-build-03.torproject.org/~bea/yeet/ts.apk" + assert calls == [(tmp_path, "/tmp/app.apk", "android_x86_64", True)] + + +def test_resolve_installer_url_propagates_upload_error(tmp_path, monkeypatch): + monkeypatch.setattr(remote.upload, "run", _failing_upload_run) + + with pytest.raises(RuntimeError, match="boom"): + remote._resolve_installer_url( + tmp_path, + "/tmp/app.apk", + "android_x86_64", + assume_yes=False, + dry_run=False, + ) + + +def test_prompt_platform_rejects_invalid_choice_then_accepts(monkeypatch): + values = iter(["0", "99", "not-a-number", "2"]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(values)) + + assert remote._prompt_platform() == remote.PLATFORMS[1] + + +def test_prompt_channel_rejects_invalid_choice_then_accepts(monkeypatch): + values = iter(["0", "99", "not-a-number", "2"]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(values)) + + assert remote._prompt_channel() == remote.ANDROID_CHANNELS[1] + + +def _run_kwargs(**overrides): + kwargs = dict( + platform="android_x86_64", + channel="debug", + installer_url="https://example.org/app.apk", + sha256sums_url=None, + artifacts_url="https://example.org/artifacts/", + mozharness_url="https://example.org/mozharness.zip", + version=None, + ref="main", + tags=["tor"], + dry_run=False, + ) + kwargs.update(overrides) + return kwargs + + +def test_run_aborts_without_config(tmp_path, capsys): + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = remote.run(command_context, **_run_kwargs()) + + assert result == 1 + assert "mach yeet auth" in capsys.readouterr().out + + +def test_run_dry_run_prints_curl_and_skips_network(tmp_path, monkeypatch, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + def fail_urlopen(*args, **kwargs): + raise AssertionError("urlopen should not be called in dry-run mode") + + monkeypatch.setattr(remote.urllib.request, "urlopen", fail_urlopen) + + result = remote.run(command_context, **_run_kwargs(dry_run=True)) + + assert result == 0 + out = capsys.readouterr().out + assert "curl --request POST" in out + assert "app.apk" in out + + +def test_run_prompts_for_platform_when_missing(tmp_path, monkeypatch, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + calls = [] + + def fake_prompt_platform(): + calls.append(True) + return "debian_x86_64" + + monkeypatch.setattr(remote, "_prompt_platform", fake_prompt_platform) + + result = remote.run(command_context, **_run_kwargs(platform=None, dry_run=True)) + + assert result == 0 + assert len(calls) == 1 + assert "debian_x86_64_installer_url" in capsys.readouterr().out + + +def test_run_prompts_for_channel_when_android_and_missing( + tmp_path, monkeypatch, capsys +): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + calls = [] + + def fake_prompt_channel(): + calls.append(True) + return "nightly" + + monkeypatch.setattr(remote, "_prompt_channel", fake_prompt_channel) + + result = remote.run(command_context, **_run_kwargs(channel=None, dry_run=True)) + + assert result == 0 + assert len(calls) == 1 + assert remote.ANDROID_CHANNEL_PACKAGE_NAMES["nightly"] in capsys.readouterr().out + + +def test_run_adds_package_name_input_for_android(tmp_path, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = remote.run(command_context, **_run_kwargs(channel="nightly", dry_run=True)) + + assert result == 0 + out = capsys.readouterr().out + assert "android_x86_64_package_name" in out + assert remote.ANDROID_CHANNEL_PACKAGE_NAMES["nightly"] in out + + +def test_run_skips_channel_for_non_android_platforms(tmp_path, monkeypatch, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + monkeypatch.setattr( + remote, + "_prompt_channel", + lambda: (_ for _ in ()).throw(AssertionError("should not prompt")), + ) + + result = remote.run( + command_context, + **_run_kwargs( + platform="debian_x86_64", + channel=None, + artifacts_url="https://example.org/artifacts/", + mozharness_url="https://example.org/mozharness.zip", + dry_run=True, + ), + ) + + assert result == 0 + out = capsys.readouterr().out + assert "package_name" not in out + + +def test_run_rejects_unknown_channel(tmp_path, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = remote.run(command_context, **_run_kwargs(channel="not-a-channel")) + + assert result == 1 + assert "ERROR! Unknown channel 'not-a-channel'." in capsys.readouterr().out + + +@pytest.mark.parametrize("dry_run", [False, True]) +@pytest.mark.parametrize("installer_url", ["", " ", "\t\n"]) +def test_run_rejects_empty_installer( + tmp_path, monkeypatch, capsys, installer_url, dry_run +): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + monkeypatch.setattr( + remote, "_prompt_platform", lambda: pytest.fail("prompted for platform") + ) + monkeypatch.setattr( + remote.upload, "run", lambda *args, **kwargs: pytest.fail("uploaded") + ) + monkeypatch.setattr( + remote.urllib.request, + "urlopen", + lambda *args, **kwargs: pytest.fail("pipeline triggered"), + ) + + result = remote.run( + command_context, + **_run_kwargs(platform=None, installer_url=installer_url, dry_run=dry_run), + ) + + assert result == 1 + out = capsys.readouterr().out + assert "ERROR! The installer URL or path was empty." in out + assert "curl" not in out + + +def test_run_trims_installer_url(tmp_path, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = remote.run( + command_context, + **_run_kwargs(installer_url=" https://example.org/app.apk\n", dry_run=True), + ) + + assert result == 0 + assert '"android_x86_64_installer_url": "https://example.org/app.apk"' in ( + capsys.readouterr().out + ) + + +def test_run_ignores_channel_for_non_android_platforms(tmp_path, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = remote.run( + command_context, + **_run_kwargs(platform="debian_x86_64", channel="nightly", dry_run=True), + ) + + assert result == 0 + out = capsys.readouterr().out + assert "package_name" not in out + assert remote.ANDROID_CHANNEL_PACKAGE_NAMES["nightly"] not in out + + +@pytest.mark.parametrize( + "platform,installer", + [ + ("debian_x86_64", "tor-browser-linux-x86_64.tar.xz"), + ("windows_x86_64", "tor-browser-windows-x86_64-portable.exe"), + ("macos_x86_64", "tor-browser-macos.dmg"), + ("android_x86_64", "tor-browser-android-x86_64.apk"), + ], +) +def test_run_uploads_a_local_path_before_triggering( + tmp_path, monkeypatch, platform, installer +): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + installer_path = f"/tmp/{installer}" + uploaded_url = ( + f"https://tb-build-03.torproject.org/~bea/yeet/ts-{platform}/{installer}" + ) + uploads = [] + + def fake_upload_run(topsrcdir, file, platform, assume_yes): + uploads.append((file, platform)) + return uploaded_url + + monkeypatch.setattr(remote.upload, "run", fake_upload_run) + requests = [] + + def fake_urlopen(request, timeout=None): + requests.append(request) + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode()) + + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen) + + result = remote.run( + command_context, + **_run_kwargs(platform=platform, installer_url=installer_path), + ) + + assert result == 0 + assert uploads == [(installer_path, platform)] + inputs = json.loads(requests[0].data)["inputs"] + assert inputs[f"{platform}_installer_url"] == uploaded_url + assert (f"{platform}_package_name" in inputs) == (platform == "android_x86_64") + + +def test_run_prompts_for_platform_before_uploading(tmp_path, monkeypatch): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + monkeypatch.setattr(remote, "_prompt_platform", lambda: "debian_x86_64") + seen = [] + + def fake_upload_run(topsrcdir, file, platform, assume_yes): + seen.append(platform) + raise RuntimeError("stop here") + + monkeypatch.setattr(remote.upload, "run", fake_upload_run) + + remote.run( + command_context, **_run_kwargs(platform=None, installer_url="/tmp/app.tar.xz") + ) + + assert seen == ["debian_x86_64"] + + +@pytest.mark.parametrize("assume_yes", [False, True]) +def test_run_forwards_assume_yes_to_upload(tmp_path, monkeypatch, assume_yes): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + seen = [] + + def fake_upload_run(topsrcdir, file, platform, assume_yes): + seen.append(assume_yes) + raise RuntimeError("stop here") + + monkeypatch.setattr(remote.upload, "run", fake_upload_run) + + remote.run( + command_context, + **_run_kwargs(installer_url="/tmp/app.apk", assume_yes=assume_yes), + ) + + assert seen == [assume_yes] + + +@pytest.mark.parametrize( + "tags,expected", + [ + (["tor", "base-browser"], "--tag tor --tag base-browser"), + (["a b"], "--tag 'a b'"), + (["a b", '"c"'], """--tag 'a b' --tag '"c"'"""), + ], +) +def test_run_formats_tags_as_flags(tmp_path, monkeypatch, tags, expected): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + requests = [] + + def fake_urlopen(request, timeout=None): + requests.append(request) + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode()) + + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen) + + remote.run(command_context, **_run_kwargs(tags=tags)) + + assert json.loads(requests[0].data)["inputs"]["tags"] == expected + + +def test_run_omits_tags_input_when_empty(tmp_path, monkeypatch): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + requests = [] + + def fake_urlopen(request, timeout=None): + requests.append(request) + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode()) + + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen) + + remote.run(command_context, **_run_kwargs(tags=[])) + + assert "tags" not in json.loads(requests[0].data)["inputs"] + + +def _triggered_inputs(tmp_path, monkeypatch, **overrides): + write_config(tmp_path) + _write_version(tmp_path, "1.2.3") + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + requests = [] + + def fake_urlopen(request, timeout=None): + requests.append(request) + return _FakeResponse(json.dumps({"web_url": "https://x"}).encode()) + + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen) + + assert remote.run(command_context, **_run_kwargs(**overrides)) == 0 + return json.loads(requests[0].data)["inputs"] + + +@pytest.mark.parametrize("sha256sums_url", [None, "https://example.org/sha256sums.txt"]) +def test_run_includes_sha256sums_input_only_when_given( + tmp_path, monkeypatch, sha256sums_url +): + inputs = _triggered_inputs(tmp_path, monkeypatch, sha256sums_url=sha256sums_url) + + assert inputs.get("android_x86_64_sha256sums_url") == sha256sums_url + + +@pytest.mark.parametrize("artifacts_url", [None, "https://example.org/artifacts/"]) +@pytest.mark.parametrize("mozharness_url", [None, "https://example.org/mozharness.zip"]) +def test_run_uses_given_artifacts_and_mozharness_urls_or_infers_them( + tmp_path, monkeypatch, artifacts_url, mozharness_url +): + inputs = _triggered_inputs( + tmp_path, + monkeypatch, + artifacts_url=artifacts_url, + mozharness_url=mozharness_url, + ) + + assert inputs["android_x86_64_artifacts_url"] == ( + artifacts_url or remote._artifacts_url("1.2.3", "android_x86_64") + ) + assert inputs["mozharness_url"] == ( + mozharness_url or remote._mozharness_url("1.2.3", "android_x86_64") + ) + + +def test_run_aborts_when_local_upload_fails(tmp_path, monkeypatch, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + monkeypatch.setattr(remote.upload, "run", _failing_upload_run) + monkeypatch.setattr( + remote.urllib.request, + "urlopen", + lambda *args, **kwargs: pytest.fail("pipeline triggered after failed upload"), + ) + + result = remote.run(command_context, **_run_kwargs(installer_url="/tmp/app.apk")) + + assert result == 1 + assert "ERROR! boom" in capsys.readouterr().out + + +def test_run_dry_run_does_not_upload_a_local_path(tmp_path, monkeypatch, capsys): + write_config(tmp_path) + apk = tmp_path / "app.apk" + apk.write_bytes(b"not really an apk") + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + monkeypatch.setattr( + remote.upload, + "run", + lambda *args, **kwargs: pytest.fail("uploaded during a dry run"), + ) + + result = remote.run( + command_context, **_run_kwargs(installer_url=str(apk), dry_run=True) + ) + + assert result == 0 + assert "<URL of uploaded app.apk>" in capsys.readouterr().out + + +def test_run_dry_run_rejects_missing_local_path(tmp_path, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + missing = tmp_path / "missing.apk" + + result = remote.run( + command_context, **_run_kwargs(installer_url=str(missing), dry_run=True) + ) + + assert result == 1 + out = capsys.readouterr().out + assert f"ERROR! No such file: {missing}" in out + assert "curl" not in out + + +def test_run_infers_version_artifacts_and_mozharness(tmp_path, capsys): + write_config(tmp_path) + _write_version(tmp_path, "1.2.3") + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + result = remote.run( + command_context, + **_run_kwargs(artifacts_url=None, mozharness_url=None, dry_run=True), + ) + + assert result == 0 + out = capsys.readouterr().out + assert "1.2.3" in out + assert "android-x86_64" in out + + +def test_run_triggers_pipeline_and_reports_url(tmp_path, monkeypatch, capsys): + config = write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + requests = [] + + def fake_urlopen(request, timeout=None): + requests.append(request) + body = json.dumps({"web_url": "https://gitlab.example/pipelines/1"}).encode() + return _FakeResponse(body) + + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen) + + result = remote.run(command_context, **_run_kwargs()) + + assert result == 0 + assert "https://gitlab.example/pipelines/1" in capsys.readouterr().out + assert requests[0].full_url == ( + f"{remote.GITLAB_API_BASE}/projects/" + f"{config['project'].replace('/', '%2F')}/trigger/pipeline" + ) + payload = json.loads(requests[0].data) + assert payload["token"] == config["token"] + assert payload["ref"] == "main" + assert payload["inputs"]["tags"] == "--tag tor" + + +def test_run_reports_http_error(tmp_path, monkeypatch, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + def fake_urlopen(request, timeout=None): + raise urllib.error.HTTPError( + request.full_url, + 401, + "Unauthorized", + None, + SimpleNamespace(read=lambda: b"bad token", close=lambda: None), + ) + + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen) + + result = remote.run(command_context, **_run_kwargs()) + + assert result == 1 + out = capsys.readouterr().out + assert "401" in out + assert "bad token" in out + + +def test_run_reports_url_error(tmp_path, monkeypatch, capsys): + write_config(tmp_path) + command_context = SimpleNamespace(topsrcdir=str(tmp_path)) + + def fake_urlopen(request, timeout=None): + raise urllib.error.URLError("unreachable") + + monkeypatch.setattr(remote.urllib.request, "urlopen", fake_urlopen) + + result = remote.run(command_context, **_run_kwargs()) + + assert result == 1 + assert "unreachable" in capsys.readouterr().out + + +if __name__ == "__main__": + mozunit.main() ===================================== tools/yeet/tests/python/test_upload.py ===================================== @@ -0,0 +1,242 @@ +# 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 http://mozilla.org/MPL/2.0/. + +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import mozunit +import pytest +from conftest import write_config +from yeet import upload + + +def _freeze_time(monkeypatch, fixed): + class _FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return fixed.astimezone(tz) + + monkeypatch.setattr(upload, "datetime", _FrozenDatetime) + + +def test_resolve_file_missing(): + with pytest.raises(FileNotFoundError, match="No such file"): + upload.resolve_file("/no/such/file/here") + + +def test_resolve_file_existing(tmp_path): + apk = tmp_path / "fenix-x86_64-debug.apk" + apk.write_bytes(b"not really an apk") + + assert upload.resolve_file(str(apk)) == apk.resolve() + + +@pytest.mark.parametrize( + "fixed,expected", + [ + (datetime(2026, 9, 14, 12, 34, 56, tzinfo=timezone.utc), "20260914-123456"), + ( + datetime( + 2026, 9, 14, 18, 4, 56, tzinfo=timezone(timedelta(hours=5, minutes=30)) + ), + "20260914-123456", + ), + ( + datetime(2026, 9, 14, 21, 0, 0, tzinfo=timezone(timedelta(hours=-5))), + "20260915-020000", + ), + ], +) +def test_remote_dir_name_is_current_utc_time_and_platform(monkeypatch, fixed, expected): + _freeze_time(monkeypatch, fixed) + + assert upload._remote_dir_name("android_x86_64") == f"{expected}-android_x86_64" + + +def test_upload_success(monkeypatch): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + assert kwargs["stdin"] is upload.subprocess.DEVNULL + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(upload.subprocess, "run", fake_run) + + url = upload._upload(upload.Path("/tmp/app.apk"), "bea", "example.org", "ts-os") + + assert url == "https://example.org/~bea/yeet/ts-os/app.apk" + assert calls[0][0] == "ssh" + assert calls[0][-3:] == ["mkdir", "-p", "~/public_html/yeet/ts-os"] + assert calls[1][0] == "scp" + assert calls[1][-2:] == [ + "/tmp/app.apk", + "bea@example.org:~/public_html/yeet/ts-os/app.apk", + ] + + +def test_upload_replaces_unsafe_filename_characters(monkeypatch): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(upload.subprocess, "run", fake_run) + + url = upload._upload( + upload.Path("/tmp/app (1)#%?.apk"), "bea", "example.org", "ts-os" + ) + + assert url == "https://example.org/~bea/yeet/ts-os/app__1____.apk" + assert calls[1][-2:] == [ + "/tmp/app (1)#%?.apk", + "bea@example.org:~/public_html/yeet/ts-os/app__1____.apk", + ] + + +def test_upload_stops_if_mkdir_fails(monkeypatch): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=1) + + monkeypatch.setattr(upload.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError): + upload._upload(upload.Path("/tmp/app.apk"), "bea", "example.org", "ts-os") + + assert len(calls) == 1 + + +def test_upload_fails_if_scp_fails(monkeypatch): + def fake_run(cmd, **kwargs): + return SimpleNamespace(returncode=0 if cmd[0] == "ssh" else 1) + + monkeypatch.setattr(upload.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError): + upload._upload(upload.Path("/tmp/app.apk"), "bea", "example.org", "ts-os") + + +def test_run_aborts_without_config(tmp_path): + apk = tmp_path / "app.apk" + apk.touch() + + with pytest.raises(RuntimeError, match="mach yeet auth"): + upload.run(tmp_path, str(apk), "android_x86_64") + + +def test_run_aborts_when_file_missing(tmp_path): + write_config(tmp_path) + + with pytest.raises(FileNotFoundError): + upload.run(tmp_path, str(tmp_path / "missing.apk"), "android_x86_64") + + +def _write_apk(tmp_path): + apk = tmp_path / "app.apk" + apk.write_bytes(b"not really an apk") + return apk + + +def _set_confirm_answers(monkeypatch, *answers): + answers = iter(answers) + monkeypatch.setattr("builtins.input", lambda prompt="": next(answers)) + + +def test_run_uploads_into_timestamped_dir_and_prints_url(tmp_path, monkeypatch, capsys): + config = write_config(tmp_path) + apk = tmp_path / "app.apk" + apk.write_bytes(b"not really an apk") + _freeze_time(monkeypatch, datetime(2026, 9, 14, 12, 34, 56, tzinfo=timezone.utc)) + + calls = [] + + def fake_upload(file_path, ssh_user, ssh_host, remote_dir): + calls.append((file_path, ssh_user, ssh_host, remote_dir)) + return f"https://{ssh_host}/~{ssh_user}/yeet/{remote_dir}/{file_path.name}" + + monkeypatch.setattr(upload, "_upload", fake_upload) + + url = upload.run(tmp_path, str(apk), "android_x86_64", assume_yes=True) + + assert url == ( + f"https://{config['ssh_host']}/~{config['ssh_user']}/yeet/20260914-123456-android_x86_64/app.apk" + ) + assert calls == [ + ( + apk.resolve(), + config["ssh_user"], + config["ssh_host"], + "20260914-123456-android_x86_64", + ) + ] + assert url in capsys.readouterr().out + + +def test_run_reports_upload_failure(tmp_path, monkeypatch): + write_config(tmp_path) + apk = tmp_path / "app.apk" + apk.write_bytes(b"not really an apk") + + def fake_upload(file_path, ssh_user, ssh_host, remote_dir): + raise RuntimeError("boom") + + monkeypatch.setattr(upload, "_upload", fake_upload) + + with pytest.raises(RuntimeError, match="boom"): + upload.run(tmp_path, str(apk), "android_x86_64", assume_yes=True) + + +@pytest.mark.parametrize("answer", ["y", "yes", "Y"]) +def test_run_uploads_after_confirmation(tmp_path, monkeypatch, answer): + write_config(tmp_path) + apk = _write_apk(tmp_path) + _set_confirm_answers(monkeypatch, answer) + monkeypatch.setattr( + upload, + "_upload", + lambda file_path, ssh_user, ssh_host, remote_dir: "https://x/y", + ) + + assert upload.run(tmp_path, str(apk), "android_x86_64") == "https://x/y" + + +@pytest.mark.parametrize("answers", [("",), ("n",), ("no",), ("maybe", "n")]) +def test_run_does_not_upload_when_declined(tmp_path, monkeypatch, answers): + write_config(tmp_path) + apk = _write_apk(tmp_path) + _set_confirm_answers(monkeypatch, *answers) + monkeypatch.setattr( + upload, + "_upload", + lambda *args: pytest.fail("_upload called after the user declined"), + ) + + with pytest.raises(RuntimeError, match="cancelled"): + upload.run(tmp_path, str(apk), "android_x86_64") + + +def test_run_skips_confirmation_with_assume_yes(tmp_path, monkeypatch): + write_config(tmp_path) + apk = _write_apk(tmp_path) + monkeypatch.setattr( + "builtins.input", lambda prompt="": pytest.fail("unexpected prompt") + ) + monkeypatch.setattr( + upload, + "_upload", + lambda file_path, ssh_user, ssh_host, remote_dir: "https://x/y", + ) + + assert ( + upload.run(tmp_path, str(apk), "android_x86_64", assume_yes=True) + == "https://x/y" + ) + + +if __name__ == "__main__": + mozunit.main() ===================================== tools/yeet/upload.py ===================================== @@ -0,0 +1,96 @@ +# 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 http://mozilla.org/MPL/2.0/. + +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +from yeet.common import TERM, YEET_CONFIG_FILENAME, confirm, read_config + + +def resolve_file(file_arg): + path = Path(file_arg).resolve() + if not path.is_file(): + raise FileNotFoundError(f"No such file: {path}") + return path + + +def _remote_dir_name(platform): + """Name the upload's directory after the current time and `platform`, + so repeated uploads never collide or overwrite each other. + """ + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + return f"{timestamp}-{platform}" + + +def _upload(file_path, ssh_user, ssh_host, remote_dir): + """scp `file_path` into ~/public_html/yeet/`remote_dir` on + ssh_user@ssh_host, keeping its name (with characters unsafe in a URL or + shell replaced by `_`) and creating that directory first if needed. Both + commands get /dev/null as stdin so they can't swallow input buffered for + us; their touch/PIN/passphrase prompts go through /dev/tty and still reach + the user. + + Returns the file's final https:// URL. Raises RuntimeError on failure. + """ + ssh_target = f"{ssh_user}@{ssh_host}" + remote_name = re.sub(r"[^A-Za-z0-9._-]", "_", file_path.name) + dest_dir = f"~/public_html/yeet/{remote_dir}" + dest_path = f"{dest_dir}/{remote_name}" + html_path = f"~{ssh_user}/yeet/{remote_dir}/{remote_name}" + + mkdir = subprocess.run( + ["ssh", ssh_target, "mkdir", "-p", dest_dir], + stdin=subprocess.DEVNULL, + check=False, + ) + if mkdir.returncode != 0: + raise RuntimeError(f"Could not create {dest_dir} on {ssh_target}.") + + scp = subprocess.run( + ["scp", str(file_path), f"{ssh_target}:{dest_path}"], + stdin=subprocess.DEVNULL, + check=False, + ) + if scp.returncode != 0: + raise RuntimeError(f"Upload to {ssh_target} failed.") + + return f"https://{ssh_host}/{html_path}" + + +def run(topsrcdir, file, platform, assume_yes=False): + """Upload `file` for `platform` to the configured server, asking for + confirmation first unless `assume_yes`. Returns the file's final https:// + URL. Raises FileNotFoundError if `file` or the config is missing, and + RuntimeError if the upload fails or is cancelled. + """ + config = read_config(topsrcdir) + if config is None: + raise RuntimeError( + f"No {YEET_CONFIG_FILENAME} found. Run `mach yeet auth` first." + ) + + file_path = resolve_file(file) + + remote_dir = _remote_dir_name(platform) + + if not assume_yes: + print() + print( + TERM.yellow( + f"{file_path} will be uploaded to {config['ssh_host']} and " + "be publicly accessible." + ) + ) + if not confirm("Upload it?"): + raise RuntimeError("Upload cancelled.") + + print() + print(f"Uploading {file_path.name} to {remote_dir}/...") + url = _upload(file_path, config["ssh_user"], config["ssh_host"], remote_dir) + + print() + print(TERM.green(f"Uploaded: {url}")) + return url View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/722a587... -- View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/722a587... You're receiving this email because of your account on gitlab.torproject.org. Manage all notifications: https://gitlab.torproject.org/-/profile/notifications | Help: https://gitlab.torproject.org/help
participants (1)
-
brizental (@brizental)