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 "[![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 "GitHub Clones" >> "$BADGE_DOC" - echo ' - ```' >> "$BADGE_DOC" - echo "GitHub Clones" >> "$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 "GitHub Clones" + echo '```' + echo + echo "GitHub Clones" + echo + echo "**HTML (for-the-badge)**" + echo + echo '```html' + echo "GitHub Clones" + echo '```' + echo + echo "GitHub Clones" + } > "$BADGE_DOC" - **HTML (for-the-badge)** - ```html' >> "$BADGE_DOC" - echo "GitHub Clones" >> "$BADGE_DOC" - echo '```' >> "$BADGE_DOC" - echo "GitHub Clones" >> "$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