diff --git a/.github/workflows/backport_release.yaml b/.github/workflows/backport_release.yaml index ba1f70e58c2..474e7045bc2 100644 --- a/.github/workflows/backport_release.yaml +++ b/.github/workflows/backport_release.yaml @@ -3,8 +3,8 @@ name: Backport Release on: workflow_dispatch: inputs: - branch: - description: 'Source branch containing the backported commits (PR source branch into master)' + commit: + description: 'Full 40-char SHA of the tip commit of the backport source branch (the PR head commit that passed tests). The branch is resolved from this SHA and must be unique.' required: true type: string @@ -39,17 +39,72 @@ jobs: git config user.name "fen-release[bot]" git config user.email "fen-release[bot]@users.noreply.github.com" - - name: Validate source branch exists + - name: Resolve source branch from commit SHA + id: resolve env: - SOURCE_BRANCH: ${{ inputs.branch }} + SOURCE_COMMIT: ${{ inputs.commit }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail - git fetch origin "refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" - if ! git show-ref --verify --quiet "refs/remotes/origin/${SOURCE_BRANCH}"; then - echo "::error::Source branch '${SOURCE_BRANCH}' not found on origin." + + # Require a full 40-char lowercase-hex SHA. Short SHAs are ambiguous + # and we will be comparing this value against API responses (PR head + # SHA, ref tips) that always return the full form. + if [[ ! "${SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Input commit '${SOURCE_COMMIT}' is not a full 40-char lowercase hex SHA." + exit 1 + fi + + # Fetch all remote branches so we can search for which one(s) point + # at this SHA. `actions/checkout` with fetch-depth: 0 fetches full + # history of the checked-out ref but does not necessarily populate + # every refs/remotes/origin/*, so do it explicitly. + git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*' + + # Verify the commit actually exists in this repo's object DB. + if ! git cat-file -e "${SOURCE_COMMIT}^{commit}" 2>/dev/null; then + echo "::error::Commit ${SOURCE_COMMIT} was not found in the repository." exit 1 fi + # Find every remote branch whose tip == SOURCE_COMMIT. Exactly one + # branch must point at it. If zero, the commit isn't anyone's tip + # (likely stale, force-pushed past, or never the PR head). If more + # than one, the (branch -> SHA) mapping is ambiguous and we refuse + # to guess — the operator must give us a unique branch to release. + mapfile -t matching_branches < <( + git for-each-ref \ + --format='%(refname:strip=3)' \ + --points-at="${SOURCE_COMMIT}" \ + refs/remotes/origin/ \ + | grep -vx 'HEAD' || true + ) + + if [[ "${#matching_branches[@]}" -eq 0 ]]; then + echo "::error::No branch on origin has ${SOURCE_COMMIT} as its tip." + echo "::error::Either the branch was updated after you copied this SHA, or this commit was never the head of a branch." + exit 1 + fi + + if [[ "${#matching_branches[@]}" -gt 1 ]]; then + echo "::error::More than one branch on origin has ${SOURCE_COMMIT} as its tip; cannot pick one:" + for b in "${matching_branches[@]}"; do + echo "::error:: - ${b}" + done + echo "::error::Refusing to proceed with an ambiguous source branch." + exit 1 + fi + + source_branch="${matching_branches[0]}" + + if [[ "${source_branch}" == "${DEFAULT_BRANCH}" ]]; then + echo "::error::Source branch must not be the default branch ('${DEFAULT_BRANCH}')." + exit 1 + fi + + echo "Resolved commit ${SOURCE_COMMIT} to branch '${source_branch}'." + echo "source_branch=${source_branch}" >> "$GITHUB_OUTPUT" + - name: Determine latest stable release id: latest env: @@ -102,23 +157,26 @@ jobs: - name: Validate source branch is cut directly from the latest stable release env: - SOURCE_BRANCH: ${{ inputs.branch }} + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }} LATEST_TAG: ${{ steps.latest.outputs.latest_tag }} run: | set -euo pipefail - source_sha="$(git rev-parse "refs/remotes/origin/${SOURCE_BRANCH}")" - - # The source branch must be cut directly off the latest stable tag. - # "Cut directly off" means: walking first-parent from the source tip - # eventually reaches LATEST_TAG_SHA. This rejects branches that were - # cut from master after the tag (which would carry unrelated commits), - # while accepting a branch rooted at the tag with N backport commits - # on top (each of which may itself be a merge — first-parent walks - # through the mainline of the branch). - if ! git rev-list --first-parent "${source_sha}" \ - | grep -qx "${LATEST_TAG_SHA}"; then + # Use the user-provided SHA directly rather than re-resolving the branch + # tip — the resolve step already proved the branch tip equals SOURCE_COMMIT, + # and pinning to the SHA here makes the rest of the job TOCTOU-safe against + # someone pushing to the branch mid-run. + source_sha="${SOURCE_COMMIT}" + + # Walking first-parent from the source tip must reach LATEST_TAG_SHA. + # We capture rev-list into a variable and grep against a here-string + # rather than piping `rev-list | grep -q`: under `set -o pipefail`, + # `grep -q` would exit on first match and SIGPIPE the still-streaming + # `rev-list`, propagating exit 141 as a spurious "not found". + first_parent_chain="$(git rev-list --first-parent "${source_sha}")" + if ! grep -Fxq "${LATEST_TAG_SHA}" <<< "${first_parent_chain}"; then echo "::error::Source branch '${SOURCE_BRANCH}' is not cut from '${LATEST_TAG}'." echo "::error::Its first-parent history does not include ${LATEST_TAG_SHA}." exit 1 @@ -153,10 +211,11 @@ jobs: added_count="$(printf '%s\n' "${all_added}" | grep -c . || true)" echo "Source branch is cut directly from ${LATEST_TAG} with ${added_count} commit(s) on top." - - name: Validate PR exists, is named correctly, and checks pass + - name: Validate PR exists, is open, named correctly, has latest commit, and checks pass env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - SOURCE_BRANCH: ${{ inputs.branch }} + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} NEW_VERSION: ${{ steps.latest.outputs.new_version }} REPO: ${{ github.repository }} run: | @@ -164,20 +223,22 @@ jobs: expected_title="ComfyUI backport release ${NEW_VERSION}" - # Find open PRs from this branch into master + # Find open PRs from this branch into master. The --state open filter + # is load-bearing: a closed/merged PR with passing checks must not be + # accepted as authorization for a new release. pr_json="$( gh pr list \ --repo "${REPO}" \ --state open \ --head "${SOURCE_BRANCH}" \ --base master \ - --json number,title,headRefOid \ + --json number,title,headRefOid,state \ --limit 10 )" pr_count="$(echo "${pr_json}" | jq 'length')" if [[ "${pr_count}" -eq 0 ]]; then - echo "::error::No open PR found from '${SOURCE_BRANCH}' into 'master'." + echo "::error::No open PR found from '${SOURCE_BRANCH}' into 'master'. The PR must exist and be open." exit 1 fi @@ -196,7 +257,19 @@ jobs: exit 1 fi - echo "Found PR #${pr_number} titled '${expected_title}' (head ${pr_head_sha})." + # The PR's current head commit must equal the SHA the operator gave us. + # This is what closes the door on releasing stale code: if anyone has + # pushed to the branch since the operator validated tests passed, the + # PR head will have advanced past SOURCE_COMMIT and we abort. (The + # resolve step already proved the branch tip == SOURCE_COMMIT; this + # ties that same SHA to the PR that authorizes the release.) + if [[ "${pr_head_sha}" != "${SOURCE_COMMIT}" ]]; then + echo "::error::PR #${pr_number} head commit is ${pr_head_sha}, but the operator-provided commit is ${SOURCE_COMMIT}." + echo "::error::The PR has new commits since this release was authorized. Re-run with the new head SHA after verifying its checks." + exit 1 + fi + + echo "Found open PR #${pr_number} titled '${expected_title}' at head ${pr_head_sha} (matches operator-provided commit)." # Verify all check runs on the head commit have completed successfully. # A check is considered passing if conclusion is success, neutral, or skipped. @@ -238,7 +311,6 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} REPO: ${{ github.repository }} - SOURCE_BRANCH: ${{ inputs.branch }} RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} LATEST_TAG: ${{ steps.latest.outputs.latest_tag }} LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }} @@ -274,7 +346,8 @@ jobs: - name: Fast-forward merge source branch into release branch env: - SOURCE_BRANCH: ${{ inputs.branch }} + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} run: | set -euo pipefail @@ -285,12 +358,16 @@ jobs: # that the source branch is rooted on the latest stable tag, and the # release branch tip equals that same tag, this fast-forward should # always succeed for a well-formed backport branch. - if ! git merge --ff-only "refs/remotes/origin/${SOURCE_BRANCH}"; then - echo "::error::Cannot fast-forward '${RELEASE_BRANCH}' to '${SOURCE_BRANCH}'. A merge commit would be required. Aborting." + # + # We merge the operator-provided SHA, not the branch ref, so a push to + # the branch in the window between resolve and now cannot smuggle new + # commits into the release. + if ! git merge --ff-only "${SOURCE_COMMIT}"; then + echo "::error::Cannot fast-forward '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}'). A merge commit would be required. Aborting." exit 1 fi - echo "Fast-forwarded '${RELEASE_BRANCH}' to tip of '${SOURCE_BRANCH}'." + echo "Fast-forwarded '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}')." - name: Bump version files env: @@ -387,14 +464,20 @@ jobs: NEW_VERSION: ${{ steps.latest.outputs.new_version }} RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }} LATEST_TAG: ${{ steps.latest.outputs.latest_tag }} - SOURCE_BRANCH: ${{ inputs.branch }} + SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }} + SOURCE_COMMIT: ${{ inputs.commit }} run: | + # SOURCE_BRANCH is empty if the resolve step never produced an output + # (e.g. the workflow failed in or before that step). Show a placeholder + # in that case so the summary table still renders cleanly. + source_branch_display="${SOURCE_BRANCH:-(unresolved)}" { echo "## Backport release" echo "" echo "| Field | Value |" echo "|---|---|" - echo "| Source branch | \`${SOURCE_BRANCH}\` |" + echo "| Source commit | \`${SOURCE_COMMIT}\` |" + echo "| Source branch | \`${source_branch_display}\` |" echo "| Previous stable | \`${LATEST_TAG}\` |" echo "| New version | \`${NEW_VERSION}\` |" echo "| Release branch | \`${RELEASE_BRANCH}\` |" diff --git a/README.md b/README.md index 0eecd8a4bbe..5125bad1417 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ [website-url]: https://www.comfy.org/ [discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total -[discord-url]: https://www.comfy.org/discord +[discord-url]: https://discord.com/invite/comfyorg [twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI [twitter-url]: https://x.com/ComfyUI diff --git a/app/frontend_management.py b/app/frontend_management.py index d0596b276ae..483da2d29da 100644 --- a/app/frontend_management.py +++ b/app/frontend_management.py @@ -62,6 +62,8 @@ def get_comfy_package_versions(): def check_comfy_packages_versions(): """Warn for every comfy* package whose installed version is below requirements.txt.""" from packaging.version import InvalidVersion, parse as parse_pep440 + outdated_packages = [] + for pkg in get_comfy_package_versions(): installed_str = pkg["installed"] required_str = pkg["required"] @@ -73,19 +75,26 @@ def check_comfy_packages_versions(): logging.error(f"Failed to check {pkg['name']} version: {e}") continue if outdated: - app.logger.log_startup_warning( - f""" + outdated_packages.append((pkg["name"], installed_str, required_str)) + else: + logging.info("{} version: {}".format(pkg["name"], installed_str)) + + if outdated_packages: + package_warnings = "\n".join( + f"Installed {name} version {installed} is lower than the recommended version {required}." + for name, installed, required in outdated_packages + ) + app.logger.log_startup_warning( + f""" ________________________________________________________________________ WARNING WARNING WARNING WARNING WARNING -Installed {pkg["name"]} version {installed_str} is lower than the recommended version {required_str}. +{package_warnings} {get_missing_requirements_message()} ________________________________________________________________________ """.strip() - ) - else: - logging.info("{} version: {}".format(pkg["name"], installed_str)) + ) REQUEST_TIMEOUT = 10 # seconds diff --git a/comfy_extras/nodes_logic.py b/comfy_extras/nodes_logic.py index c066064acc4..65c7eebcafe 100644 --- a/comfy_extras/nodes_logic.py +++ b/comfy_extras/nodes_logic.py @@ -8,6 +8,82 @@ MISSING = object() +class NotNode(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="ComfyNotNode", + display_name="Not", + category="utils/logic", + description="Logical NOT operation. Returns true if the value is falsy. Uses Python's rules for truthiness.", + search_aliases=["invert", "toggle", "negate", "flip boolean"], + inputs=[ + io.AnyType.Input("value"), + ], + outputs=[ + io.Boolean.Output(), + ], + ) + + @classmethod + def execute(cls, value) -> io.NodeOutput: + return io.NodeOutput(not value) + + +class AndNode(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.Autogrow.TemplatePrefix( + input=io.AnyType.Input("value"), + prefix="value", + min=1, + ) + return io.Schema( + node_id="ComfyAndNode", + display_name="And", + category="utils/logic", + description="Logical AND operation. Returns true if all of the values are truthy. Uses Python's rules for truthiness.", + search_aliases=["all", "every"], + inputs=[ + io.Autogrow.Input("values", template=template), + ], + outputs=[ + io.Boolean.Output(), + ], + ) + + @classmethod + def execute(cls, values: io.Autogrow.Type) -> io.NodeOutput: + return io.NodeOutput(all(values.values())) + + +class OrNode(io.ComfyNode): + @classmethod + def define_schema(cls): + template = io.Autogrow.TemplatePrefix( + input=io.AnyType.Input("value"), + prefix="value", + min=1, + ) + return io.Schema( + node_id="ComfyOrNode", + display_name="Or", + category="utils/logic", + description="Logical OR operation. Returns true if any of the values are truthy. Uses Python's rules for truthiness.", + search_aliases=["any", "some"], + inputs=[ + io.Autogrow.Input("values", template=template), + ], + outputs=[ + io.Boolean.Output(), + ], + ) + + @classmethod + def execute(cls, values: io.Autogrow.Type) -> io.NodeOutput: + return io.NodeOutput(any(values.values())) + + class SwitchNode(io.ComfyNode): @classmethod def define_schema(cls): @@ -261,6 +337,9 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ SwitchNode, CustomComboNode, + NotNode, + AndNode, + OrNode, # SoftSwitchNode, # ConvertStringToComboNode, # DCTestNode,