Make GitHub workflows reusable and validate inputs dynamically

- Replace hardcoded repository and token checks with generic default-branch and pyproject.toml validation
- Require fine-grained PAT permissions and validate Gist ownership and description
- Add concurrency groups to badge update workflows
- Bump softprops/action-gh-release to v3 and use DisplayName from pyproject.toml
This commit is contained in:
Dariusz L
2026-07-25 01:43:58 +02:00
parent cd5e8fcac2
commit 5d1930fe27
4 changed files with 364 additions and 95 deletions
+51 -6
View File
@@ -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" \
'{
+254 -78
View File
@@ -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 "[![GitHub Clones]($shields$url&logo=github)]($repo)" >> "$BADGE_DOC"
echo '
```' >> "$BADGE_DOC"
echo "[![GitHub Clones]($shields$url&logo=github)]($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 "<a href='$repo'><img alt='GitHub Clones' src='$shields$url&logo=github'></a>" >> "$BADGE_DOC"
echo '
```' >> "$BADGE_DOC"
echo "<a href='$repo'><img alt='GitHub Clones' src='$shields$url&logo=github'></a>" >> "$BADGE_DOC"
echo '
{
echo "**Markdown**"
echo
echo '```markdown'
echo "[![GitHub Clones](${shields}${gist_url}&logo=github)](${repo_url})"
echo '```'
echo
echo "[![GitHub Clones](${shields}${gist_url}&logo=github)](${repo_url})"
echo
echo "**HTML**"
echo
echo '```html'
echo "<a href='${repo_url}'><img alt='GitHub Clones' src='${shields}${gist_url}&logo=github'></a>"
echo '```'
echo
echo "<a href='${repo_url}'><img alt='GitHub Clones' src='${shields}${gist_url}&logo=github'></a>"
echo
echo "**HTML (for-the-badge)**"
echo
echo '```html'
echo "<a href='${repo_url}'><img alt='GitHub Clones' src='${shields_for_the_badge}${gist_url}&logo=github&style=for-the-badge'></a>"
echo '```'
echo
echo "<a href='${repo_url}'><img alt='GitHub Clones' src='${shields_for_the_badge}${gist_url}&logo=github&style=for-the-badge'></a>"
} > "$BADGE_DOC"
**HTML (for-the-badge)**
```html' >> "$BADGE_DOC"
echo "<a href='$repo'><img alt='GitHub Clones' src='$shields_for_the_badge$url&logo=github&style=for-the-badge'></a>" >> "$BADGE_DOC"
echo '```' >> "$BADGE_DOC"
echo "<a href='$repo'><img alt='GitHub Clones' src='$shields_for_the_badge$url&logo=github&style=for-the-badge'></a>" >> "$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
+41 -4
View File
@@ -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
+18 -7
View File
@@ -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