diff --git a/.github/workflows/ComfyUIdownloads.yml b/.github/workflows/ComfyUIdownloads.yml
index 7d2699f..7d50798 100644
--- a/.github/workflows/ComfyUIdownloads.yml
+++ b/.github/workflows/ComfyUIdownloads.yml
@@ -6,7 +6,11 @@
# COMFY_DOWNLOADS_GIST_ID
#
# Required repository secret:
-# SECRET_TOKEN GitHub token with Gist and repository Actions variables write access
+# SECRET_TOKEN Fine-grained personal access token with access to this repository:
+# Repository permissions:
+# Variables: Read and write
+# Account permissions:
+# Gists: Read and write
name: ComfyUI Top Downloads Badge
@@ -18,11 +22,14 @@ on:
permissions:
contents: write
+concurrency:
+ group: badge-updates-${{ github.repository }}
+ cancel-in-progress: false
+
env:
COMFY_DOWNLOADS_GIST_ID: ${{ vars.COMFY_DOWNLOADS_GIST_ID }}
BADGE_DOC: badges/DOWNLOADS.md
DATA_FILE: comfyui_downloads.json
- GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
jobs:
build:
@@ -33,9 +40,11 @@ jobs:
- name: Read project metadata and validate authentication
id: project
+ env:
+ GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
- echo "::error title=Missing SECRET_TOKEN::Add a GitHub token with Gist and repository Actions variables write access."
+ echo "::error title=Missing SECRET_TOKEN::Add a fine-grained PAT with Variables: Read and write and Gists: Read and write."
exit 1
fi
@@ -149,9 +158,14 @@ jobs:
- name: Create or reuse downloads Gist
id: gist
+ env:
+ GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
run: |
gist_id="$COMFY_DOWNLOADS_GIST_ID"
+ expected_description="${GITHUB_REPOSITORY} ComfyUI download statistics"
+ authenticated_user=$(gh api user --jq '.login')
save_gist_id=false
+ valid_gist=false
if [ -z "$gist_id" ]; then
gist_id=$(python - <<'PY'
@@ -182,6 +196,36 @@ jobs:
fi
if [ -n "$gist_id" ] && gh gist view "$gist_id" >/dev/null 2>&1; then
+ gist_filename=$(gh api "gists/$gist_id" --jq '
+ .files
+ | keys
+ | if index("comfyui_downloads.json") then
+ "comfyui_downloads.json"
+ else
+ (map(select(endswith(".json"))) | first // "comfyui_downloads.json")
+ end
+ ')
+ gist_owner=$(gh api "gists/$gist_id" --jq '.owner.login')
+ gist_description=$(gh api "gists/$gist_id" --jq '.description // ""')
+ gist_node_slug=$(
+ gh api "gists/$gist_id" |
+ jq -r --arg filename "$gist_filename" '
+ .files[$filename].content // ""
+ | fromjson?
+ | ((.id // empty) | ascii_downcase)
+ '
+ )
+
+ if [ "$gist_owner" = "$authenticated_user" ] &&
+ { [ "$gist_description" = "$expected_description" ] ||
+ [ "$gist_node_slug" = "$COMFY_NODE_SLUG" ]; }; then
+ valid_gist=true
+ else
+ echo "The detected Gist belongs to another repository, token owner, or ComfyUI node."
+ fi
+ fi
+
+ if [ "$valid_gist" = true ]; then
echo "Using existing Gist: $gist_id"
else
if [ -n "$gist_id" ]; then
@@ -191,7 +235,7 @@ jobs:
fi
gist_url=$(gh gist create "$DATA_FILE" \
- --desc "ComfyUI downloads for $COMFY_NODE_DISPLAY_NAME")
+ --desc "$expected_description")
gist_id="${gist_url##*/}"
save_gist_id=true
fi
@@ -214,7 +258,7 @@ jobs:
(map(select(endswith(".json"))) | first // "comfyui_downloads.json")
end
')
- gist_owner=$(gh api user --jq '.login')
+ gist_owner=$(gh api "gists/$gist_id" --jq '.owner.login')
echo "id=$gist_id" >> "$GITHUB_OUTPUT"
echo "filename=$gist_filename" >> "$GITHUB_OUTPUT"
echo "owner=$gist_owner" >> "$GITHUB_OUTPUT"
@@ -291,6 +335,7 @@ jobs:
- name: Update Gist
env:
+ GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
GIST_ID: ${{ steps.gist.outputs.id }}
GIST_FILENAME: ${{ steps.gist.outputs.filename }}
run: |
@@ -301,7 +346,7 @@ jobs:
content=$(jq -c . "$DATA_FILE")
jq -n \
- --arg description "ComfyUI downloads for $COMFY_NODE_DISPLAY_NAME" \
+ --arg description "${GITHUB_REPOSITORY} ComfyUI download statistics" \
--arg filename "$GIST_FILENAME" \
--arg content "$content" \
'{
diff --git a/.github/workflows/clone.yml b/.github/workflows/clone.yml
index f02c0eb..421a49d 100644
--- a/.github/workflows/clone.yml
+++ b/.github/workflows/clone.yml
@@ -1,110 +1,286 @@
-name: GitHub Clone Count Update Everyday
+# Reusable workflow
+#
+# No project-specific repository variables need to be configured manually.
+# The workflow creates and maintains this internal repository variable automatically:
+# COMFY_CLONES_GIST_ID
+#
+# Required repository secret:
+# SECRET_TOKEN Fine-grained personal access token with access to this repository:
+# Repository permissions:
+# Administration: Read
+# Variables: Read and write
+# Account permissions:
+# Gists: Read and write
+
+name: Update GitHub Clone Count Badge
on:
schedule:
- - cron: "0 */24 * * *"
+ - cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: write
+concurrency:
+ group: badge-updates-${{ github.repository }}
+ cancel-in-progress: false
+
+env:
+ BADGE_DOC: badges/CLONE.md
+ CLONE_GIST_ID: ${{ vars.COMFY_CLONES_GIST_ID }}
+ DATA_FILE: clone.json
+ PREVIOUS_DATA_FILE: clone_before.json
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v7
-
- - name: gh login
- run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token
+ - name: Check out code
+ uses: actions/checkout@v7
- - name: parse latest clone count
+ - name: Validate authentication
+ env:
+ GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
run: |
- curl --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
- -H "Accept: application/vnd.github.v3+json" \
- https://api.github.com/repos/${{ github.repository }}/traffic/clones \
- > clone.json
+ if [ -z "$GH_TOKEN" ]; then
+ echo "::error title=Missing SECRET_TOKEN::Add a fine-grained PAT with Administration: Read, Variables: Read and write, and Gists: Read and write."
+ exit 1
+ fi
- - name: create gist and download previous count
- id: set_id
+ gh auth status
+
+ - name: Fetch latest clone traffic
+ env:
+ GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
run: |
- BADGE_DOC="badges/CLONE.md"
+ if ! gh api \
+ -H "Accept: application/vnd.github+json" \
+ "repos/${GITHUB_REPOSITORY}/traffic/clones" > "$DATA_FILE"; then
+ echo "::error title=Cannot read clone traffic::SECRET_TOKEN needs Administration: Read access to this repository."
+ exit 1
+ fi
- if gh secret list | grep -q "GIST_ID"
- then
- echo "GIST_ID found"
- echo "GIST=${{ secrets.GIST_ID }}" >> $GITHUB_OUTPUT
- curl https://gist.githubusercontent.com/${{ github.actor }}/${{ secrets.GIST_ID }}/raw/clone.json > clone_before.json
- if cat clone_before.json | grep '404: Not Found'; then
- echo "GIST_ID not valid anymore. Creating another gist..."
- gist_id=$(gh gist create clone.json | awk -F / '{print $NF}')
- echo $gist_id | gh secret set GIST_ID
- echo "GIST=$gist_id" >> $GITHUB_OUTPUT
- cp clone.json clone_before.json
- git rm --ignore-unmatch "$BADGE_DOC" CLONE.md
- fi
+ if ! jq -e '
+ type == "object" and
+ (.clones | type == "array")
+ ' "$DATA_FILE" >/dev/null; then
+ echo "::error title=Invalid traffic response::GitHub did not return clone traffic data."
+ exit 1
+ fi
+
+ - name: Create or reuse clone statistics Gist
+ id: gist
+ env:
+ GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
+ run: |
+ gist_id="$CLONE_GIST_ID"
+ expected_description="${GITHUB_REPOSITORY} clone statistics"
+ authenticated_user=$(gh api user --jq '.login')
+ save_gist_id=false
+ valid_gist=false
+
+ if [ -z "$gist_id" ]; then
+ gist_id=$(python - <<'PY'
+ from pathlib import Path
+ import os
+ import re
+
+ pattern = re.compile(
+ r"query=count.*?url=https://gist\.githubusercontent\.com/[^/]+/([0-9a-f]+)",
+ re.IGNORECASE,
+ )
+
+ for filename in (os.environ["BADGE_DOC"], "README.md"):
+ path = Path(filename)
+ if not path.is_file():
+ continue
+ match = pattern.search(path.read_text(encoding="utf-8"))
+ if match:
+ print(match.group(1))
+ break
+ PY
+ )
+
+ if [ -n "$gist_id" ]; then
+ echo "Found an existing clone statistics Gist in the badge documentation: $gist_id"
+ save_gist_id=true
+ fi
+ fi
+
+ if [ -n "$gist_id" ] && gh gist view "$gist_id" >/dev/null 2>&1; then
+ gist_owner=$(gh api "gists/$gist_id" --jq '.owner.login')
+ gist_description=$(gh api "gists/$gist_id" --jq '.description // ""')
+
+ if [ "$gist_owner" = "$authenticated_user" ] &&
+ [ "$gist_description" = "$expected_description" ]; then
+ valid_gist=true
+ else
+ echo "The detected Gist belongs to another repository or token owner."
+ fi
+ fi
+
+ if [ "$valid_gist" = true ]; then
+ echo "Using existing Gist: $gist_id"
else
- echo "GIST_ID not found. Creating a gist..."
- gist_id=$(gh gist create clone.json | awk -F / '{print $NF}')
- echo $gist_id | gh secret set GIST_ID
- echo "GIST=$gist_id" >> $GITHUB_OUTPUT
- cp clone.json clone_before.json
+ if [ -n "$gist_id" ]; then
+ echo "Configured Gist does not exist or is inaccessible; creating a new one."
+ else
+ echo "No clone statistics Gist is configured; creating one."
+ fi
+
+ gist_url=$(gh gist create "$DATA_FILE" --desc "$expected_description")
+ gist_id="${gist_url##*/}"
+ save_gist_id=true
fi
- - name: update clone.json
+ if [ "$save_gist_id" = true ]; then
+ if ! gh variable set COMFY_CLONES_GIST_ID --body "$gist_id"; then
+ echo "::error title=Cannot save Gist ID::Grant SECRET_TOKEN repository Actions variables write access."
+ exit 1
+ fi
+
+ echo "Saved COMFY_CLONES_GIST_ID=$gist_id"
+ fi
+
+ if ! gh api "gists/$gist_id" \
+ --jq '.files["clone.json"].content // empty' > "$PREVIOUS_DATA_FILE"; then
+ cp "$DATA_FILE" "$PREVIOUS_DATA_FILE"
+ fi
+
+ if ! jq -e '
+ type == "object" and
+ (.clones | type == "array")
+ ' "$PREVIOUS_DATA_FILE" >/dev/null 2>&1; then
+ echo "The Gist has no valid clone history yet; starting with the current data."
+ cp "$DATA_FILE" "$PREVIOUS_DATA_FILE"
+ fi
+
+ gist_owner=$(gh api "gists/$gist_id" --jq '.owner.login')
+ echo "id=$gist_id" >> "$GITHUB_OUTPUT"
+ echo "owner=$gist_owner" >> "$GITHUB_OUTPUT"
+
+ - name: Merge clone history
run: |
- curl https://raw.githubusercontent.com/MShawon/github-clone-count-badge/master/main.py > main.py
- python3 main.py
+ python - <<'PY'
+ import json
+ import os
+ from pathlib import Path
- - name: Update gist with latest count
+ current_path = Path(os.environ["DATA_FILE"])
+ previous_path = Path(os.environ["PREVIOUS_DATA_FILE"])
+
+ current = json.loads(current_path.read_text(encoding="utf-8"))
+ previous = json.loads(previous_path.read_text(encoding="utf-8"))
+
+ by_timestamp = {}
+ for entry in previous.get("clones", []) + current.get("clones", []):
+ timestamp = str(entry.get("timestamp", "")).strip()
+ if timestamp:
+ by_timestamp[timestamp] = {
+ "timestamp": timestamp,
+ "count": int(entry.get("count", 0)),
+ "uniques": int(entry.get("uniques", 0)),
+ }
+
+ clones = [by_timestamp[key] for key in sorted(by_timestamp)]
+
+ if len(clones) > 100:
+ older = clones[:-35]
+ recent = clones[-35:]
+ monthly = {}
+
+ for entry in older:
+ month = entry["timestamp"][:7]
+ aggregate = monthly.setdefault(
+ month,
+ {"timestamp": month, "count": 0, "uniques": 0},
+ )
+ aggregate["count"] += entry["count"]
+ aggregate["uniques"] += entry["uniques"]
+
+ clones = [monthly[key] for key in sorted(monthly)] + recent
+
+ merged = dict(current)
+ merged["clones"] = clones
+ merged["count"] = sum(entry["count"] for entry in clones)
+ merged["uniques"] = sum(entry["uniques"] for entry in clones)
+
+ current_path.write_text(
+ json.dumps(merged, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ PY
+
+ jq . "$DATA_FILE"
+
+ - name: Update Gist and badge document
+ id: badge
+ env:
+ GH_TOKEN: ${{ secrets.SECRET_TOKEN }}
+ GIST_ID: ${{ steps.gist.outputs.id }}
+ GIST_OWNER: ${{ steps.gist.outputs.owner }}
run: |
- BADGE_DOC="badges/CLONE.md"
+ content=$(jq -c . "$DATA_FILE")
+ jq -n \
+ --arg description "${GITHUB_REPOSITORY} clone statistics" \
+ --arg content "$content" \
+ '{
+ description: $description,
+ files: {
+ "clone.json": {
+ content: $content
+ }
+ }
+ }' > gist_patch.json
- content=$(sed -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\"/\\"/g' -e 's/\r//g' "clone.json" | sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g')
- echo '{"description": "${{ github.repository }} clone statistics", "files": {"clone.json": {"content": "'"$content"'"}}}' > post_clone.json
- curl -s -X PATCH \
- --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
- -H "Content-Type: application/json" \
- -d @post_clone.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }} > /dev/null 2>&1
+ gh api --method PATCH "gists/${GIST_ID}" --input gist_patch.json >/dev/null
- if [ ! -f "$BADGE_DOC" ]; then
- mkdir -p badges
- shields="https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url="
- shields_for_the_badge="https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Clone&query=count&url="
- url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/clone.json"
- repo="https://github.com/MShawon/github-clone-count-badge"
- echo ''> "$BADGE_DOC"
- echo '
- **Markdown**
+ mkdir -p "$(dirname "$BADGE_DOC")"
- ```markdown' >> "$BADGE_DOC"
- echo "[]($repo)" >> "$BADGE_DOC"
- echo '
- ```' >> "$BADGE_DOC"
- echo "[]($repo)" >> "$BADGE_DOC"
- echo '
+ gist_url="https://gist.githubusercontent.com/${GIST_OWNER}/${GIST_ID}/raw/clone.json"
+ repo_url="https://github.com/${GITHUB_REPOSITORY}"
+ shields="https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url="
+ shields_for_the_badge="https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Clone&query=count&url="
- **HTML**
- ```html' >> "$BADGE_DOC"
- echo "
" >> "$BADGE_DOC"
- echo '
- ```' >> "$BADGE_DOC"
- echo "
" >> "$BADGE_DOC"
- echo '
+ {
+ echo "**Markdown**"
+ echo
+ echo '```markdown'
+ echo "[](${repo_url})"
+ echo '```'
+ echo
+ echo "[](${repo_url})"
+ echo
+ echo "**HTML**"
+ echo
+ echo '```html'
+ echo "
"
+ echo '```'
+ echo
+ echo "
"
+ echo
+ echo "**HTML (for-the-badge)**"
+ echo
+ echo '```html'
+ echo "
"
+ echo '```'
+ echo
+ echo "
"
+ } > "$BADGE_DOC"
- **HTML (for-the-badge)**
- ```html' >> "$BADGE_DOC"
- echo "
" >> "$BADGE_DOC"
- echo '```' >> "$BADGE_DOC"
- echo "
" >> "$BADGE_DOC"
-
+ if [ -n "$(git status --porcelain -- "$BADGE_DOC")" ]; then
git add "$BADGE_DOC"
- git config --global user.name "GitHub Action"
- git config --global user.email "action@github.com"
- git commit -m "create clone count badge"
+ git config user.name "GitHub Action"
+ git config user.email "action@github.com"
+ git commit -m "Create or update GitHub clone count badge"
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "Badge document is already up to date."
+ echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- - name: Push
- uses: ad-m/github-push-action@master
- with:
- github_token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Push badge document
+ if: steps.badge.outputs.changed == 'true'
+ run: git push
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index c344e30..52c0c6f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,10 +1,16 @@
+# Reusable workflow
+#
+# Project metadata is read by the publishing action from pyproject.toml.
+# No project-specific repository variables need to be configured manually.
+#
+# Required repository secret:
+# REGISTRY_ACCESS_TOKEN Comfy Registry publishing API key
+
name: Publish to Comfy Registry
on:
workflow_dispatch:
push:
- branches:
- - main
paths:
- "pyproject.toml"
@@ -19,12 +25,12 @@ jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
- if: ${{ github.repository == 'Azornes/Comfyui-Model-Resolver' }}
+ if: github.ref_name == github.event.repository.default_branch
steps:
- name: Check out code
uses: actions/checkout@v7
- - name: Check registry token
+ - name: Validate publishing configuration
env:
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
run: |
@@ -33,7 +39,38 @@ jobs:
exit 1
fi
+ python - <<'PY'
+ from pathlib import Path
+ import tomllib
+
+ path = Path("pyproject.toml")
+ if not path.is_file():
+ raise SystemExit("pyproject.toml was not found")
+
+ with path.open("rb") as file:
+ config = tomllib.load(file)
+
+ required_fields = {
+ "[project].name": config.get("project", {}).get("name"),
+ "[project].version": config.get("project", {}).get("version"),
+ "[tool.comfy].PublisherId": (
+ config.get("tool", {}).get("comfy", {}).get("PublisherId")
+ ),
+ }
+ missing = [
+ field
+ for field, value in required_fields.items()
+ if not str(value or "").strip()
+ ]
+
+ if missing:
+ raise SystemExit(
+ "Missing required pyproject.toml fields: " + ", ".join(missing)
+ )
+ PY
+
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+ skip_checkout: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f0fa4b6..d391d40 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,10 +1,13 @@
+# Reusable workflow
+#
+# The release name and version are read from pyproject.toml.
+# No project-specific repository variables or additional secrets are required.
+
name: Release
on:
workflow_dispatch:
push:
- branches:
- - main
paths:
- "pyproject.toml"
@@ -18,6 +21,7 @@ concurrency:
jobs:
release:
runs-on: ubuntu-latest
+ if: github.ref_name == github.event.repository.default_branch
steps:
- name: Checkout repo
@@ -35,14 +39,21 @@ jobs:
with open("pyproject.toml", "rb") as handle:
data = tomllib.load(handle)
- project = data["project"]
- version = str(project["version"]).strip()
- name = str(project["name"]).strip()
+ project = data.get("project", {})
+ version = str(project.get("version", "")).strip()
+ name = str(project.get("name", "")).strip()
+ display_name = str(
+ data.get("tool", {}).get("comfy", {}).get("DisplayName") or name
+ ).strip()
+
+ if not name:
+ raise SystemExit("[project].name is missing in pyproject.toml")
if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version):
raise SystemExit(f"Invalid semantic version in pyproject.toml: {version}")
print(f"project_name={name}")
+ print(f"display_name={display_name}")
print(f"version={version}")
print(f"tag=v{version}")
PY
@@ -86,9 +97,9 @@ jobs:
- name: Create GitHub Release
if: steps.tag_check.outputs.exists != 'true'
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.metadata.outputs.tag }}
- name: ComfyUI Model Resolver ${{ steps.metadata.outputs.tag }}
+ name: ${{ steps.metadata.outputs.display_name }} ${{ steps.metadata.outputs.tag }}
body: ${{ steps.notes.outputs.body }}
generate_release_notes: true