[PR #206] [MERGED] feat(public-repo-hygiene): centralize the public-repo leak guard as a reusable workflow (BE-8654) #215

Closed
opened 2026-10-26 22:37:26 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/Comfy-Org/github-workflows/pull/206
Author: @mattmillerai
Created: 8/22/2026
Status: ✅ Merged
Merged: 8/22/2026
Merged by: @mattmillerai

Base: main ← Head: matt/be-8654-public-repo-hygiene


📝 Commits (6)

  • e6b5b72 feat(public-repo-hygiene): centralize the public-repo leak guard as a reusable workflow (BE-8654)
  • b804098 fix(public-repo-hygiene): make every "declined to look" route fail loudly (BE-8654)
  • 1b93baf fix(public-repo-hygiene): close the coverage and log-volume holes the panel found (BE-8654)
  • b0ea12d fix(public-repo-hygiene): case-insensitive matching and sentence-period stripping (BE-8697) (#207)
  • 8af49e6 fix(public-repo-hygiene): close the work-tree-encoding bypass and the remaining coverage-accounting holes (BE-8654)
  • 982f5dc fix(public-repo-hygiene): close the LFS/homoglyph/encoding bypasses the panel found (BE-8654)

📊 Changes

13 files changed (+3841 additions, -18 deletions)

View changed files

📝 .github/bump-callers/README.md (+1 -0)
➕ .github/public-repo-hygiene/README.md (+190 -0)
➕ .github/public-repo-hygiene/check_public_repo_hygiene.py (+1232 -0)
➕ .github/public-repo-hygiene/tests/test_check_public_repo_hygiene.py (+1326 -0)
📝 .github/reviewers.yml (+7 -1)
➕ .github/workflows/bump-public-repo-hygiene-callers.yml (+127 -0)
➕ .github/workflows/public-repo-hygiene.yml (+273 -0)
➕ .github/workflows/test-public-repo-hygiene.yml (+436 -0)
📝 AGENTS.md (+28 -4)
📝 CONTRIBUTING.md (+3 -0)
📝 README.md (+11 -4)
📝 docs/callers/README.md (+24 -9)
➕ docs/callers/public-repo-hygiene.md (+183 -0)

📄 Description

ELI-5

There is a check that stops internal-only things — ticket ids, Datadog/Linear/Notion links, private repo names — from being published in a public repo. It existed in exactly two repos, and in both of them it ran out of the pull request's own checkout, which means a PR could edit or delete the check that was judging it. This moves the check (and, more importantly, the list of which repos are public) here, where a caller pins it by SHA and cannot touch it.

What changed

  • public-repo-hygiene.yml — the reusable workflow, modelled on agents-md-integrity.yml. workflows_ref is required with no default, and the checkout's own job runs the empty-ref guard.
  • .github/public-repo-hygiene/ — one merged checker replacing two divergent copies (a 178-line Python one and a 187-line JavaScript one), plus the org-wide known-public repo/team allowlist, plus a README covering the tamper boundary and the known limitations.
  • test-public-repo-hygiene.yml — unit tests (40) plus seven end-to-end smoke steps.
  • bump-public-repo-hygiene-callers.yml — the SHA-propagation fleet, over the shared bump-callers.sh.
  • Catalog/table/index updates: README.md, docs/callers/ (index + new setup guide), AGENTS.md, CONTRIBUTING.md, .github/bump-callers/README.md, .github/reviewers.yml.

The tamper boundary, stated plainly

The known-public repo and team allowlists are not a workflow input and are not readable from the caller's tree. An allowlist a caller can pass is an allowlist a PR in the caller repo can widen, which is the hole being closed. The two knobs that are inputs are scoped so they cannot loosen the rule:

Knob Reaches Weakening possible?
ticket_allowlist category 1 only No — additive; it adds an acronym, it cannot drop a built-in one
exclude_paths which files are scanned It narrows scope, never a category. A value naming the repo root is rejected (exit 2), and every entry is logged with its skipped-file count including one that skipped nothing

Both are visible in the caller's workflow diff and in the run log. That is the honest boundary: the rule is untouchable from a caller repo, and narrowing the scope is loud.

Asserted, not just asserted-in-prose: TamperResistanceTest plants a wide-open copy of the checker at both the historical per-repo path and the centralized one, a config file, and environment variables named after the module constants — and the verdict does not move. test-public-repo-hygiene.yml runs the same fixture end to end.

Parity, proven before anything is deleted

Both SDK checkers and this one were run over the same seeded trees (each SDK repo at its default branch tip plus a corpus exercising every category and shape boundary), and the normalized findings diffed.

  • 18 of 18 findings identical across all three implementations on the parity corpus.
  • Exactly two intentional divergences, isolated in their own fixture file:
    1. Comfy-Org/github-workflows no longer flagged — it is now allowlisted, which is the point of the ticket.
    2. Comfy-Org/comfy-cli.git no longer flagged — the trailing .git strip, which the JavaScript copy already had and the Python one did not. Where the two copies disagreed, the stricter/more-correct one won; the choice is documented rather than silent.
  • Both SDK default branches are clean under old and new with the same exclusions.

Nothing has been deleted. See "not done" below.

That proof describes e6b5b72, not the branch tip. Two later commits changed detection on
purpose, each in its own reviewed change, so the 18/18 diff no longer describes this branch
byte-for-byte. Both divergences are strictly in the stricter direction and are pinned by unit
tests:

  • b0ea12d (#207, BE-8697) — the org segment and allowlist membership are now matched
    case-insensitively, and a sentence-final period is stripped. That closes the two carried-over
    defects described below and makes comfy-org/<private-repo> a finding where it previously was
    not.
  • 8af49e6 — no category, regex or allowlist entry changed. What changed is coverage
    accounting
    : an LFS pointer stub and a submodule gitlink no longer count as scanned, and a
    working-tree-encoding gitattribute is refused outright.

Re-running the parity diff is the first thing the SDK-conversion PRs should do, since it is what
licenses deleting those two copies.

Carried-over quirks — carried over, then fixed on their own PR

A parity proof is only worth something if the migration changed nothing, so two real defects in the original scripts were deliberately carried over rather than fixed in the move:

  • A sentence-final period was swallowed by the repo-name character class — prose ending …built on Comfy-Org/ComfyUI. was flagged as ComfyUI.. This fires on ordinary English.
  • The org segment was matched case-sensitively — a lowercased comfy-org/<private-repo> reference was not flagged, though GitHub resolves owner names case-insensitively. That is a one-keystroke bypass of a default-deny control.

Both are now fixed, in b0ea12d (#207, BE-8697) — its own reviewable change, exactly as intended, rather than bundled into a move. That is where detection parity with the two per-repo scripts deliberately ended; the checker README says so, and unit tests pin both behaviours. The remaining detection findings from the review rounds below are still tracked as follow-ups.

Coverage: the half this does not fix

Sized with the same sweep, pointed at the remainder:

  • 77 non-archived public Comfy-Org repos (80 including archived). The ticket said 60; the count has grown.
  • 2 of them have any hygiene guard today — comfy-python-sdk and comfy-typescript-sdk. Confirmed org-wide by code search, not by inference.
  • 75 still have none after this merges, and this PR enrols zero callers, so effective coverage is unchanged at 2/77 until adoption lands. Adoption is explicitly out of scope on the ticket: rolling to repos that have never been scanned will surface genuine findings that need triage, which is a ramp, not a flip.

Not done, and why

  • The two SDK repos are not converted to thin callers, and their local scripts are not deleted. That is a change in Comfy-Org/comfy-python-sdk and Comfy-Org/comfy-typescript-sdk, outside this repo. Parity — the half the acceptance criteria puts before deletion — is proven above and is what makes that conversion safe to do next; the conversion itself needs a PR in each of those repos.
  • The PUBLIC_REPO_HYGIENE_CALLERS roster is not seeded, since there are no callers yet. ALLOW_EMPTY is true and the fleet is a clean no-op; flip it to false with the first enrolment. Enrolling a caller is two steps — merge the caller and add it to the roster — and skipping the second is the most repeated mistake in this repo.
  • The allowlist was not expanded beyond adding github-workflows. Pre-blessing 60-odd names nobody reviewed would trade the default-deny posture for convenience, and a repo that later goes private would silently stop being flagged. Adding one is now a single reviewed edit here instead of one per consumer.
  • A possible third mention was never chased down. A code search for the checker name returned one more hit in a Comfy-Org repo that is not public, in a config file rather than an implementation, on the evidence available. The follow-up read was blocked by a GitHub secondary rate limit, so it is recorded rather than assumed away; worth a look during adoption in case it is a third copy. The repo is deliberately not named here — this one is public, which is the entire point of the change.
  • This repo is not self-enrolled. Running the checker against this tree returns 901 findings: the test fixtures are internal-reference-shaped on purpose and the repo's own convention puts (BE-####) in commit messages. detect-unreviewed-merge.yml is un-self-enrolled here for a comparable reason.

Review rounds — what the panel found, and what moved

Rounds 1–2 (b804098, 1b93baf)

Ten findings from the cursor-review panel. Five are fixed; five are detection changes and were deferred, with a follow-up specified for each. All ten threads carry a reply and are resolved.

Fixed — every route by which the guard could decline to look and still report green:

# Finding Fix
🟠 High workflows_ref that is not a 40-hex SHA only warned, so nothing enforced that the checker came from the commit uses: pinned Both halves now FAIL the run: 40-hex required, and the ref must equal job.workflow_sha — the commit this reusable resolved from, which the runner computes from uses: and a caller cannot set
🟠 High open() follows symlinks: a tracked link read whatever it pointed at, so one out of the repo pulled arbitrary runner content into a public run log, and one to /dev/zero or a FIFO was an OOM or a hang os.lstat skips anything not a regular file, with a ::warning:: naming it; reads capped at 5 MiB with the tail warned about; check job given timeout-minutes: 15 (it had none)
🟠 High Tracked content at the reserved _public_repo_hygiene/ path was skipped unconditionally — and since the workflow's own checkout lands untracked, that skip only ever fired in the bypass case Hard config error, exit 2, naming the path
🟠 High Binary / non-UTF-8 files were skipped silently, so one stray byte hid a whole file that still renders as text on GitHub — and scanned counted them anyway Per-run NOT SCANNED: counts; scanned now counts only files actually read as text, so an all-binary tree trips the zero-scan net
🟡 Med A run that scanned zero files still exited 0, leaving the root-exclusion rejection one spelling away from pointless Exit 2, matching the non-git-root case. Every run prints SCANNED: <n> file(s) read as text

Deferred — the five that change what counts as a finding. This PR's acceptance criterion is a parity proof: old and new run over the same trees, findings diffed 18/18 identical, which is what licenses deleting the two per-repo SDK copies next. Each of these invalidates that proof, so each is its own reviewable change:

  • Category 3 case-sensitivity — since fixed, in b0ea12d (#207, BE-8697): the org segment and allowlist membership are now matched case-insensitively and the trailing-period false positive is gone. Routed exactly as intended — deferred here, filed, and landed as its own reviewed PR.
  • Paths are never scanned — only file contents are. docs/<TICKET>-migration.md or a notion-exports/ directory passes clean, and the path is the more exposed half.
  • Category 1 shape — partly closed in round 4: the CVE-2024-style false positive is fixed (public identifier namespaces now clear by prefix, so nothing expires each January). What remains deferred is the other half — \b treats _ as a word character, so BE-1234_design never matches — which is a widening of what counts as a finding and belongs on its own PR.

None of these is live coverage being lost: this PR enrols zero callers. The remaining follow-ups should land before adoption.

Round 3 (8af49e6)

Ten more findings. Nine acted on, one declined; all ten threads carry a reply and are resolved. Every one of the fixes is the same shape as round 2's: a route by which the guard declines to look at something and still reports green.

# Finding Fix
🟠 High A tracked .gitattributes setting working-tree-encoding=UTF-16 keeps the committed blob UTF-8 — plainly readable on github.com, in the API and in the diff — while checkout writes NUL-laden UTF-16 to disk, which the scan skipped as binary Hard config error, exit 2, naming the file. Resolved via git check-attr, so the answer is git's own, at every directory level plus .git/info/attributes. An excluded path may still carry one — that hole is already named by its exclusion count
🟠 High workflows_ref == job.workflow_sha binds the checker to whatever uses: resolved to, but cannot bind which uses: line runs — a pull_request caller executes its workflow file from the PR head Claim narrowed rather than defended, in all three places it appeared (workflow header, checker README, caller docs), each naming the out-of-band control: CODEOWNERS / a ruleset on the caller's .github/workflows/. See "The tamper boundary" above
🟡 Med A git-LFS pointer stub counted towards scanned, so git lfs track '*.md' plus a leaking commit exited 0 on a required check Its own skipped kind. An all-LFS tree now reads zero files and exits 2
🟡 Med The reserved-path guard lives in the checker, two steps after the checkout that resolves that path — so it reported the collision instead of preventing it A pre-checkout step in the reusable workflow refuses first (-e, -L, and git ls-files). The checker's guard stays as the in-tree backstop for direct invocation
🟡 Med warnings had no cap, unlike the findings — six producers each embed a path, so a tree of tracked symlinks floods a public run log MAX_WARNINGS_TOTAL (200) with a +N more tail. The counts stay uncapped, so the coverage arithmetic is still complete
🟢 Low Submodule gitlinks were reported as "a FIFO, socket or device node" — the wrong type and the wrong reason for the commonest way this branch is reached A stat.S_ISDIR branch ahead of it, with its own skip kind and a message naming it as an unfetched submodule
🟢 Low test-public-repo-hygiene.yml set no timeout-minutes, while its own fixtures build a FIFO and a link to /dev/zero timeout-minutes: 15, matching the scan job
🟢 Low Adding this workflow to the top-level README's workflows_ref list left the sentence below it wrong about which workflows reject a non-SHA The catalog now carries this workflow's stricter tier (40-hex and equal to job.workflow_sha, runner ≥ v2.334.0) and links to the caller docs for the full three tiers
⚪ Nit Category-2 host patterns have no left DNS-label boundary (fooslack.com/archives/x is flagged) and require / straight after the host (notion.so:443/team/page is not) Deferred — a detection change, routed like the others. Follow-up filed

Declined — one. A symlink counting towards scanned was flagged as holding the zero-scan net open. It is not: for a symlink the target string is the blob git stores and publishes at that path, and os.readlink reads it in full. There is no unexamined "elsewhere", which is exactly what distinguishes it from the LFS stub in the same round — that one did move to skipped. Reasoning is on the thread.

Round 4 (982f5dc)

Ten more findings, all ten fixed; every thread carries a reply and is resolved. Unlike round 3, this round does move detection semantics — see the note under Risk.

# Finding Fix
🟠 High Moving the LFS stub to skipped also stopped scanning it, and the classifier was a bare startswith — so any tracked file whose first line is the pointer header was skipped whole, with its references unread, while still rendering as plain text on github.com The full pointer grammar is required (sha256:<64 hex> oid + digit size, stub-sized), so a lookalike is ordinary text again. And a genuine stub is now read for findings while still not counting toward scanned — coverage and detection are separate questions, and this is the one file kind where they diverge
🟡 Med The ASCII name class had no right boundary, so the allowlist was tested against a prefix of what the file said: Comfy-Org/comfyui‐internal written with U+2010 (visually identical to -) cleared as comfyui A name the class only partly read is never cleared — reported whole, with its own remedy. Not done as a lookahead: refusing to match would turn a partly-readable name into no finding at all. The left edge is bounded too, so NotComfy-Org/private is no longer read as this org
🟡 Med A file committed as UTF-16/UTF-32 needs no gitattribute at all — the NUL bytes are in what git stores — so it took the binary skip while GitHub served the blob as readable text BOM-sniffed and decoded ahead of the NUL check. UTF-8's BOM is deliberately not in the table (those bytes already decode down the ordinary path, which carries a mid-codepoint recovery the table has no equivalent of)
🟡 Med The warning cap could bury the only record that coverage was partial: a truncated oversize file counts as scanned with no skipped kind, and a per-file findings truncation had no count either A third coverage field, PARTIAL: <n> file(s) …, with two kinds (read cap, per-file findings cap). Never capped, so 200 symlink warnings can no longer hide that a large file's tail went unread
🟡 Med git check-attr without --cached resolves attributes from the work tree — the same .gitattributes a conversion may have mangled, so a commit converting that file too made the guard fail open --cached: the property being asserted is about the bytes git stores. Verified the bypass first — with a UTF-16 .gitattributes on disk, check-attr returns unspecified and --cached returns UTF-16
🟡 Med The out-of-band control was written as "CODEOWNERS and/or a ruleset requiring review" — but CODEOWNERS alone only requests reviewers and blocks nothing All three places now name a branch-protection rule or ruleset requiring a non-author approving review, and say plainly that CODEOWNERS is not that control on its own
🟡 Med \b[A-Z]{2,6}-\d{2,6}\b matches CVE-2021 inside CVE-2021-44228, so a SECURITY.md or a dependency changelog reddens a required check — and an exact-token carve-out expires every January Public identifier namespaces clear by prefix: CVE-, CWE-, PEP-, RFC-, ISO-, UTF-. (UTF closes a gap the test suite was already working around in its own fixture.) CVEX-1234 is still a finding
🟢 Low Admitting the repo allowlist on the @ path to spare npm scopes also cleared any CODEOWNERS team whose slug matches a public repo name — the commonest naming convention there is Narrowed to the all-lowercase spelling npm requires, so @comfy-org/<pkg> still clears and @Comfy-Org/ComfyUI_frontend is a finding again. Residual noted on the thread: an all-lowercase handle colliding with a public repo name still crosses, and what that discloses is a public string by construction
🟢 Low Treating every working-tree-encoding value as a conversion hard-failed for two that convert nothing: UTF-8 (the identity mapping) and any rule matching a symlink or gitlink, which check-attr answers by path pattern alone UTF-8 exempt casefolded (BOM variants stay fatal); converted filtered to stat.S_ISREG. A path that cannot be stat'd stays in the list — unresolvable is not harmless
🟢 Low The top-level catalog row still carried the absolute tamper claim the rest of the PR had already softened — and it is what most adopters copy from Narrowed to the same honest claim as the other three places, with the caveat attached

One deliberate cost is recorded rather than hidden: because a non-ASCII character after a reference now reads as part of the name, an allowlisted name butted straight against non-Latin prose with no separator (Comfy-Org/ComfyUIを使う) is a finding. Narrowing the rule to characters that casefold onto ASCII would reopen the bypass for every alphabet that does not, so it is deliberately broad; a space clears it, the finding message says so, and a test pins it as a decision.

Risk

The riskiest thing a guard can do is decline to look at something and still report green. After three review rounds, every such route is either traced or fatal:

  • every run prints SCANNED: <n> file(s) read as text — the number everything below has to account against;
  • an exclusion logs its count even at zero; an unreadable file, a non-regular one (FIFO, device), a submodule gitlink, a symlink or an LFS stub is a ::warning:: naming it; binary, non-UTF-8, submodule and LFS files get a per-run NOT SCANNED: count; an oversized file is truncated loudly. Those per-file warnings are capped at 200 with a +N more tail — but the counts are not, so the arithmetic stays complete, and a file read only as far as a cap now has its own uncapped PARTIAL: count rather than relying on a warning that the cap can drop;
  • only two things count towards SCANNED:: a file whose bytes were read as text, and a symlink whose target string was read — that string being the blob git stores and publishes at that path. An LFS stub and a submodule gitlink deliberately do not (the stub is still checked for references — it just cannot prop up the coverage claim);
  • a run that read zero files exits 2, not 0 — as does a tree with no git metadata, an unusable --exclude, tracked content at the reserved _public_repo_hygiene/ path, and a working-tree-encoding gitattribute on any path the run would read;
  • the reusable workflow refuses a collision at _public_repo_hygiene/ before its own checkout, so nothing at that path is ever replaced;
  • workflows_ref must be a full commit SHA and equal job.workflow_sha, or the run fails — so the checker cannot be loaded from a commit other than the one uses: pins. What that cannot cover — which uses: line runs at all — is now documented rather than claimed away, with the out-of-band control named.

Detection semantics have now moved twice: in b0ea12d (#207, under its own review) and again in 982f5dc, where round 4's findings could not be answered any other way — seven of its ten are bypasses in the matching itself. That invalidates the 18/18 parity diff below as a current claim: it describes e6b5b72, and the divergences since are enumerated under "Parity". Re-run it as the first step of the SDK conversions. Every semantic change in 982f5dc is in the strict-tightening direction except two, both narrowings of a false positive (CVE--style namespaces, UTF-8/symlink encoding rules), and each has a test asserting the tightening did not become a loosening.

Verified

Every command below was run against this branch; see the Provenance block for the summary.

Refs BE-8654 — Refs, not Closes: the ticket's SDK-conversion criterion is unmet (it needs PRs in two other repos) and the coverage gap it opens with is untouched by this change. The ticket stays open to carry those.

Provenance

  • Authored by: agent-work loop
  • Verified: re-run at 982f5dc. python3 -m unittest discover -s .github/public-repo-hygiene/tests: 97 tests (was 74), 96 passed / 1 skipped (a non-UTF-8 filename cannot be created on APFS; it runs on the ubuntu-latest runner), 0 failed. All 13 run: steps of test-public-repo-hygiene.yml executed locally (11 previous + 2 new: a pointer-lookalike file is still scanned, a homoglyph name is not cleared): 13 pass — including the clean-repo, per-category, tamper-fixture, additive-inputs, symlink, work-tree-encoding, all-LFS and non-git smokes, all unchanged and all still green after the detection changes. check_workflow_pins.py: OK — 11 workflows declare workflows_ref, none with a default, every ref checkout guarded. workflow-pins tests: 216 passed. git check-attr behaviour asserted empirically before relying on it (--cached returns UTF-16 where the work-tree read returns unspecified, on git 2.54.0). bump-callers suites: test_paths_contract.sh 70 passed, test_preflight.sh 278 passed, test_bump_callers.sh 429 passed, 0 failed. agents-md-integrity tests: 46 passed. shellcheck -x on bump-callers.sh + preflight.sh: clean, and on the new pre-checkout guard step's script: clean, plus a functional run of it against a fixture that tracks the reserved path. Both new workflow files parse as YAML. Parity: the 18/18 diff describes e6b5b72, not the branch tip — see "Parity" above for the two intentional divergences since, and re-run it as the first step of the SDK conversions.
  • Deviations: the two SDK repos are not converted to callers and their scripts are not deleted (cross-repo); the callers roster is not seeded; three detection findings across the review rounds remain deferred to follow-ups rather than fixed here (category-2 host boundaries, unscanned paths, and the _-word-boundary half of category-1 shape) — a fourth was deferred the same way and has since landed as #207, and the CVE--namespace half of the fifth is fixed in 982f5dc. Round 4 did not defer: all ten of its findings are fixed here, because seven were bypasses in the matching itself and deferring a bypass is not the same trade as deferring a widening. Each is explained above. One round-3 finding was declined rather than fixed, with the reasoning on its thread. Round 4 moves detection semantics, so the 18/18 parity diff is stale as a current claim and must be re-run before the SDK copies are deleted — stated under Risk rather than left implicit. One deliberate new false-positive shape (an allowlisted name butted against non-Latin prose with no separator) is accepted, documented and pinned by a test, because the alternative reopens a homoglyph bypass. Separately: AGENTS.md is 305 lines against the 200-line ceiling its own standard enforces — pre-existing (main is already 281) and not gated here, since this repo is not self-enrolled on agents-md-integrity.yml, but this PR does add to it and a trim belongs on its own PR.

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/Comfy-Org/github-workflows/pull/206 **Author:** [@mattmillerai](https://github.com/mattmillerai) **Created:** 8/22/2026 **Status:** ✅ Merged **Merged:** 8/22/2026 **Merged by:** [@mattmillerai](https://github.com/mattmillerai) **Base:** `main` ← **Head:** `matt/be-8654-public-repo-hygiene` --- ### 📝 Commits (6) - [`e6b5b72`](https://github.com/Comfy-Org/github-workflows/commit/e6b5b72559a4d3188be8c5506d8899324eae674f) feat(public-repo-hygiene): centralize the public-repo leak guard as a reusable workflow (BE-8654) - [`b804098`](https://github.com/Comfy-Org/github-workflows/commit/b80409806bd105cce2473baf665e6d335f1d4128) fix(public-repo-hygiene): make every "declined to look" route fail loudly (BE-8654) - [`1b93baf`](https://github.com/Comfy-Org/github-workflows/commit/1b93baf10826126dd92e5ac652178ff50c9d96dd) fix(public-repo-hygiene): close the coverage and log-volume holes the panel found (BE-8654) - [`b0ea12d`](https://github.com/Comfy-Org/github-workflows/commit/b0ea12d7ed244e84668f2e61c9c5acd0d54657a6) fix(public-repo-hygiene): case-insensitive matching and sentence-period stripping (BE-8697) (#207) - [`8af49e6`](https://github.com/Comfy-Org/github-workflows/commit/8af49e6e015bfe0ed044f8ce1c2a6ff56fc86ee1) fix(public-repo-hygiene): close the work-tree-encoding bypass and the remaining coverage-accounting holes (BE-8654) - [`982f5dc`](https://github.com/Comfy-Org/github-workflows/commit/982f5dcec2971a7a5417d7969ee6965aa0fb3606) fix(public-repo-hygiene): close the LFS/homoglyph/encoding bypasses the panel found (BE-8654) ### 📊 Changes **13 files changed** (+3841 additions, -18 deletions) <details> <summary>View changed files</summary> 📝 `.github/bump-callers/README.md` (+1 -0) ➕ `.github/public-repo-hygiene/README.md` (+190 -0) ➕ `.github/public-repo-hygiene/check_public_repo_hygiene.py` (+1232 -0) ➕ `.github/public-repo-hygiene/tests/test_check_public_repo_hygiene.py` (+1326 -0) 📝 `.github/reviewers.yml` (+7 -1) ➕ `.github/workflows/bump-public-repo-hygiene-callers.yml` (+127 -0) ➕ `.github/workflows/public-repo-hygiene.yml` (+273 -0) ➕ `.github/workflows/test-public-repo-hygiene.yml` (+436 -0) 📝 `AGENTS.md` (+28 -4) 📝 `CONTRIBUTING.md` (+3 -0) 📝 `README.md` (+11 -4) 📝 `docs/callers/README.md` (+24 -9) ➕ `docs/callers/public-repo-hygiene.md` (+183 -0) </details> ### 📄 Description ## ELI-5 There is a check that stops internal-only things — ticket ids, Datadog/Linear/Notion links, private repo names — from being published in a public repo. It existed in exactly two repos, and in both of them it ran out of the pull request's own checkout, which means a PR could edit or delete the check that was judging it. This moves the check (and, more importantly, the list of which repos are public) here, where a caller pins it by SHA and cannot touch it. ## What changed - **`public-repo-hygiene.yml`** — the reusable workflow, modelled on `agents-md-integrity.yml`. `workflows_ref` is required with no default, and the checkout's own job runs the empty-ref guard. - **`.github/public-repo-hygiene/`** — one merged checker replacing two divergent copies (a 178-line Python one and a 187-line JavaScript one), plus the org-wide known-public repo/team allowlist, plus a README covering the tamper boundary and the known limitations. - **`test-public-repo-hygiene.yml`** — unit tests (40) plus seven end-to-end smoke steps. - **`bump-public-repo-hygiene-callers.yml`** — the SHA-propagation fleet, over the shared `bump-callers.sh`. - Catalog/table/index updates: `README.md`, `docs/callers/` (index + new setup guide), `AGENTS.md`, `CONTRIBUTING.md`, `.github/bump-callers/README.md`, `.github/reviewers.yml`. ## The tamper boundary, stated plainly The known-public repo and team allowlists are **not** a workflow input and are not readable from the caller's tree. An allowlist a caller can pass is an allowlist a PR in the caller repo can widen, which is the hole being closed. The two knobs that *are* inputs are scoped so they cannot loosen the rule: | Knob | Reaches | Weakening possible? | |---|---|---| | `ticket_allowlist` | category 1 only | No — additive; it adds an acronym, it cannot drop a built-in one | | `exclude_paths` | which files are scanned | It narrows scope, never a category. A value naming the repo root is rejected (exit 2), and every entry is logged with its skipped-file count *including one that skipped nothing* | Both are visible in the caller's workflow diff and in the run log. That is the honest boundary: the *rule* is untouchable from a caller repo, and narrowing the *scope* is loud. Asserted, not just asserted-in-prose: `TamperResistanceTest` plants a wide-open copy of the checker at both the historical per-repo path and the centralized one, a config file, and environment variables named after the module constants — and the verdict does not move. `test-public-repo-hygiene.yml` runs the same fixture end to end. ## Parity, proven before anything is deleted Both SDK checkers and this one were run over the same seeded trees (each SDK repo at its default branch tip plus a corpus exercising every category and shape boundary), and the normalized findings diffed. - **18 of 18 findings identical** across all three implementations on the parity corpus. - Exactly **two intentional divergences**, isolated in their own fixture file: 1. `Comfy-Org/github-workflows` no longer flagged — it is now allowlisted, which is the point of the ticket. 2. `Comfy-Org/comfy-cli.git` no longer flagged — the trailing `.git` strip, which the JavaScript copy already had and the Python one did not. Where the two copies disagreed, the stricter/more-correct one won; the choice is documented rather than silent. - Both SDK default branches are clean under old *and* new with the same exclusions. **Nothing has been deleted.** See "not done" below. **That proof describes `e6b5b72`, not the branch tip.** Two later commits changed detection on purpose, each in its own reviewed change, so the 18/18 diff no longer describes this branch byte-for-byte. Both divergences are strictly in the stricter direction and are pinned by unit tests: - `b0ea12d` (#207, BE-8697) — the org segment and allowlist membership are now matched case-insensitively, and a sentence-final period is stripped. That closes the two carried-over defects described below and makes `comfy-org/<private-repo>` a finding where it previously was not. - `8af49e6` — no category, regex or allowlist entry changed. What changed is *coverage accounting*: an LFS pointer stub and a submodule gitlink no longer count as scanned, and a `working-tree-encoding` gitattribute is refused outright. Re-running the parity diff is the first thing the SDK-conversion PRs should do, since it is what licenses deleting those two copies. ## Carried-over quirks — carried over, then fixed on their own PR A parity proof is only worth something if the migration changed nothing, so two real defects in the original scripts were deliberately carried over rather than fixed in the move: - **A sentence-final period was swallowed by the repo-name character class** — prose ending `…built on Comfy-Org/ComfyUI.` was flagged as `ComfyUI.`. This fires on ordinary English. - **The org segment was matched case-sensitively** — a lowercased `comfy-org/<private-repo>` reference was not flagged, though GitHub resolves owner names case-insensitively. That is a one-keystroke bypass of a default-deny control. Both are now **fixed**, in `b0ea12d` (#207, BE-8697) — its own reviewable change, exactly as intended, rather than bundled into a move. That is where detection parity with the two per-repo scripts deliberately ended; the checker README says so, and unit tests pin both behaviours. The remaining detection findings from the review rounds below are still tracked as follow-ups. ## Coverage: the half this does not fix Sized with the same sweep, pointed at the remainder: - **77** non-archived public Comfy-Org repos (80 including archived). The ticket said 60; the count has grown. - **2** of them have any hygiene guard today — `comfy-python-sdk` and `comfy-typescript-sdk`. Confirmed org-wide by code search, not by inference. - **75 still have none after this merges**, and this PR enrols **zero** callers, so effective coverage is unchanged at 2/77 until adoption lands. Adoption is explicitly out of scope on the ticket: rolling to repos that have never been scanned will surface genuine findings that need triage, which is a ramp, not a flip. ## Not done, and why - **The two SDK repos are not converted to thin callers, and their local scripts are not deleted.** That is a change in `Comfy-Org/comfy-python-sdk` and `Comfy-Org/comfy-typescript-sdk`, outside this repo. Parity — the half the acceptance criteria puts *before* deletion — is proven above and is what makes that conversion safe to do next; the conversion itself needs a PR in each of those repos. - **The `PUBLIC_REPO_HYGIENE_CALLERS` roster is not seeded**, since there are no callers yet. `ALLOW_EMPTY` is `true` and the fleet is a clean no-op; flip it to `false` with the first enrolment. Enrolling a caller is two steps — merge the caller *and* add it to the roster — and skipping the second is the most repeated mistake in this repo. - **The allowlist was not expanded beyond adding `github-workflows`.** Pre-blessing 60-odd names nobody reviewed would trade the default-deny posture for convenience, and a repo that later goes private would silently stop being flagged. Adding one is now a single reviewed edit here instead of one per consumer. - **A possible third mention was never chased down.** A code search for the checker name returned one more hit in a Comfy-Org repo that is *not* public, in a config file rather than an implementation, on the evidence available. The follow-up read was blocked by a GitHub secondary rate limit, so it is recorded rather than assumed away; worth a look during adoption in case it is a third copy. The repo is deliberately not named here — this one is public, which is the entire point of the change. - **This repo is not self-enrolled.** Running the checker against this tree returns 901 findings: the test fixtures are internal-reference-shaped on purpose and the repo's own convention puts `(BE-####)` in commit messages. `detect-unreviewed-merge.yml` is un-self-enrolled here for a comparable reason. ## Review rounds — what the panel found, and what moved ### Rounds 1–2 (`b804098`, `1b93baf`) Ten findings from the cursor-review panel. Five are fixed; five are detection changes and were deferred, with a follow-up specified for each. All ten threads carry a reply and are resolved. **Fixed — every route by which the guard could decline to look and still report green:** | # | Finding | Fix | |---|---|---| | 🟠 High | `workflows_ref` that is not a 40-hex SHA only warned, so nothing enforced that the checker came from the commit `uses:` pinned | Both halves now FAIL the run: 40-hex required, and the ref must equal `job.workflow_sha` — the commit this reusable resolved from, which the runner computes from `uses:` and a caller cannot set | | 🟠 High | `open()` follows symlinks: a tracked link read whatever it pointed at, so one out of the repo pulled arbitrary runner content into a **public** run log, and one to `/dev/zero` or a FIFO was an OOM or a hang | `os.lstat` skips anything not a regular file, with a `::warning::` naming it; reads capped at 5 MiB with the tail warned about; `check` job given `timeout-minutes: 15` (it had none) | | 🟠 High | Tracked content at the reserved `_public_repo_hygiene/` path was skipped unconditionally — and since the workflow's own checkout lands *untracked*, that skip only ever fired in the bypass case | Hard config error, exit 2, naming the path | | 🟠 High | Binary / non-UTF-8 files were skipped silently, so one stray byte hid a whole file that still renders as text on GitHub — and `scanned` counted them anyway | Per-run `NOT SCANNED:` counts; `scanned` now counts only files actually read as text, so an all-binary tree trips the zero-scan net | | 🟡 Med | A run that scanned zero files still exited 0, leaving the root-exclusion rejection one spelling away from pointless | Exit 2, matching the non-git-root case. Every run prints `SCANNED: <n> file(s) read as text` | **Deferred — the five that change what counts as a finding.** This PR's acceptance criterion is a parity proof: old and new run over the same trees, findings diffed 18/18 identical, which is what licenses deleting the two per-repo SDK copies next. Each of these invalidates that proof, so each is its own reviewable change: - ~~**Category 3 case-sensitivity**~~ — **since fixed**, in `b0ea12d` (#207, BE-8697): the org segment and allowlist membership are now matched case-insensitively and the trailing-period false positive is gone. Routed exactly as intended — deferred here, filed, and landed as its own reviewed PR. - **Paths are never scanned** — only file contents are. `docs/<TICKET>-migration.md` or a `notion-exports/` directory passes clean, and the path is the more exposed half. - **Category 1 shape** — *partly closed in round 4*: the `CVE-2024`-style false positive is fixed (public identifier namespaces now clear by prefix, so nothing expires each January). What remains deferred is the other half — `\b` treats `_` as a word character, so `BE-1234_design` never matches — which is a widening of what counts as a finding and belongs on its own PR. None of these is live coverage being lost: this PR enrols **zero** callers. The remaining follow-ups should land before adoption. ### Round 3 (`8af49e6`) Ten more findings. Nine acted on, one declined; all ten threads carry a reply and are resolved. Every one of the fixes is the same shape as round 2's: a route by which the guard declines to look at something and still reports green. | # | Finding | Fix | |---|---|---| | 🟠 High | A tracked `.gitattributes` setting `working-tree-encoding=UTF-16` keeps the committed blob UTF-8 — plainly readable on github.com, in the API and in the diff — while checkout writes NUL-laden UTF-16 to disk, which the scan skipped as `binary` | Hard config error, exit 2, naming the file. Resolved via `git check-attr`, so the answer is git's own, at every directory level plus `.git/info/attributes`. An **excluded** path may still carry one — that hole is already named by its exclusion count | | 🟠 High | `workflows_ref == job.workflow_sha` binds the checker to whatever `uses:` resolved to, but cannot bind *which* `uses:` line runs — a `pull_request` caller executes its workflow file from the PR head | Claim narrowed rather than defended, in all three places it appeared (workflow header, checker README, caller docs), each naming the out-of-band control: CODEOWNERS / a ruleset on the caller's `.github/workflows/`. See "The tamper boundary" above | | 🟡 Med | A git-LFS pointer stub counted towards `scanned`, so `git lfs track '*.md'` plus a leaking commit exited 0 on a required check | Its own `skipped` kind. An all-LFS tree now reads zero files and exits **2** | | 🟡 Med | The reserved-path guard lives in the checker, two steps *after* the checkout that resolves that path — so it reported the collision instead of preventing it | A pre-checkout step in the reusable workflow refuses first (`-e`, `-L`, and `git ls-files`). The checker's guard stays as the in-tree backstop for direct invocation | | 🟡 Med | `warnings` had no cap, unlike the findings — six producers each embed a path, so a tree of tracked symlinks floods a **public** run log | `MAX_WARNINGS_TOTAL` (200) with a `+N more` tail. The **counts** stay uncapped, so the coverage arithmetic is still complete | | 🟢 Low | Submodule gitlinks were reported as "a FIFO, socket or device node" — the wrong type and the wrong reason for the commonest way this branch is reached | A `stat.S_ISDIR` branch ahead of it, with its own skip kind and a message naming it as an unfetched submodule | | 🟢 Low | `test-public-repo-hygiene.yml` set no `timeout-minutes`, while its own fixtures build a FIFO and a link to `/dev/zero` | `timeout-minutes: 15`, matching the scan job | | 🟢 Low | Adding this workflow to the top-level README's `workflows_ref` list left the sentence below it wrong about which workflows reject a non-SHA | The catalog now carries this workflow's stricter tier (40-hex **and** equal to `job.workflow_sha`, runner ≥ v2.334.0) and links to the caller docs for the full three tiers | | ⚪ Nit | Category-2 host patterns have no left DNS-label boundary (`fooslack.com/archives/x` is flagged) and require `/` straight after the host (`notion.so:443/team/page` is not) | **Deferred** — a detection change, routed like the others. Follow-up filed | **Declined — one.** A symlink counting towards `scanned` was flagged as holding the zero-scan net open. It is not: for a symlink the target string *is* the blob git stores and publishes at that path, and `os.readlink` reads it in full. There is no unexamined "elsewhere", which is exactly what distinguishes it from the LFS stub in the same round — that one *did* move to `skipped`. Reasoning is on the thread. ### Round 4 (`982f5dc`) Ten more findings, **all ten fixed**; every thread carries a reply and is resolved. Unlike round 3, this round does move detection semantics — see the note under Risk. | # | Finding | Fix | |---|---|---| | 🟠 High | Moving the LFS stub to `skipped` also stopped *scanning* it, and the classifier was a bare `startswith` — so any tracked file whose first line is the pointer header was skipped whole, with its references unread, while still rendering as plain text on github.com | The full pointer grammar is required (`sha256:<64 hex>` oid + digit `size`, stub-sized), so a lookalike is ordinary text again. And a genuine stub is now **read for findings while still not counting toward `scanned`** — coverage and detection are separate questions, and this is the one file kind where they diverge | | 🟡 Med | The ASCII name class had no right boundary, so the allowlist was tested against a **prefix** of what the file said: `Comfy-Org/comfyui‐internal` written with U+2010 (visually identical to `-`) cleared as `comfyui` | A name the class only partly read is **never cleared** — reported whole, with its own remedy. Not done as a lookahead: refusing to *match* would turn a partly-readable name into no finding at all. The left edge is bounded too, so `NotComfy-Org/private` is no longer read as this org | | 🟡 Med | A file committed as UTF-16/UTF-32 needs no gitattribute at all — the NUL bytes are in what git *stores* — so it took the `binary` skip while GitHub served the blob as readable text | BOM-sniffed and decoded ahead of the NUL check. UTF-8's BOM is deliberately not in the table (those bytes already decode down the ordinary path, which carries a mid-codepoint recovery the table has no equivalent of) | | 🟡 Med | The warning cap could bury the *only* record that coverage was partial: a truncated oversize file counts as `scanned` with no skipped kind, and a per-file findings truncation had no count either | A third coverage field, `PARTIAL: <n> file(s) …`, with two kinds (read cap, per-file findings cap). Never capped, so 200 symlink warnings can no longer hide that a large file's tail went unread | | 🟡 Med | `git check-attr` without `--cached` resolves attributes from the **work tree** — the same `.gitattributes` a conversion may have mangled, so a commit converting that file too made the guard fail open | `--cached`: the property being asserted is about the bytes git *stores*. Verified the bypass first — with a UTF-16 `.gitattributes` on disk, `check-attr` returns `unspecified` and `--cached` returns `UTF-16` | | 🟡 Med | The out-of-band control was written as "CODEOWNERS and/or a ruleset requiring review" — but CODEOWNERS alone only *requests* reviewers and blocks nothing | All three places now name a branch-protection rule or ruleset **requiring a non-author approving review**, and say plainly that CODEOWNERS is not that control on its own | | 🟡 Med | `\b[A-Z]{2,6}-\d{2,6}\b` matches `CVE-2021` inside `CVE-2021-44228`, so a SECURITY.md or a dependency changelog reddens a required check — and an exact-token carve-out expires every January | Public identifier namespaces clear **by prefix**: `CVE-`, `CWE-`, `PEP-`, `RFC-`, `ISO-`, `UTF-`. (`UTF` closes a gap the test suite was already working around in its own fixture.) `CVEX-1234` is still a finding | | 🟢 Low | Admitting the repo allowlist on the `@` path to spare npm scopes also cleared any CODEOWNERS team whose slug matches a public repo name — the commonest naming convention there is | Narrowed to the all-lowercase spelling npm *requires*, so `@comfy-org/<pkg>` still clears and `@Comfy-Org/ComfyUI_frontend` is a finding again. Residual noted on the thread: an all-lowercase handle colliding with a public repo name still crosses, and what that discloses is a public string by construction | | 🟢 Low | Treating every `working-tree-encoding` value as a conversion hard-failed for two that convert nothing: `UTF-8` (the identity mapping) and any rule matching a symlink or gitlink, which `check-attr` answers by path pattern alone | `UTF-8` exempt casefolded (BOM variants stay fatal); `converted` filtered to `stat.S_ISREG`. A path that cannot be stat'd stays in the list — unresolvable is not harmless | | 🟢 Low | The top-level catalog row still carried the absolute tamper claim the rest of the PR had already softened — and it is what most adopters copy from | Narrowed to the same honest claim as the other three places, with the caveat attached | One deliberate cost is recorded rather than hidden: because a non-ASCII character after a reference now reads as *part of the name*, an allowlisted name butted straight against non-Latin prose with no separator (`Comfy-Org/ComfyUIを使う`) is a finding. Narrowing the rule to characters that casefold onto ASCII would reopen the bypass for every alphabet that does not, so it is deliberately broad; a space clears it, the finding message says so, and a test pins it as a decision. ## Risk The riskiest thing a guard can do is decline to look at something and still report green. After three review rounds, every such route is either traced or fatal: - every run prints `SCANNED: <n> file(s) read as text` — the number everything below has to account against; - an exclusion logs its count even at zero; an unreadable file, a non-regular one (FIFO, device), a submodule gitlink, a symlink or an LFS stub is a `::warning::` naming it; binary, non-UTF-8, submodule and LFS files get a per-run `NOT SCANNED:` count; an oversized file is truncated *loudly*. Those per-file warnings are capped at 200 with a `+N more` tail — but the **counts** are not, so the arithmetic stays complete, and a file read only as far as a cap now has its own uncapped `PARTIAL:` count rather than relying on a warning that the cap can drop; - **only two things count towards `SCANNED:`**: a file whose bytes were read as text, and a symlink whose target *string* was read — that string being the blob git stores and publishes at that path. An LFS stub and a submodule gitlink deliberately do not (the stub is still *checked* for references — it just cannot prop up the coverage claim); - a run that read **zero** files exits **2**, not 0 — as does a tree with no git metadata, an unusable `--exclude`, tracked content at the reserved `_public_repo_hygiene/` path, and a `working-tree-encoding` gitattribute on any path the run would read; - the reusable workflow refuses a collision at `_public_repo_hygiene/` *before* its own checkout, so nothing at that path is ever replaced; - `workflows_ref` must be a full commit SHA **and** equal `job.workflow_sha`, or the run fails — so the checker cannot be loaded from a commit other than the one `uses:` pins. What that cannot cover — which `uses:` line runs at all — is now documented rather than claimed away, with the out-of-band control named. **Detection semantics have now moved twice**: in `b0ea12d` (#207, under its own review) and again in `982f5dc`, where round 4's findings could not be answered any other way — seven of its ten are bypasses in the matching itself. That invalidates the 18/18 parity diff below as a *current* claim: it describes `e6b5b72`, and the divergences since are enumerated under "Parity". Re-run it as the first step of the SDK conversions. Every semantic change in `982f5dc` is in the strict-tightening direction except two, both narrowings of a false positive (`CVE-`-style namespaces, `UTF-8`/symlink encoding rules), and each has a test asserting the tightening did not become a loosening. ## Verified Every command below was run against this branch; see the Provenance block for the summary. Refs BE-8654 — `Refs`, not `Closes`: the ticket's SDK-conversion criterion is unmet (it needs PRs in two other repos) and the coverage gap it opens with is untouched by this change. The ticket stays open to carry those. ## Provenance - **Authored by:** agent-work loop - **Verified:** re-run at `982f5dc`. `python3 -m unittest discover -s .github/public-repo-hygiene/tests`: **97** tests (was 74), 96 passed / 1 skipped (a non-UTF-8 filename cannot be created on APFS; it runs on the ubuntu-latest runner), 0 failed. All **13** `run:` steps of `test-public-repo-hygiene.yml` executed locally (11 previous + 2 new: a pointer-lookalike file is still scanned, a homoglyph name is not cleared): 13 pass — including the clean-repo, per-category, tamper-fixture, additive-inputs, symlink, work-tree-encoding, all-LFS and non-git smokes, all unchanged and all still green after the detection changes. `check_workflow_pins.py`: OK — 11 workflows declare `workflows_ref`, none with a default, every ref checkout guarded. `workflow-pins` tests: 216 passed. `git check-attr` behaviour asserted empirically before relying on it (`--cached` returns `UTF-16` where the work-tree read returns `unspecified`, on git 2.54.0). `bump-callers` suites: `test_paths_contract.sh` 70 passed, `test_preflight.sh` 278 passed, `test_bump_callers.sh` 429 passed, 0 failed. `agents-md-integrity` tests: 46 passed. `shellcheck -x` on `bump-callers.sh` + `preflight.sh`: clean, and on the new pre-checkout guard step's script: clean, plus a functional run of it against a fixture that tracks the reserved path. Both new workflow files parse as YAML. Parity: the 18/18 diff describes `e6b5b72`, not the branch tip — see "Parity" above for the two intentional divergences since, and re-run it as the first step of the SDK conversions. - **Deviations:** the two SDK repos are not converted to callers and their scripts are not deleted (cross-repo); the callers roster is not seeded; three detection findings across the review rounds remain deferred to follow-ups rather than fixed here (category-2 host boundaries, unscanned paths, and the `_`-word-boundary half of category-1 shape) — a fourth was deferred the same way and has since landed as #207, and the `CVE-`-namespace half of the fifth is fixed in `982f5dc`. Round 4 did **not** defer: all ten of its findings are fixed here, because seven were bypasses in the matching itself and deferring a bypass is not the same trade as deferring a widening. Each is explained above. One round-3 finding was **declined** rather than fixed, with the reasoning on its thread. Round 4 moves detection semantics, so the 18/18 parity diff is stale as a current claim and must be re-run before the SDK copies are deleted — stated under Risk rather than left implicit. One deliberate new false-positive shape (an allowlisted name butted against non-Latin prose with no separator) is accepted, documented and pinned by a test, because the alternative reopens a homoglyph bypass. Separately: `AGENTS.md` is 305 lines against the 200-line ceiling its own standard enforces — pre-existing (`main` is already 281) and not gated here, since this repo is not self-enrolled on `agents-md-integrity.yml`, but this PR does add to it and a trim belongs on its own PR. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-26 22:37:26 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Comfy-Org/github-workflows#215
No description provided.