Automate conda recipe synchronization

This commit is contained in:
Victor Kuznetsov
2026-07-25 21:57:22 -07:00
parent d21dc62bbb
commit 685f0679bd
4 changed files with 137 additions and 10 deletions
+31 -2
View File
@@ -1,8 +1,9 @@
name: Distribute on release
# Fans a published GitHub Release out to the channels that need a nudge.
# PyPI is handled by publish.yml; conda-forge is handled by its autotick bot.
# This workflow event-drives the two channels that would otherwise be manual:
# PyPI is handled by publish.yml; the conda-forge channel is handled by its
# autotick bot. This workflow updates the repository recipe and event-drives
# the two channels that would otherwise be manual:
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
# Both wait for the freshly published sdist to appear on PyPI first, since the
@@ -87,6 +88,34 @@ jobs:
git commit -m "remove-ai-watermarks $VERSION" || { echo "Formula already current"; exit 0; }
git push
conda-recipe:
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
- name: Bump the repository conda recipe
env:
SHA: ${{ needs.resolve.outputs.sha }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
python scripts/sync_conda_recipe.py --version "$VERSION" --sha256 "$SHA"
git diff --check
if git diff --quiet -- packaging/conda/recipe.yaml; then
echo "Conda recipe already current"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add packaging/conda/recipe.yaml
git commit -m "Sync conda recipe with v$VERSION"
git push
hf-space:
needs: resolve
runs-on: ubuntu-latest
+12 -8
View File
@@ -43,6 +43,7 @@ PyPI API token from the repository.
waits for the matching source distribution to appear on PyPI, then:
- updates the Homebrew tap formula URL and SHA-256;
- updates the repository conda recipe version and source-distribution SHA-256;
- triggers a factory rebuild of the Hugging Face Space.
The workflow can also be started manually with an optional version input.
@@ -52,12 +53,11 @@ If a distribution job fails because a repository or Hugging Face credential is
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
manual Homebrew formula update is the fallback when its automation is blocked.
After PyPI publication, update `packaging/conda/recipe.yaml` to the released
version and the SHA-256 of the published source distribution. Use the published
artifact rather than a locally built archive as the hash source. Keep the
recipe's runtime dependencies aligned with the core dependencies in
`pyproject.toml`; document any conda-forge package that is unavailable and must
be omitted.
The conda job uses the published artifact rather than a locally built archive
as the hash source and commits the resulting recipe change to `main`. Runtime
dependency mapping remains review-controlled: keep it aligned with the core
dependencies in `pyproject.toml`, and document any conda-forge package that is
unavailable and must be omitted.
## Source distribution boundary
@@ -77,8 +77,12 @@ The package uses hatchling through the unpinned `hatchling` build requirement in
The repository includes a conda recipe under `packaging/conda/recipe.yaml`.
The ComfyUI nodes are maintained and versioned separately from this package.
A library release does not by itself publish a new ComfyUI node version.
The ComfyUI nodes are maintained and versioned in their own repository. Its
scheduled workflow detects a newer PyPI library release, updates the dependency
floor, runs compatibility tests, bumps the node patch version, and publishes to
the ComfyUI Registry only when those tests pass. The library release event does
not publish the node package directly, so the registry update follows on that
schedule rather than inside `distribute.yml`.
## Release verification
+53
View File
@@ -0,0 +1,53 @@
"""Update the conda recipe to a published package version and sdist hash."""
from __future__ import annotations
import argparse
import logging
import re
from pathlib import Path
log = logging.getLogger(__name__)
_VERSION_LINE = re.compile(r'^ version: "[^"]+"$', re.MULTILINE)
_SHA256_LINE = re.compile(r"^ sha256: [0-9a-f]{64}$", re.MULTILINE)
def update_recipe(text: str, *, version: str, sha256: str) -> str:
"""Return recipe text with exactly one context version and source hash updated."""
if not version or any(char.isspace() for char in version):
raise ValueError("version must be a non-empty value without whitespace")
if re.fullmatch(r"[0-9a-f]{64}", sha256) is None:
raise ValueError("sha256 must be 64 lowercase hexadecimal characters")
updated, version_count = _VERSION_LINE.subn(f' version: "{version}"', text)
updated, sha_count = _SHA256_LINE.subn(f" sha256: {sha256}", updated)
if version_count != 1 or sha_count != 1:
raise ValueError(
"expected exactly one context version and one source sha256 "
f"(found version={version_count}, sha256={sha_count})"
)
return updated
def main() -> None:
"""Update the selected recipe in place."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--sha256", required=True)
parser.add_argument(
"--recipe",
type=Path,
default=Path("packaging/conda/recipe.yaml"),
)
args = parser.parse_args()
original = args.recipe.read_text()
updated = update_recipe(original, version=args.version, sha256=args.sha256)
args.recipe.write_text(updated)
log.info("Updated %s to version %s", args.recipe, args.version)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
main()
+41
View File
@@ -0,0 +1,41 @@
"""Tests for the release-time conda recipe synchronizer."""
from __future__ import annotations
import pytest
from scripts.sync_conda_recipe import update_recipe
_OLD_SHA = "a" * 64
_NEW_SHA = "b" * 64
_RECIPE = f"""\
schema_version: 1
context:
version: "1.2.3"
source:
sha256: {_OLD_SHA}
"""
def test_update_recipe_replaces_version_and_hash() -> None:
updated = update_recipe(_RECIPE, version="2.0.0", sha256=_NEW_SHA)
assert ' version: "2.0.0"' in updated
assert f" sha256: {_NEW_SHA}" in updated
assert "1.2.3" not in updated
assert _OLD_SHA not in updated
@pytest.mark.parametrize("sha256", ["", "A" * 64, "f" * 63, "not-a-hash"])
def test_update_recipe_rejects_invalid_hash(sha256: str) -> None:
with pytest.raises(ValueError, match="sha256"):
update_recipe(_RECIPE, version="2.0.0", sha256=sha256)
def test_update_recipe_rejects_ambiguous_recipe() -> None:
duplicate = _RECIPE + f"\nsource_two:\n sha256: {_OLD_SHA}\n"
with pytest.raises(ValueError, match="exactly one"):
update_recipe(duplicate, version="2.0.0", sha256=_NEW_SHA)