diff --git a/.claude/skills/robostack-versions/SKILL.md b/.claude/skills/robostack-versions/SKILL.md index 42e28625..b7112665 100644 --- a/.claude/skills/robostack-versions/SKILL.md +++ b/.claude/skills/robostack-versions/SKILL.md @@ -7,8 +7,8 @@ description: Update ROS package versions (rosdistro snapshot) and conda-forge pi robostack-bot does the routine part: `update-rosdistro-snapshot` (daily, when a new rosdistro sync is tagged) and -`update-conda-forge-pinning` (weekly, or on request) open PRs that already contain the -bumps below. Do it by hand when the bot fails or something extra is needed. +`update-conda-forge-pinning` (daily, when the pinning version or the usable migrations +change, or on request) open PRs that already contain the bumps below. Do it by hand when the bot fails or something extra is needed. ## What is pinned where @@ -50,7 +50,8 @@ pixi run rs create-snapshot ## Update the conda-forge pinning ```bash -pixi run rs update-pinning # = bot command, for all distributions that follow shared/ +pixi run rs update-pinning # = bot command: latest pinning + rebuild plan for +pixi run rs update-pinning # the same for every distribution pixi run rs render-pinning # after editing shared/pinning or distro.yaml pixi run rs check-deps # solve all non-ROS deps + mutex constraints, nothing built ``` @@ -65,9 +66,36 @@ pixi run rs check-deps # solve all non-ROS deps + mutex constraints Exit code 1 means something conflicts. Status of migrations: https://conda-forge.org/status/. +`check-deps` takes the requirements of every selected package from vinca (`--source +recipes` uses `recipes/` instead, which with skip-existing only holds unbuilt packages). + +### What the pinning PR rebuilds + +`update-pinning` moves the distribution's pinning (its own `distro.yaml` version, or +the shared one it follows) forward and compares the old and new +`conda_build_config.yaml` with `vinca-rebuild-plan`: packages with a changed pin among their build or +host requirements, plus everything that depends on them (host or run, transitively), +are rebuilt; the rest keep their published builds. + +- **Partial rebuild** (the usual case): those packages get `build_number` = highest + released build + 1 in `distros//pkg_additional_info.yaml`, and when the mutex + `run_constraints` changed, the mutex gets a new build at the same version + (`mutex_package.build_number`), so all other published packages stay installable. +- **Full rebuild** (more than half of the packages affected): the bump below. + +The PR lists the packages and why, the migrations used / waiting for our dependencies' +feedstocks, and a linux-64 `check-deps` result; it is labelled `dependency-conflict` +when the new pins can't be installed together. + +```bash +pixi run vinca-rebuild-plan --old --new distros//conda_build_config.yaml \ + --vinca-dir distros//work # the plan by hand (after `rs prepare`) +``` + ## Rebuild bump (full rebuild) -When the snapshot or the pins change, everything is rebuilt: +When the snapshot changes (or a pinning change affects most packages), everything is +rebuilt: 1. `build_number` in `distros//vinca.yaml` = the highest build number of the distribution's released packages + 1, so every package gets a new build. diff --git a/.github/ISSUE_TEMPLATE/3_robostack_bot.yaml b/.github/ISSUE_TEMPLATE/3_robostack_bot.yaml index e588404e..31b354ab 100644 --- a/.github/ISSUE_TEMPLATE/3_robostack_bot.yaml +++ b/.github/ISSUE_TEMPLATE/3_robostack_bot.yaml @@ -14,7 +14,7 @@ body: | `add-package` | anyone | Add ROS packages to the shared package selection (built by every distribution that has them); the PR lists what will be built. A maintainer reviews and merges it. | | `update-rosdistro-snapshot` | maintainers | Refresh `rosdistro_snapshot.yaml` to the latest rosdistro sync (master for rolling) and bump the build number (highest released + 1, per-package build numbers removed) and mutex: a full rebuild. The PR lists the version bumps. Runs daily when a new sync is tagged. | | `find-stale-packages` | maintainers | List published packages built against pins that no longer match, with the build-number snippet to rebuild them. Replies only. | - | `update-conda-forge-pinning` | maintainers | Move the shared conda-forge pinning to the latest version; re-renders `conda_build_config.yaml` and bumps the build number and mutex of the distributions whose pins changed. Runs weekly. | + | `update-conda-forge-pinning` | maintainers | Move the distribution's conda-forge pinning to the latest version, with the migrations that are done for its dependencies; rebuilds only the packages that use a changed pin and what depends on them (a full rebuild when that is most of them). The PR lists the packages, the migrations and a dependency check. Runs daily. | - type: dropdown id: command attributes: @@ -32,7 +32,7 @@ body: id: distributions attributes: label: Distributions - description: "`all`: every distribution (for add-package: every one that has the package). Ignored by update-conda-forge-pinning." + description: "`all`: every distribution (for add-package: every one that has the package)." multiple: true options: [all, rolling, lyrical, jazzy, humble] default: 0 diff --git a/.github/workflows/bot.yaml b/.github/workflows/bot.yaml index fce7157f..0321f599 100644 --- a/.github/workflows/bot.yaml +++ b/.github/workflows/bot.yaml @@ -2,12 +2,15 @@ # # - daily: a snapshot PR for every distribution with a new rosdistro sync (the ROS # release team tags each sync, e.g. jazzy/2026-10-05; `rosdistro_sync: manual` in -# distro.yaml opts out, like rolling); Mondays also the shared conda-forge pinning. +# distro.yaml opts out, like rolling), and a conda-forge pinning PR when there's a new +# pinning version or a migration became usable (only packages using a changed pin, and +# what depends on them, are rebuilt; the PR is labelled dependency-conflict when the +# new pins can't be installed together). # - on demand (owners, members and collaborators): # Actions > robostack-bot > Run workflow, an issue from the "robostack-bot command" # template, or a comment: @robostack-bot [] # commands: update-rosdistro-snapshot [...|all] | find-stale-packages [...|all] | -# update-conda-forge-pinning | add-package ... [...] +# update-conda-forge-pinning [...|all] | add-package ... [...] # - package requests (the "Package request" issue form or `@robostack-bot add-package`, # from anyone): the bot opens a PR that adds the packages; maintainers review it. # Every other command is for owners, members and collaborators. @@ -72,10 +75,8 @@ jobs: run: | case "$EVENT" in schedule) - jobs=$(pixi run -q rs rosdistro-syncs) - if [ "$(date -u +%u)" = 1 ]; then - jobs=$(echo "$jobs" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin) + [{"command":"update-conda-forge-pinning","distro":"","args":"","preview":False}]))') - fi ;; + # rosdistro syncs, and the conda-forge pinning of every distribution + jobs=$(pixi run -q rs rosdistro-syncs | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin) + [{"command":"update-conda-forge-pinning","distro":d,"args":"","preview":False} for d in sys.argv[1:]]))' $(ls distros)) ;; workflow_dispatch) # only people with write access can dispatch jobs=$(ROBOSTACK_BOT_BODY="@robostack-bot $COMMAND $PACKAGES $DISTRO" ROBOSTACK_BOT_ASSOCIATION=OWNER pixi run -q rs parse-command) ;; *) @@ -132,7 +133,7 @@ jobs: pixi run rs add-package $ARGS "${extra[@]}" --summary "$RUNNER_TEMP/summary.md" ;; update-rosdistro-snapshot) pixi run rs "$DISTRO" update-snapshot --summary "$RUNNER_TEMP/summary.md" ;; find-stale-packages) pixi run rs "$DISTRO" find-stale --summary "$RUNNER_TEMP/summary.md" ;; - update-conda-forge-pinning) pixi run rs update-pinning --summary "$RUNNER_TEMP/summary.md" ;; + update-conda-forge-pinning) pixi run rs "$DISTRO" update-pinning --summary "$RUNNER_TEMP/summary.md" ;; esac status=$? cat "$RUNNER_TEMP/summary.md" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null @@ -153,7 +154,9 @@ jobs: commit-message: ${{ steps.bot.outputs.title }} title: ${{ steps.bot.outputs.title }} body-path: ${{ runner.temp }}/summary.md - labels: robostack-bot + labels: | + robostack-bot + ${{ steps.bot.outputs.labels }} - name: Reply on the issue / pull request if: always() && (github.event_name == 'issue_comment' || github.event_name == 'issues') env: diff --git a/README.md b/README.md index 1337b3e1..6c3e050c 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ of a distribution uses `full_rebuild: true` there. A new build number goes into | `add-package ... [...]` | anyone | Adds ROS packages to the shared selection in `shared/vinca.yaml`, in sorted position; every distribution that has them builds them. Opens a PR listing, for the given (default: every) distribution, what will be built on linux-64 (and what is already published as a dependency). Valid ROS package names only, at most 10 per request. | | `update-rosdistro-snapshot [... \| all]` | maintainers | Snapshots the distribution's latest rosdistro sync (the release team's tag, e.g. `jazzy/2026-10-05`, recorded as `rosdistro_sync` in `distro.yaml`; rosdistro master for `rosdistro_sync: manual`, like rolling), then sets `build_number` to the highest build of the distribution's released packages + 1, bumps the mutex minor version and removes all per-package build numbers: a full rebuild. The PR links the sync's announcement and lists the version bumps. Runs daily for distributions with a new sync. | | `find-stale-packages [... \| all]` | maintainers | Published packages built against pins that no longer match, with the build-number snippet to rebuild them. Replies only. | -| `update-conda-forge-pinning` | maintainers | Moves `shared/pinning/conda_forge.yaml` to the latest conda-forge pinning (migrations selected for the dependencies of all distributions) and re-renders the distributions that follow it. Those whose pins changed get the same build number and mutex bump, and their mutex `run_constraints` of the form ` .*` follow the new pins. Runs weekly. | +| `update-conda-forge-pinning [... \| all]` | maintainers | Moves the distribution's conda-forge pinning (`conda_forge_pinning_version` / `conda_forge_migrations` in `distro.yaml`, or `shared/pinning/conda_forge.yaml` for distributions that follow it) to the latest version, with the migrations that are done for the distribution's dependencies, and re-renders `conda_build_config.yaml`. Only packages that use a changed pin, and everything depending on them, are rebuilt (per-package build numbers, a new build of the mutex at the same version); more than half affected means a full rebuild. Mutex `run_constraints` of the form ` .*` follow the new pins. The PR lists the rebuilt packages, the migrations used and those waiting for feedstocks, and the dependency check; it is labelled `dependency-conflict` when the pins can't be installed together. Runs daily, one PR per distribution. | There are three ways to trigger a command: - a *Package request* or *robostack-bot command* issue; diff --git a/distros/humble/ci.yaml b/distros/humble/ci.yaml index 1bda7c74..63d3a18e 100644 --- a/distros/humble/ci.yaml +++ b/distros/humble/ci.yaml @@ -23,8 +23,4 @@ full_rebuild: false # # Remember that dependents are not rebuilt automatically: evict them as well if # they must pick up the change. -evict_cache: - # built from ROS sources under the conda-forge names before shared/vinca.yaml - # skipped them; the conda-forge packages must be used instead - - cpp-expected - - sophus +evict_cache: [] diff --git a/distros/jazzy/ci.yaml b/distros/jazzy/ci.yaml index 4b8ce0f3..63d3a18e 100644 --- a/distros/jazzy/ci.yaml +++ b/distros/jazzy/ci.yaml @@ -23,10 +23,4 @@ full_rebuild: false # # Remember that dependents are not rebuilt automatically: evict them as well if # they must pick up the change. -evict_cache: - # built from ROS sources under the conda-forge names before shared/vinca.yaml - # skipped them; the conda-forge packages must be used instead - - cpp-expected - - sophus - # empty win-64 build from a cancelled run - - rmw_fastrtps_shared_cpp +evict_cache: [] diff --git a/distros/lyrical/ci.yaml b/distros/lyrical/ci.yaml index aaad16df..63d3a18e 100644 --- a/distros/lyrical/ci.yaml +++ b/distros/lyrical/ci.yaml @@ -23,7 +23,4 @@ full_rebuild: false # # Remember that dependents are not rebuilt automatically: evict them as well if # they must pick up the change. -evict_cache: - - ros_image_to_qimage # rebuilt with Qt6 - - openvdb_vendor # rebuilt with the OpenVDB module path fix - - nlohmann_json_schema_validator_vendor # rebuilt with exported symbols on Windows +evict_cache: [] diff --git a/pixi.lock b/pixi.lock index a99986b4..0f20154b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -80,7 +80,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.14-9_cp314.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-81.0.0-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=ed3493cb303e571fd78dd8812869e5b44726acb0#ed3493cb303e571fd78dd8812869e5b44726acb0 + - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6#b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6 - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl @@ -152,7 +152,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.14-9_cp314.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-81.0.0-pyh332efcf_0.conda - conda: https://prefix.dev/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=ed3493cb303e571fd78dd8812869e5b44726acb0#ed3493cb303e571fd78dd8812869e5b44726acb0 + - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6#b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6 - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl @@ -217,7 +217,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://prefix.dev/conda-forge/osx-64/yaml-0.2.5-had63097_3.conda - conda: https://prefix.dev/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=ed3493cb303e571fd78dd8812869e5b44726acb0#ed3493cb303e571fd78dd8812869e5b44726acb0 + - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6#b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6 - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl @@ -282,7 +282,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://prefix.dev/conda-forge/osx-arm64/yaml-0.2.5-h74c22ad_3.conda - conda: https://prefix.dev/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=ed3493cb303e571fd78dd8812869e5b44726acb0#ed3493cb303e571fd78dd8812869e5b44726acb0 + - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6#b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6 - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl @@ -347,7 +347,7 @@ environments: - conda: https://prefix.dev/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://prefix.dev/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - conda: https://prefix.dev/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=ed3493cb303e571fd78dd8812869e5b44726acb0#ed3493cb303e571fd78dd8812869e5b44726acb0 + - pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6#b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6 - pypi: https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/74/ef303ec980ef962ffe548fa36cb138bb399d1d1315731ba7bb81da85f7e0/rosdistro-1.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl @@ -3079,7 +3079,7 @@ packages: - zstd >=1.5.7,<1.6.0a0 size: 387535 timestamp: 1786599623274 -- pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=ed3493cb303e571fd78dd8812869e5b44726acb0#ed3493cb303e571fd78dd8812869e5b44726acb0 +- pypi: git+https://github.com/Tobias-Fischer/vinca.git?rev=b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6#b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6 name: vinca version: 0.2.0 requires_dist: diff --git a/pixi.toml b/pixi.toml index d38d2b07..3db21828 100644 --- a/pixi.toml +++ b/pixi.toml @@ -34,7 +34,7 @@ git = "*" [pypi-dependencies] # One vinca for all distributions. # Tobias-Fischer/vinca robostack-integration (RoboStack/vinca#162 + extends, test selectors, gha fix) until merged upstream -vinca = { git = "https://github.com/Tobias-Fischer/vinca.git", rev = "ed3493cb303e571fd78dd8812869e5b44726acb0" } +vinca = { git = "https://github.com/Tobias-Fischer/vinca.git", rev = "b5c524bfc06221d8f9345ad851f7d4d9fd9b79b6" } # For local development, temporarily use (and regenerate pixi.lock): # vinca = { path = "../vinca", editable = true } diff --git a/tools/check_dependency_compat.py b/tools/check_dependency_compat.py index 456d186f..8deb46a2 100644 --- a/tools/check_dependency_compat.py +++ b/tools/check_dependency_compat.py @@ -38,10 +38,12 @@ import shutil import subprocess import sys +import time import tomllib from collections import defaultdict from pathlib import Path from typing import Any, Iterable, Iterator, Optional +from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen import ruamel.yaml @@ -52,8 +54,20 @@ USER_AGENT = "robostack-check-dependency-compat" -def _urlopen(url: str, timeout: int): - return urlopen(Request(url, headers={"User-Agent": USER_AGENT}), timeout=timeout) # noqa: S310 +def _urlopen(url: str, timeout: int, attempts: int = 3): + """urlopen with a few retries: conda-forge's status pages and the channels fail + now and then.""" + for attempt in range(1, attempts + 1): + try: + return urlopen(Request(url, headers={"User-Agent": USER_AGENT}), timeout=timeout) # noqa: S310 + except HTTPError as exc: + if exc.code < 500 or attempt == attempts: + raise + except (URLError, TimeoutError, ConnectionError): + if attempt == attempts: + raise + time.sleep(5 * attempt) + raise AssertionError("unreachable") FAKE_PACKAGE_NAME = "robostack-dependency-compat-check" DEFAULT_GLIBC = "2.17" # fallback when c_stdlib_version is not in the variant config DEFAULT_OSX = "15.0" @@ -205,6 +219,29 @@ def collect_requirements( return requirements +def collect_requirements_from_vinca( + vinca_path: Path, + platform: str, + sections: Iterable[str] = ("host", "run"), + host_names: Optional[set[str]] = None, +) -> dict[tuple[Optional[str], str], set[str]]: + """Like collect_requirements, for every selected package of the configuration + (not only the recipes that happen to be generated, e.g. with skip_existing).""" + from vinca.rebuild import requirements_from_vinca + + requirements: dict[tuple[Optional[str], str], set[str]] = defaultdict(set) + packages, _ = requirements_from_vinca(vinca_path.resolve().parent, [platform]) + for name, reqs in packages.items(): + for section in sections: + for condition, spec in walk_requirements(reqs.get(section)): + if not spec or "${{" in spec or is_ros_dependency(spec_name(spec)): + continue + requirements[(condition, spec)].add(name) + if section == "host" and host_names is not None: + host_names.add(normalized(spec_name(spec))) + return requirements + + def mutex_constraints(vinca_conf: dict[str, Any]) -> list[str]: mutex = vinca_conf.get("mutex_package") if isinstance(mutex, dict): @@ -268,6 +305,7 @@ def run_solve( output_dir: Path, *, verbose: bool = False, + glibc: Optional[str] = None, ) -> tuple[bool, str]: cmd = rattler_build_executable() + [ "build", @@ -292,6 +330,8 @@ def run_solve( # Solving for a foreign platform yields __glibc=0 / __osx=0 virtual packages, which # makes every package look uninstallable. Provide sane defaults unless overridden. if platform.startswith("linux"): + if glibc: + env["CONDA_OVERRIDE_GLIBC"] = glibc env.setdefault("CONDA_OVERRIDE_GLIBC", glibc_floor(variant_config, platform)) elif platform.startswith("osx") and sys.platform != "darwin": env.setdefault("CONDA_OVERRIDE_OSX", DEFAULT_OSX) @@ -390,7 +430,13 @@ def __init__(self, args: argparse.Namespace, channels: list[str], workdir: Path) self.workdir = workdir self.calls = 0 - def solve(self, label: str, pins: list[str], requirements: Iterable[tuple[Optional[str], str]]) -> tuple[bool, str]: + def solve( + self, + label: str, + pins: list[str], + requirements: Iterable[tuple[Optional[str], str]], + glibc: Optional[str] = None, + ) -> tuple[bool, str]: recipe = self.workdir / label / "recipe.yaml" write_fake_recipe(recipe, pins, requirements) self.calls += 1 @@ -401,6 +447,7 @@ def solve(self, label: str, pins: list[str], requirements: Iterable[tuple[Option self.args.platform, self.workdir / "output", verbose=self.args.verbose, + glibc=glibc, ) def find_partner( @@ -444,14 +491,20 @@ def load_vinca(path: Path, platform: str | None = None) -> dict[str, Any]: def solve_mode(args: argparse.Namespace) -> int: recipes_dir = Path(args.recipes_dir) - if not any(recipes_dir.glob("*/recipe.yaml")): + if args.source == "recipes" and not any(recipes_dir.glob("*/recipe.yaml")): raise SystemExit( f"No recipes found in {recipes_dir}; run `pixi run generate-recipes` first." ) vinca_conf = load_vinca(Path(args.vinca), getattr(args, "platform", None)) pins = mutex_constraints(vinca_conf) + list(args.pin) variant = variant_pins(Path(args.variant_config), args.platform) - requirements = collect_requirements(recipes_dir) + host_names: Optional[set[str]] = set() if args.source != "recipes" else None + if args.source == "recipes": + requirements = collect_requirements(recipes_dir) + source = f"{len(list(recipes_dir.glob('*/recipe.yaml')))} recipes in {recipes_dir}" + else: + requirements = collect_requirements_from_vinca(Path(args.vinca), args.platform, host_names=host_names) + source = f"{len(set().union(*requirements.values())) if requirements else 0} selected packages of {args.vinca}" names = {spec_name(spec) for _, spec in requirements} channels = args.channel or channels_from_pixi(Path("pixi.toml")) workdir = Path(args.workdir) @@ -461,7 +514,7 @@ def solve_mode(args: argparse.Namespace) -> int: print(f"Platform: {args.platform}") print(f"Channels: {' '.join(channels)}") print(f"Variant config: {args.variant_config} ({len(variant)} single-valued pins)") - print(f"Recipes: {len(list(recipes_dir.glob('*/recipe.yaml')))} in {recipes_dir}") + print(f"Packages: {source}") print(f"Dependencies: {len(names)} distinct non-ROS packages, {len(requirements)} specs") print(f"Hard pins: {', '.join(pins) if pins else '(none)'}") print() @@ -475,7 +528,8 @@ def solve_mode(args: argparse.Namespace) -> int: print(" -> align mutex_package.run_constraints in vinca.yaml with the rendered pins" " (or drop the migration from vinca_pinning.yaml).\n") - # 2. iterative solve of the whole dependency set + # 2. iterative solve of the whole dependency set (the mutex run_constraints are + # installed too, so each of them must be satisfiable on its own as well) protected = {spec_name(spec) for spec in pins} active_pins = list(pins) excluded: dict[str, str] = {} @@ -521,7 +575,11 @@ def solve_mode(args: argparse.Namespace) -> int: print("OK: every dependency is co-installable under the current pins.") return 0 - report_conflicts(args, solver, excluded, pin_conflicts, requirements, active_pins, variant, solved) + real = report_conflicts(args, solver, excluded, pin_conflicts, requirements, active_pins, variant, solved, + host_names) + if solved and not real and not pin_conflicts: + print("OK: every dependency is co-installable under the current pins (see the notes above).") + return 0 return 1 @@ -534,7 +592,9 @@ def report_conflicts( pins: list[str], variant: dict[str, str], solved: bool, -) -> None: + host_names: Optional[set[str]] = None, +) -> int: + """Print the conflicts; returns how many are real (not just notes).""" protected = {spec_name(spec) for spec in pins} if pin_conflicts: print(f"{len(pin_conflicts)} mutex constraint(s) contradict {args.variant_config}:") @@ -605,6 +665,14 @@ def report_conflicts( print(" note: the spec is version-restricted (dummy package in pkg_additional_info.yaml?);" " a newer conda-forge version may already be built against the pinned libraries.") glibc_needs = sorted({m.group(1) for m in _GLIBC_NEED_RE.finditer(block)}, key=version_tuple) + if (glibc_needs and host_names is not None and normalized(culprit) not in host_names + and args.platform.startswith("linux") + and solver.solve(f"glibc-{culprit}", pins, specs, glibc=glibc_needs[-1])[0]): + # only a run requirement, and it only needs a newer glibc than the build + # floor: that limits the systems it installs on, it's not a pin conflict + details[culprit]["category"] = "runtime-glibc" + print(f" note: only a run requirement, built for glibc >= {glibc_needs[-1]}: installs on such" + " systems only, but doesn't conflict with the pins.") if glibc_needs and args.platform.startswith("linux"): floor = glibc_floor(Path(args.variant_config), args.platform) print(f" note: needs glibc >= {glibc_needs[-1]} but the build floor (c_stdlib_version /" @@ -616,15 +684,19 @@ def report_conflicts( print(f" | … ({len(lines) - args.max_lines} more lines)") print() + notes = {name: info for name, info in details.items() if info.get("category")} + real = {name: info for name, info in details.items() if not info.get("category")} if args.json: Path(args.json).write_text( - json.dumps({"pin_conflicts": pin_conflicts, "conflicts": details}, indent=2), encoding="utf-8" + json.dumps({"pin_conflicts": pin_conflicts, "conflicts": real, "notes": notes}, indent=2), + encoding="utf-8", ) print(f"Wrote {args.json}") print(f"({solver.calls} solver runs)") if args.migrations: - report_migrations(details, pin_conflicts, pins, Path(args.pinning)) + report_migrations(real, pin_conflicts, pins, Path(args.pinning)) + return len(real) # ------------------------------------------------------------------- migrations @@ -874,10 +946,15 @@ def ros_name_map(vinca_conf: dict[str, Any]) -> dict[str, str]: def stale_mode(args: argparse.Namespace) -> int: vinca_conf = load_vinca(Path(args.vinca), getattr(args, "platform", None)) distro = vinca_conf.get("ros_distro", "") - prefix = f"ros-{distro}-" + prefixes = ("ros2-", f"ros-{distro}-") pins: dict[str, str] = {} if not args.mutex_only: - pins.update(variant_pins(Path(args.variant_config), args.platform)) + for key, value in variant_pins(Path(args.variant_config), args.platform).items(): + pins[key] = value + # conda_build_config.yaml pins the -devel package (libboost_devel), while the + # built packages depend on what its run_exports name (libboost) + if key.endswith("-devel"): + pins.setdefault(key[: -len("-devel")], value) mutex_pins = {} for spec in mutex_constraints(vinca_conf) + list(args.pin): parts = spec.split() @@ -892,7 +969,7 @@ def stale_mode(args: argparse.Namespace) -> int: packages = { filename: record for filename, record in packages.items() - if record.get("name", "").startswith(prefix) or record.get("name") == mutex_name + if record.get("name", "").startswith(prefixes) or record.get("name") == mutex_name } if not args.all_builds: # Only the newest build of every package matters for what users install now. @@ -952,7 +1029,8 @@ def stale_mode(args: argparse.Namespace) -> int: for name in names: if name == mutex_name: continue - suffix = name[len(prefix):] if name.startswith(prefix) else name + prefix = next((p for p in prefixes if name.startswith(p)), "") + suffix = name[len(prefix):] ros_names.append(mapping.get(normalized(suffix), suffix.replace("-", "_"))) build_number = int(vinca_conf.get("build_number", 0)) + 1 @@ -1007,6 +1085,12 @@ def parse_args() -> argparse.Namespace: description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument("--platform", default=detect_platform(), help="conda platform (default: current machine)") + parser.add_argument( + "--source", + choices=("vinca", "recipes"), + default="vinca", + help="requirements of every selected package (vinca, default) or of the recipes in --recipes-dir", + ) parser.add_argument("--recipes-dir", default="recipes") parser.add_argument("--vinca", default="vinca.yaml") parser.add_argument("--variant-config", default="conda_build_config.yaml") diff --git a/tools/maintenance.py b/tools/maintenance.py index 4cb5fffc..1f5132a1 100644 --- a/tools/maintenance.py +++ b/tools/maintenance.py @@ -4,7 +4,7 @@ pixi run rs update-snapshot # snapshot the latest rosdistro sync, summarise bumps pixi run rs rosdistro-syncs # bot jobs for distributions with a new sync pixi run rs find-stale # published packages built against outdated pins - pixi run rs update-pinning # move shared/pinning/conda_forge.yaml forward + pixi run rs update-pinning # latest conda-forge pinning + rebuild plan pixi run rs new-distro NAME --from DISTRO # add distros/NAME, seeded from DISTRO pixi run rs add-package PKG... [DISTRO...] [--preview] # select packages for building pixi run rs parse-command --body TEXT --association ROLE # for @robostack-bot comments @@ -23,7 +23,7 @@ import subprocess import sys import tempfile -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import yaml @@ -34,9 +34,9 @@ "add-package": "add ROS packages to the package selection (opens a PR; anyone can ask)", "update-rosdistro-snapshot": "refresh the distribution's rosdistro_snapshot.yaml (opens a PR)", "find-stale-packages": "list its published packages built against outdated pins", - "update-conda-forge-pinning": "move the shared conda-forge pinning to the latest version (opens a PR)", + "update-conda-forge-pinning": "move the conda-forge pinning to the latest version, rebuilding what changed pins affect (opens a PR)", } -PER_DISTRO_COMMANDS = ("update-rosdistro-snapshot", "find-stale-packages") +PER_DISTRO_COMMANDS = ("update-rosdistro-snapshot", "find-stale-packages", "update-conda-forge-pinning") ALLOWED_ASSOCIATIONS = {"OWNER", "MEMBER", "COLLABORATOR"} @@ -46,6 +46,7 @@ class Result: summary: str changed: bool = False ok: bool = True + labels: list[str] = field(default_factory=list) # extra labels for the bot's PR def tail(text: str, lines: int = 80) -> str: @@ -143,18 +144,17 @@ def repl(m: re.Match) -> str: REPODATA_SUBDIRS = ("noarch", "linux-64", "linux-aarch64", "osx-64", "osx-arm64", "win-64") -def released_build_number(distro: str) -> int | None: - """Highest build number of the distribution's packages on its channel (the mutex - aside), or None when the channel can't be read.""" +def released_builds(distro: str) -> dict[str, int] | None: + """Highest build number of every package on the distribution's channel (all + platforms), or None when the channel can't be read.""" import urllib.error import urllib.request s = rs.settings(distro) name = s.get("channel_name", f"robostack-{distro}") base = f"https://repo.prefix.dev/{name}" if s.get("upload_target", "prefix") == "prefix" else rs.channel_url(distro) - mutex = (rs.read_vinca(distro).get("mutex_package") or {}).get("name") - prefixes = (f"ros-{distro}-", "ros2-") - highest, read = None, False + builds: dict[str, int] = {} + read = False for subdir in REPODATA_SUBDIRS: try: request = urllib.request.Request(f"{base}/{subdir}/repodata.json", headers={"User-Agent": "robostack-bot"}) @@ -166,9 +166,20 @@ def released_build_number(distro: str) -> int | None: raise read = True for record in [*data.get("packages", {}).values(), *data.get("packages.conda", {}).values()]: - if record["name"].startswith(prefixes) and record["name"] != mutex: - highest = max(highest or 0, int(record.get("build_number", 0))) - return highest if read else None + builds[record["name"]] = max(builds.get(record["name"], 0), int(record.get("build_number", 0))) + return builds if read else None + + +def released_build_number(distro: str, builds: dict[str, int] | None = None) -> int | None: + """Highest build number of the distribution's packages on its channel (the mutex + aside), or None when the channel can't be read.""" + builds = released_builds(distro) if builds is None else builds + if builds is None: + return None + mutex = (rs.read_vinca(distro).get("mutex_package") or {}).get("name") + prefixes = (f"ros-{distro}-", "ros2-") + numbers = [n for name, n in builds.items() if name.startswith(prefixes) and name != mutex] + return max(numbers) if numbers else None def bump_rebuild(distro: str) -> list[str]: @@ -210,6 +221,84 @@ def bump_rebuild(distro: str) -> list[str]: return lines +# A pinning change that rebuilds more than this share of a distribution's packages is +# done as a full rebuild (new mutex minor version) instead of a partial one. +FULL_REBUILD_SHARE = 0.5 + + +def plan_rebuild(distro: str, old_cbc: str) -> tuple[dict[str, str], int]: + """Packages of the distribution that the change from old_cbc to its current + conda_build_config.yaml has to rebuild (with the reason), and how many packages + it has (vinca-rebuild-plan).""" + from vinca import rebuild + + new = yaml.safe_load((rs.DISTROS / distro / "conda_build_config.yaml").read_text()) or {} + changed = rebuild.changed_pins(yaml.safe_load(old_cbc) or {}, new) + if not changed: + return {}, 0 + requirements, prefix = rebuild.requirements_from_vinca(rs.prepare(distro)) + return rebuild.plan(requirements, changed, prefix), len(requirements) + + +def bump_partial(distro: str, packages: dict[str, str], mutex_changed: bool) -> list[str]: + """Rebuild just these packages: a per-package build number above everything the + channel has, and, when its run_constraints changed, a new build of the mutex (its + version stays, so every other published package keeps working with it).""" + builds = released_builds(distro) + current = int(re.search(r"(?m)^build_number:\s*(\d+)", (rs.DISTROS / distro / "vinca.yaml").read_text()).group(1)) + released = released_build_number(distro, builds) + number = (current if released is None else max(current, released)) + 1 + + info = rs.DISTROS / distro / "pkg_additional_info.yaml" + ry = _ruamel() + data = (ry.load(info.read_text()) if info.is_file() else None) or {} + for pkg in packages: + entry = data.get(pkg) + if entry is None: + data[pkg] = {"build_number": number} + else: + entry["build_number"] = number + with info.open("w") as fh: + ry.dump(data, fh) + lines = [f"{len(packages)} packages get `build_number: {number}` in `distros/{distro}/pkg_additional_info.yaml`"] + + if mutex_changed: + path = rs.DISTROS / distro / "vinca.yaml" + text = path.read_text() + mutex = (rs.read_vinca(distro).get("mutex_package") or {}).get("name") + mutex_build = ((builds or {}).get(mutex, number - 1)) + 1 + block = re.search(r"(?m)^mutex_package:\n((?:[ \t]+.*\n|\n)*)", text) + if not block: + raise SystemExit(f"{path}: no mutex_package") + body = block.group(1) + if re.search(r"(?m)^ build_number:", body): + body = re.sub(r"(?m)^( build_number:\s*)\d+", rf"\g<1>{mutex_build}", body, count=1) + else: + body = f" build_number: {mutex_build}\n" + body + path.write_text(text[: block.start(1)] + body + text[block.end(1):]) + lines.append(f"mutex `{mutex}`: new build {mutex_build} with the updated run_constraints (same version)") + return lines + + +def dependency_conflicts(distro: str) -> set[str] | None: + """Packages that can't be installed together with the rest of the distribution's + dependencies under its current pins and mutex constraints (check_dependency_compat.py, + linux-64); None when the check itself failed.""" + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "conflicts.json" + rc = rs.task(distro, "check-deps", ["--platform", "linux-64", "--no-migrations", "--json", str(out)]) + if rc == 0: + return set() + if not out.is_file(): + return None + data = json.loads(out.read_text()) + return set(data.get("pin_conflicts") or {}) | set(data.get("conflicts") or {}) + + +def _details(summary: str, lines: list[str]) -> list[str]: + return ["
", f"{summary}", "", *lines, "", "
"] + + SYNC_TAG = re.compile(r"^(?P[a-z]+)/(?P\d{4}-\d{2}-\d{2})$") ROSDISTRO = "https://github.com/ros/rosdistro" @@ -346,55 +435,111 @@ def find_stale(distro: str) -> Result: # --------------------------------------------------------------------------- # # whole repository # --------------------------------------------------------------------------- # -def update_pinning() -> Result: - """Latest conda-forge pinning for everyone that follows the shared one; migrations - are selected for the dependencies of all distributions.""" - from vinca import pinning - +def _set_pinning(distro: str, version: str, migrations: list[str]) -> str: + """Write the new pinning version and migrations where the distribution takes them + from: its own distro.yaml, or shared/pinning/conda_forge.yaml. Returns that file.""" + if rs.settings(distro).get("conda_forge_pinning_version"): + path = rs.DISTROS / distro / "distro.yaml" + text = path.read_text() + text = re.sub(r"(?m)^conda_forge_pinning_version:.*$", f"conda_forge_pinning_version: {version}", text, count=1) + text = re.sub(r"(?m)^conda_forge_migrations:(?:.*\n)(?:[ \t]+.*\n)*", + f"conda_forge_migrations: [{', '.join(migrations)}]\n", text, count=1) + path.write_text(text) + return f"distros/{distro}/distro.yaml" shared = rs.SHARED / "pinning" / "conda_forge.yaml" - before = yaml.safe_load(shared.read_text()) or {} - dependencies: set[str] = set() - for distro in rs.distros(): - print(f"Collecting dependencies of {distro}", flush=True) - dependencies |= pinning.dependencies_from_vinca(rs.prepare(distro), pinning.DEFAULT_PLATFORMS) - with tempfile.TemporaryDirectory() as tmp: - spec = Path(tmp) / "vinca_pinning.yaml" - spec.write_text(shared.read_text() + "\n" + (rs.SHARED / "pinning" / "overrides.yaml").read_text()) - version, migrations, reports = pinning.update_pinning(spec, dependencies=dependencies) - if str(before.get("conda_forge_pinning_version")) == str(version) and list(before.get("migrations") or []) == list(migrations): - return Result("Pinning is up to date", f"Already on conda-forge-pinning {version}.") header = [l for l in shared.read_text().splitlines() if l.startswith("#")] body = [f"conda_forge_pinning_version: {version}", "migrations:"] + [f" - {m}" for m in migrations] shared.write_text("\n".join(header + body) + "\n") - followers = [d for d in rs.distros() if not rs.settings(d).get("conda_forge_pinning_version")] - rebuilds: list[str] = [] - for distro in followers: - cbc = rs.DISTROS / distro / "conda_build_config.yaml" - before_cbc = cbc.read_text() if cbc.is_file() else "" - rs.task(distro, "render-pinning", []) - if cbc.read_text() == before_cbc: - rebuilds.append(f"- **{distro}**: pins unchanged, no rebuild") - continue - constraints = sync_mutex_constraints(distro) - rebuilds.append(f"- **{distro}**: " + "; ".join(bump_rebuild(distro))) - if constraints: - rebuilds.append(" - mutex run_constraints: " + ", ".join(constraints)) + return "shared/pinning/conda_forge.yaml" + + +def update_pinning(distro: str) -> Result: + """Move the distribution's conda-forge pinning to the latest version, with the + migrations that are done for its dependencies. Only the packages that use a changed + pin (and what depends on them) are rebuilt, unless that is most of them.""" + from vinca import pinning + + settings = rs.settings(distro) + before = (settings.get("conda_forge_pinning_version"), list(settings.get("conda_forge_migrations") or [])) + if not before[0]: + shared = yaml.safe_load((rs.SHARED / "pinning" / "conda_forge.yaml").read_text()) or {} + before = (shared.get("conda_forge_pinning_version"), list(shared.get("migrations") or [])) + print(f"Collecting dependencies of {distro}", flush=True) + work = rs.prepare(distro) + dependencies = pinning.dependencies_from_vinca(work, pinning.DEFAULT_PLATFORMS) + version, migrations, reports = pinning.update_pinning(work / "vinca_pinning.yaml", dependencies=dependencies) + if str(before[0]) == str(version) and before[1] == list(migrations): + return Result(f"{distro}: pinning is up to date", f"{distro} is on conda-forge-pinning {version}.") + where = _set_pinning(distro, str(version), list(migrations)) + + cbc = rs.DISTROS / distro / "conda_build_config.yaml" + before_cbc = cbc.read_text() if cbc.is_file() else "" + print(f"Checking the dependencies of {distro} with the current pins", flush=True) + conflicts_before = dependency_conflicts(distro) + rs.task(distro, "render-pinning", []) + added = sorted(set(migrations) - set(before[1])) + candidates = {name for name, _ in reports} + finished = sorted(set(before[1]) - set(migrations) - candidates) + removed = sorted(set(before[1]) - set(migrations) - set(finished)) lines = [ - f"Moved `shared/pinning/conda_forge.yaml` to conda-forge-pinning `{version}`, with migrations " - "selected for the dependencies of every distribution.", + f"Moves **{distro}** from conda-forge-pinning `{before[0]}` to `{version}` (`{where}`).", "", - "Applied migrations: " + (", ".join(f"`{m}`" for m in migrations) or "none"), + "Migrations: " + (", ".join(f"`{m}`" for m in migrations) or "none") + + (f"; new: {', '.join(f'`{m}`' for m in added)}" if added else "") + + (f"; finished (now part of the base pinning): {', '.join(f'`{m}`' for m in finished)}" if finished else "") + + (f"; dropped: {', '.join(f'`{m}`' for m in removed)}" if removed else ""), "", - *[f"- `{name}`: {report}" for name, report in reports], + ] + conflicts = False + if cbc.read_text() == before_cbc: + lines.append("The rendered pins don't change: nothing to rebuild.") + else: + constraints = sync_mutex_constraints(distro) + print(f"Planning the rebuild of {distro}", flush=True) + packages, total = plan_rebuild(distro, before_cbc) + if not packages and not constraints: + lines.append("The pins change, but no package uses them: nothing to rebuild.") + elif total and len(packages) > FULL_REBUILD_SHARE * total: + lines.append(f"**Full rebuild**: {len(packages)} of {total} packages use a changed pin or depend " + "on one. " + "; ".join(bump_rebuild(distro))) + else: + lines.append(f"**Rebuild {len(packages)} of {total} packages** (they use a changed pin or depend " + "on one): " + "; ".join(bump_partial(distro, packages, bool(constraints)))) + if packages: + lines += ["", *_details(f"Packages to rebuild ({len(packages)})", [ + f"- `{name}`: {reason}" for name, reason in sorted(packages.items())])] + if constraints: + lines += ["", "Mutex run_constraints: " + ", ".join(constraints)] + conflicts_after = dependency_conflicts(distro) + if conflicts_after is None or conflicts_before is None: + conflicts = conflicts_after is None + lines += ["", "Dependency check (linux-64): ❌ the check failed, see the workflow log" + if conflicts else "Dependency check (linux-64): no comparison (the check with the old pins failed)"] + else: + new_conflicts = sorted(conflicts_after - conflicts_before) + conflicts = bool(new_conflicts) + lines += ["", "Dependency check (linux-64): " + ( + "❌ new conflicts: " + ", ".join(f"`{n}`" for n in new_conflicts) if new_conflicts + else "✅ no new conflicts")] + if conflicts_before: + lines += ["", *_details(f"Conflicts that already exist with the current pins ({len(conflicts_before)})", + [", ".join(f"`{n}`" for n in sorted(conflicts_before))])] + + groups: dict[str, list[str]] = {"selected": [], "waiting": [], "other": []} + for name, text in reports: + key = "selected" if text.startswith(("selected", "kept")) else "waiting" if text.startswith("waiting") else "other" + groups[key].append(f"- `{name}`: {text}") + lines += [ "", - "Re-rendered `conda_build_config.yaml` of: " + (", ".join(followers) or "none") - + " (the others pin their own version in `distro.yaml`).", + *_details(f"Migrations used ({len(groups['selected'])})", groups["selected"] or ["none"]), "", - *rebuilds, + *_details(f"Migrations waiting for our dependencies' feedstocks ({len(groups['waiting'])})", + groups["waiting"] or ["none"]), "", - "Check the mutex `run_constraints` that aren't plain pins with `pixi run rs check-deps`.", + *_details(f"Migrations that don't concern {distro} ({len(groups['other'])})", groups["other"] or ["none"]), ] - return Result("Update conda-forge pinning", "\n".join(lines), changed=True) + return Result(f"{distro}: update conda-forge pinning to {version}", "\n".join(lines), changed=True, + labels=["dependency-conflict"] if conflicts else []) def new_distro(name: str, source: str) -> Result: @@ -667,6 +812,7 @@ def report(result: Result, summary_file: str | None) -> int: if out := os.environ.get("GITHUB_OUTPUT"): with open(out, "a") as fh: fh.write(f"title={result.title}\nchanged={str(result.changed).lower()}\nok={str(result.ok).lower()}\n") + fh.write(f"labels={','.join(result.labels)}\n") return 0 if result.ok else 1 @@ -698,7 +844,12 @@ def main(command: str, argv: list[str], distro: str | None = None) -> int: print(json.dumps(rosdistro_syncs())) return 0 if command == "update-pinning": - return report(update_pinning(), args.summary) + if distro: + return report(update_pinning(distro), args.summary) + results = [update_pinning(d) for d in rs.distros()] + return report(Result("Update conda-forge pinning", "\n\n---\n\n".join(r.summary for r in results), + changed=any(r.changed for r in results), ok=all(r.ok for r in results), + labels=sorted({label for r in results for label in r.labels})), args.summary) if command == "new-distro": return report(new_distro(args.name, args.source), args.summary) if command == "update-snapshot": diff --git a/tools/robostack.py b/tools/robostack.py index 0208f716..6a3f5ebc 100644 --- a/tools/robostack.py +++ b/tools/robostack.py @@ -5,6 +5,7 @@ pixi run rs jazzy build-one ros2-ros-workspace pixi run rs changed-distros --base origin/main # for CI pixi run rs check | update-pinning | new-distro NAME --from DISTRO (whole repository) + pixi run rs update-pinning (one distribution) Every distribution lives in distros// and only holds what is specific to it; everything else is in shared/. distros//vinca.yaml extends shared/vinca.yaml, @@ -222,7 +223,7 @@ def task(distro: str, name: str, args: list[str]) -> int: return rc or run(["vinca-sort-yaml-keys", *args, *files], d) if name == "upload": return upload(distro, args, w if w.is_dir() else d) - if name in ("update-snapshot", "find-stale"): + if name in ("update-snapshot", "find-stale", "update-pinning"): import maintenance return maintenance.main(name, args, distro)