From 685f0679bd3112af1ac50025e7fd30f029779114 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sat, 25 Jul 2026 21:57:22 -0700 Subject: [PATCH] Automate conda recipe synchronization --- .github/workflows/distribute.yml | 33 ++++++++++++++++++-- docs/release-and-distribution.md | 20 +++++++----- scripts/sync_conda_recipe.py | 53 ++++++++++++++++++++++++++++++++ tests/test_sync_conda_recipe.py | 41 ++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 scripts/sync_conda_recipe.py create mode 100644 tests/test_sync_conda_recipe.py diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml index 3b778fa..157b7e4 100644 --- a/.github/workflows/distribute.yml +++ b/.github/workflows/distribute.yml @@ -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 diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index 76579d6..49f08aa 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -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 diff --git a/scripts/sync_conda_recipe.py b/scripts/sync_conda_recipe.py new file mode 100644 index 0000000..f02aaeb --- /dev/null +++ b/scripts/sync_conda_recipe.py @@ -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() diff --git a/tests/test_sync_conda_recipe.py b/tests/test_sync_conda_recipe.py new file mode 100644 index 0000000..c9664c4 --- /dev/null +++ b/tests/test_sync_conda_recipe.py @@ -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)