mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-24 01:57:37 +08:00
Compare commits
10 Commits
origin/iss
...
feature/cu
| Author | SHA1 | Date | |
|---|---|---|---|
| 7259e664ef | |||
| ae539cfa0a | |||
| 8f82b16993 | |||
| 72fe66a18b | |||
| 07ff14ae02 | |||
| ba1c039a04 | |||
| 6220400ad5 | |||
| af55a2308f | |||
| 3a649984f2 | |||
| a145651cc0 |
484
.github/workflows/backport_release.yaml
vendored
484
.github/workflows/backport_release.yaml
vendored
@ -1,484 +0,0 @@
|
|||||||
name: Backport Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
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
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
checks: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backport-release:
|
|
||||||
name: Create backport release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: backport release
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Generate GitHub App token
|
|
||||||
id: app-token
|
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
|
||||||
with:
|
|
||||||
app-id: ${{ secrets.FEN_RELEASE_APP_ID }}
|
|
||||||
private-key: ${{ secrets.FEN_RELEASE_PRIVATE_KEY }}
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
|
||||||
with:
|
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
|
||||||
fetch-depth: 0
|
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
- name: Configure git
|
|
||||||
run: |
|
|
||||||
git config user.name "fen-release[bot]"
|
|
||||||
git config user.email "fen-release[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
- name: Resolve source branch from commit SHA
|
|
||||||
id: resolve
|
|
||||||
env:
|
|
||||||
SOURCE_COMMIT: ${{ inputs.commit }}
|
|
||||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# List all tags matching vMAJOR.MINOR.PATCH and pick the highest by numeric
|
|
||||||
# comparison of each component. We DO NOT use `sort -V` because it treats
|
|
||||||
# v0.19.99 as higher than v0.20.1.
|
|
||||||
latest_tag="$(
|
|
||||||
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \
|
|
||||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
|
||||||
| awk -F'[v.]' '{ printf "%010d %010d %010d %s\n", $2, $3, $4, $0 }' \
|
|
||||||
| sort -k1,1n -k2,2n -k3,3n \
|
|
||||||
| tail -n1 \
|
|
||||||
| awk '{print $4}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -z "${latest_tag}" ]]; then
|
|
||||||
echo "::error::No stable release tags (vMAJOR.MINOR.PATCH) were found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parse components
|
|
||||||
ver="${latest_tag#v}"
|
|
||||||
major="${ver%%.*}"
|
|
||||||
rest="${ver#*.}"
|
|
||||||
minor="${rest%%.*}"
|
|
||||||
patch="${rest#*.}"
|
|
||||||
|
|
||||||
new_patch=$((patch + 1))
|
|
||||||
new_version="v${major}.${minor}.${new_patch}"
|
|
||||||
release_branch="release/v${major}.${minor}"
|
|
||||||
|
|
||||||
latest_sha="$(git rev-list -n 1 "refs/tags/${latest_tag}")"
|
|
||||||
|
|
||||||
echo "latest_tag=${latest_tag}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "latest_sha=${latest_sha}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "major=${major}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "minor=${minor}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "patch=${patch}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "new_version=${new_version}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "new_version_no_v=${major}.${minor}.${new_patch}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release_branch=${release_branch}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
echo "Latest stable release: ${latest_tag} (${latest_sha})"
|
|
||||||
echo "New version will be: ${new_version}"
|
|
||||||
echo "Release branch: ${release_branch}"
|
|
||||||
|
|
||||||
- name: Validate source branch is cut directly from the latest stable release
|
|
||||||
env:
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Additionally, every commit added on top of the tag (the set we are
|
|
||||||
# about to publish) must itself be a descendant of the tag along
|
|
||||||
# first-parent — i.e. no sibling commits from master sneak in via a
|
|
||||||
# non-first-parent path. Enforce by requiring that the symmetric
|
|
||||||
# difference is empty in one direction: commits in source that are
|
|
||||||
# NOT first-parent-reachable from source starting at the tag.
|
|
||||||
# We do this by intersecting:
|
|
||||||
# A = commits reachable from source but not from tag (full DAG)
|
|
||||||
# B = commits on the first-parent chain from source down to tag
|
|
||||||
# and requiring A == B.
|
|
||||||
all_added="$(git rev-list "${LATEST_TAG_SHA}..${source_sha}" | sort)"
|
|
||||||
first_parent_added="$(
|
|
||||||
git rev-list --first-parent "${LATEST_TAG_SHA}..${source_sha}" | sort
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ "${all_added}" != "${first_parent_added}" ]]; then
|
|
||||||
echo "::error::Source branch '${SOURCE_BRANCH}' contains commits not on its first-parent chain from '${LATEST_TAG}'."
|
|
||||||
echo "::error::This usually means the branch was cut from master (not from the tag) or contains a merge from master."
|
|
||||||
echo "Commits reachable but not on first-parent chain:"
|
|
||||||
comm -23 <(printf '%s\n' "${all_added}") <(printf '%s\n' "${first_parent_added}") \
|
|
||||||
| while read -r sha; do
|
|
||||||
echo " $(git log -1 --format='%h %s' "${sha}")"
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
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 open, named correctly, has latest commit, and checks pass
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
||||||
SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }}
|
|
||||||
SOURCE_COMMIT: ${{ inputs.commit }}
|
|
||||||
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
expected_title="ComfyUI backport release ${NEW_VERSION}"
|
|
||||||
|
|
||||||
# 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,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'. The PR must exist and be open."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Pick the PR matching the expected title
|
|
||||||
pr_number="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" '
|
|
||||||
map(select(.title == $t)) | .[0].number // empty
|
|
||||||
')"
|
|
||||||
pr_head_sha="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" '
|
|
||||||
map(select(.title == $t)) | .[0].headRefOid // empty
|
|
||||||
')"
|
|
||||||
|
|
||||||
if [[ -z "${pr_number}" ]]; then
|
|
||||||
echo "::error::No open PR from '${SOURCE_BRANCH}' into 'master' is titled '${expected_title}'."
|
|
||||||
echo "Found PRs:"
|
|
||||||
echo "${pr_json}" | jq -r '.[] | " #\(.number): \(.title)"'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
checks_json="$(
|
|
||||||
gh api \
|
|
||||||
--paginate \
|
|
||||||
"repos/${REPO}/commits/${pr_head_sha}/check-runs" \
|
|
||||||
--jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}'
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ -z "${checks_json}" ]]; then
|
|
||||||
echo "::error::No check runs found on PR head commit ${pr_head_sha}."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Check runs on ${pr_head_sha}:"
|
|
||||||
echo "${checks_json}" | jq -s '.'
|
|
||||||
|
|
||||||
failing="$(echo "${checks_json}" | jq -s '
|
|
||||||
map(select(
|
|
||||||
.status != "completed"
|
|
||||||
or (.conclusion as $c
|
|
||||||
| ["success","neutral","skipped"]
|
|
||||||
| index($c) | not)
|
|
||||||
))
|
|
||||||
')"
|
|
||||||
|
|
||||||
failing_count="$(echo "${failing}" | jq 'length')"
|
|
||||||
if [[ "${failing_count}" -gt 0 ]]; then
|
|
||||||
echo "::error::One or more checks have not passed on PR head commit ${pr_head_sha}:"
|
|
||||||
echo "${failing}" | jq -r '.[] | " - \(.name): status=\(.status) conclusion=\(.conclusion)"'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "All checks have passed on ${pr_head_sha}."
|
|
||||||
|
|
||||||
- name: Prepare release branch
|
|
||||||
id: prepare
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
||||||
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
|
|
||||||
LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }}
|
|
||||||
PATCH: ${{ steps.latest.outputs.patch }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Try to fetch the release branch. If patch == 0, it shouldn't exist yet
|
|
||||||
# and we'll create it from the latest stable tag. If patch > 0, it must
|
|
||||||
# already exist and its tip must equal the latest stable tag commit (i.e.
|
|
||||||
# the previous patch release).
|
|
||||||
if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
|
|
||||||
echo "Release branch '${RELEASE_BRANCH}' already exists on origin."
|
|
||||||
git fetch origin "refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}"
|
|
||||||
git checkout -B "${RELEASE_BRANCH}" "refs/remotes/origin/${RELEASE_BRANCH}"
|
|
||||||
|
|
||||||
current_tip="$(git rev-parse HEAD)"
|
|
||||||
if [[ "${current_tip}" != "${LATEST_TAG_SHA}" ]]; then
|
|
||||||
echo "::error::Release branch '${RELEASE_BRANCH}' tip (${current_tip}) is not at the latest stable release '${LATEST_TAG}' (${LATEST_TAG_SHA})."
|
|
||||||
echo "::error::Refusing to release on top of a divergent branch."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "branch_existed=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
if [[ "${PATCH}" != "0" ]]; then
|
|
||||||
echo "::error::Release branch '${RELEASE_BRANCH}' does not exist on origin, but the latest stable release '${LATEST_TAG}' has patch=${PATCH} (>0). This is inconsistent."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Release branch '${RELEASE_BRANCH}' does not exist. Creating from ${LATEST_TAG}."
|
|
||||||
git checkout -B "${RELEASE_BRANCH}" "refs/tags/${LATEST_TAG}"
|
|
||||||
echo "branch_existed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Fast-forward merge source branch into release branch
|
|
||||||
env:
|
|
||||||
SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }}
|
|
||||||
SOURCE_COMMIT: ${{ inputs.commit }}
|
|
||||||
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# --ff-only guarantees no merge commit is created. If a fast-forward is
|
|
||||||
# not possible (i.e. the release branch has commits the source branch
|
|
||||||
# doesn't), the merge will fail and we abort. Because we already validated
|
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# 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 ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}')."
|
|
||||||
|
|
||||||
- name: Bump version files
|
|
||||||
env:
|
|
||||||
NEW_VERSION_NO_V: ${{ steps.latest.outputs.new_version_no_v }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ ! -f comfyui_version.py ]]; then
|
|
||||||
echo "::error::comfyui_version.py not found in repo root."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! -f pyproject.toml ]]; then
|
|
||||||
echo "::error::pyproject.toml not found in repo root."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Replace the version string in comfyui_version.py.
|
|
||||||
# Expected format: __version__ = "X.Y.Z"
|
|
||||||
python3 - "$NEW_VERSION_NO_V" <<'PY'
|
|
||||||
import re, sys, pathlib
|
|
||||||
new = sys.argv[1]
|
|
||||||
|
|
||||||
p = pathlib.Path("comfyui_version.py")
|
|
||||||
src = p.read_text()
|
|
||||||
new_src, n = re.subn(
|
|
||||||
r'(__version__\s*=\s*[\'"])[^\'"]+([\'"])',
|
|
||||||
lambda m: f'{m.group(1)}{new}{m.group(2)}',
|
|
||||||
src,
|
|
||||||
count=1,
|
|
||||||
)
|
|
||||||
if n != 1:
|
|
||||||
sys.exit("Could not find __version__ assignment in comfyui_version.py")
|
|
||||||
p.write_text(new_src)
|
|
||||||
|
|
||||||
p = pathlib.Path("pyproject.toml")
|
|
||||||
src = p.read_text()
|
|
||||||
# Replace the first `version = "..."` inside [project] or [tool.poetry].
|
|
||||||
new_src, n = re.subn(
|
|
||||||
r'(?m)^(version\s*=\s*")[^"]+(")',
|
|
||||||
lambda m: f'{m.group(1)}{new}{m.group(2)}',
|
|
||||||
src,
|
|
||||||
count=1,
|
|
||||||
)
|
|
||||||
if n != 1:
|
|
||||||
sys.exit("Could not find version assignment in pyproject.toml")
|
|
||||||
p.write_text(new_src)
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "Updated version to ${NEW_VERSION_NO_V} in comfyui_version.py and pyproject.toml."
|
|
||||||
git --no-pager diff -- comfyui_version.py pyproject.toml
|
|
||||||
|
|
||||||
- name: Commit version bump and tag release
|
|
||||||
env:
|
|
||||||
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
git add comfyui_version.py pyproject.toml
|
|
||||||
git commit -m "ComfyUI ${NEW_VERSION}"
|
|
||||||
|
|
||||||
if git rev-parse -q --verify "refs/tags/${NEW_VERSION}" >/dev/null; then
|
|
||||||
echo "::error::Tag ${NEW_VERSION} already exists locally."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
git tag "${NEW_VERSION}"
|
|
||||||
|
|
||||||
- name: Verify tag does not already exist on origin
|
|
||||||
env:
|
|
||||||
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if git ls-remote --exit-code --tags origin "refs/tags/${NEW_VERSION}" >/dev/null 2>&1; then
|
|
||||||
echo "::error::Tag ${NEW_VERSION} already exists on origin. Aborting."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push release branch and tag
|
|
||||||
env:
|
|
||||||
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
||||||
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Push the branch first, then the tag. Atomic-ish: if the branch push
|
|
||||||
# fails we never publish the tag.
|
|
||||||
git push origin "refs/heads/${RELEASE_BRANCH}:refs/heads/${RELEASE_BRANCH}"
|
|
||||||
git push origin "refs/tags/${NEW_VERSION}"
|
|
||||||
|
|
||||||
echo "Released ${NEW_VERSION} on ${RELEASE_BRANCH}."
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
|
|
||||||
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
|
|
||||||
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
|
|
||||||
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 commit | \`${SOURCE_COMMIT}\` |"
|
|
||||||
echo "| Source branch | \`${source_branch_display}\` |"
|
|
||||||
echo "| Previous stable | \`${LATEST_TAG}\` |"
|
|
||||||
echo "| New version | \`${NEW_VERSION}\` |"
|
|
||||||
echo "| Release branch | \`${RELEASE_BRANCH}\` |"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
@ -20,7 +20,7 @@
|
|||||||
[website-url]: https://www.comfy.org/
|
[website-url]: https://www.comfy.org/
|
||||||
<!-- Workaround to display total user from https://github.com/badges/shields/issues/4500#issuecomment-2060079995 -->
|
<!-- Workaround to display total user from https://github.com/badges/shields/issues/4500#issuecomment-2060079995 -->
|
||||||
[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-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://discord.com/invite/comfyorg
|
[discord-url]: https://www.comfy.org/discord
|
||||||
[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI
|
[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI
|
||||||
[twitter-url]: https://x.com/ComfyUI
|
[twitter-url]: https://x.com/ComfyUI
|
||||||
|
|
||||||
|
|||||||
@ -62,8 +62,6 @@ def get_comfy_package_versions():
|
|||||||
def check_comfy_packages_versions():
|
def check_comfy_packages_versions():
|
||||||
"""Warn for every comfy* package whose installed version is below requirements.txt."""
|
"""Warn for every comfy* package whose installed version is below requirements.txt."""
|
||||||
from packaging.version import InvalidVersion, parse as parse_pep440
|
from packaging.version import InvalidVersion, parse as parse_pep440
|
||||||
outdated_packages = []
|
|
||||||
|
|
||||||
for pkg in get_comfy_package_versions():
|
for pkg in get_comfy_package_versions():
|
||||||
installed_str = pkg["installed"]
|
installed_str = pkg["installed"]
|
||||||
required_str = pkg["required"]
|
required_str = pkg["required"]
|
||||||
@ -75,26 +73,19 @@ def check_comfy_packages_versions():
|
|||||||
logging.error(f"Failed to check {pkg['name']} version: {e}")
|
logging.error(f"Failed to check {pkg['name']} version: {e}")
|
||||||
continue
|
continue
|
||||||
if outdated:
|
if outdated:
|
||||||
outdated_packages.append((pkg["name"], installed_str, required_str))
|
app.logger.log_startup_warning(
|
||||||
else:
|
f"""
|
||||||
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
|
WARNING WARNING WARNING WARNING WARNING
|
||||||
|
|
||||||
{package_warnings}
|
Installed {pkg["name"]} version {installed_str} is lower than the recommended version {required_str}.
|
||||||
|
|
||||||
{get_missing_requirements_message()}
|
{get_missing_requirements_message()}
|
||||||
________________________________________________________________________
|
________________________________________________________________________
|
||||||
""".strip()
|
""".strip()
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logging.info("{} version: {}".format(pkg["name"], installed_str))
|
||||||
|
|
||||||
|
|
||||||
REQUEST_TIMEOUT = 10 # seconds
|
REQUEST_TIMEOUT = 10 # seconds
|
||||||
|
|||||||
@ -8,82 +8,6 @@ from comfy_api.latest import _io
|
|||||||
MISSING = object()
|
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):
|
class SwitchNode(io.ComfyNode):
|
||||||
@classmethod
|
@classmethod
|
||||||
def define_schema(cls):
|
def define_schema(cls):
|
||||||
@ -337,9 +261,6 @@ class LogicExtension(ComfyExtension):
|
|||||||
return [
|
return [
|
||||||
SwitchNode,
|
SwitchNode,
|
||||||
CustomComboNode,
|
CustomComboNode,
|
||||||
NotNode,
|
|
||||||
AndNode,
|
|
||||||
OrNode,
|
|
||||||
# SoftSwitchNode,
|
# SoftSwitchNode,
|
||||||
# ConvertStringToComboNode,
|
# ConvertStringToComboNode,
|
||||||
# DCTestNode,
|
# DCTestNode,
|
||||||
|
|||||||
33
main.py
33
main.py
@ -27,6 +27,7 @@ from utils.mime_types import init_mime_types
|
|||||||
import faulthandler
|
import faulthandler
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from comfy_execution.progress import get_progress_state
|
from comfy_execution.progress import get_progress_state
|
||||||
from comfy_execution.utils import get_executing_context
|
from comfy_execution.utils import get_executing_context
|
||||||
from comfy_api import feature_flags
|
from comfy_api import feature_flags
|
||||||
@ -135,7 +136,20 @@ def apply_custom_paths():
|
|||||||
folder_paths.set_user_directory(user_dir)
|
folder_paths.set_user_directory(user_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# Buffer for prestartup failures. Recorded into `nodes.NODE_STARTUP_ERRORS`
|
||||||
|
# only AFTER the normal `import nodes` line below, so a failing prestartup
|
||||||
|
# script never triggers an early `import nodes` (and therefore `import torch`)
|
||||||
|
# on the error path.
|
||||||
|
_PRESTARTUP_FAILURES: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
def execute_prestartup_script():
|
def execute_prestartup_script():
|
||||||
|
"""Run every custom_nodes/*/prestartup_script.py once, before importing nodes.
|
||||||
|
|
||||||
|
Failures are buffered into the module-level ``_PRESTARTUP_FAILURES`` list and
|
||||||
|
must be flushed via ``record_node_startup_error`` after ``import nodes`` has
|
||||||
|
happened at its normal bootstrap point.
|
||||||
|
"""
|
||||||
if args.disable_all_custom_nodes and len(args.whitelist_custom_nodes) == 0:
|
if args.disable_all_custom_nodes and len(args.whitelist_custom_nodes) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -148,6 +162,15 @@ def execute_prestartup_script():
|
|||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to execute startup-script: {script_path} / {e}")
|
logging.error(f"Failed to execute startup-script: {script_path} / {e}")
|
||||||
|
# Buffer the failure - do NOT `import nodes` here, that would drag
|
||||||
|
# torch in before the intended bootstrap point.
|
||||||
|
_PRESTARTUP_FAILURES.append({
|
||||||
|
"module_path": os.path.dirname(script_path),
|
||||||
|
"source": "custom_nodes",
|
||||||
|
"phase": "prestartup",
|
||||||
|
"error": e,
|
||||||
|
"tb": traceback.format_exc(),
|
||||||
|
})
|
||||||
return False
|
return False
|
||||||
|
|
||||||
node_paths = folder_paths.get_folder_paths("custom_nodes")
|
node_paths = folder_paths.get_folder_paths("custom_nodes")
|
||||||
@ -207,6 +230,16 @@ import execution
|
|||||||
import server
|
import server
|
||||||
from protocol import BinaryEventTypes
|
from protocol import BinaryEventTypes
|
||||||
import nodes
|
import nodes
|
||||||
|
|
||||||
|
# Flush any prestartup failures that were buffered before `nodes` was
|
||||||
|
# importable. Doing this here (rather than from the prestartup error
|
||||||
|
# handler) keeps the bootstrap order deterministic: `nodes` (and torch)
|
||||||
|
# import at this single line whether prestartup succeeded or failed.
|
||||||
|
if _PRESTARTUP_FAILURES:
|
||||||
|
for _failure in _PRESTARTUP_FAILURES:
|
||||||
|
nodes.record_node_startup_error(**_failure)
|
||||||
|
_PRESTARTUP_FAILURES.clear()
|
||||||
|
|
||||||
import comfy.model_management
|
import comfy.model_management
|
||||||
import comfyui_version
|
import comfyui_version
|
||||||
import app.logger
|
import app.logger
|
||||||
|
|||||||
83
nodes.py
83
nodes.py
@ -2158,6 +2158,71 @@ EXTENSION_WEB_DIRS = {}
|
|||||||
# Dictionary of successfully loaded module names and associated directories.
|
# Dictionary of successfully loaded module names and associated directories.
|
||||||
LOADED_MODULE_DIRS = {}
|
LOADED_MODULE_DIRS = {}
|
||||||
|
|
||||||
|
# Dictionary of custom node startup errors, keyed by "<source>:<module_name>"
|
||||||
|
# so that name collisions across custom_nodes / comfy_extras / comfy_api_nodes
|
||||||
|
# do not overwrite each other. Each value contains: source, module_name,
|
||||||
|
# module_path, error, traceback, phase.
|
||||||
|
#
|
||||||
|
# `source` is the same string as the internal `module_parent` used at load
|
||||||
|
# time (e.g. "custom_nodes", "comfy_extras", "comfy_api_nodes"). It is
|
||||||
|
# intentionally a free-form string rather than a fixed enum so the contract
|
||||||
|
# survives node-source layouts evolving (e.g. comfy_api_nodes eventually
|
||||||
|
# moving out of core). Consumers should treat any new value as a new bucket
|
||||||
|
# rather than rejecting it.
|
||||||
|
NODE_STARTUP_ERRORS: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pyproject_metadata(module_path: str) -> dict | None:
|
||||||
|
"""Best-effort extraction of node-pack identity from pyproject.toml.
|
||||||
|
|
||||||
|
Returns a dict with the Comfy Registry-style identity (pack_id,
|
||||||
|
display_name, publisher_id, version, repository) when the module
|
||||||
|
directory contains a pyproject.toml. Returns None when no toml is
|
||||||
|
present or parsing fails for any reason — startup-error tracking
|
||||||
|
must never itself raise.
|
||||||
|
"""
|
||||||
|
if not module_path or not os.path.isdir(module_path):
|
||||||
|
return None
|
||||||
|
toml_path = os.path.join(module_path, "pyproject.toml")
|
||||||
|
if not os.path.isfile(toml_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from comfy_config import config_parser
|
||||||
|
|
||||||
|
cfg = config_parser.extract_node_configuration(module_path)
|
||||||
|
if cfg is None:
|
||||||
|
return None
|
||||||
|
meta = {
|
||||||
|
"pack_id": cfg.project.name or None,
|
||||||
|
"display_name": cfg.tool_comfy.display_name or None,
|
||||||
|
"publisher_id": cfg.tool_comfy.publisher_id or None,
|
||||||
|
"version": cfg.project.version or None,
|
||||||
|
"repository": cfg.project.urls.repository or None,
|
||||||
|
}
|
||||||
|
# Drop empty fields so the API payload stays compact.
|
||||||
|
return {k: v for k, v in meta.items() if v}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def record_node_startup_error(
|
||||||
|
*, module_path: str, source: str, phase: str, error: BaseException, tb: str
|
||||||
|
) -> None:
|
||||||
|
"""Record a startup error for a node module so it can be exposed via the API."""
|
||||||
|
module_name = get_module_name(module_path)
|
||||||
|
entry = {
|
||||||
|
"source": source,
|
||||||
|
"module_name": module_name,
|
||||||
|
"module_path": module_path,
|
||||||
|
"error": str(error),
|
||||||
|
"traceback": tb,
|
||||||
|
"phase": phase,
|
||||||
|
}
|
||||||
|
pyproject = _read_pyproject_metadata(module_path)
|
||||||
|
if pyproject:
|
||||||
|
entry["pyproject"] = pyproject
|
||||||
|
NODE_STARTUP_ERRORS[f"{source}:{module_name}"] = entry
|
||||||
|
|
||||||
|
|
||||||
def get_module_name(module_path: str) -> str:
|
def get_module_name(module_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
@ -2267,14 +2332,30 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom
|
|||||||
NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name
|
NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
tb = traceback.format_exc()
|
||||||
logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}")
|
logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}")
|
||||||
|
record_node_startup_error(
|
||||||
|
module_path=module_path,
|
||||||
|
source=module_parent,
|
||||||
|
phase="entrypoint",
|
||||||
|
error=e,
|
||||||
|
tb=tb,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or comfy_entrypoint (need one).")
|
logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or comfy_entrypoint (need one).")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(traceback.format_exc())
|
tb = traceback.format_exc()
|
||||||
|
logging.warning(tb)
|
||||||
logging.warning(f"Cannot import {module_path} module for custom nodes: {e}")
|
logging.warning(f"Cannot import {module_path} module for custom nodes: {e}")
|
||||||
|
record_node_startup_error(
|
||||||
|
module_path=module_path,
|
||||||
|
source=module_parent,
|
||||||
|
phase="import",
|
||||||
|
error=e,
|
||||||
|
tb=tb,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def init_external_custom_nodes():
|
async def init_external_custom_nodes():
|
||||||
|
|||||||
23
server.py
23
server.py
@ -765,6 +765,29 @@ class PromptServer():
|
|||||||
out[node_class] = node_info(node_class)
|
out[node_class] = node_info(node_class)
|
||||||
return web.json_response(out)
|
return web.json_response(out)
|
||||||
|
|
||||||
|
@routes.get("/node_startup_errors")
|
||||||
|
async def get_node_startup_errors(request):
|
||||||
|
"""Return startup errors recorded during node loading, grouped by source.
|
||||||
|
|
||||||
|
Group errors by source so the frontend/Manager can render them in
|
||||||
|
distinct sections. ``source`` is the same string as the
|
||||||
|
``module_parent`` used at load time (e.g. ``"custom_nodes"``,
|
||||||
|
``"comfy_extras"``, ``"comfy_api_nodes"``) and is left as a
|
||||||
|
free-form string so the contract survives node-source layouts
|
||||||
|
evolving. The response only contains source buckets that actually
|
||||||
|
had a failure; consumers should not assume any particular set of
|
||||||
|
keys is always present.
|
||||||
|
|
||||||
|
``module_path`` is stripped because the absolute on-disk path is
|
||||||
|
internal detail that the frontend has no use for.
|
||||||
|
"""
|
||||||
|
grouped: dict[str, dict[str, dict]] = {}
|
||||||
|
for entry in nodes.NODE_STARTUP_ERRORS.values():
|
||||||
|
source = entry.get("source", "custom_nodes")
|
||||||
|
public_entry = {k: v for k, v in entry.items() if k != "module_path"}
|
||||||
|
grouped.setdefault(source, {})[entry["module_name"]] = public_entry
|
||||||
|
return web.json_response(grouped)
|
||||||
|
|
||||||
@routes.get("/api/jobs")
|
@routes.get("/api/jobs")
|
||||||
async def get_jobs(request):
|
async def get_jobs(request):
|
||||||
"""List all jobs with filtering, sorting, and pagination.
|
"""List all jobs with filtering, sorting, and pagination.
|
||||||
|
|||||||
146
tests-unit/node_startup_errors_test.py
Normal file
146
tests-unit/node_startup_errors_test.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""Tests for the custom node startup error tracking introduced for
|
||||||
|
Comfy-Org/ComfyUI-Launcher#303.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- load_custom_node populates NODE_STARTUP_ERRORS with the correct source
|
||||||
|
for each module_parent (custom_nodes / comfy_extras / comfy_api_nodes).
|
||||||
|
- Composite keying prevents collisions between modules with the same name
|
||||||
|
in different sources.
|
||||||
|
- record_node_startup_error stores the expected fields.
|
||||||
|
- pyproject.toml metadata is attached when present and omitted when absent.
|
||||||
|
"""
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import nodes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_startup_errors():
|
||||||
|
nodes.NODE_STARTUP_ERRORS.clear()
|
||||||
|
yield
|
||||||
|
nodes.NODE_STARTUP_ERRORS.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_broken_module(tmp_path, name: str) -> str:
|
||||||
|
path = tmp_path / f"{name}.py"
|
||||||
|
path.write_text(textwrap.dedent("""\
|
||||||
|
# Deliberately broken module to exercise startup-error tracking.
|
||||||
|
raise RuntimeError("boom from " + __name__)
|
||||||
|
"""))
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_node_startup_error_fields(tmp_path):
|
||||||
|
err = ValueError("kaboom")
|
||||||
|
nodes.record_node_startup_error(
|
||||||
|
module_path=str(tmp_path / "my_pack"),
|
||||||
|
source="custom_nodes",
|
||||||
|
phase="import",
|
||||||
|
error=err,
|
||||||
|
tb="traceback-text",
|
||||||
|
)
|
||||||
|
assert "custom_nodes:my_pack" in nodes.NODE_STARTUP_ERRORS
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS["custom_nodes:my_pack"]
|
||||||
|
assert entry["source"] == "custom_nodes"
|
||||||
|
assert entry["module_name"] == "my_pack"
|
||||||
|
assert entry["phase"] == "import"
|
||||||
|
assert entry["error"] == "kaboom"
|
||||||
|
assert entry["traceback"] == "traceback-text"
|
||||||
|
assert entry["module_path"].endswith("my_pack")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"module_parent",
|
||||||
|
["custom_nodes", "comfy_extras", "comfy_api_nodes"],
|
||||||
|
)
|
||||||
|
async def test_load_custom_node_records_source(tmp_path, module_parent):
|
||||||
|
# `source` in the entry should be the same string as `module_parent`.
|
||||||
|
module_path = _write_broken_module(tmp_path, "broken_pack")
|
||||||
|
|
||||||
|
success = await nodes.load_custom_node(module_path, module_parent=module_parent)
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
key = f"{module_parent}:broken_pack"
|
||||||
|
assert key in nodes.NODE_STARTUP_ERRORS, nodes.NODE_STARTUP_ERRORS
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS[key]
|
||||||
|
assert entry["source"] == module_parent
|
||||||
|
assert entry["module_name"] == "broken_pack"
|
||||||
|
assert entry["phase"] == "import"
|
||||||
|
assert "boom from" in entry["error"]
|
||||||
|
assert "RuntimeError" in entry["traceback"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_custom_node_collision_across_sources(tmp_path):
|
||||||
|
# Same module name registered as both a custom node and a comfy_extra;
|
||||||
|
# composite keying should keep both entries.
|
||||||
|
cn_dir = tmp_path / "cn"
|
||||||
|
extras_dir = tmp_path / "extras"
|
||||||
|
cn_dir.mkdir()
|
||||||
|
extras_dir.mkdir()
|
||||||
|
cn_path = _write_broken_module(cn_dir, "nodes_audio")
|
||||||
|
extras_path = _write_broken_module(extras_dir, "nodes_audio")
|
||||||
|
|
||||||
|
assert await nodes.load_custom_node(cn_path, module_parent="custom_nodes") is False
|
||||||
|
assert await nodes.load_custom_node(extras_path, module_parent="comfy_extras") is False
|
||||||
|
|
||||||
|
assert "custom_nodes:nodes_audio" in nodes.NODE_STARTUP_ERRORS
|
||||||
|
assert "comfy_extras:nodes_audio" in nodes.NODE_STARTUP_ERRORS
|
||||||
|
assert (
|
||||||
|
nodes.NODE_STARTUP_ERRORS["custom_nodes:nodes_audio"]["module_path"]
|
||||||
|
!= nodes.NODE_STARTUP_ERRORS["comfy_extras:nodes_audio"]["module_path"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_custom_node_attaches_pyproject_metadata(tmp_path):
|
||||||
|
pack_dir = tmp_path / "MyCoolPack"
|
||||||
|
pack_dir.mkdir()
|
||||||
|
(pack_dir / "__init__.py").write_text("raise RuntimeError('boom')\n")
|
||||||
|
(pack_dir / "pyproject.toml").write_text(textwrap.dedent("""\
|
||||||
|
[project]
|
||||||
|
name = "comfyui-mycoolpack"
|
||||||
|
version = "1.2.3"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/example/comfyui-mycoolpack"
|
||||||
|
|
||||||
|
[tool.comfy]
|
||||||
|
PublisherId = "example"
|
||||||
|
DisplayName = "My Cool Pack"
|
||||||
|
"""))
|
||||||
|
|
||||||
|
success = await nodes.load_custom_node(str(pack_dir), module_parent="custom_nodes")
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS["custom_nodes:MyCoolPack"]
|
||||||
|
assert "pyproject" in entry, entry
|
||||||
|
py = entry["pyproject"]
|
||||||
|
assert py["pack_id"] == "comfyui-mycoolpack"
|
||||||
|
assert py["display_name"] == "My Cool Pack"
|
||||||
|
assert py["publisher_id"] == "example"
|
||||||
|
assert py["version"] == "1.2.3"
|
||||||
|
assert py["repository"] == "https://github.com/example/comfyui-mycoolpack"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_custom_node_no_pyproject_skips_metadata(tmp_path):
|
||||||
|
# Single-file extras-style module: no pyproject.toml exists alongside it,
|
||||||
|
# so the entry must not contain a 'pyproject' key.
|
||||||
|
module_path = _write_broken_module(tmp_path, "lonely")
|
||||||
|
assert await nodes.load_custom_node(module_path, module_parent="comfy_extras") is False
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS["comfy_extras:lonely"]
|
||||||
|
assert "pyproject" not in entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_custom_node_arbitrary_module_parent_passes_through(tmp_path):
|
||||||
|
# `source` is a free-form string — an unknown module_parent (e.g. a future
|
||||||
|
# node-source bucket) should be recorded as-is, not coerced or rejected.
|
||||||
|
module_path = _write_broken_module(tmp_path, "future_pack")
|
||||||
|
assert await nodes.load_custom_node(module_path, module_parent="future_source") is False
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS["future_source:future_pack"]
|
||||||
|
assert entry["source"] == "future_source"
|
||||||
Reference in New Issue
Block a user