Comfy Registry release pipeline: release-please + lockfile drift traps, the empty-web/dist publish bug, version status states, phantom versions, icon/banner generation. Use when debugging a pack's publish pipeline.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add laurigates/claude-plugins --skill comfy-registry-lifecycle --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Comfy Registry Lifecycle?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/laurigates-comfy-registry-lifecycle)More formats (shields.io, HTML) on the badges page.
---
created: 2026-07-07
modified: 2026-08-14
reviewed: 2026-07-07
name: comfy-registry-lifecycle
description: >-
Comfy Registry release pipeline: release-please + lockfile drift traps, the empty-web/dist publish bug, version status states, phantom versions, icon/banner generation. Use when debugging a pack's publish pipeline.
allowed-tools: Bash, Read, Grep, Glob, Write, Edit
---
# comfy-registry-lifecycle
A ComfyUI custom-node pack's release flow: conventional commits →
release-please bumps `pyproject.toml` + `CHANGELOG.md` → merge the release
PR → the published GitHub release triggers `publish.yml` →
`Comfy-Org/publish-node-action` publishes to registry.comfy.org. The
failures below live in this flow (or the CI that gates it) and are easy to
ship without noticing, because the pipeline reports green while the
registry artifact is broken.
## When to Use This Skill
| Use this skill when... | Use instead when... |
|---|---|
| Setting up or debugging a pack's release-please -> publish.yml -> registry pipeline | Writing the pack's frontend/backend code itself -> `comfyui-node-authoring` |
| A published node's frontend or artwork isn't showing up correctly | Smoke-testing the pack in a running instance -> `comfyui-pack-live-smoke` |
## 1. `uv.lock` self-version drifts — release-please has no native uv.lock support
The lockfile records the **workspace package's own version** in its
`[[package]]` entry. release-please's `python` release-type bumps
`pyproject.toml` but **not** `uv.lock`
([googleapis/release-please#2561](https://github.com/googleapis/release-please/issues/2561)),
so the lock's self-version silently trails `pyproject.toml` across releases.
There is **no uv/`pyproject.toml` setting** to omit the project's own
version from the lock, so the lock must be kept in sync explicitly.
**Fix — a structured `toml` `extra-files` updater** in
`release-please-config.json` (the declarative equivalent of
`uv lock --upgrade-package`, not a hand-edit):
```json
"extra-files": [
{
"type": "toml",
"path": "uv.lock",
"jsonpath": "$.package[?(@.name.value=='<pack-name>')].version"
}
]
```
`<pack-name>` is the directory/package name. The `toml` updater parses the
lock and sets only the matched `version` value at the JSONPath — everything
else stays byte-stable. Note `.name.value` (release-please's TOML AST wraps
scalar values).
To repair an already-drifted lock once: `uv lock` regenerates it to match
`pyproject.toml` (the only diff is the self-version line, plus occasional uv
specifier normalization like `>=1.40` → `>=1.40.0`).
**New packs are born with it.** `comfyui-node-scaffold` emits this updater in
the pack's `release-please-config.json`, and `scaffold.py --verify <pack>`
grades an existing pack's wiring as `RELEASE_PLEASE_UVLOCK=wired|unwired|
mistargeted` (issue #2187). Packs scaffolded before that still need the manual
add below.
**Sweeps miss packs — check before the first release, not after.** A pack
created after a fix sweep can silently lack the updater. Before merging any
pack's release PR — and when scaffolding or auditing a pack — confirm
`grep -c extra-files release-please-config.json` ≥ 1 and that the release
PR's changed files include `uv.lock`. Adding the updater to `main` while a
release PR is open regenerates that PR to include the lock bump.
## 2. The registry "Updates" changelog — use native `COMFY_NODE_CHANGELOG`, not a post-publish PUT
`comfy node publish` sets the per-version changelog **natively** via the
`COMFY_NODE_CHANGELOG` env var
([Comfy-Org/comfy-cli#467](https://github.com/Comfy-Org/comfy-cli/issues/467),
released in comfy-cli 1.11+), populating the registry's "Updates" section
atomically at publish time.
**Do not** hand-roll a post-publish step that resolves the version UUID and
`PUT`s to `https://api.comfy.org/publishers/.../versions/{id}`. A
hand-rolled step that extracts `node_id`/`version` via `python3 -c 'import
tomllib'` fails on every release with `ModuleNotFoundError: No module named
'tomllib'`, because `Comfy-Org/publish-node-action` pins **Python 3.10**
and `tomllib` is 3.11+ stdlib. Under `bash -e` it dies before any `|| exit
0` guard — the node still publishes, but the Updates section is empty and
the job shows red, which is easy to miss.
**Correct shape**: a step *before* the publish-node-action step that
flattens the release notes to plain text (the registry renders Updates as
plain text) and exports it, so the action's `comfy node publish` reads it
from the job environment:
```yaml
- name: Compute registry changelog from release notes
if: github.event_name == 'release' && github.event.release.body != ''
env:
RELEASE_BODY: ${{ github.event.release.body }} # via env, not inline interpolation
run: |
changelog=$(python3 <<'PY'
# pure-`re` markdown→plaintext flatten (no tomllib); prints the changelog
PY
)
{
echo "COMFY_NODE_CHANGELOG<<__CHANGELOG_EOF__"
echo "$changelog"
echo "__CHANGELOG_EOF__"
} >> "$GITHUB_ENV"
```
`$GITHUB_ENV` exports the var to all later steps in the job, including the
composite `publish-node-action` run. No PUT, no UUID lookup, no `tomllib`.
## 3. Bumping a shared frontend-kit dependency: regenerate the lockfile *and* the built bundle together
For a TS-built pack that consumes a shared TypeScript package inlined at
build time (`bun build` bundles the import into `web/dist`), bumping the
version range in `package.json` looks like a one-line change but silently
desyncs **two** other committed artifacts, and CI catches each with a
different, non-obvious error:
- **`bun.lock` goes stale** — its pinned resolution still satisfies the
*old* range, so it isn't touched by hand-editing `package.json`. CI runs
`bun install --frozen-lockfile`, which fails with `error: No version
matching "^X.Y.0" found for specifier "<pkg>" (but package exists)` — a
confusing message, since the version genuinely is published; the real
problem is the *lockfile's stale resolution*.
- **`web/dist` goes stale** — the committed bundle still contains the old
inlined code. A "verify committed `web/dist` is up to date" CI gate (a
`git diff --exit-code -- web/dist` after a fresh build) fails.
Both gates are correct — the footgun is that the fix requires **two**
commands, and the second failure only surfaces *after* the first is fixed
(the frozen-lockfile failure blocks the build step that would otherwise
reveal the dist-drift):
```sh
bun install # regenerates bun.lock to resolve the new range
bun run build # rebuilds web/dist against the new dependency version
git add bun.lock web/dist/index.js
```
Commit both in the same commit as the `package.json` bump — don't split
them, and don't stop after fixing the lockfile install failure without also
rebuilding `web/dist`.
Across a whole pack set, check for consistency (range equals locked version
in every consumer):
```sh
for repo in <pack-glob>; do
[ -f "$repo/package.json" ] || continue
range=$(grep -o '"<shared-pkg>": *"[^"]*"' "$repo/package.json" | grep -o '\^[0-9.]*')
locked=$(grep -o '<shared-pkg>@[0-9.]*' "$repo/bun.lock" 2>/dev/null | head -1)
echo "$repo | range=$range | $locked"
done
```
## The empty-`web/dist` publish trap
For TS-built packs, the registry tarball is supposed to force-ship the
built frontend via `[tool.comfy] includes = ["web/dist"]`. The trap: a
published tarball can contain `web/dist/` as an **empty directory** — no
`index.js` — while `publish.yml` reports green.
Root cause is the publish action, not the include:
- `Comfy-Org/publish-node-action@v1` (and tags `1.0.0` / `1.0.1`) run an
**unconditional `actions/checkout@v4`** that wipes the git-ignored
`web/dist` a prior `bun run build` step produced. **None of the tagged
releases have a `skip_checkout` input.**
- `skip_checkout` exists **only on the action's `main` branch** (added
2025-05-03, commit `c742414d`; no tagged release carries it).
- `comfy node publish` then packs git-tracked files + `includes`; its
`zip_files` walks an included dir's contents **only if the dir exists at
pack time** — if absent it writes an empty-dir entry. A wiped `web/dist`
→ empty `web/dist/` in the tarball.
**Fix** — pin the action to the commit that gates checkout on
`skip_checkout` (no tag has it yet):
```yaml
- name: Publish Custom Node
# @v1/1.0.x lack skip_checkout and wipe the built web/dist. Pin
# the main commit that gates checkout on skip_checkout (no tag has it).
uses: Comfy-Org/publish-node-action@d2366e7abb6ab16f3bb03e3520ae25c8cf749bc9 # v1.0.2-dev (main HEAD; skip_checkout not yet tagged)
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
skip_checkout: 'true'
```
`skip_checkout: 'true'` is **silently ignored** by `@v1` — passing it
without repinning does nothing, which is exactly what lets this hide for
weeks.
### Verify a publish actually shipped the frontend
Never trust a green `publish.yml` run — it succeeds even when the tarball
is empty. Download the real artifact and inspect it:
```sh
python3 - <<'PY'
import json, urllib.request, zipfile, io
nid, ver = "<node-id>", "<version>"
d = json.load(urllib.request.urlopen(f"https://api.comfy.org/nodes/{nid}/versions"))
v = next(x for x in d if x["version"] == ver)
z = zipfile.ZipFile(io.BytesIO(urllib.request.urlopen(v["downloadUrl"]).read()))
print([n for n in z.namelist() if n.startswith("web/dist/") and n.endswith((".js",".css"))])
PY
```
Empty list ⇒ broken tarball. The `downloadUrl`
(`cdn.comfy.org/<owner>/<id>/<ver>/node.zip`) is in each version object.
## Version status: Pending vs Flagged vs Active
`api.comfy.org/nodes/<id>/versions` returns every version with a `status`:
| Status | Meaning | Action |
|---|---|---|
| `NodeVersionStatusPending` | held while the automated security scan runs | **auto-transitions** to Active, usually < a few hours — just wait |
| `NodeVersionStatusActive` | scan passed; installable | none |
| `NodeVersionStatusFlagged` | scan flagged it | **stuck** — does NOT auto-clear. Full reasons: `GET /nodes/<id>/versions?include_status_reason=true` (undocumented public param — see the security-scan section below). Republishing re-runs the scan; appeal via Comfy-Org if a false positive |
`comfy node install` resolves to the **highest-semver Active** version. So
while a fixed version is Pending, installs still serve the older (possibly
broken) Active one. `comfy node registry-install` can fetch a Pending
version directly.
Flag false-positives are real: an identical commit can flag one pack but
not a structurally-identical sibling. Don't assume your code is the
problem — get the scan reasons (email) first.
## The security scan: what flags, what the reasons mean, how to shrink the surface
Learned across an 11-pack flag epidemic (2026-06/07; appeal:
Comfy-Org/registry-backend#180, third-party confirmations in
Comfy-Org/ComfyUI-Manager#2927):
- **Any finding flags the version — severity is irrelevant.** A single
`info`-severity yara match (e.g. `os.environ.get(...)` or a
`requests.get` in an API client) produces `Flagged`, which blocks
distribution. There is no self-service resolution path.
- **Reasons are on the public API — behind an undocumented param.**
`GET api.comfy.org/nodes/<id>/versions?include_status_reason=true`
returns per-version `status_reason` JSON (issue_type, scanner,
file_path, line, description, admin_tags); without the param the field
is scrubbed. Scanner *notifications* post to the Comfy Org Discord
`SUPPORT/#security-review-council` channel; the
`registry.comfy.org/admin/nodeversions` links in them are **staff-only
(403)** and the publisher dashboard shows nothing. Poll the API — no
Discord access needed (the scaffold's `registry-health.yml` does this
and writes the findings into its tracking issue).
- **Known issue classes** (from `status_reason` payloads):
- `python_network_operations` (`yara_scan`) — any
`urllib`/`requests`/socket use in shipped `.py`, including dev
scripts that should never have shipped. Siblings:
`python_environment_manipulation` (`os.environ`),
`python_command_injection_risk` (`subprocess`).
- `vendored_unknown` (`provenance_scan`) — "Vendored file detected
but upstream origin could not be identified". Fires on **any
bundler-built `web/dist` file**, including bundles of the repo's
own `src/` with no third-party code at all (comfyui-touch-shim
evidence, 2026-07) — not just inlined `node_modules` deps. Every
TS-built pack hits this class on every publish until Comfy-Org can
attribute bundler output; only the appeal path clears it.
- **Shrink the scan surface mechanically:**
- `.comfyignore` must exclude every dev-only path — and it silently
rots: a `scripts/` directory added *after* the ignore file was
written shipped a `urllib` dev tool and flagged the version. Pair
the ignore file with a `tests/test_publish_hygiene.py` that
simulates the comfy-cli tarball (git-tracked − `.comfyignore` +
`[tool.comfy] includes`, via `pathspec`) and fails on unclassified
shipped paths or scanner-tripwire patterns in shipped Python. The
scaffold now emits both.
- **Publish bundled first-party deps with real provenance**: the
shared kit must carry a `LICENSE` file and a `license` field in
`package.json` (an unlicensed npm package is unclassifiable — that
was true of comfy-modal-kit until comfy-modal-kit#17) plus npm
provenance attestation. Open the built bundle with a `bun build
--banner` comment attributing what is inlined.
- Runtime code whose *function* is scanner-hostile (a manager pack
doing registry lookups, installs, env feature-gates) cannot be
trimmed — allowlist it in the hygiene test with a justification and
cite it in the appeal.
- **Appeal via Discord first — it's processed faster.** Post the
re-review request in a **persistent per-publisher thread** (e.g.
`Publisher <name> — re-review`) in the Comfy Org Discord
`SUPPORT/#security-review-council` channel, not as a loose channel
message: the channel is a firehose of automated flagged-release
notifications, so a bare message is unfindable within hours and a
multi-round appeal (a reply days later, the next release re-flagged)
loses its history. Reuse the same thread for every appeal. Keep the
message under the 2,000-char limit with no markdown tables — fitting a
multi-pack appeal usually costs several trim passes, so put the
version-ID table and per-finding detail in a GitHub issue on
`Comfy-Org/registry-backend` as the durable record and have the
Discord message link to it rather than duplicate it.
- **Then verify by publishing**: a republish re-runs the scan, so the
definitive test of any fix is the next release's verdict via
`api.comfy.org/nodes/<id>/versions?include_status_reason=true`.
## Phantom versions (higher semver, ahead of git)
A version published once from a stale local copy can sit in the registry
**ahead of git** (e.g. `0.2.0` Active while git is at `0.1.7`). Because
install resolves to highest-semver Active, that phantom becomes the
clean-install target and **outranks every later fix** below it. Two ways
out:
1. Release a version **> the phantom** (`Release-As`, below) — the fix must
outrank it.
2. **Remove the phantom** from the registry dashboard — then the next
Active version wins.
## `Release-As` is stripped by squash-merge
To force release-please to a specific version (e.g. to leapfrog a
phantom), a commit needs a `Release-As: X.Y.Z` **trailer**. The trap:
**GitHub squash-merge rebuilds the commit body from the PR description**,
so the trailer only survives if it's a clean line in the *PR description*
— a trailer that lives only in the branch commit, or sits inside backticks
or mid-sentence, is dropped and release-please falls back to a plain patch
bump. Reliable options:
- **"Rebase and merge"** the trigger PR — preserves the commit message
verbatim, trailer intact.
- Or put `Release-As: X.Y.Z` as a plain last line of the **PR description**
(no backticks) for squash.
Do **not** hand-edit `.release-please-manifest.json` / `CHANGELOG.md` to
force a version — it conflicts with the automation.
## Standing feedback: the `registry-health` workflow
A `.github/workflows/registry-health.yml` (runs after publish, daily, and
on demand) that looks up the `pyproject.toml` version in the registry and
**fails + opens a `registry-health` issue** when that version is Flagged,
stuck Pending, missing, or outranked by a phantom — auto-closing when
healthy — is the early-warning the publish pipeline lacks on its own.
On Flagged it queries `?include_status_reason=true` and writes the scan
findings (issue type / scanner / file / description) into the issue body,
so the security-scan verdict is readable without Discord. Worth adding to
any new pack (the scaffold emits it).
## Backport to the scaffold
If `publish.yml` is generated by a scaffold template (as it is for
`comfyui-node-scaffold` → `scaffold.py`), fix the template in the same
sweep as any live packs, or every newly-scaffolded pack inherits the same
broken step.
## Icons & banners
Without `[tool.comfy] Icon`, the registry shows a generic placeholder.
### Icon design system
The exact spec every pack shares (canonical — the scaffold's `icon.svg`
placeholder and every hand-drawn glyph target this):
- **400×400 canvas**, `viewBox 0 0 400 400`.
- **Dark inset tile**, verbatim:
`<rect x="28" y="28" width="344" height="344" rx="76" fill="url(#tile)" stroke="#2a2a36" stroke-width="2"/>`
where `#tile` is a **vertical** gradient `#1f1f2a`→`#12121a`
(`x1=0 y1=0 x2=0 y2=1`). The 28 px inset is the family margin — not
full-bleed.
- **One bespoke pictographic glyph**, centred, filling the tile. **No
letters** in final art — every sibling uses a pictogram; a letter is a
placeholder only.
- **Glyph colour encodes the sub-family**: **`#ffb02e` (orange)** for
touch/interaction packs, **`#6ba6ff` (blue)** for info/gallery packs.
Secondary accents (`#ffd866`, `#6bff8e`) appear sparingly. Keep tile,
radius, stroke, and gradient **identical** across packs.
**Framing gate (the consistency check).** Because the tile is the outermost
drawn element, a correctly-framed icon *always* trims to the same box —
run it on every pack:
```sh
identify -format '%wx%h / %@\n' icon.png # MUST be: 400x400 / 346x346+27+27
```
A `512x512 / 512x512+0+0` result is the classic drift: a full-bleed tile on
the wrong canvas, which renders visibly larger and off-palette on the
registry grid. This is exactly how `comfyui-touch-shim` shipped the raw
512×512 green-letter scaffold placeholder while the other 10 packs matched —
the trim box is the one-line audit that catches it across the fleet:
```sh
for d in comfyui-*/; do printf '%-28s %s\n' "$d" "$(identify -format '%wx%h/%@' "$d/icon.png" 2>/dev/null)"; done
```
### Rasterize SVG with cairosvg — NOT ImageMagick
ImageMagick's internal MSVG renderer **silently drops `stroke`,
`fill="none"`, and gradients** — only *filled* shapes render, so
stroke-based glyphs come out half-missing and a gradient tile goes flat
black. Use cairosvg:
```sh
uvx --from cairosvg cairosvg icon.svg -o icon.png -W 400 -H 400
```
`magick`/ImageMagick is fine for PNG compositing/montages — just never for
SVG→PNG of stroke art. If neither `rsvg-convert` nor `inkscape`/`resvg` is
available, cairosvg via `uvx` is the reliable fallback path.
### Banners (21:9) — AI background + composited branding
Diffusion is the right tool for the *background*, the wrong tool for
*icons* and *text*. Generate a clean background, then composite crisp
branding on top. `scripts/registry_banner_bg.py` and
`registry_banner_compose.py` (this skill's `scripts/`) implement this
two-step pipeline against a running ComfyUI instance:
```sh
python3 scripts/registry_banner_bg.py \
--accent "warm amber and orange" --seed 101 --out bg.png \
--host "${COMFYUI_HOST:-127.0.0.1:8188}"
```
Generates a 1344×576 (exact 21:9) abstract on-brand texture with **no
text/letters/logo/symbols** requested in the prompt.
```sh
python3 scripts/registry_banner_compose.py \
--bg bg.png --icon icon.png --name "Display Name" \
--tagline "Short tagline" --accent '#ffb02e' --out banner.png
```
Composites the icon + name wordmark + tagline with ImageMagick. Text stays
sharp and correctly spelled this way — never trust a diffusion model to
render pack names.
### Wiring into the registry (`pyproject.toml` `[tool.comfy]`)
- `Icon` / `Banner` are **URLs**, not tarball paths. Use
raw.githubusercontent off the default branch:
`Icon = "https://raw.githubusercontent.com/<owner>/<repo>/main/icon.png"`
- They 404 on the PR branch and resolve the instant the PR merges to
`main` (icon + version land together). Keep the art files and the
metadata change in the **same PR**.
- Leave `[tool.comfy] includes` unchanged — the icon is fetched from the
URL, not shipped in the publish tarball.
### release-please owns the version — never hand-bump
Add Icon/Banner + art with a conventional-commit PR (`fix:` → patch,
`feat:` → minor). **Do not touch `version`.** Squash-merge → release-please
opens a release PR with the bump → merge that → it cuts the GitHub Release
(which publishes).
### `publish.yml` — trigger on the release event, not the pyproject path
A publish workflow triggering on `push: paths: [pyproject.toml]` re-fires
on *any* pyproject edit and 400s with `"node version already exists"`. Fix
it to fire once per real release:
```yaml
on:
workflow_dispatch:
release:
types: [published]
```
release-please emits a *published* Release when its release PR merges →
publish runs exactly once with the bumped version. Bonus: merging the icon
PR itself no longer triggers a spurious publish.
### Verify after merge
```sh
curl -sI https://raw.githubusercontent.com/<owner>/<repo>/main/icon.png # 200 image/png
gh run list -R <owner>/<repo> --workflow=publish.yml -L1 # release/success
curl -s https://api.comfy.org/nodes/<id>/versions # new version present
```
## Agentic Optimizations
| Context | Command |
|---|---|
| Read scan verdicts | `curl -s "https://api.comfy.org/nodes/<id>/versions?include_status_reason=true" \| jq -c '.[] \| select(.status == "NodeVersionStatusFlagged") \| {version, status_reason}'` |
## Verify
**#1/#2/lockfile issues**: static-checkable (YAML + actionlint; `uv lock`
diff is one line), but the changelog path is only exercised at real
publish time. Definitive proof = the **next release's publish job goes
green with a populated Updates section**.
**publish action pin**: the pack-set consistency check above returning
`range == locked` for every consumer, plus a downloaded-tarball inspection
showing a non-empty `web/dist/`.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!