From 3664cab7ce45016797c321a6b7404be3bae0a265 Mon Sep 17 00:00:00 2001 From: Ben Lovell Date: Wed, 13 May 2026 23:26:30 +0200 Subject: [PATCH 1/6] fix: align pagination with API semantics and make it configurable The CLI pagination loop added in #275 was 0-indexed but the API treats page=0 (or null) as 'return everything in one response' and uses 1-indexed pages otherwise. The loop happened to work against real prod only because the first request asked for 'all' and immediately exited the loop. The mock server was 0-indexed and so diverged from the real API's semantics. Changes: - Add four hidden global flags for paginated list commands: --page-size, --page, --no-paginate, --max-items. Modeled on the AWS CLI's pagination knobs but matched to our page-number API (--starting-token would be cursor-shaped, which our API does not use). - Thread page_size / paginate / page / max_items through Config. - Fix fetch_all_pages: page=0 means 'all in one response and stop'; page>=1 iterates 1..=num_pages. - Align the mock /v1/apps/etc. pagination to the same semantics so the mock and prod behave the same way. - Rewrite cli_pagination.feature to create apps via the real CLI and delete them in after_scenario. With --page 1 --page-size 2 against three apps, the test forces a real two-page traversal against any backend. - Drop the mock-only /test/seed-apps and /test/reset endpoints that the old pagination feature relied on. --- crates/config/src/lib.rs | 51 +++++++++ crates/tower-cmd/src/api.rs | 44 ++++++-- crates/tower-cmd/src/lib.rs | 31 ++++++ .../features/cli_pagination.feature | 24 ++-- tests/integration/features/environment.py | 18 +++ tests/integration/features/steps/cli_steps.py | 77 ++++++++----- tests/mock-api-server/main.py | 104 +++--------------- 7 files changed, 210 insertions(+), 139 deletions(-) diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index cca17e88..2e4a48a2 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -26,8 +26,27 @@ pub struct Config { // cache_dir is the directory that we should cache uv artifacts within. pub cache_dir: Option, + + // Override for the page size used when fetching paginated list endpoints. + // None means use the built-in default. + #[serde(skip_serializing, skip_deserializing)] + pub page_size: Option, + + // When false, only the first page of paginated endpoints is fetched. + #[serde(skip_serializing, skip_deserializing)] + pub paginate: bool, + + // Hard upper bound on the number of items returned by a paginated list call. + #[serde(skip_serializing, skip_deserializing)] + pub max_items: Option, + + // Starting page (0-indexed) for paginated list calls. + #[serde(skip_serializing, skip_deserializing)] + pub page: Option, } +pub const DEFAULT_PAGE_SIZE: i64 = 100; + impl Config { pub fn default() -> Self { Self { @@ -37,9 +56,17 @@ impl Config { session: None, api_key: None, cache_dir: Some(default_cache_dir()), + page_size: None, + paginate: true, + max_items: None, + page: None, } } + pub fn page_size(&self) -> i64 { + self.page_size.unwrap_or(DEFAULT_PAGE_SIZE) + } + pub fn from_env() -> Self { let debug = std::env::var("TOWER_DEBUG").is_ok(); let tower_url = if let Some(url) = std::env::var("TOWER_URL").ok() { @@ -56,6 +83,10 @@ impl Config { session: None, api_key, cache_dir: Some(default_cache_dir()), + page_size: None, + paginate: true, + max_items: None, + page: None, } } @@ -74,6 +105,22 @@ impl Config { config.tower_url = Url::parse(tower_url).unwrap(); } + if let Some(page_size) = matches.get_one::("page_size") { + config.page_size = Some(*page_size); + } + + if matches.get_flag("no_paginate") { + config.paginate = false; + } + + if let Some(max_items) = matches.get_one::("max_items") { + config.max_items = Some(*max_items); + } + + if let Some(page) = matches.get_one::("page") { + config.page = Some(*page); + } + config } @@ -85,6 +132,10 @@ impl Config { session: Some(sess), api_key: self.api_key, cache_dir: Some(default_cache_dir()), + page_size: self.page_size, + paginate: self.paginate, + max_items: self.max_items, + page: self.page, } } diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 4daeab17..0185c7cb 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -64,24 +64,46 @@ impl PaginatedResponse for tower_api::models::ListSchedulesResponse { fn into_items(self) -> Vec { self.schedules } } -/// Fetches all pages from a paginated API endpoint. -async fn fetch_all_pages(fetch: F) -> Result, Error> +/// Fetches pages from a paginated API endpoint, honoring the caller's +/// page_size / paginate / page / max_items settings on Config. +/// +/// Page numbers follow the API convention: pages are 1-indexed, and `page = 0` +/// (or unset on the server side) means "no pagination, return everything in one +/// response". The loop sends `page = 0` exactly once if the caller did not opt +/// into a starting page, otherwise it iterates 1..=num_pages. +async fn fetch_all_pages(config: &Config, fetch: F) -> Result, Error> where R: PaginatedResponse, F: Fn(i64, i64) -> Fut, Fut: Future>>, { - let page_size: i64 = 100; + let page_size = config.page_size(); let mut all_items = Vec::new(); - let mut page: i64 = 0; + let mut page: i64 = config.page.unwrap_or(0); loop { let response = fetch(page, page_size).await?; let num_pages = response.pagination().num_pages; all_items.extend(response.into_items()); + if let Some(max) = config.max_items { + if all_items.len() as i64 >= max { + all_items.truncate(max as usize); + break; + } + } + + // page == 0 means the server returned all items in a single response. + if page == 0 { + break; + } + + if !config.paginate { + break; + } + page += 1; - if page >= num_pages || page >= 100 { + if page > num_pages || page > 100 { break; } } @@ -119,7 +141,7 @@ pub async fn list_apps( { let api_config: configuration::Configuration = config.into(); - fetch_all_pages(|page, page_size| { + fetch_all_pages(config, |page, page_size| { let api_config = &api_config; async move { let params = tower_api::apis::default_api::ListAppsParams { @@ -305,7 +327,7 @@ pub async fn list_catalogs( let api_config: configuration::Configuration = config.into(); let env = env.to_string(); - fetch_all_pages(|page, page_size| { + fetch_all_pages(config, |page, page_size| { let api_config = &api_config; let env = &env; async move { @@ -354,7 +376,7 @@ pub async fn list_secrets( let api_config: configuration::Configuration = config.into(); let env = env.to_string(); - fetch_all_pages(|page, page_size| { + fetch_all_pages(config, |page, page_size| { let api_config = &api_config; let env = &env; async move { @@ -493,7 +515,7 @@ pub async fn list_teams( ) -> Result, Error> { let api_config: configuration::Configuration = config.into(); - fetch_all_pages(|page, page_size| { + fetch_all_pages(config, |page, page_size| { let api_config = &api_config; async move { let params = tower_api::apis::default_api::ListTeamsParams { @@ -971,7 +993,7 @@ pub async fn list_environments( > { let api_config: configuration::Configuration = config.into(); - fetch_all_pages(|page, page_size| { + fetch_all_pages(config, |page, page_size| { let api_config = &api_config; async move { let params = tower_api::apis::default_api::ListEnvironmentsParams { @@ -1018,7 +1040,7 @@ pub async fn list_schedules( let api_config: configuration::Configuration = config.into(); let environment = environment.map(String::from); - fetch_all_pages(|page, page_size| { + fetch_all_pages(config, |page, page_size| { let api_config = &api_config; let environment = &environment; async move { diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index e42789b7..29011d71 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -249,6 +249,37 @@ fn root_cmd() -> Command { .value_parser(value_parser!(String)) .action(clap::ArgAction::Set), ) + .arg( + Arg::new("page_size") + .long("page-size") + .hide(true) + .value_parser(value_parser!(i64)) + .action(clap::ArgAction::Set) + .global(true), + ) + .arg( + Arg::new("no_paginate") + .long("no-paginate") + .help("Fetch only the first page of paginated results") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + Arg::new("max_items") + .long("max-items") + .help("Maximum number of items to return from paginated list commands") + .value_parser(value_parser!(i64)) + .action(clap::ArgAction::Set) + .global(true), + ) + .arg( + Arg::new("page") + .long("page") + .help("Page number to start at for paginated list commands; 0 (the default) requests all results without pagination") + .value_parser(value_parser!(i64)) + .action(clap::ArgAction::Set) + .global(true), + ) .subcommand_required(false) .arg_required_else_help(false) .subcommand(session::login_cmd()) diff --git a/tests/integration/features/cli_pagination.feature b/tests/integration/features/cli_pagination.feature index ada10629..f71262e8 100644 --- a/tests/integration/features/cli_pagination.feature +++ b/tests/integration/features/cli_pagination.feature @@ -1,16 +1,16 @@ -@serial -Feature: CLI Pagination +@serial @pagination +Feature: CLI Apps List Pagination As a developer using Tower CLI - I want all items to be returned from list commands - So that I don't miss apps or resources due to pagination limits + I want all of my apps to be returned from list commands + So that I don't miss apps due to pagination limits - Scenario: CLI apps list fetches all pages when results exceed page size - Given the mock API has 105 seeded apps - When I run "tower apps list" via CLI - Then the output should contain all 105 seeded app names + Scenario: CLI apps list returns every app across multiple pages + Given I have created 3 apps via CLI + When I run "tower --page 1 --page-size 2 apps list" via CLI + Then the output should contain all created app names - Scenario: CLI apps list in JSON mode returns all paginated results - Given the mock API has 105 seeded apps - When I run "tower apps list --json" via CLI + Scenario: CLI apps list in JSON mode returns every app across multiple pages + Given I have created 3 apps via CLI + When I run "tower --page 1 --page-size 2 apps list --json" via CLI Then the output should be valid JSON - And the JSON should contain at least 105 apps + And the JSON should contain all created app names diff --git a/tests/integration/features/environment.py b/tests/integration/features/environment.py index 62fbffbb..b6fb2d71 100644 --- a/tests/integration/features/environment.py +++ b/tests/integration/features/environment.py @@ -26,6 +26,24 @@ def before_scenario(context, scenario): def after_scenario(context, scenario): import shutil + # Delete any apps created by the scenario via "I have created N apps via CLI". + if getattr(context, "created_app_names", None): + env = os.environ.copy() + env["TOWER_URL"] = context.tower_url + test_home = Path(__file__).parent.parent / "test-home" + env["HOME"] = str(test_home.absolute()) + for name in context.created_app_names: + try: + subprocess.run( + [context.tower_binary, "apps", "delete", name], + env=env, + capture_output=True, + timeout=30, + ) + except subprocess.TimeoutExpired: + pass + context.created_app_names = [] + # Clean up any MCP servers started by this scenario for attr in ["http_mcp_process", "sse_mcp_process"]: if hasattr(context, attr): diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index f2f82934..2c552311 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -438,41 +438,62 @@ def step_app_description_should_be(context, expected_description): # Pagination test steps -@step("the mock API has {count:d} seeded apps") -def step_seed_apps(context, count): - """Seed the mock API with a number of apps to test pagination.""" - # Reset first to get a clean slate - requests.post(f"{context.tower_url}/test/reset") - # Seed apps (no page_size override — uses default 20, so >20 apps forces pagination) - resp = requests.post( - f"{context.tower_url}/test/seed-apps", - json={"count": count}, - ) - assert resp.status_code == 200, f"Failed to seed apps: {resp.text}" - context.seeded_app_count = count +def _pagination_env(context): + env = os.environ.copy() + env["TOWER_URL"] = context.tower_url + test_home = Path(__file__).parent.parent.parent / "test-home" + env["HOME"] = str(test_home.absolute()) + return env -@step("the output should contain all {count:d} seeded app names") -def step_output_should_contain_all_seeded_apps(context, count): - """Verify the CLI output contains all seeded app names.""" - # Strip ANSI codes for checking - output = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", context.cli_output) - missing = [] +@step("I have created {count:d} apps via CLI") +def step_create_apps_via_cli(context, count): + """Create a number of apps via the real CLI so the list command has something to return.""" + env = _pagination_env(context) + context.created_app_names = [] for i in range(count): - name = f"paginated-app-{i:03d}" - if name not in output: - missing.append(name) + name = f"pagination-test-app-{i:03d}" + # Best-effort delete in case a prior run left this name behind. + subprocess.run( + [context.tower_binary, "apps", "delete", name], + env=env, + capture_output=True, + ) + result = subprocess.run( + [context.tower_binary, "apps", "create", "--name", name], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"Failed to create {name}: {result.stdout}\n{result.stderr}" + ) + context.created_app_names.append(name) + + +@step("the output should contain all created app names") +def step_output_should_contain_all_created_apps(context): + output = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", context.cli_output) + missing = [name for name in context.created_app_names if name not in output] assert not missing, ( - f"Missing {len(missing)} apps from output (first 5): {missing[:5]}\n" + f"Missing {len(missing)} apps from output: {missing}\n" f"Full output: {output[:2000]}" ) -@step("the JSON should contain at least {count:d} apps") -def step_json_should_contain_at_least_n_apps(context, count): - """Verify JSON output contains at least the expected number of apps.""" +@step("the JSON should contain all created app names") +def step_json_should_contain_all_created_apps(context): data = parse_cli_json(context) assert isinstance(data, list), f"Expected JSON array, got: {type(data)}" - assert ( - len(data) >= count - ), f"Expected at least {count} apps in JSON, got {len(data)}" + listed = set() + for entry in data: + if not isinstance(entry, dict): + continue + # apps list returns AppSummary objects shaped as {"app": {"name": ...}, "runs": [...]}. + if "app" in entry and isinstance(entry["app"], dict): + listed.add(entry["app"].get("name")) + else: + listed.add(entry.get("name")) + missing = [name for name in context.created_app_names if name not in listed] + assert not missing, f"Missing {len(missing)} apps from JSON: {missing}" diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index 5e86b7c7..4b87b276 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -48,7 +48,6 @@ async def log_requests(request: Request, call_next): mock_runs_db = {} mock_schedules_db = {} mock_deployed_apps = set() # Track which apps have been deployed -mock_max_page_size: Optional[int] = None # Override max page size for testing # Pre-populate with test-app for CLI validation/spinner tests mock_apps_db["predeployed-test-app"] = { @@ -462,19 +461,26 @@ async def describe_secrets_key(): def paginate(items: list, page: Optional[int], page_size: Optional[int]) -> tuple: - """Apply pagination to a list of items. Returns (page_items, pages_metadata).""" - global mock_max_page_size - if page_size is None: + """Apply pagination to a list of items. Returns (page_items, pages_metadata). + + Matches the real API: page is 1-indexed; page=0 or page=None means + "no pagination, return everything". + """ + if page_size is None or page_size <= 0: page_size = 20 - # Cap page_size if mock override is set (simulates server-side limit) - if mock_max_page_size is not None: - page_size = min(page_size, mock_max_page_size) - if page is None: - page = 0 total = len(items) + + if page is None or page == 0: + return items, { + "page": 0, + "total": total, + "num_pages": 1, + "page_size": page_size, + } + num_pages = max(1, (total + page_size - 1) // page_size) - start = page * page_size + start = (page - 1) * page_size end = start + page_size page_items = items[start:end] @@ -734,81 +740,3 @@ async def health_check(): return {"status": "healthy", "timestamp": datetime.datetime.now().isoformat()} -# Test helper endpoints (for seeding data in integration tests) -@app.post("/test/seed-apps") -async def seed_apps(data: Dict[str, Any]): - """Seed the mock apps DB with a specified number of apps.""" - global mock_max_page_size - count = data.get("count", 10) - page_size_override = data.get("page_size_override") - if page_size_override is not None: - mock_max_page_size = page_size_override - - for i in range(count): - name = f"paginated-app-{i:03d}" - mock_apps_db[name] = { - "name": name, - "owner": "mock_owner", - "short_description": f"App number {i}", - "version": None, - "schedule": None, - "created_at": now_iso(), - "next_run_at": None, - "health_status": "healthy", - "pending_timeout": 300, - "running_timeout": 0, - "run_results": { - "cancelled": 0, - "crashed": 0, - "errored": 0, - "exited": 0, - "pending": 0, - "retrying": 0, - "running": 0, - }, - "subdomain": None, - "is_externally_accessible": False, - "status": "active", - } - - return {"seeded": count} - - -@app.post("/test/reset") -async def reset_test_data(): - """Reset all mock data stores to initial state.""" - global mock_max_page_size - mock_max_page_size = None - mock_apps_db.clear() - mock_secrets_db.clear() - mock_teams_db.clear() - mock_runs_db.clear() - mock_schedules_db.clear() - mock_deployed_apps.clear() - - # Re-add the pre-populated test app - mock_apps_db["predeployed-test-app"] = { - "name": "predeployed-test-app", - "owner": "mock_owner", - "short_description": "Pre-existing test app for CLI tests", - "version": None, - "schedule": None, - "created_at": now_iso(), - "next_run_at": None, - "health_status": "healthy", - "pending_timeout": 300, - "running_timeout": 0, - "run_results": { - "cancelled": 0, - "crashed": 0, - "errored": 0, - "exited": 0, - "pending": 0, - "retrying": 0, - "running": 0, - }, - "subdomain": None, - "is_externally_accessible": False, - } - mock_deployed_apps.add("predeployed-test-app") - return {"status": "reset"} From d7e92d3339b23abb1de3f8f89229da753214236e Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 14 May 2026 10:40:15 +0100 Subject: [PATCH 2/6] chore: Reformat the things --- tests/integration/features/steps/cli_steps.py | 6 +++--- tests/mock-api-server/main.py | 2 -- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index 2c552311..c4a38eb0 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -466,9 +466,9 @@ def step_create_apps_via_cli(context, count): text=True, timeout=60, ) - assert result.returncode == 0, ( - f"Failed to create {name}: {result.stdout}\n{result.stderr}" - ) + assert ( + result.returncode == 0 + ), f"Failed to create {name}: {result.stdout}\n{result.stderr}" context.created_app_names.append(name) diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index 4b87b276..ba386f7a 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -738,5 +738,3 @@ async def delete_schedule(schedule_data: Dict[str, Any]): @app.get("/health") async def health_check(): return {"status": "healthy", "timestamp": datetime.datetime.now().isoformat()} - - From fdbcf13ae551e4ed5740bee35a82a99508312fb3 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 14 May 2026 11:35:14 +0100 Subject: [PATCH 3/6] chore: Clean up old, broken rust that comes with Homebrew --- .github/workflows/build-binaries.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index decb1e2f..035cf84e 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -75,6 +75,12 @@ jobs: with: submodules: recursive - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: "Remove Homebrew rustup" + # Homebrew's rustup on macos-15-arm64 runner image 20260511.0048+ breaks + # PyO3/maturin-action's `+toolchain` self-exec with + # `unexpected argument '+1.88' found`. Force the upstream rustup + # installed by setup-rust-toolchain (at ~/.cargo/bin) to win. + run: brew uninstall --ignore-dependencies rustup || true - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -103,6 +109,12 @@ jobs: with: submodules: recursive - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: "Remove Homebrew rustup" + # Homebrew's rustup on macos-15-arm64 runner image 20260511.0048+ breaks + # PyO3/maturin-action's `+toolchain` self-exec with + # `unexpected argument '+1.88' found`. Force the upstream rustup + # installed by setup-rust-toolchain (at ~/.cargo/bin) to win. + run: brew uninstall --ignore-dependencies rustup || true - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From c6e16c2a7a29c9019b5949fc0ccc8287eba1e062 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 14 May 2026 11:50:18 +0100 Subject: [PATCH 4/6] chore: Go the other way with it--clobber the Homebrew rustup --- .github/workflows/build-binaries.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 035cf84e..f498c419 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -74,13 +74,14 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + - name: "Prefer upstream rustup over Homebrew's" + # As of 2026-05-14 there's a bug in the images Homebrew that blocks + # Maturin install. The solution, for the time being, is to install a + # fresh Rust toolchain. + run: | + curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain none --profile minimal -y + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - - name: "Remove Homebrew rustup" - # Homebrew's rustup on macos-15-arm64 runner image 20260511.0048+ breaks - # PyO3/maturin-action's `+toolchain` self-exec with - # `unexpected argument '+1.88' found`. Force the upstream rustup - # installed by setup-rust-toolchain (at ~/.cargo/bin) to win. - run: brew uninstall --ignore-dependencies rustup || true - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -108,13 +109,14 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + - name: "Prefer upstream rustup over Homebrew's" + # As of 2026-05-14 there's a bug in the images Homebrew that blocks + # Maturin install. The solution, for the time being, is to install a + # fresh Rust toolchain. + run: | + curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain none --profile minimal -y + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - - name: "Remove Homebrew rustup" - # Homebrew's rustup on macos-15-arm64 runner image 20260511.0048+ breaks - # PyO3/maturin-action's `+toolchain` self-exec with - # `unexpected argument '+1.88' found`. Force the upstream rustup - # installed by setup-rust-toolchain (at ~/.cargo/bin) to win. - run: brew uninstall --ignore-dependencies rustup || true - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From a7b5188864f5ab9fbfe5557f641177b854838732 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 14 May 2026 11:59:42 +0100 Subject: [PATCH 5/6] chore: One more attempt to fix this broken rust toolchain in macos15 images --- .github/workflows/build-binaries.yml | 32 +++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index f498c419..4b2db326 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -74,12 +74,18 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - name: "Prefer upstream rustup over Homebrew's" - # As of 2026-05-14 there's a bug in the images Homebrew that blocks - # Maturin install. The solution, for the time being, is to install a - # fresh Rust toolchain. + - name: "Install fresh upstream rustup" + # As of 2026-05-14, the macos-15-arm64 runner image (20260511.0048+) + # ships a broken Homebrew rustup whose `+toolchain` proxy fails. A + # plain `curl https://sh.rustup.rs | sh` ends up linking back to that + # binary, so we have to scrub Homebrew's rustup AND any stale upstream + # rustup before reinstalling cleanly. Pin the toolchain to what + # rust-toolchain.toml requests so setup-rust-toolchain has nothing + # to do but verify. run: | - curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain none --profile minimal -y + brew uninstall --ignore-dependencies rustup || true + rm -f "$HOME/.cargo/bin/rustup" "$HOME/.cargo/bin/rustup-init" + curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain 1.88 --profile minimal -y echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 @@ -109,12 +115,18 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - name: "Prefer upstream rustup over Homebrew's" - # As of 2026-05-14 there's a bug in the images Homebrew that blocks - # Maturin install. The solution, for the time being, is to install a - # fresh Rust toolchain. + - name: "Install fresh upstream rustup" + # As of 2026-05-14, the macos-15-arm64 runner image (20260511.0048+) + # ships a broken Homebrew rustup whose `+toolchain` proxy fails. A + # plain `curl https://sh.rustup.rs | sh` ends up linking back to that + # binary, so we have to scrub Homebrew's rustup AND any stale upstream + # rustup before reinstalling cleanly. Pin the toolchain to what + # rust-toolchain.toml requests so setup-rust-toolchain has nothing + # to do but verify. run: | - curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain none --profile minimal -y + brew uninstall --ignore-dependencies rustup || true + rm -f "$HOME/.cargo/bin/rustup" "$HOME/.cargo/bin/rustup-init" + curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain 1.88 --profile minimal -y echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 From 4f42bc2c717266ff95a72fecf1664b99190ee1b5 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 14 May 2026 12:14:55 +0100 Subject: [PATCH 6/6] chore: Another attempt at resolving the issue with the upstream --- .github/workflows/build-binaries.yml | 40 ++++++++++++++++++---------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 4b2db326..5b8ef934 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -75,19 +75,25 @@ jobs: with: submodules: recursive - name: "Install fresh upstream rustup" - # As of 2026-05-14, the macos-15-arm64 runner image (20260511.0048+) - # ships a broken Homebrew rustup whose `+toolchain` proxy fails. A - # plain `curl https://sh.rustup.rs | sh` ends up linking back to that - # binary, so we have to scrub Homebrew's rustup AND any stale upstream - # rustup before reinstalling cleanly. Pin the toolchain to what - # rust-toolchain.toml requests so setup-rust-toolchain has nothing - # to do but verify. + # The macos-15-arm64 runner image (20260511.0048+) ships a broken + # Homebrew rustup whose bundled rustup-init rejects standard argv + # forwarded from the rustup proxy, breaking `rustup component add` + # and other commands maturin-action invokes. Upstream: + # https://github.com/actions/runner-images/issues/14097 + # Scrub Homebrew's rustup AND any stale upstream rustup, then + # reinstall cleanly with 1.88 as the default so setup-rust-toolchain + # has nothing left to do but verify. run: | brew uninstall --ignore-dependencies rustup || true rm -f "$HOME/.cargo/bin/rustup" "$HOME/.cargo/bin/rustup-init" curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain 1.88 --profile minimal -y echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + with: + # Don't restore ~/.cargo/bin from cache — it'd clobber the + # freshly-installed upstream rustup with a stale (and possibly + # Homebrew-linked) binary from a previous run. + cache-bin: false - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -116,19 +122,25 @@ jobs: with: submodules: recursive - name: "Install fresh upstream rustup" - # As of 2026-05-14, the macos-15-arm64 runner image (20260511.0048+) - # ships a broken Homebrew rustup whose `+toolchain` proxy fails. A - # plain `curl https://sh.rustup.rs | sh` ends up linking back to that - # binary, so we have to scrub Homebrew's rustup AND any stale upstream - # rustup before reinstalling cleanly. Pin the toolchain to what - # rust-toolchain.toml requests so setup-rust-toolchain has nothing - # to do but verify. + # The macos-15-arm64 runner image (20260511.0048+) ships a broken + # Homebrew rustup whose bundled rustup-init rejects standard argv + # forwarded from the rustup proxy, breaking `rustup component add` + # and other commands maturin-action invokes. Upstream: + # https://github.com/actions/runner-images/issues/14097 + # Scrub Homebrew's rustup AND any stale upstream rustup, then + # reinstall cleanly with 1.88 as the default so setup-rust-toolchain + # has nothing left to do but verify. run: | brew uninstall --ignore-dependencies rustup || true rm -f "$HOME/.cargo/bin/rustup" "$HOME/.cargo/bin/rustup-init" curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain 1.88 --profile minimal -y echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + with: + # Don't restore ~/.cargo/bin from cache — it'd clobber the + # freshly-installed upstream rustup with a stale (and possibly + # Homebrew-linked) binary from a previous run. + cache-bin: false - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }}