From bb3bbf83df4421ea57043660a64a41d069a66065 Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Tue, 20 Oct 2026 14:13:07 +1000 Subject: [PATCH 1/5] Upload every distribution to both prefix.dev and anaconda.org [skip ci] Each distribution's packages now go to its channel on both servers (same channel name): prefix.dev with trusted publishing as before, and anaconda.org with the ANACONDA_API_TOKEN secret that vinca-gha already passes to the build jobs (no longer stripped). rattler-build's anaconda upload has no --skip-existing, so files anaconda.org already has are skipped first, keeping re-runs idempotent. ROBOSTACK_UPLOAD_CHANNEL still sends uploads to one prefix.dev (test) channel only. distro.yaml's upload_target becomes read_from: it now only says where the builds look up what is already published (humble and jazzy keep reading from anaconda.org until their prefix.dev channels hold their packages). humble moves from robostack-staging to robostack-humble, so it no longer needs the hourly staging -> robostack-humble copy in robostack.github.io. Co-Authored-By: Claude Opus 5.5 --- .scripts/build_unix.sh | 4 +- .scripts/build_win.bat | 4 +- README.md | 20 ++++---- distros/humble/distro.yaml | 6 ++- distros/jazzy/distro.yaml | 4 +- distros/lyrical/distro.yaml | 4 +- distros/rolling/distro.yaml | 4 +- tools/abi_check.py | 6 +-- tools/import_distro.py | 6 +-- tools/maintenance.py | 17 +++---- tools/robostack.py | 93 ++++++++++++++++++++++++++++--------- tools/tests/test_upload.py | 50 ++++++++++++++++++++ 12 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 tools/tests/test_upload.py diff --git a/.scripts/build_unix.sh b/.scripts/build_unix.sh index 2e836cab..39d855bd 100755 --- a/.scripts/build_unix.sh +++ b/.scripts/build_unix.sh @@ -39,8 +39,8 @@ done shopt -s nullglob conda_packages=( "${CONDA_BLD_PATH}/${target}"*/*.conda ) if (( ${#conda_packages[@]} > 0 )); then - # Upload packages one-by-one; the upload task (tools/robostack.py) skips or overwrites - # packages that already exist, depending on the upload target. + # Upload packages one-by-one; the upload task (tools/robostack.py) uploads them to + # prefix.dev and anaconda.org and skips packages that are already there. for conda_package in "${conda_packages[@]}"; do pixi run rs "${ROBOSTACK_DISTRO}" upload "${conda_package}" done diff --git a/.scripts/build_win.bat b/.scripts/build_win.bat index d72020dd..a6c29e12 100644 --- a/.scripts/build_win.bat +++ b/.scripts/build_win.bat @@ -32,8 +32,8 @@ for %%X in (%CURRENT_RECIPES%) do ( :: Check if .conda files exist in the win-64 directory if exist "%CONDA_BLD_PATH%\win-64\*.conda" ( echo Found .conda files, starting upload... - rem Upload packages one-by-one; the upload task (tools/robostack.py) skips or overwrites - rem packages that already exist, depending on the upload target. + rem Upload packages one-by-one; the upload task (tools/robostack.py) uploads them to + rem prefix.dev and anaconda.org and skips packages that are already there. for %%F in ("%CONDA_BLD_PATH%\win-64\*.conda") do ( echo Uploading %%~fF pixi run rs %ROBOSTACK_DISTRO% upload "%%~fF" diff --git a/README.md b/README.md index e7969631..64d0c1b3 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ Conda packages of [ROS](https://www.ros.org) for Linux, macOS and Windows, built this repository for every supported ROS 2 distribution. To install and use them, see the [RoboStack documentation](https://robostack.github.io/GettingStarted.html). -| Distribution | Channel | +| Distribution | Channel (the same packages on both) | |---|---| -| rolling | [robostack-rolling](https://prefix.dev/channels/robostack-rolling) | -| lyrical | [robostack-lyrical](https://prefix.dev/channels/robostack-lyrical) | -| jazzy | [robostack-jazzy](https://anaconda.org/robostack-jazzy) | -| humble | [robostack-staging](https://anaconda.org/robostack-staging) | +| rolling | robostack-rolling on [prefix.dev](https://prefix.dev/channels/robostack-rolling) and [anaconda.org](https://anaconda.org/robostack-rolling) | +| lyrical | robostack-lyrical on [prefix.dev](https://prefix.dev/channels/robostack-lyrical) and [anaconda.org](https://anaconda.org/robostack-lyrical) | +| jazzy | robostack-jazzy on [prefix.dev](https://prefix.dev/channels/robostack-jazzy) and [anaconda.org](https://anaconda.org/robostack-jazzy) | +| humble | robostack-humble on [prefix.dev](https://prefix.dev/channels/robostack-humble) and [anaconda.org](https://anaconda.org/robostack-humble) | Packages are named `ros2-`, e.g. `ros2-desktop` or `ros2-rclcpp`; the distribution comes from the channel. The older names keep working: for every package @@ -41,7 +41,7 @@ If you use RoboStack in your academic work, please cite: ``` distros// only what is specific to one distribution - distro.yaml channel, upload target, conda-forge pinning version/migrations, own pins, rosdistro sync + distro.yaml channel (and where builds read it from), conda-forge pinning version/migrations, own pins, rosdistro sync vinca.yaml extends shared/vinca.yaml: ros_distro, build number, mutex version, its exceptions pkg_additional_info.yaml its own per-package settings (build numbers, version-specific cmake args) patch/ its patches; patch/dependencies.yaml its own dependency fixes @@ -119,10 +119,12 @@ label doesn't start one by itself. Nothing has to be reset before merging. - Each selected distribution is built on all five platforms, with its own build cache. The `check` job runs `pixi run rs check`. - **After a merge** (`main.yaml`): for every changed distribution and platform, the job regenerates the recipes and the staged build workflow, and pushes them to `buildbranch__`. - Each build branch carries its workflow as `.github/workflows/build.yaml`, which runs `.scripts/build_*.sh` in `distros//work`. - - Those workflows build the packages and upload them to the distribution's channel. + - Those workflows build the packages and upload them to the distribution's channel on both prefix.dev and anaconda.org (same channel name; packages already there are skipped). `read_from` in `distro.yaml` only says where the builds look up what's already published. - **Repository variables:** - - `ROBOSTACK_UPLOAD_CHANNEL`: upload to this prefix.dev channel instead (for example a test channel), or `none` to build without uploading. Use the canonical reference: `/` for a channel that isn't the namespace's primary one (e.g. `tobias-fischer/robostack-distro-test`). - - Upload credentials: prefix.dev channels use Repository Access (OIDC, no stored key). In the channel's *Settings > Repository Access*, authorize GitHub, this repository, workflow filename `build.yaml`, mode *Read/write*. There are no upload tokens: distributions whose channel is still on anaconda.org (`upload_target: anaconda`, read from there) can only upload to a prefix.dev channel set in `ROBOSTACK_UPLOAD_CHANNEL`. + - `ROBOSTACK_UPLOAD_CHANNEL`: upload only to this prefix.dev channel instead (for example a test channel), or `none` to build without uploading. Use the canonical reference: `/` for a channel that isn't the namespace's primary one (e.g. `tobias-fischer/robostack-distro-test`). + - Upload credentials: + - prefix.dev: Repository Access (OIDC, no stored key). In each channel's *Settings > Repository Access*, authorize GitHub, this repository, workflow filename `build.yaml`, mode *Read/write*. + - anaconda.org: the repository secret `ANACONDA_API_TOKEN`, an anaconda.org API token (scope *Allow write access to the API site*, `api:write`) of an account that can upload to every `robostack-` organization. - `ROBOSTACK_BOT_CLIENT_ID` (the App's Client ID, `Iv23…`) with the secret `ROBOSTACK_BOT_PRIVATE_KEY`: the robostack-bot GitHub App. It pushes the build branches (which contain workflow files) and opens bot PRs so that CI runs on them. `GHA_PAT` works as a fallback. The app needs repository permissions Contents, Pull requests, Issues and Workflows (read and write), with its webhook inactive. diff --git a/distros/humble/distro.yaml b/distros/humble/distro.yaml index 3401f176..e8569408 100644 --- a/distros/humble/distro.yaml +++ b/distros/humble/distro.yaml @@ -1,7 +1,9 @@ # Settings of ros-humble that differ from the other distributions # (everything else is shared, see the README). -channel_name: robostack-staging -upload_target: anaconda +# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# read_from: where the builds look up what is already published +channel_name: robostack-humble +read_from: anaconda conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6, vtk970] diff --git a/distros/jazzy/distro.yaml b/distros/jazzy/distro.yaml index 13c89340..9e9bb470 100644 --- a/distros/jazzy/distro.yaml +++ b/distros/jazzy/distro.yaml @@ -1,7 +1,9 @@ # Settings of ros-jazzy that differ from the other distributions # (everything else is shared, see the README). +# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# read_from: where the builds look up what is already published channel_name: robostack-jazzy -upload_target: anaconda +read_from: anaconda conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6, vtk970] diff --git a/distros/lyrical/distro.yaml b/distros/lyrical/distro.yaml index 9d7f0f5b..c5b75b7b 100644 --- a/distros/lyrical/distro.yaml +++ b/distros/lyrical/distro.yaml @@ -1,7 +1,9 @@ # Settings of ros-lyrical that differ from the other distributions # (everything else is shared, see the README). +# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# read_from: where the builds look up what is already published channel_name: robostack-lyrical -upload_target: prefix +read_from: prefix conda_forge_pinning_version: 2026.10.09.13.11.31 conda_forge_migrations: [elfutils0196, giflib6, go_macos, gsl28, gstreamer128, hdf52, libboost190, libhwloc2150, proj99, urdfdom6] pinning_overrides: diff --git a/distros/rolling/distro.yaml b/distros/rolling/distro.yaml index daeb404b..f7e7c388 100644 --- a/distros/rolling/distro.yaml +++ b/distros/rolling/distro.yaml @@ -1,7 +1,9 @@ # Settings of ros-rolling that differ from the other distributions # (everything else is shared, see the README). +# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# read_from: where the builds look up what is already published channel_name: robostack-rolling -upload_target: prefix +read_from: prefix conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6, vtk970] diff --git a/tools/abi_check.py b/tools/abi_check.py index a601256b..0aab03c6 100644 --- a/tools/abi_check.py +++ b/tools/abi_check.py @@ -338,13 +338,9 @@ def channel_for(distro: str) -> list[str]: sys.path.insert(0, str(Path(__file__).parent)) import robostack as rs - s = rs.settings(distro) - name = s.get("channel_name", f"robostack-{distro}") upload = os.environ.get("ROBOSTACK_UPLOAD_CHANNEL", "").strip() extra = [f"https://repo.prefix.dev/{upload}"] if upload and upload != "none" else [] - if s.get("upload_target", "prefix") == "prefix": - return extra + [f"https://repo.prefix.dev/{name}"] - return extra + [f"https://conda.anaconda.org/{name}"] + return extra + [rs.repodata_url(distro)] def download(channel: str, platform: str, filename: str, dest: Path) -> Path: diff --git a/tools/import_distro.py b/tools/import_distro.py index 3a2ff073..a5cbd1ab 100644 --- a/tools/import_distro.py +++ b/tools/import_distro.py @@ -14,7 +14,7 @@ ros-- dependency names become ros2- - patch/ patches renamed to ros2-[.].patch - rosdistro_snapshot.yaml, rosdistro_additional_recipes.yaml as they are -- distro.yaml channel and upload target (from its pixi.toml), its +- distro.yaml channel and where builds read it from (from its pixi.toml), its conda-forge pinning version, migrations and the pins that differ from shared/pinning/overrides.yaml (vinca_pinning.yaml) - ci.yaml reset to the defaults @@ -208,9 +208,9 @@ def import_distro(src: Source, distro: str | None) -> str: pixi = src.text("pixi.toml") or "" settings: dict = {} if m := re.search(r'upload\s*=\s*"rattler-build upload prefix -c (\S+)', pixi): - settings.update(channel_name=m.group(1), upload_target="prefix") + settings.update(channel_name=m.group(1), read_from="prefix") elif m := re.search(r'upload\s*=\s*"rattler-build upload anaconda -o (\S+)', pixi): - settings.update(channel_name=m.group(1), upload_target="anaconda") + settings.update(channel_name=m.group(1), read_from="anaconda") pinning = yaml.safe_load(src.text("vinca_pinning.yaml") or "") or {} if pinning.get("conda_forge_pinning_version"): settings["conda_forge_pinning_version"] = str(pinning["conda_forge_pinning_version"]) diff --git a/tools/maintenance.py b/tools/maintenance.py index f995e735..893b9f32 100644 --- a/tools/maintenance.py +++ b/tools/maintenance.py @@ -108,9 +108,7 @@ def released_builds(distro: str) -> dict[str, int] | None: 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) + base = rs.repodata_url(distro) builds: dict[str, int] = {} read = False for subdir in REPODATA_SUBDIRS: @@ -414,10 +412,7 @@ def update_snapshot(distro: str) -> Result: def find_stale(distro: str) -> Result: - s = rs.settings(distro) - name = s.get("channel_name", f"robostack-{distro}") - # repo.prefix.dev serves repodata directly (prefix.dev redirects) - url = f"https://repo.prefix.dev/{name}" if s.get("upload_target", "prefix") == "prefix" else rs.channel_url(distro) + url = rs.repodata_url(distro) rs.prepare(distro) proc = subprocess.run( [sys.executable, str(rs.TOOLS / "check_dependency_compat.py"), "--stale", "--repodata", url], @@ -611,7 +606,7 @@ def new_distro(name: str, source: str) -> Result: def _populate_new_distro(name: str, source: str, src: Path, dest: Path) -> Result: settings = rs.settings(source) - keep = {k: settings[k] for k in ("upload_target",) if k in settings} + keep = {k: settings[k] for k in ("read_from",) if k in settings} (dest / "distro.yaml").write_text( f"# Settings of ros-{name} that differ from the other distributions\n" "# (everything else is shared, see the README).\n" @@ -1232,15 +1227,15 @@ def check() -> Result: """Sanity checks: every distribution assembles and its generated files are consistent.""" problems: list[str] = [] all_packages: set[str] = set() - known = {"channel_name", "upload_target", "conda_forge_pinning_version", "conda_forge_migrations", "pinning_overrides", + known = {"channel_name", "read_from", "conda_forge_pinning_version", "conda_forge_migrations", "pinning_overrides", "rosdistro_sync"} for distro in rs.distros(): d = rs.DISTROS / distro settings = rs.settings(distro) if unknown := set(settings) - known: problems.append(f"{distro}: unknown keys in distro.yaml: {sorted(unknown)}") - if settings.get("upload_target", "prefix") not in ("prefix", "anaconda"): - problems.append(f"{distro}: upload_target must be prefix or anaconda") + if settings.get("read_from", "prefix") not in ("prefix", "anaconda"): + problems.append(f"{distro}: read_from must be prefix or anaconda") sync = settings.get("rosdistro_sync") if sync is not None and sync != "manual" and not ((m := SYNC_TAG.match(str(sync))) and m["distro"] == distro): problems.append(f"{distro}: rosdistro_sync must be 'manual' or a sync tag like {distro}/2026-10-05") diff --git a/tools/robostack.py b/tools/robostack.py index fa2de6e5..489f92fc 100644 --- a/tools/robostack.py +++ b/tools/robostack.py @@ -49,12 +49,26 @@ def settings(distro: str) -> dict: return yaml.safe_load((DISTROS / distro / "distro.yaml").read_text()) or {} +def channel_name(distro: str) -> str: + return settings(distro).get("channel_name", f"robostack-{distro}") + + +def reads_from_prefix(distro: str) -> bool: + """Whether builds read the distribution's published packages from prefix.dev (else + anaconda.org). Uploads go to both (see upload).""" + return settings(distro).get("read_from", "prefix") == "prefix" + + def channel_url(distro: str) -> str: - s = settings(distro) - name = s.get("channel_name", f"robostack-{distro}") - if s.get("upload_target", "prefix") == "prefix": - return f"https://prefix.dev/{name}" - return f"https://conda.anaconda.org/{name}" + name = channel_name(distro) + return f"https://prefix.dev/{name}" if reads_from_prefix(distro) else f"https://conda.anaconda.org/{name}" + + +def repodata_url(distro: str) -> str: + """Base URL to read the channel's repodata from (repo.prefix.dev serves it directly, + prefix.dev redirects).""" + name = channel_name(distro) + return f"https://repo.prefix.dev/{name}" if reads_from_prefix(distro) else f"https://conda.anaconda.org/{name}" def run(cmd: list[str], cwd: Path) -> int: @@ -251,37 +265,70 @@ def rattler_build(distro: str, args: list[str], skip_existing: bool = True) -> l def upload(distro: str, files: list[str], cwd: Path) -> int: - """Upload to the distribution's channel. + """Upload to the distribution's channel on both prefix.dev and anaconda.org (same + channel name on each). + + prefix.dev uploads use trusted publishing (Repository Access, OIDC; no stored key) + and publish an attestation. anaconda.org uploads use the ANACONDA_API_TOKEN secret, + which the build workflows pass on (vinca-gha). Both skip packages that are already + there, so a re-run only uploads what's missing. ROBOSTACK_UPLOAD_CHANNEL (set by the staged build workflows from the repository - variable of the same name) redirects uploads to another prefix.dev channel, e.g. - a test channel (the builds then also read from it, see rattler_build); "none" - skips uploading. prefix.dev uploads use trusted publishing (Repository Access, - OIDC; no stored key) and publish an attestation. + variable of the same name) redirects uploads to another prefix.dev channel only, + e.g. a test channel (the builds then also read from it, see rattler_build); "none" + skips uploading. """ override = os.environ.get("ROBOSTACK_UPLOAD_CHANNEL", "").strip() if override == "none": print(f"ROBOSTACK_UPLOAD_CHANNEL=none: not uploading {' '.join(files)}") return 0 - s = settings(distro) - if not override and s.get("upload_target", "prefix") != "prefix": - # anaconda.org has no trusted publishing, and we keep no tokens - print(f"{distro}'s channel {s.get('channel_name')} is on anaconda.org: uploads go to prefix.dev only " - "(set ROBOSTACK_UPLOAD_CHANNEL, or move the channel to prefix.dev)", file=sys.stderr) + channel = override or channel_name(distro) + rc = retry(["rattler-build", "upload", "prefix", "-c", channel, "--skip-existing", "--generate-attestation", + *files], cwd, "prefix.dev") + if override: + return rc + token = os.environ.get("ANACONDA_API_TOKEN", "").strip() + if not token: + print(f"no ANACONDA_API_TOKEN: not uploading to anaconda.org/{channel}", file=sys.stderr) return 1 - channel = override or s.get("channel_name", f"robostack-{distro}") - cmd = ["rattler-build", "upload", "prefix", "-c", channel, "--skip-existing", "--generate-attestation"] - # Sigstore (attestations) and the channel fail now and then; uploads are - # idempotent (--skip-existing), so retry. + missing = [f for f in files if not on_anaconda(channel, Path(cwd, f))] + if missing: + rc |= retry(["rattler-build", "upload", "anaconda", "-o", channel, "--api-key", token, *missing], cwd, + "anaconda.org") + return rc + + +def retry(cmd: list[str], cwd: Path, what: str) -> int: + # the channels (and Sigstore, for attestations) fail now and then; uploads are + # idempotent, so retry for attempt in range(1, 4): - rc = run(cmd + files, cwd) + rc = run(cmd, cwd) if rc == 0 or attempt == 3: return rc - print(f"upload attempt {attempt} failed, retrying in {30 * attempt} s", file=sys.stderr) + print(f"{what} upload attempt {attempt} failed, retrying in {30 * attempt} s", file=sys.stderr) time.sleep(30 * attempt) return rc +def on_anaconda(channel: str, package: Path) -> bool: + """Whether anaconda.org already has this package file (rattler-build's anaconda upload + has no --skip-existing). The builds write packages to output//.""" + import urllib.error + import urllib.request + + stem = package.name.removesuffix(".conda").removesuffix(".tar.bz2") + name, version, _build = stem.rsplit("-", 2) + url = f"https://api.anaconda.org/dist/{channel}/{name}/{version}/{package.parent.name}/{package.name}" + try: + with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "robostack"}), timeout=60): + print(f"{package.name} is already on anaconda.org/{channel}") + return True + except urllib.error.HTTPError as e: + if e.code == 404: + return False + raise + + def task(distro: str, name: str, args: list[str]) -> int: d, w = DISTROS / distro, work_dir(distro) py = [sys.executable] @@ -384,8 +431,8 @@ def generate_gha(distro: str, args: list[str]) -> int: lines = [f"name: build\nrun-name: {distro} {ns.platform}\n" f"concurrency:\n group: build-{distro}-{ns.platform}\n cancel-in-progress: false" if l.startswith("name: ") else l for l in lines] - # uploads use prefix.dev trusted publishing only: no anaconda.org token - lines = [l for l in lines if "ANACONDA_API_TOKEN" not in l] + # vinca-gha passes secrets.ANACONDA_API_TOKEN to the build jobs: the anaconda.org + # upload (prefix.dev uses trusted publishing, id-token: write) # jobs that declare permissions get none they don't list: checking out the # repository needs contents: read (only works without it while it is public) lines = [f"{l}\n{l[:len(l) - len(l.lstrip())]}contents: read" if l.strip() == "id-token: write" else l diff --git a/tools/tests/test_upload.py b/tools/tests/test_upload.py new file mode 100644 index 00000000..8fb75d46 --- /dev/null +++ b/tools/tests/test_upload.py @@ -0,0 +1,50 @@ +"""upload(): every distribution goes to its channel on both prefix.dev and anaconda.org.""" + +from pathlib import Path + +import pytest + +import robostack as rs + + +@pytest.fixture +def calls(monkeypatch): + calls = [] + monkeypatch.setattr(rs, "run", lambda cmd, cwd: calls.append(cmd) or 0) + monkeypatch.setattr(rs, "settings", lambda distro: {"channel_name": f"robostack-{distro}"}) + monkeypatch.delenv("ROBOSTACK_UPLOAD_CHANNEL", raising=False) + monkeypatch.setenv("ANACONDA_API_TOKEN", "secret") + return calls + + +def test_uploads_to_both(calls, monkeypatch): + monkeypatch.setattr(rs, "on_anaconda", lambda channel, package: False) + assert rs.upload("jazzy", ["linux-64/a-1-0.conda"], Path(".")) == 0 + assert calls[0][:5] == ["rattler-build", "upload", "prefix", "-c", "robostack-jazzy"] + assert calls[1][:5] == ["rattler-build", "upload", "anaconda", "-o", "robostack-jazzy"] + assert calls[1][-1] == "linux-64/a-1-0.conda" + + +def test_skips_what_anaconda_has(calls, monkeypatch): + monkeypatch.setattr(rs, "on_anaconda", lambda channel, package: True) + assert rs.upload("jazzy", ["linux-64/a-1-0.conda"], Path(".")) == 0 + assert [c[2] for c in calls] == ["prefix"] + + +def test_test_channel_only_prefix(calls, monkeypatch): + monkeypatch.setenv("ROBOSTACK_UPLOAD_CHANNEL", "someone/test") + assert rs.upload("jazzy", ["linux-64/a-1-0.conda"], Path(".")) == 0 + assert len(calls) == 1 and calls[0][4] == "someone/test" + + +def test_missing_token_fails(calls, monkeypatch): + monkeypatch.delenv("ANACONDA_API_TOKEN") + assert rs.upload("jazzy", ["linux-64/a-1-0.conda"], Path(".")) == 1 + assert [c[2] for c in calls] == ["prefix"] + + +def test_reads_from(monkeypatch): + monkeypatch.setattr(rs, "settings", lambda distro: {"channel_name": "robostack-humble", "read_from": "anaconda"}) + assert rs.channel_url("humble") == "https://conda.anaconda.org/robostack-humble" + monkeypatch.setattr(rs, "settings", lambda distro: {"channel_name": "robostack-rolling"}) + assert rs.repodata_url("rolling") == "https://repo.prefix.dev/robostack-rolling" From 0452842f418fce59f18bd765a2a4df49b762424f Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Tue, 20 Oct 2026 14:23:35 +1000 Subject: [PATCH 2/5] Upload servers per distribution: lyrical and rolling to both, humble and jazzy to anaconda.org [skip ci] distro.yaml gets upload_to (prefix and/or anaconda; both by default). As agreed with prefix.dev for now: lyrical and rolling upload to prefix.dev and anaconda.org, humble and jazzy to anaconda.org only (prefix.dev mirrors them). rs check validates the setting, and new distributions inherit it. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 ++++++++-------- distros/humble/distro.yaml | 3 ++- distros/jazzy/distro.yaml | 3 ++- distros/lyrical/distro.yaml | 3 ++- distros/rolling/distro.yaml | 3 ++- tools/maintenance.py | 7 +++++-- tools/robostack.py | 22 +++++++++++++++++----- tools/tests/test_upload.py | 7 +++++++ 8 files changed, 45 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 64d0c1b3..da65884f 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ Conda packages of [ROS](https://www.ros.org) for Linux, macOS and Windows, built this repository for every supported ROS 2 distribution. To install and use them, see the [RoboStack documentation](https://robostack.github.io/GettingStarted.html). -| Distribution | Channel (the same packages on both) | +| Distribution | Channel | |---|---| -| rolling | robostack-rolling on [prefix.dev](https://prefix.dev/channels/robostack-rolling) and [anaconda.org](https://anaconda.org/robostack-rolling) | -| lyrical | robostack-lyrical on [prefix.dev](https://prefix.dev/channels/robostack-lyrical) and [anaconda.org](https://anaconda.org/robostack-lyrical) | -| jazzy | robostack-jazzy on [prefix.dev](https://prefix.dev/channels/robostack-jazzy) and [anaconda.org](https://anaconda.org/robostack-jazzy) | -| humble | robostack-humble on [prefix.dev](https://prefix.dev/channels/robostack-humble) and [anaconda.org](https://anaconda.org/robostack-humble) | +| rolling | robostack-rolling on [prefix.dev](https://prefix.dev/channels/robostack-rolling) and [anaconda.org](https://anaconda.org/robostack-rolling) (the same packages) | +| lyrical | robostack-lyrical on [prefix.dev](https://prefix.dev/channels/robostack-lyrical) and [anaconda.org](https://anaconda.org/robostack-lyrical) (the same packages) | +| jazzy | [robostack-jazzy](https://anaconda.org/robostack-jazzy) (prefix.dev mirrors it) | +| humble | [robostack-humble](https://anaconda.org/robostack-humble) (prefix.dev mirrors it) | Packages are named `ros2-`, e.g. `ros2-desktop` or `ros2-rclcpp`; the distribution comes from the channel. The older names keep working: for every package @@ -41,7 +41,7 @@ If you use RoboStack in your academic work, please cite: ``` distros// only what is specific to one distribution - distro.yaml channel (and where builds read it from), conda-forge pinning version/migrations, own pins, rosdistro sync + distro.yaml channel, where it's uploaded and read from, conda-forge pinning version/migrations, own pins, rosdistro sync vinca.yaml extends shared/vinca.yaml: ros_distro, build number, mutex version, its exceptions pkg_additional_info.yaml its own per-package settings (build numbers, version-specific cmake args) patch/ its patches; patch/dependencies.yaml its own dependency fixes @@ -119,12 +119,12 @@ label doesn't start one by itself. Nothing has to be reset before merging. - Each selected distribution is built on all five platforms, with its own build cache. The `check` job runs `pixi run rs check`. - **After a merge** (`main.yaml`): for every changed distribution and platform, the job regenerates the recipes and the staged build workflow, and pushes them to `buildbranch__`. - Each build branch carries its workflow as `.github/workflows/build.yaml`, which runs `.scripts/build_*.sh` in `distros//work`. - - Those workflows build the packages and upload them to the distribution's channel on both prefix.dev and anaconda.org (same channel name; packages already there are skipped). `read_from` in `distro.yaml` only says where the builds look up what's already published. + - Those workflows build the packages and upload them to the distribution's channel on the servers `upload_to` in `distro.yaml` lists (prefix.dev and/or anaconda.org, the same channel name on each; packages already there are skipped). `read_from` says where the builds look up what's already published. - **Repository variables:** - `ROBOSTACK_UPLOAD_CHANNEL`: upload only to this prefix.dev channel instead (for example a test channel), or `none` to build without uploading. Use the canonical reference: `/` for a channel that isn't the namespace's primary one (e.g. `tobias-fischer/robostack-distro-test`). - Upload credentials: - prefix.dev: Repository Access (OIDC, no stored key). In each channel's *Settings > Repository Access*, authorize GitHub, this repository, workflow filename `build.yaml`, mode *Read/write*. - - anaconda.org: the repository secret `ANACONDA_API_TOKEN`, an anaconda.org API token (scope *Allow write access to the API site*, `api:write`) of an account that can upload to every `robostack-` organization. + - anaconda.org: the repository secret `ANACONDA_API_TOKEN`, an anaconda.org API token (scope *Allow write access to the API site*, `api:write`) of an account that can upload to every `robostack-` organization that a distribution uploads to. - `ROBOSTACK_BOT_CLIENT_ID` (the App's Client ID, `Iv23…`) with the secret `ROBOSTACK_BOT_PRIVATE_KEY`: the robostack-bot GitHub App. It pushes the build branches (which contain workflow files) and opens bot PRs so that CI runs on them. `GHA_PAT` works as a fallback. The app needs repository permissions Contents, Pull requests, Issues and Workflows (read and write), with its webhook inactive. diff --git a/distros/humble/distro.yaml b/distros/humble/distro.yaml index e8569408..677b58f4 100644 --- a/distros/humble/distro.yaml +++ b/distros/humble/distro.yaml @@ -1,8 +1,9 @@ # Settings of ros-humble that differ from the other distributions # (everything else is shared, see the README). -# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# upload_to: where packages are uploaded, to this channel on each (prefix.dev, anaconda.org); # read_from: where the builds look up what is already published channel_name: robostack-humble +upload_to: [anaconda] read_from: anaconda conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6, diff --git a/distros/jazzy/distro.yaml b/distros/jazzy/distro.yaml index 9e9bb470..e853be31 100644 --- a/distros/jazzy/distro.yaml +++ b/distros/jazzy/distro.yaml @@ -1,8 +1,9 @@ # Settings of ros-jazzy that differ from the other distributions # (everything else is shared, see the README). -# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# upload_to: where packages are uploaded, to this channel on each (prefix.dev, anaconda.org); # read_from: where the builds look up what is already published channel_name: robostack-jazzy +upload_to: [anaconda] read_from: anaconda conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6, diff --git a/distros/lyrical/distro.yaml b/distros/lyrical/distro.yaml index c5b75b7b..1d9dfdd0 100644 --- a/distros/lyrical/distro.yaml +++ b/distros/lyrical/distro.yaml @@ -1,8 +1,9 @@ # Settings of ros-lyrical that differ from the other distributions # (everything else is shared, see the README). -# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# upload_to: where packages are uploaded, to this channel on each (prefix.dev, anaconda.org); # read_from: where the builds look up what is already published channel_name: robostack-lyrical +upload_to: [prefix, anaconda] read_from: prefix conda_forge_pinning_version: 2026.10.09.13.11.31 conda_forge_migrations: [elfutils0196, giflib6, go_macos, gsl28, gstreamer128, hdf52, libboost190, libhwloc2150, proj99, urdfdom6] diff --git a/distros/rolling/distro.yaml b/distros/rolling/distro.yaml index f7e7c388..f969aa4d 100644 --- a/distros/rolling/distro.yaml +++ b/distros/rolling/distro.yaml @@ -1,8 +1,9 @@ # Settings of ros-rolling that differ from the other distributions # (everything else is shared, see the README). -# packages are uploaded to this channel on both prefix.dev and anaconda.org; +# upload_to: where packages are uploaded, to this channel on each (prefix.dev, anaconda.org); # read_from: where the builds look up what is already published channel_name: robostack-rolling +upload_to: [prefix, anaconda] read_from: prefix conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6, diff --git a/tools/maintenance.py b/tools/maintenance.py index 893b9f32..38d2d952 100644 --- a/tools/maintenance.py +++ b/tools/maintenance.py @@ -606,7 +606,7 @@ def new_distro(name: str, source: str) -> Result: def _populate_new_distro(name: str, source: str, src: Path, dest: Path) -> Result: settings = rs.settings(source) - keep = {k: settings[k] for k in ("read_from",) if k in settings} + keep = {k: settings[k] for k in ("read_from", "upload_to") if k in settings} (dest / "distro.yaml").write_text( f"# Settings of ros-{name} that differ from the other distributions\n" "# (everything else is shared, see the README).\n" @@ -1227,7 +1227,7 @@ def check() -> Result: """Sanity checks: every distribution assembles and its generated files are consistent.""" problems: list[str] = [] all_packages: set[str] = set() - known = {"channel_name", "read_from", "conda_forge_pinning_version", "conda_forge_migrations", "pinning_overrides", + known = {"channel_name", "read_from", "upload_to", "conda_forge_pinning_version", "conda_forge_migrations", "pinning_overrides", "rosdistro_sync"} for distro in rs.distros(): d = rs.DISTROS / distro @@ -1236,6 +1236,9 @@ def check() -> Result: problems.append(f"{distro}: unknown keys in distro.yaml: {sorted(unknown)}") if settings.get("read_from", "prefix") not in ("prefix", "anaconda"): problems.append(f"{distro}: read_from must be prefix or anaconda") + upload_to = settings.get("upload_to", list(rs.UPLOAD_HOSTS)) + if not upload_to or not set(upload_to) <= set(rs.UPLOAD_HOSTS): + problems.append(f"{distro}: upload_to must list prefix and/or anaconda") sync = settings.get("rosdistro_sync") if sync is not None and sync != "manual" and not ((m := SYNC_TAG.match(str(sync))) and m["distro"] == distro): problems.append(f"{distro}: rosdistro_sync must be 'manual' or a sync tag like {distro}/2026-10-05") diff --git a/tools/robostack.py b/tools/robostack.py index 489f92fc..072eec83 100644 --- a/tools/robostack.py +++ b/tools/robostack.py @@ -264,9 +264,18 @@ def rattler_build(distro: str, args: list[str], skip_existing: bool = True) -> l return cmd + (["--skip-existing"] if skip_existing else []) + args +UPLOAD_HOSTS = ("prefix", "anaconda") + + +def upload_hosts(distro: str) -> list[str]: + """Where the distribution's packages are uploaded (upload_to in distro.yaml; both by + default), to the channel of the same name on each.""" + return list(settings(distro).get("upload_to", UPLOAD_HOSTS)) + + def upload(distro: str, files: list[str], cwd: Path) -> int: - """Upload to the distribution's channel on both prefix.dev and anaconda.org (same - channel name on each). + """Upload to the distribution's channel on prefix.dev and/or anaconda.org (upload_to; + same channel name on each). prefix.dev uploads use trusted publishing (Repository Access, OIDC; no stored key) and publish an attestation. anaconda.org uploads use the ANACONDA_API_TOKEN secret, @@ -283,9 +292,12 @@ def upload(distro: str, files: list[str], cwd: Path) -> int: print(f"ROBOSTACK_UPLOAD_CHANNEL=none: not uploading {' '.join(files)}") return 0 channel = override or channel_name(distro) - rc = retry(["rattler-build", "upload", "prefix", "-c", channel, "--skip-existing", "--generate-attestation", - *files], cwd, "prefix.dev") - if override: + hosts = ["prefix"] if override else upload_hosts(distro) + rc = 0 + if "prefix" in hosts: + rc = retry(["rattler-build", "upload", "prefix", "-c", channel, "--skip-existing", "--generate-attestation", + *files], cwd, "prefix.dev") + if "anaconda" not in hosts: return rc token = os.environ.get("ANACONDA_API_TOKEN", "").strip() if not token: diff --git a/tools/tests/test_upload.py b/tools/tests/test_upload.py index 8fb75d46..2ee2c4e0 100644 --- a/tools/tests/test_upload.py +++ b/tools/tests/test_upload.py @@ -48,3 +48,10 @@ def test_reads_from(monkeypatch): assert rs.channel_url("humble") == "https://conda.anaconda.org/robostack-humble" monkeypatch.setattr(rs, "settings", lambda distro: {"channel_name": "robostack-rolling"}) assert rs.repodata_url("rolling") == "https://repo.prefix.dev/robostack-rolling" + + +def test_anaconda_only(calls, monkeypatch): + monkeypatch.setattr(rs, "settings", lambda distro: {"channel_name": "robostack-humble", "upload_to": ["anaconda"]}) + monkeypatch.setattr(rs, "on_anaconda", lambda channel, package: False) + assert rs.upload("humble", ["linux-64/a-1-0.conda"], Path(".")) == 0 + assert [c[2] for c in calls] == ["anaconda"] and calls[0][4] == "robostack-humble" From ad823f29788314a6d06726160a47a7aeb200ae7f Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Tue, 20 Oct 2026 15:06:45 +1000 Subject: [PATCH 3/5] anaconda.org uploads: one token per distribution [skip ci] Each robostack- organization gets its own token: the repository secret ANACONDA_API_TOKEN_. The generated build workflow of each distribution reads its own secret (instead of vinca-gha's secrets.ANACONDA_API_TOKEN) into ANACONDA_API_TOKEN, which the upload uses. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- tools/robostack.py | 20 +++++++++++++++----- tools/tests/test_generate_gha.py | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index da65884f..1efbb41c 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ label doesn't start one by itself. Nothing has to be reset before merging. - `ROBOSTACK_UPLOAD_CHANNEL`: upload only to this prefix.dev channel instead (for example a test channel), or `none` to build without uploading. Use the canonical reference: `/` for a channel that isn't the namespace's primary one (e.g. `tobias-fischer/robostack-distro-test`). - Upload credentials: - prefix.dev: Repository Access (OIDC, no stored key). In each channel's *Settings > Repository Access*, authorize GitHub, this repository, workflow filename `build.yaml`, mode *Read/write*. - - anaconda.org: the repository secret `ANACONDA_API_TOKEN`, an anaconda.org API token (scope *Allow write access to the API site*, `api:write`) of an account that can upload to every `robostack-` organization that a distribution uploads to. + - anaconda.org: one repository secret per distribution, `ANACONDA_API_TOKEN_` (e.g. `ANACONDA_API_TOKEN_HUMBLE`): an API token of its `robostack-` organization with scope *Allow write access to the API site* (`api:write`). The generated build workflow passes it to the upload as `ANACONDA_API_TOKEN`. - `ROBOSTACK_BOT_CLIENT_ID` (the App's Client ID, `Iv23…`) with the secret `ROBOSTACK_BOT_PRIVATE_KEY`: the robostack-bot GitHub App. It pushes the build branches (which contain workflow files) and opens bot PRs so that CI runs on them. `GHA_PAT` works as a fallback. The app needs repository permissions Contents, Pull requests, Issues and Workflows (read and write), with its webhook inactive. diff --git a/tools/robostack.py b/tools/robostack.py index 072eec83..27f4fa88 100644 --- a/tools/robostack.py +++ b/tools/robostack.py @@ -267,6 +267,12 @@ def rattler_build(distro: str, args: list[str], skip_existing: bool = True) -> l UPLOAD_HOSTS = ("prefix", "anaconda") +def anaconda_token_secret(distro: str) -> str: + """The repository secret with the anaconda.org API token of the distribution's + organization (robostack-); the build workflows pass it on as ANACONDA_API_TOKEN.""" + return f"ANACONDA_API_TOKEN_{distro.upper()}" + + def upload_hosts(distro: str) -> list[str]: """Where the distribution's packages are uploaded (upload_to in distro.yaml; both by default), to the channel of the same name on each.""" @@ -278,8 +284,8 @@ def upload(distro: str, files: list[str], cwd: Path) -> int: same channel name on each). prefix.dev uploads use trusted publishing (Repository Access, OIDC; no stored key) - and publish an attestation. anaconda.org uploads use the ANACONDA_API_TOKEN secret, - which the build workflows pass on (vinca-gha). Both skip packages that are already + and publish an attestation. anaconda.org uploads use ANACONDA_API_TOKEN, which the + build workflow sets from the secret ANACONDA_API_TOKEN_ (see generate_gha). Both skip packages that are already there, so a re-run only uploads what's missing. ROBOSTACK_UPLOAD_CHANNEL (set by the staged build workflows from the repository @@ -301,7 +307,8 @@ def upload(distro: str, files: list[str], cwd: Path) -> int: return rc token = os.environ.get("ANACONDA_API_TOKEN", "").strip() if not token: - print(f"no ANACONDA_API_TOKEN: not uploading to anaconda.org/{channel}", file=sys.stderr) + print(f"no ANACONDA_API_TOKEN (repository secret {anaconda_token_secret(distro)}): not uploading to " + f"anaconda.org/{channel}", file=sys.stderr) return 1 missing = [f for f in files if not on_anaconda(channel, Path(cwd, f))] if missing: @@ -443,8 +450,11 @@ def generate_gha(distro: str, args: list[str]) -> int: lines = [f"name: build\nrun-name: {distro} {ns.platform}\n" f"concurrency:\n group: build-{distro}-{ns.platform}\n cancel-in-progress: false" if l.startswith("name: ") else l for l in lines] - # vinca-gha passes secrets.ANACONDA_API_TOKEN to the build jobs: the anaconda.org - # upload (prefix.dev uses trusted publishing, id-token: write) + # vinca-gha passes secrets.ANACONDA_API_TOKEN to the build jobs, for the anaconda.org + # upload (prefix.dev uses trusted publishing, id-token: write): each distribution + # has its own token (its organization's), the secret ANACONDA_API_TOKEN_ + secret = anaconda_token_secret(distro) + lines = [l.replace("secrets.ANACONDA_API_TOKEN }}", f"secrets.{secret} }}}}") for l in lines] # jobs that declare permissions get none they don't list: checking out the # repository needs contents: read (only works without it while it is public) lines = [f"{l}\n{l[:len(l) - len(l.lstrip())]}contents: read" if l.strip() == "id-token: write" else l diff --git a/tools/tests/test_generate_gha.py b/tools/tests/test_generate_gha.py index 085c85e2..04ba1fe7 100644 --- a/tools/tests/test_generate_gha.py +++ b/tools/tests/test_generate_gha.py @@ -31,3 +31,23 @@ def fake_run_tee(cmd, cwd): assert rs.generate_gha("jazzy", ["--platform", "win-64"]) == 1 assert sizes == [25] + + +def test_gha_uses_the_distributions_anaconda_token(monkeypatch, tmp_path): + work, root = tmp_path / "work", tmp_path / "root" + work.mkdir(); (root / ".github" / "workflows").mkdir(parents=True) + + def fake_run_tee(cmd, cwd): + (work / "linux.yml").write_text( + "name: build\njobs:\n stage_0:\n permissions:\n id-token: write\n env:\n" + " ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }}\n") + return 0, b"" + + monkeypatch.setattr(rs, "run_tee", fake_run_tee) + monkeypatch.setattr(rs, "work_dir", lambda distro: work) + monkeypatch.setattr(rs, "ROOT", root) + + assert rs.generate_gha("lyrical", ["--platform", "linux-64"]) == 0 + text = (root / ".github" / "workflows" / "build.yaml").read_text() + assert "ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN_LYRICAL }}" in text + assert "secrets.ANACONDA_API_TOKEN }}" not in text From 9f0f75771f78bff1edb1eeeb5d86a8905c5720ec Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Tue, 20 Oct 2026 15:15:36 +1000 Subject: [PATCH 4/5] rolling: upload to prefix.dev only [skip ci] There's no access to the robostack-rolling organization on anaconda.org, so rolling keeps uploading to prefix.dev only (as now). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- distros/rolling/distro.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1efbb41c..9dc8ea29 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ the [RoboStack documentation](https://robostack.github.io/GettingStarted.html). | Distribution | Channel | |---|---| -| rolling | robostack-rolling on [prefix.dev](https://prefix.dev/channels/robostack-rolling) and [anaconda.org](https://anaconda.org/robostack-rolling) (the same packages) | +| rolling | [robostack-rolling](https://prefix.dev/channels/robostack-rolling) | | lyrical | robostack-lyrical on [prefix.dev](https://prefix.dev/channels/robostack-lyrical) and [anaconda.org](https://anaconda.org/robostack-lyrical) (the same packages) | | jazzy | [robostack-jazzy](https://anaconda.org/robostack-jazzy) (prefix.dev mirrors it) | | humble | [robostack-humble](https://anaconda.org/robostack-humble) (prefix.dev mirrors it) | diff --git a/distros/rolling/distro.yaml b/distros/rolling/distro.yaml index f969aa4d..a7574bbe 100644 --- a/distros/rolling/distro.yaml +++ b/distros/rolling/distro.yaml @@ -3,7 +3,7 @@ # upload_to: where packages are uploaded, to this channel on each (prefix.dev, anaconda.org); # read_from: where the builds look up what is already published channel_name: robostack-rolling -upload_to: [prefix, anaconda] +upload_to: [prefix] read_from: prefix conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6, From 2c7297d256b32437a9058325af22f536ff8b9e7a Mon Sep 17 00:00:00 2001 From: Tobias Fischer Date: Wed, 21 Oct 2026 07:30:01 +1000 Subject: [PATCH 5/5] rolling: upload to anaconda.org too [skip ci] robostack-rolling on anaconda.org is accessible now and its token is the repository secret ANACONDA_API_TOKEN_ROLLING, so rolling uploads to both servers like lyrical. Builds keep reading from prefix.dev. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- distros/rolling/distro.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9dc8ea29..1efbb41c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ the [RoboStack documentation](https://robostack.github.io/GettingStarted.html). | Distribution | Channel | |---|---| -| rolling | [robostack-rolling](https://prefix.dev/channels/robostack-rolling) | +| rolling | robostack-rolling on [prefix.dev](https://prefix.dev/channels/robostack-rolling) and [anaconda.org](https://anaconda.org/robostack-rolling) (the same packages) | | lyrical | robostack-lyrical on [prefix.dev](https://prefix.dev/channels/robostack-lyrical) and [anaconda.org](https://anaconda.org/robostack-lyrical) (the same packages) | | jazzy | [robostack-jazzy](https://anaconda.org/robostack-jazzy) (prefix.dev mirrors it) | | humble | [robostack-humble](https://anaconda.org/robostack-humble) (prefix.dev mirrors it) | diff --git a/distros/rolling/distro.yaml b/distros/rolling/distro.yaml index a7574bbe..f969aa4d 100644 --- a/distros/rolling/distro.yaml +++ b/distros/rolling/distro.yaml @@ -3,7 +3,7 @@ # upload_to: where packages are uploaded, to this channel on each (prefix.dev, anaconda.org); # read_from: where the builds look up what is already published channel_name: robostack-rolling -upload_to: [prefix] +upload_to: [prefix, anaconda] read_from: prefix conda_forge_pinning_version: 2026.09.01.16.28.00 conda_forge_migrations: [giflib6, go_macos, gstreamer128, hdf52, libboost190, pybind11_abi11, urdfdom6,