From 67e03bbd0bedc3f4fe80a0cf021c6d337a47b68a Mon Sep 17 00:00:00 2001 From: ComfyUI Wiki Date: Fri, 19 Jun 2026 21:02:43 +0800 Subject: [PATCH 1/5] Add CMS Strapi changelog sync pipeline for in-app release notes. Introduces prepare/sync/publish tooling that LLM-simplifies English changelog into gitignored staging, pushes comfyui and cloud drafts to Strapi, auto-refreshes published-versions.json on publish, and adds CI workflows plus agent skills. --- .cursor/skills/cms-changelog-sync/SKILL.md | 227 ++++++++ .cursor/skills/docs-i18n-review/SKILL.md | 102 ++++ .cursor/skills/docs-i18n-translate/SKILL.md | 163 ++++++ .env.local.example | 16 +- .github/scripts/cms/README.md | 124 +++++ .github/scripts/cms/attention-overrides.json | 5 + .github/scripts/cms/changelog-parse.ts | 48 ++ .github/scripts/cms/cms-args.ts | 34 ++ .github/scripts/cms/cms-attention.ts | 57 ++ .github/scripts/cms/cms-config.json | 74 +++ .github/scripts/cms/cms-config.ts | 120 +++++ .github/scripts/cms/cms-env.ts | 19 + .github/scripts/cms/cms-simplify-en.ts | 88 ++++ .github/scripts/cms/cms-simplify-prompt.ts | 60 +++ .github/scripts/cms/cms-staging-io.ts | 108 ++++ .github/scripts/cms/cms-staging-translate.ts | 95 ++++ .github/scripts/cms/cms-strapi-utils.ts | 23 + .github/scripts/cms/cms-translate-client.ts | 95 ++++ .github/scripts/cms/cms-versions.ts | 66 +++ .github/scripts/cms/delete-cms-drafts.ts | 166 ++++++ .github/scripts/cms/format-cms-content.ts | 15 + .github/scripts/cms/prepare-cms-changelog.ts | 184 +++++++ .github/scripts/cms/publish-cms-drafts.ts | 310 +++++++++++ .../scripts/cms/published-versions-sync.ts | 123 +++++ .github/scripts/cms/published-versions.json | 282 ++++++++++ .../cms/published-versions.schema.json | 25 + .github/scripts/cms/published-versions.ts | 97 ++++ .github/scripts/cms/set-cms-attention.ts | 163 ++++++ .github/scripts/cms/simplify-changelog.ts | 34 ++ .github/scripts/cms/strapi-client.ts | 251 +++++++++ .github/scripts/cms/sync-to-strapi.ts | 490 ++++++++++++++++++ .../scripts/cms/update-published-versions.ts | 76 +++ .github/workflows/cms-changelog-sync.yml | 49 ++ .../cms-published-versions-check.yml | 24 + .gitignore | 2 + .mintignore | 1 + AGENTS.md | 92 ++++ changelog/index.mdx | 1 - package.json | 9 +- 39 files changed, 3915 insertions(+), 3 deletions(-) create mode 100644 .cursor/skills/cms-changelog-sync/SKILL.md create mode 100644 .cursor/skills/docs-i18n-review/SKILL.md create mode 100644 .cursor/skills/docs-i18n-translate/SKILL.md create mode 100644 .github/scripts/cms/README.md create mode 100644 .github/scripts/cms/attention-overrides.json create mode 100644 .github/scripts/cms/changelog-parse.ts create mode 100644 .github/scripts/cms/cms-args.ts create mode 100644 .github/scripts/cms/cms-attention.ts create mode 100644 .github/scripts/cms/cms-config.json create mode 100644 .github/scripts/cms/cms-config.ts create mode 100644 .github/scripts/cms/cms-env.ts create mode 100644 .github/scripts/cms/cms-simplify-en.ts create mode 100644 .github/scripts/cms/cms-simplify-prompt.ts create mode 100644 .github/scripts/cms/cms-staging-io.ts create mode 100644 .github/scripts/cms/cms-staging-translate.ts create mode 100644 .github/scripts/cms/cms-strapi-utils.ts create mode 100644 .github/scripts/cms/cms-translate-client.ts create mode 100644 .github/scripts/cms/cms-versions.ts create mode 100644 .github/scripts/cms/delete-cms-drafts.ts create mode 100644 .github/scripts/cms/format-cms-content.ts create mode 100644 .github/scripts/cms/prepare-cms-changelog.ts create mode 100644 .github/scripts/cms/publish-cms-drafts.ts create mode 100644 .github/scripts/cms/published-versions-sync.ts create mode 100644 .github/scripts/cms/published-versions.json create mode 100644 .github/scripts/cms/published-versions.schema.json create mode 100644 .github/scripts/cms/published-versions.ts create mode 100644 .github/scripts/cms/set-cms-attention.ts create mode 100644 .github/scripts/cms/simplify-changelog.ts create mode 100644 .github/scripts/cms/strapi-client.ts create mode 100644 .github/scripts/cms/sync-to-strapi.ts create mode 100644 .github/scripts/cms/update-published-versions.ts create mode 100644 .github/workflows/cms-changelog-sync.yml create mode 100644 .github/workflows/cms-published-versions-check.yml create mode 100644 AGENTS.md diff --git a/.cursor/skills/cms-changelog-sync/SKILL.md b/.cursor/skills/cms-changelog-sync/SKILL.md new file mode 100644 index 000000000..3e4a27ce3 --- /dev/null +++ b/.cursor/skills/cms-changelog-sync/SKILL.md @@ -0,0 +1,227 @@ +--- +name: cms-changelog-sync +description: >- + Sync ComfyUI release notes to Strapi CMS: LLM-simplify English changelog for + in-app popup, translate to zh/ja/ko/fr/ru/es in staging, push drafts to CMS. + Use when updating changelog/index.mdx for CMS, running cms:prepare/cms:sync, + Strapi release-notes, published-versions.json, CMS staging, simplifying + release notes for the notification popup, or cms:publish to go live. +--- + +# CMS Changelog Sync + +Push **draft** release notes to Strapi (`release-notes` content type). Docs changelog stays full; CMS uses **gitignored staging** with popup-sized copy. + +## Architecture + +``` +changelog/index.mdx ← docs source of truth (do not shorten for CMS) + │ + ▼ pnpm cms:prepare:en / cms:prepare +.github/scripts/cms/staging/ + en/changelog/index.mdx ← LLM-simplified popup EN + {zh,ja,ko,fr,ru,es}/… ← translated from simplified EN + │ + ▼ pnpm cms:sync +Strapi CMS (draft only) → manual Publish → published-versions.json +``` + +**Never** edit docs `zh/changelog/` for CMS. **Never** auto-publish in Strapi. + +## Environment (`.env.local`) + +| Variable | Used by | Notes | +|----------|---------|-------| +| `TRANSLATE_API_KEY` | prepare | Same as `pnpm translate` | +| `TRANSLATE_API_BASE_URL` | prepare | e.g. `https://api.deepseek.com` | +| `TRANSLATE_API_MODEL` | prepare | e.g. `deepseek-v4-pro` | +| `CMS_BASE_URL` | sync, delete-drafts | e.g. `https://cms.comfy.org` | +| `CMS_API_TOKEN` | sync, delete-drafts | Strapi API token | +| `CMS_PROJECT` | optional | Default `comfyui`; also `--project cloud` on CLI | + +CI: `CMS_BASE_URL` / `TRANSLATE_API_BASE_URL` → GitHub **Variables**; tokens → **Secrets**. + +## Simplification rules (EN popup) + +Prompt: `.github/scripts/cms/cms-simplify-prompt.ts` +Config: `.github/scripts/cms/cms-config.json` → `simplify` + +| Rule | Value | +|------|-------| +| Total bullets per version | **3–5** (`max_bullets_total: 5`) | +| Section headings max | **2** (`max_sections: 2`) | +| Words per version | ~60–120 | +| Bullet format | `[**Name**](pr_url): 5–12 word function` | +| PR links | **Keep** when source has them; never invent URLs | +| Sections allowed | `**New Open-Source Model Support**`, `**Partner Node Updates**` only | +| Drop | Bug fixes, performance, Load3D, UI, internal refactors | + +Example staging output: + +```markdown +**New Open-Source Model Support** +* [**Depth Anything 3**](https://github.com/Comfy-Org/ComfyUI/pull/13853): Monocular depth estimation model + +**Partner Node Updates** +* [**Kling V3-Turbo**](https://github.com/Comfy-Org/ComfyUI/pull/14528): Text-to-video generation model +``` + +Sync adds header: `# ComfyUI vX.Y.Z` via `format-cms-content.ts`. + +## Projects (comfyui + cloud) + +**Default: both projects** on `cms:prepare`, `cms:sync`, and `cms:publish`. Same changelog content; Strapi `project` field and CMS header differ (`# ComfyUI` vs `# Cloud`). + +| Project | Staging path | CMS header | +|---------|--------------|------------| +| `comfyui` | `staging/{locale}/…` | `# ComfyUI vX.Y.Z` | +| `cloud` | `staging/cloud/{locale}/…` | `# Cloud vX.Y.Z` | + +Prepare runs LLM once on comfyui, then **copies staging to cloud**. Sync/publish push both to Strapi. + +Single project only: `--project cloud` or `CMS_PROJECT=cloud`. + +Mark a version **high** attention: + +```bash +pnpm cms:set-attention -- cloud v0.24.0 high --save +``` + +## Commands + +| Command | Action | +|---------|--------| +| `pnpm cms:prepare:en` | Simplify EN → staging (comfyui + cloud copy) | +| `pnpm cms:prepare` | Simplify EN + translate all locales (both projects) | +| `pnpm cms:preview -- v0.25.1` | Dry-run Strapi push (both projects) | +| `pnpm cms:sync -- v0.25.1` | Push/update **drafts** (both projects) | +| `pnpm cms:publish -- v0.25.1` | Publish + refresh `published-versions.json` (both) | +| `pnpm cms:set-attention -- cloud v0.24.0 high` | Set attention low/high in Strapi | +| `pnpm cms:delete-drafts --preview` | List deletable Strapi drafts | +| `pnpm cms:delete-drafts` | Delete drafts (keeps published) | + +Flags (after `--`): + +- `--force` — re-simplify/re-translate even if staging has the version +- `--preview` / `--dry-run` — no API writes +- `--project cloud` — single project only (default = both) +- `v0.25.1` — explicit version(s) + +Env: + +- `CMS_SYNC_ALL=1` — include already-published versions (backfill) +- Without it, local default = **all unpublished EN versions** per `published-versions.json` + +Requires **Bun**. Loads `.env.local` automatically. + +## Standard workflow + +### New release version + +1. Add full `` block to `changelog/index.mdx` (docs quality — unchanged). +2. **English first** — review popup copy before translating: + + ```bash + pnpm cms:prepare:en -- --force v0.25.1 + ``` + + Inspect: `.github/scripts/cms/staging/en/changelog/index.mdx` + +3. **Translate** after EN approved: + + ```bash + pnpm cms:prepare -- v0.25.1 + ``` + +4. **Push drafts**: + + ```bash + pnpm cms:preview -- v0.25.1 + pnpm cms:sync -- v0.25.1 + ``` + +5. **Publish** after review (CLI or Strapi admin): + + ```bash + pnpm cms:publish --preview -- v0.25.1 + pnpm cms:publish -- v0.25.1 + # published-versions.json is refreshed automatically — commit if changed + ``` + +6. Commit `.github/scripts/cms/published-versions.json` after publish (auto-refreshed by `cms:publish`). + +### Catch up all unpublished versions locally + +```bash +pnpm cms:prepare:en -- --force # all unpublished EN +pnpm cms:prepare -- --force # then all locales +pnpm cms:preview +pnpm cms:sync +``` + +### After prompt or config changes + +Re-run with `--force`. Staging without `--force` **skips** existing `` blocks. + +## Version selection logic + +| Context | Versions processed | +|---------|-------------------| +| Local, no args | EN not in `published-versions.json` (≥ `min_version` 0.21.0) | +| Local + `CMS_SYNC_ALL=1` | All ≥ min_version | +| Explicit `v0.25.1` | That version only | +| CI (`CMS_SYNC_BEFORE` / `CMS_SYNC_AFTER`) | New/changed `` blocks in git diff only | + +`cms:sync` skips locales already published per registry. Published EN in CMS is never overwritten. + +## Key files + +| Path | Role | +|------|------| +| `changelog/index.mdx` | Full docs EN changelog | +| `.github/scripts/cms/staging/` | CMS content (gitignored) | +| `.github/scripts/cms/cms-config.json` | Locales, min version, simplify limits | +| `.github/scripts/cms/published-versions.json` | Published registry (commit after Strapi publish) | +| `.github/scripts/cms/prepare-cms-changelog.ts` | Prepare pipeline | +| `.github/scripts/cms/sync-to-strapi.ts` | Strapi draft sync | +| `.github/scripts/cms/publish-cms-drafts.ts` | Draft → published | +| `.github/scripts/cms/delete-cms-drafts.ts` | Clean bad drafts | +| `.github/workflows/cms-changelog-sync.yml` | CI: prepare → preview → sync on main | + +## Agent checklist + +When user asks to update CMS release notes: + +- [ ] Confirm `changelog/index.mdx` has the new `` block +- [ ] Run `pnpm cms:prepare:en` (with `--force` if redoing); show staging EN for review +- [ ] Wait for user approval before `pnpm cms:prepare` (translations) +- [ ] Run `pnpm cms:preview` then `pnpm cms:sync` +- [ ] Remind: Strapi publish is manual; then `--write` on published-versions +- [ ] Do **not** commit staging/ (gitignored) +- [ ] Do **not** shorten docs changelog for CMS — staging is separate +- [ ] Do **not** run bulk `CMS_SYNC_ALL` prepare/sync without user consent (many API calls) + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| Only one version simplified | Old behavior was latest-only; now defaults to unpublished. Use `CMS_SYNC_ALL=1` for all. | +| Staging skipped | Version already exists — add `--force` | +| Strapi VERSION shows `-` | Bulk sync bug: `version` field null; delete drafts and re-sync | +| Delete draft 500 | Use `pnpm cms:delete-drafts` (locale-only DELETE, not `status=draft`) | +| `English base draft missing` on locale sync | Ensure EN draft exists first; sync creates EN before other locales | +| Background prepare still running | `pkill -f prepare-cms-changelog.ts` | + +## Related skills + +- **`docs-i18n-translate`** — Mintlify docs ja/zh/ko (`pnpm translate`) +- **`docs-i18n-review`** — translation quality review (`pnpm translate:review`) + +## Docs vs CMS (do not confuse) + +| | Docs site | CMS popup | +|--|-----------|-----------| +| Source | `changelog/index.mdx` | `staging/en/…` | +| Length | Full detail | 3–5 bullets | +| i18n | `zh/changelog/` etc. | `staging/zh/` etc. | +| Deploy | Mintlify | Strapi draft → publish | diff --git a/.cursor/skills/docs-i18n-review/SKILL.md b/.cursor/skills/docs-i18n-review/SKILL.md new file mode 100644 index 000000000..38738d957 --- /dev/null +++ b/.cursor/skills/docs-i18n-review/SKILL.md @@ -0,0 +1,102 @@ +--- +name: docs-i18n-review +description: >- + Review ComfyUI docs translation quality with LLM-as-a-judge (review-i18n.ts). + Scores accuracy, completeness, terminology, fluency. Use when reviewing zh/ja/ko + translations, running translate:review, quality-report, reviewSourceHash, or + checking translation quality after pnpm translate. +--- + +# Docs i18n Quality Review + +Advisory LLM-as-a-judge for **existing** ja/zh/ko translations. Separate from translation (`pnpm translate`) and from the model's self-reported `=== MISMATCHES ===` during translate. + +**Not for CMS staging** — reviews committed docs under `{lang}/`. + +## When to use + +- After `pnpm translate` on important pages +- Before merging large i18n PRs +- When user asks to review translation quality +- Spot-check with `--sample N` + +## Environment (`.env.local`) + +Prefer a **cheap/fast** judge model (lighter than translation): + +| Variable | Purpose | +|----------|---------| +| `REVIEW_API_KEY` | Judge API key (falls back to `TRANSLATE_API_KEY`) | +| `REVIEW_API_BASE_URL` | Endpoint (falls back to `TRANSLATE_*`) | +| `REVIEW_API_MODEL` | e.g. `deepseek-v4-flash` | +| `REVIEW_CONCURRENCY` | Default 5; lower if socket errors | + +Requires **Bun**. + +## Commands + +| Command | Action | +|---------|--------| +| `pnpm translate:review` | Pending reviews, all languages | +| `pnpm translate:review -- --lang ko` | One language | +| `pnpm translate:review -- installation/x.mdx` | Specific file(s) | +| `pnpm translate:review -- --all` | Re-review everything | +| `pnpm translate:review -- --sample 20` | N random pending per language | +| `pnpm translate:review -- --min-score 4` | Report files scoring below 4/5 | +| `pnpm translate:review -- --snippets` | Review snippets instead of pages | + +## Output + +Reports (gitignored): `.github/i18n-logs/review/` + +- `quality-report.json` / `.txt` — scores + issues +- **Advisory only** — never written into MDX + +Per-file state in git: `reviewSourceHash` in translated frontmatter (mirrors `translationSourceHash`). Commit hash updates; detailed scores stay in logs. + +## Scoring axes + +1. **Accuracy** — meaning matches English +2. **Completeness** — nothing missing or added +3. **Terminology** — glossary / preserve_terms consistency +4. **Fluency** — natural target language + +## Standard workflow + +```bash +pnpm translate -- changelog/index.mdx --lang ko # translate first +pnpm translate:review -- changelog/index.mdx --lang ko +# Read .github/i18n-logs/review/quality-report.txt +# Fix issues → re-translate affected sections → re-review +``` + +Incremental: skips files already reviewed at current English hash unless `--all`. + +## Agent checklist + +- [ ] Ensure translation is up to date (`translationSourceHash` matches EN) before review +- [ ] Run review after translate, not instead of translate +- [ ] Read report from `.github/i18n-logs/review/` — do not commit logs +- [ ] Fix via re-translate or glossary overrides, not manual score edits +- [ ] For systematic term issues → `glossary/overrides/{lang}.json` (see `docs-i18n-translate` skill) + +## Key files + +| Path | Role | +|------|------| +| `.github/scripts/i18n/review-i18n.ts` | Review entry point | +| `.github/scripts/i18n/glossary.mjs` | Terminology check hints | +| `.github/scripts/i18n/README.md` | Full review section | + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| Nothing to review | Translation stale or already reviewed — run translate or `--all` | +| Socket / timeout errors | Lower `REVIEW_CONCURRENCY`; use faster model | +| Low terminology score | Update glossary overrides, re-translate, re-review | + +## Related skills + +- **`docs-i18n-translate`** — generate/update translations +- **`cms-changelog-sync`** — CMS popup staging (different pipeline) diff --git a/.cursor/skills/docs-i18n-translate/SKILL.md b/.cursor/skills/docs-i18n-translate/SKILL.md new file mode 100644 index 000000000..3034ed54f --- /dev/null +++ b/.cursor/skills/docs-i18n-translate/SKILL.md @@ -0,0 +1,163 @@ +--- +name: docs-i18n-translate +description: >- + Translate ComfyUI Mintlify docs from English MDX to ja/zh/ko using translate-i18n.ts. + Incremental hash sync, chunked long pages, changelog update_blocks, glossary terms. + Use when translating docs, updating zh/ja/ko changelog or pages, running pnpm translate, + translationSourceHash, glossary sync, docs.json i18n, or fixing truncated translations. +--- + +# Docs i18n Translation + +Translate **Mintlify docs** (not CMS). English is source of truth; ja / zh / ko are generated under `{lang}/` and `snippets/{lang}/`. + +**Separate from CMS:** `pnpm cms:prepare` writes gitignored `.github/scripts/cms/staging/` for Strapi. See skill `cms-changelog-sync`. + +## Architecture + +``` +index.mdx, changelog/index.mdx, … ← English (edit here) + │ + ▼ pnpm translate +{ja,zh,ko}/… ← translated MDX (commit to git) +snippets/{ja,zh,ko}/… + │ + ▼ optional +pnpm translate:sync-docs-json ← mirror nav paths in docs.json +``` + +Incremental: each file stores `translationSourceHash` in frontmatter. Unchanged English → skip. + +## Environment (`.env.local`) + +| Variable | Purpose | +|----------|---------| +| `TRANSLATE_API_KEY` | Primary API key | +| `TRANSLATE_API_BASE_URL` | OpenAI-compatible endpoint | +| `TRANSLATE_API_MODEL` | e.g. `deepseek-v4-pro`, `qwen-mt-plus` | +| `TRANSLATE_CONCURRENCY` | Parallel requests (default 5) | +| `FRONTEND_LOCALES_PATH` | Optional; ComfyUI frontend locales for glossary sync | + +Requires **Bun**. + +## Commands + +| Command | Action | +|---------|--------| +| `pnpm translate` | Pending pages + snippets, all languages | +| `pnpm translate:dry-run` | Preview pending work | +| `pnpm translate:force` | Re-translate everything | +| `pnpm translate -- --lang zh,ja` | Specific languages | +| `pnpm translate -- path/to/page.mdx` | Specific file(s) | +| `pnpm translate:snippets` | Snippets only | +| `pnpm translate -- --pages-only` | Skip snippets | +| `pnpm translate:check-truncation` | Scan for truncated output | +| `pnpm translate:repair-truncated -- --lang ko` | Re-translate flagged files | +| `pnpm translate:sync-docs-json` | Sync `docs.json` nav paths (labels preserved) | +| `pnpm translate:sync-docs-json -- --translate-nav-labels` | Also translate new EN nav labels | +| `pnpm glossary:sync` | Rebuild glossary from ComfyUI frontend | +| `pnpm glossary:sync:dry-run` | Preview glossary sync | + +Logs (gitignored): `.github/i18n-logs/translate/` + +## Standard workflow + +### After editing English MDX + +```bash +pnpm translate:dry-run # see pending +pnpm translate -- changelog/index.mdx # or specific paths +pnpm translate:check-truncation # if long page / changelog +``` + +### Changelog (`changelog/index.mdx`) + +- Strategy: `update_blocks` (configured in `translation-config.json`) +- Only **new/changed** `` blocks are translated +- Dates in `description` are localized automatically (ja/zh/ko formats) +- Block hashes stored in `translationBlockHashes` frontmatter + +```bash +pnpm translate -- changelog/index.mdx +pnpm translate -- changelog/index.mdx --lang zh +``` + +### Long pages (truncation risk) + +| Strategy | When | Config | +|----------|------|--------| +| `heading_sections` | Long reference pages | `chunked_files` or `auto_chunk` (≥10k chars, ≥4 `##`) | +| `update_blocks` | Changelog | `chunked_files` entry for `changelog/index.mdx` | + +Checkpoints per block — safe to resume after interrupt. + +```bash +pnpm translate -- tutorials/partner-nodes/pricing.mdx --lang ko +pnpm translate:check-truncation -- --lang ko +pnpm translate:repair-truncated -- --lang ko +``` + +## Terminology (glossary) + +Three layers — see `.github/scripts/i18n/README.md` for detail: + +| Layer | File / config | Effect | +|-------|---------------|--------| +| `preserve_terms` | `translation-config.json` | Keep English (LoRA, checkpoint, …) | +| `glossary/frontend/{lang}.json` | Machine-synced | Mirror ComfyUI frontend | +| `glossary/overrides/{lang}.json` | Hand-edited | Corrections; wins over frontend | + +```bash +pnpm glossary:sync # after frontend locale updates +# Edit overrides/{lang}.json for term decisions +# Edit preserve_terms for English-only terms +``` + +**Never hand-edit** `glossary/frontend/` — run `glossary:sync`. + +## Skipped paths + +`translation-config.json` → `skip_paths`: e.g. `built-in-nodes` (not auto-translated). + +## Agent checklist + +When user updates English docs and needs translations: + +- [ ] Identify changed files (or run `pnpm translate:dry-run`) +- [ ] Run `pnpm translate` for affected paths — **not** `cms:prepare` unless CMS/Strapi +- [ ] For changelog, translate **docs** `zh/changelog/` etc., not CMS staging +- [ ] After long pages, run `pnpm translate:check-truncation` +- [ ] Commit translated MDX + updated `translationSourceHash` / `translationBlockHashes` +- [ ] Do not commit `.github/i18n-logs/` +- [ ] If nav structure changed, run `pnpm translate:sync-docs-json` +- [ ] Optional quality pass: skill `docs-i18n-review` + +## Key files + +| Path | Role | +|------|------| +| `.github/scripts/i18n/translate-i18n.ts` | Entry point | +| `.github/scripts/i18n/chunked-translate.ts` | Block splitting/reassembly | +| `.github/scripts/i18n/translation-config.json` | Languages, skip paths, chunked files | +| `.github/scripts/i18n/glossary.mjs` | Term injection | +| `.github/scripts/i18n/README.md` | Full reference | +| `.github/workflows/i18n-sync-check.yml` | PR reminder for missing translations | + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| File skipped | English hash unchanged — use `pnpm translate:force` or edit EN source | +| Truncated translation | `translate:repair-truncated` or add to `chunked_files` | +| Wrong term | `glossary/overrides/{lang}.json` or `preserve_terms` | +| PR i18n comment | Run `pnpm translate` for listed files | +| Changelog date still English | Re-run translate for that block; dates derived from EN | + +## Docs vs CMS translation + +| | Docs (`pnpm translate`) | CMS (`pnpm cms:prepare`) | +|--|-------------------------|---------------------------| +| Output | `{lang}/changelog/index.mdx` | `staging/{lang}/…` (gitignored) | +| English source | Full docs changelog | LLM-simplified staging EN | +| Purpose | Mintlify site | Strapi in-app popup | +| Commit | Yes | No (staging gitignored) | diff --git a/.env.local.example b/.env.local.example index 5263b1378..fee21525c 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,7 +2,7 @@ # # cp .env.local.example .env.local # -# Used by: npm run translate (see package.json), etc. +# Used by: npm run translate, npm run cms:sync, etc. # Requires Bun: https://bun.sh # ----------------------------------------------------------------------------- @@ -63,3 +63,17 @@ # ----------------------------------------------------------------------------- # GITHUB_TOKEN= + +# ----------------------------------------------------------------------------- +# Strapi CMS — changelog sync (see .github/scripts/cms/README.md) +# Create token: Strapi Admin → Settings → API Tokens (find/create/update release-note) +# ----------------------------------------------------------------------------- + +# CMS_BASE_URL=https://cms.example.com +# CMS_API_TOKEN= +# CMS_PROJECT=comfyui + +# Translation (cms:prepare — same keys as pnpm translate) +# TRANSLATE_API_KEY= +# TRANSLATE_API_BASE_URL= +# TRANSLATE_API_MODEL=qwen-mt-plus diff --git a/.github/scripts/cms/README.md b/.github/scripts/cms/README.md new file mode 100644 index 000000000..ce7177ef5 --- /dev/null +++ b/.github/scripts/cms/README.md @@ -0,0 +1,124 @@ +# CMS Changelog Sync + +Push **draft** release notes to Strapi CMS. Content is **simplified for end users** in staging, separate from full docs changelog. + +## Architecture + +``` +changelog/index.mdx ← full docs (unchanged) + │ + ▼ + pnpm cms:prepare + 1. LLM simplify EN → staging/en/changelog/index.mdx + 2. Translate EN → staging/{zh,ja,ko,fr,ru,es}/… + │ + ▼ + pnpm cms:sync → Strapi drafts (reads staging only) + │ + ▼ + Editor review → Publish → update published-versions.json +``` + +| Command | Role | +|---------|------| +| `pnpm cms:prepare` | Simplify + translate into **staging/** (uses `TRANSLATE_API_KEY`) | +| `pnpm cms:preview` | Dry-run CMS push | +| `pnpm cms:sync` | Push staging → Strapi (uses `CMS_API_TOKEN`) | +| `pnpm cms:publish` | Publish reviewed Strapi drafts → live | + +**Docs site** (`zh/changelog/`, etc.) stays independent. **CMS** uses `.github/scripts/cms/staging/` only. + +## Staging layout (gitignored) + +``` +.github/scripts/cms/staging/ + en/changelog/index.mdx ← comfyui (default project) + zh/ … ja/ … ko/ … fr/ … ru/ … es/ + cloud/ + en/changelog/index.mdx ← cloud project + zh/ … (same locale set) +``` + +Each version is **saved immediately** after simplify/translate (safe to resume). + +## Projects & attention + +| Project | CLI | Default attention | +|---------|-----|-------------------| +| `comfyui` | default / `CMS_PROJECT=comfyui` | `low` | +| `cloud` | `--project cloud` / `CMS_PROJECT=cloud` | `low` | + +Set **high** attention for a specific version: + +```bash +pnpm cms:set-attention -- cloud v0.24.0 high --save +``` + +Persists to `attention-overrides.json` (used on sync). Or edit that file manually. + +## Commands + +```bash +pnpm cms:prepare:en -- --force v0.25.0 # simplify EN (comfyui + cloud copy) +pnpm cms:prepare -- v0.25.1 # translate all locales (both projects) +pnpm cms:preview -- v0.25.1 # dry-run sync (both projects) +pnpm cms:sync -- v0.25.1 # push drafts (both projects) +pnpm cms:publish -- v0.25.1 # publish + refresh published-versions.json +pnpm cms:set-attention -- cloud v0.24.0 high --save +``` + +Default: **comfyui + cloud** on prepare / sync / publish. Single project: `--project cloud`. + +Local default (no args): **all unpublished EN versions** (from `published-versions.json`). Full backfill: `CMS_SYNC_ALL=1`. + +## Simplification rules (EN) + +Prompt: `cms-simplify-prompt.ts` — tuned for a **small in-app notification**, not full docs. + +Configured in `cms-config.json` → `simplify`: + +| Key | Default | Meaning | +|-----|---------|---------| +| `max_bullets_total` | **5** | Bullets for the **entire version** (not per section) | +| `max_sections` | **2** | At most `**New Open-Source Model Support**` + `**Partner Node Updates**` | + +- Each bullet: **[**Name**](pr_url): 5–12 word function** — rewrite, do not copy docs wording +- **Keep PR links** when the source has them +- **Drop** performance tweaks, minor fixes, Load3D/UI housekeeping +- English only (skip translation): `pnpm cms:prepare:en -- --force v0.25.0` + +## Configuration + +| File | Purpose | +|------|---------| +| `cms-config.json` | Locales, projects (comfyui/cloud), simplify settings | +| `attention-overrides.json` | Per-project high-attention versions (optional) | +| `published-versions.json` | Published versions — sync skips these | + +## API tokens + +See `.env.local.example`: `TRANSLATE_API_KEY` (prepare), `CMS_API_TOKEN` (sync). + +## Publish (draft → live) + +After reviewing drafts in Strapi (or trusting staging content): + +```bash +pnpm cms:publish --preview -- v0.25.1 # dry-run +pnpm cms:publish -- v0.25.1 # publish + auto-refresh published-versions.json +pnpm cms:publish -- v0.25.1 --no-registry # publish only, skip JSON +``` + +- Publishes every locale that has a **draft** (en first, then zh/ja/ko/fr/ru/es) +- **Automatically refreshes** `published-versions.json` from Strapi (scans comfyui + cloud) +- Default (no version): all **unpublished EN versions** that have EN drafts in Strapi +- Bulk: add `--yes` or `CMS_PUBLISH_CONFIRM=1`; all drafts: `CMS_PUBLISH_ALL=1` +- Uses Strapi Content API `PUT ?status=published` + +## Published registry + +`cms:publish` refreshes the registry automatically. Manual rescan without publishing: + +```bash +bun .github/scripts/cms/update-published-versions.ts --write +``` diff --git a/.github/scripts/cms/attention-overrides.json b/.github/scripts/cms/attention-overrides.json new file mode 100644 index 000000000..0fb952067 --- /dev/null +++ b/.github/scripts/cms/attention-overrides.json @@ -0,0 +1,5 @@ +{ + "cloud": { + "0.24.0": "high" + } +} diff --git a/.github/scripts/cms/changelog-parse.ts b/.github/scripts/cms/changelog-parse.ts new file mode 100644 index 000000000..e3d21eab8 --- /dev/null +++ b/.github/scripts/cms/changelog-parse.ts @@ -0,0 +1,48 @@ +export interface ReleaseNoteEntry { + version: string; + date: string; + body: string; +} + +export function normalizeVersion(label: string): string { + return label.replace(/^v/i, "").trim(); +} + +export function parseChangelogUpdates(content: string): ReleaseNoteEntry[] { + const entries: ReleaseNoteEntry[] = []; + const re = /([\s\S]*?)<\/Update>/g; + let match: RegExpExecArray | null; + while ((match = re.exec(content)) !== null) { + entries.push({ + version: normalizeVersion(match[1]!), + date: match[2]!.trim(), + body: match[3]!.trim(), + }); + } + return entries; +} + +export function compareSemver(a: string, b: string): number { + const pa = a.split(".").map((n) => Number(n) || 0); + const pb = b.split(".").map((n) => Number(n) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +export function meetsMinVersion(version: string, minVersion: string): boolean { + return compareSemver(version, minVersion) >= 0; +} + +/** Versions added in a git diff hunk for changelog/index.mdx (+ lines only). */ +export function versionsFromGitDiff(diff: string): string[] { + const found = new Set(); + for (const line of diff.split("\n")) { + if (!line.startsWith("+") || line.startsWith("+++")) continue; + const m = line.match(/; +} + +export function attentionOverridesPath(): string { + return join(import.meta.dir, "attention-overrides.json"); +} + +export async function loadAttentionOverrides(): Promise { + try { + const raw = await readFile(attentionOverridesPath(), "utf-8"); + return JSON.parse(raw) as AttentionOverrides; + } catch { + return {}; + } +} + +export async function saveAttentionOverride( + project: string, + version: string, + attention: AttentionLevel +): Promise { + const overrides = await loadAttentionOverrides(); + const cleanVersion = version.replace(/^v/i, ""); + if (!overrides[project]) overrides[project] = {}; + if (attention === "low") { + delete overrides[project][cleanVersion]; + if (Object.keys(overrides[project]).length === 0) delete overrides[project]; + } else { + overrides[project][cleanVersion] = attention; + } + await writeFile(attentionOverridesPath(), `${JSON.stringify(overrides, null, 2)}\n`, "utf-8"); +} + +export function resolveAttention( + config: CmsConfig, + project: string, + version: string, + overrides?: AttentionOverrides +): AttentionLevel { + const cleanVersion = version.replace(/^v/i, ""); + const fromOverrides = overrides?.[project]?.[cleanVersion]; + if (fromOverrides) return fromOverrides; + try { + const p = getProjectConfig(config, project); + return (p.default_attention ?? config.default_attention ?? "low") as AttentionLevel; + } catch { + return (config.default_attention ?? "low") as AttentionLevel; + } +} diff --git a/.github/scripts/cms/cms-config.json b/.github/scripts/cms/cms-config.json new file mode 100644 index 000000000..f62744ecf --- /dev/null +++ b/.github/scripts/cms/cms-config.json @@ -0,0 +1,74 @@ +{ + "content_type_plural": "release-notes", + "project_field": "project", + "version_field": "version", + "content_field": "content", + "attention_field": "attention", + "default_project": "comfyui", + "default_attention": "low", + "min_version": "0.21.0", + "docs_en": "changelog/index.mdx", + "projects": [ + { + "id": "comfyui", + "label": "ComfyUI", + "docs_en": "changelog/index.mdx", + "default_attention": "low", + "min_version": "0.21.0" + }, + { + "id": "cloud", + "label": "Cloud", + "docs_en": "changelog/index.mdx", + "default_attention": "low", + "min_version": "0.21.0" + } + ], + "simplify": { + "max_bullets_total": 5, + "max_sections": 2 + }, + "locales": [ + { + "code": "en", + "source": "simplify", + "changelog": ".github/scripts/cms/staging/en/changelog/index.mdx" + }, + { + "code": "zh", + "source": "translate", + "name": "Simplified Chinese", + "changelog": ".github/scripts/cms/staging/zh/changelog/index.mdx" + }, + { + "code": "ja", + "source": "translate", + "name": "Japanese", + "changelog": ".github/scripts/cms/staging/ja/changelog/index.mdx" + }, + { + "code": "ko", + "source": "translate", + "name": "Korean", + "changelog": ".github/scripts/cms/staging/ko/changelog/index.mdx" + }, + { + "code": "fr", + "source": "translate", + "name": "French", + "changelog": ".github/scripts/cms/staging/fr/changelog/index.mdx" + }, + { + "code": "ru", + "source": "translate", + "name": "Russian", + "changelog": ".github/scripts/cms/staging/ru/changelog/index.mdx" + }, + { + "code": "es", + "source": "translate", + "name": "Spanish", + "changelog": ".github/scripts/cms/staging/es/changelog/index.mdx" + } + ] +} diff --git a/.github/scripts/cms/cms-config.ts b/.github/scripts/cms/cms-config.ts new file mode 100644 index 000000000..4529d1204 --- /dev/null +++ b/.github/scripts/cms/cms-config.ts @@ -0,0 +1,120 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; + +export const STAGING_ROOT = ".github/scripts/cms/staging"; + +export type LocaleSource = "simplify" | "translate"; +export type AttentionLevel = "low" | "high"; + +export interface LocaleConfig { + code: string; + source: LocaleSource; + changelog: string | null; + name?: string; +} + +export interface CmsProjectConfig { + id: string; + label?: string; + docs_en?: string; + default_attention?: AttentionLevel; + min_version?: string; +} + +export interface CmsConfig { + content_type_plural: string; + project_field: string; + version_field: string; + content_field: string; + attention_field: string; + default_project: string; + default_attention: AttentionLevel; + min_version: string; + docs_en: string; + projects?: CmsProjectConfig[]; + simplify?: { + max_bullets_total?: number; + max_sections?: number; + }; + locales: LocaleConfig[]; +} + +/** Legacy comfyui path: staging/{locale}/…; other projects: staging/{project}/{locale}/… */ +export function stagingChangelogPath(project: string, code: string): string { + if (project === "comfyui") { + return `${STAGING_ROOT}/${code}/changelog/index.mdx`; + } + return `${STAGING_ROOT}/${project}/${code}/changelog/index.mdx`; +} + +export function listProjectIds(config: CmsConfig): string[] { + return (config.projects ?? [{ id: config.default_project }]).map((p) => p.id); +} + +export function getProjectConfig(config: CmsConfig, projectId: string): CmsProjectConfig { + const projects = config.projects ?? [{ id: config.default_project }]; + const found = projects.find((p) => p.id === projectId); + if (!found) { + throw new Error(`Unknown project "${projectId}". Known: ${listProjectIds(config).join(", ")}`); + } + return found; +} + +export function projectLabel(config: CmsConfig, projectId: string): string { + const p = getProjectConfig(config, projectId); + return p.label ?? projectId; +} + +export function projectDocsEn(config: CmsConfig, projectId: string): string { + const p = getProjectConfig(config, projectId); + return p.docs_en ?? config.docs_en; +} + +export function projectMinVersion(config: CmsConfig, projectId: string): string { + const p = getProjectConfig(config, projectId); + return p.min_version ?? config.min_version; +} + +export function projectLocales(config: CmsConfig, projectId: string): LocaleConfig[] { + return config.locales.map((l) => ({ + ...l, + changelog: stagingChangelogPath(projectId, l.code), + })); +} + +/** Project-scoped view of cms-config (staging paths, docs source, min version). */ +export function configForProject(config: CmsConfig, projectId: string): CmsConfig { + return { + ...config, + docs_en: projectDocsEn(config, projectId), + min_version: projectMinVersion(config, projectId), + locales: projectLocales(config, projectId), + }; +} + +export async function loadCmsConfig(): Promise { + const raw = await readFile(join(import.meta.dir, "cms-config.json"), "utf-8"); + const config = JSON.parse(raw) as CmsConfig; + for (const locale of config.locales) { + if (!locale.changelog) { + locale.changelog = stagingChangelogPath(config.default_project, locale.code); + } + if (locale.source === "translate" && !locale.name) { + throw new Error(`Locale ${locale.code} requires "name" in cms-config.json`); + } + } + if (!config.docs_en) { + config.docs_en = "changelog/index.mdx"; + } + return config; +} + +export function translateLocales(config: CmsConfig): LocaleConfig[] { + return config.locales.filter((l) => l.code !== "en" && l.source === "translate"); +} + +export function englishStagingLocale(config: CmsConfig): LocaleConfig { + const en = config.locales.find((l) => l.code === "en"); + if (!en?.changelog) throw new Error("Missing en locale in cms-config.json"); + return en; +} diff --git a/.github/scripts/cms/cms-env.ts b/.github/scripts/cms/cms-env.ts new file mode 100644 index 000000000..62ed54ab6 --- /dev/null +++ b/.github/scripts/cms/cms-env.ts @@ -0,0 +1,19 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; + +export const ROOT = join(import.meta.dir, "../../.."); + +export async function loadEnvLocal(): Promise { + try { + const content = await readFile(join(ROOT, ".env.local"), "utf-8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const val = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = val; + } + } catch {} +} diff --git a/.github/scripts/cms/cms-simplify-en.ts b/.github/scripts/cms/cms-simplify-en.ts new file mode 100644 index 000000000..fc20c0226 --- /dev/null +++ b/.github/scripts/cms/cms-simplify-en.ts @@ -0,0 +1,88 @@ +/** + * LLM-simplify English changelog blocks for CMS (small notification copy). + */ + +import type { ReleaseNoteEntry } from "./changelog-parse.ts"; +import type { CmsConfig } from "./cms-config.ts"; +import { + blockForVersion, + readStaging, + stagingHasVersion, + writeStagingCheckpoint, +} from "./cms-staging-io.ts"; +import { + buildSimplifyUserPrompt, + CMS_SIMPLIFY_SYSTEM_PROMPT, + type SimplifyLimits, +} from "./cms-simplify-prompt.ts"; +import { callTranslateApi } from "./cms-translate-client.ts"; + +const DEFAULT_MAX_BULLETS_TOTAL = 5; +const DEFAULT_MAX_SECTIONS = 2; + +function simplifyLimits(config: CmsConfig): SimplifyLimits { + return { + maxBulletsTotal: config.simplify?.max_bullets_total ?? DEFAULT_MAX_BULLETS_TOTAL, + maxSections: config.simplify?.max_sections ?? DEFAULT_MAX_SECTIONS, + }; +} + +export async function prepareSimplifiedEnglish( + config: CmsConfig, + targetEntries: ReleaseNoteEntry[], + docsEnContent: string, + dryRun: boolean, + force = false +): Promise<{ simplified: number; skipped: number }> { + const enLocale = config.locales.find((l) => l.code === "en"); + if (!enLocale?.changelog) { + throw new Error("cms-config.json missing en locale with staging changelog path"); + } + + const limits = simplifyLimits(config); + const relPath = enLocale.changelog; + let existingContent = await readStaging(relPath); + + let simplified = 0; + let skipped = 0; + + console.log( + `[en] simplify → ${relPath} (max ${limits.maxBulletsTotal} bullets, ${limits.maxSections} sections)` + ); + + for (const target of targetEntries) { + if (!force && stagingHasVersion(existingContent, target.version)) { + skipped++; + continue; + } + + const block = blockForVersion(docsEnContent, target.version); + if (!block) continue; + + if (dryRun) { + console.log(` would simplify en/v${target.version}`); + simplified++; + continue; + } + + const simplifiedBody = await callTranslateApi([ + { role: "system", content: CMS_SIMPLIFY_SYSTEM_PROMPT }, + { role: "user", content: buildSimplifyUserPrompt(block.body, limits) }, + ]); + + if (!simplifiedBody.trim()) { + throw new Error(`Empty simplification for v${target.version}`); + } + + const updateContent = `\n\n${simplifiedBody.trim()}\n\n`; + + existingContent = await writeStagingCheckpoint(relPath, existingContent, { + label: block.label, + content: updateContent, + }); + simplified++; + console.log(` simplified en/v${target.version} → saved`); + } + + return { simplified, skipped }; +} diff --git a/.github/scripts/cms/cms-simplify-prompt.ts b/.github/scripts/cms/cms-simplify-prompt.ts new file mode 100644 index 000000000..dc4459cfa --- /dev/null +++ b/.github/scripts/cms/cms-simplify-prompt.ts @@ -0,0 +1,60 @@ +/** + * LLM prompts for simplifying English changelog blocks → CMS staging. + * Tuned for a small in-app notification popup (~3–5 bullets). + */ + +export const CMS_SIMPLIFY_SYSTEM_PROMPT = `You are an expert technical writer creating ComfyUI release notes for a small in-app notification popup. Generate clear, concise release notes based strictly on the provided release data. + +**Critical rules:** +- Use ONLY information from the provided release data. Do not speculate, supplement, or add details not explicitly stated +- Keep descriptions objective and factual. Never use marketing terms like "breakthrough," "revolutionary," or "amazing" +- If release data is incomplete or unclear, only use verifiable information. Do not fill gaps with assumptions + +**Length (strict — tiny popup, not docs):** +- **3–5 bullet points total for the entire version** (fewer if the release is small) +- **At most 2 section headings** when both are needed: + - **New Open-Source Model Support** + - **Partner Node Updates** +- Do NOT add Bug Fixes, Performance, Load3D, UI, or "Other" sections — drop or skip +- **~60–120 words total** per version + +**Bullet format (rewrite — do not copy source wording):** +- * [**Name**](pr_url): Short plain description of what it does +- **Name** = model or partner node name only (recognizable, no long compound titles) +- **Description** = one brief phrase, **5–12 words**, stating the model/node function (e.g. "Video super-resolution model", "LLM partner node for text generation") +- Do NOT paste long changelog sentences, technical implementation details, or node lists from the source +- **Keep the original GitHub PR/issue link** when the source provides one — links are required for linked items +- If the source has no URL, use **bold name only** — never invent links +- Prefer a flat bullet list (no sections) when ≤3 bullets fit + +**What to include (top 3–5 only):** +1. New open-source models (with PR links when available) +2. New or updated partner / API nodes (with PR links when available) +3. One critical user-facing item only if models/nodes are sparse + +**What to drop:** +- Minor fixes, refactors, dtype cleanups, internal tooling, template bumps +- Load3D tweaks, console logging, category reverts, long technical specs + +**Response format:** +Return ONLY the simplified markdown body. No # title, no wrapper, no code fences, no JSON, no explanations.`; + +export interface SimplifyLimits { + maxBulletsTotal: number; + maxSections: number; +} + +export function buildSimplifyUserPrompt( + sourceBody: string, + limits: SimplifyLimits +): string { + return [ + "Rewrite the release note below for a **small in-app popup** — not the docs site.", + "", + `Hard limits: **${limits.maxBulletsTotal} bullets total**, **${limits.maxSections} section headings max**.`, + "Each bullet: linked **name** + 5–12 word function description. **Keep PR links** from the source. Do not copy long source text.", + "", + "=== Release data ===", + sourceBody.trim(), + ].join("\n"); +} diff --git a/.github/scripts/cms/cms-staging-io.ts b/.github/scripts/cms/cms-staging-io.ts new file mode 100644 index 000000000..dc83f1c11 --- /dev/null +++ b/.github/scripts/cms/cms-staging-io.ts @@ -0,0 +1,108 @@ +/** + * Shared read/write helpers for CMS staging MDX (per-version checkpoint). + */ + +import { copyFile, mkdir, readFile, writeFile } from "fs/promises"; +import { dirname, join } from "path"; +import { + parseDocument, + serializeUpdateBlocksDocument, + sortUpdateBlocksByVersion, +} from "../i18n/chunked-translate.ts"; +import { parseChangelogUpdates } from "./changelog-parse.ts"; +import { configForProject, type CmsConfig } from "./cms-config.ts"; +import { ROOT } from "./cms-env.ts"; + +export const STAGING_FRONTMATTER = `--- +title: "Changelog (CMS staging)" +cmsStaging: true +--- + +`; + +export function stagingHasVersion(content: string, version: string): boolean { + return parseChangelogUpdates(content).some((e) => e.version === version); +} + +export function mergeStagingBlocks( + existingContent: string, + newBlocks: Array<{ label: string; content: string }> +): string { + const existing = existingContent.trim() + ? parseDocument(existingContent, "update_blocks").blocks + : []; + const byLabel = new Map(existing.map((b) => [b.label, b.content])); + for (const b of newBlocks) { + byLabel.set(b.label, b.content); + } + const merged = sortUpdateBlocksByVersion( + [...byLabel.entries()].map(([label, content]) => ({ + label, + content, + hash: "", + })) + ); + return serializeUpdateBlocksDocument(STAGING_FRONTMATTER, merged); +} + +export async function readStaging(relPath: string): Promise { + try { + return await readFile(join(ROOT, relPath), "utf-8"); + } catch { + return ""; + } +} + +export async function writeStagingCheckpoint( + relPath: string, + existingContent: string, + block: { label: string; content: string } +): Promise { + const targetPath = join(ROOT, relPath); + const output = mergeStagingBlocks(existingContent, [block]); + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, output); + return output; +} + +/** Copy all locale staging files from one project to another (same changelog body). */ +export async function copyProjectStaging( + baseConfig: CmsConfig, + fromProject: string, + toProject: string +): Promise { + const fromConfig = configForProject(baseConfig, fromProject); + const toConfig = configForProject(baseConfig, toProject); + let copied = 0; + + for (const locale of fromConfig.locales) { + if (!locale.changelog) continue; + const toLocale = toConfig.locales.find((l) => l.code === locale.code); + if (!toLocale?.changelog) continue; + + const fromPath = join(ROOT, locale.changelog); + const toPath = join(ROOT, toLocale.changelog); + try { + await readFile(fromPath, "utf-8"); + } catch { + continue; + } + await mkdir(dirname(toPath), { recursive: true }); + await copyFile(fromPath, toPath); + copied++; + } + + return copied; +} + +export function blockForVersion( + content: string, + version: string +): { label: string; updateBlock: string; body: string } | null { + const entry = parseChangelogUpdates(content).find((e) => e.version === version); + if (!entry) return null; + const label = `v${entry.version}`; + const body = entry.body; + const updateBlock = `\n\n${body}\n\n`; + return { label, updateBlock, body }; +} diff --git a/.github/scripts/cms/cms-staging-translate.ts b/.github/scripts/cms/cms-staging-translate.ts new file mode 100644 index 000000000..fc470ba81 --- /dev/null +++ b/.github/scripts/cms/cms-staging-translate.ts @@ -0,0 +1,95 @@ +/** + * Translate simplified English staging blocks → other locale staging files. + */ + +import { applyChangelogBlockLocalizations, parseDocument } from "../i18n/chunked-translate.ts"; +import { + buildGlossaryPrompt, + loadGlossary, + selectGlossaryForText, +} from "../i18n/glossary.mjs"; +import type { ReleaseNoteEntry } from "./changelog-parse.ts"; +import type { LocaleConfig } from "./cms-config.ts"; +import { + blockForVersion, + readStaging, + stagingHasVersion, + writeStagingCheckpoint, +} from "./cms-staging-io.ts"; +import { translateChangelogUpdateBlock } from "./cms-translate-client.ts"; + +function glossaryBlockFor(enText: string, langCode: string, langName: string): string { + try { + const glossary = loadGlossary(langCode); + const terms = selectGlossaryForText(enText, glossary); + return buildGlossaryPrompt(terms, langName); + } catch { + return ""; + } +} + +export async function prepareStagingLocale( + locale: LocaleConfig, + targetEntries: ReleaseNoteEntry[], + simplifiedEnContent: string, + dryRun: boolean, + force = false +): Promise<{ translated: number; skipped: number }> { + if (!locale.changelog || !locale.name) { + return { translated: 0, skipped: 0 }; + } + + const relPath = locale.changelog; + let existingContent = await readStaging(relPath); + + let translated = 0; + let skipped = 0; + + for (const target of targetEntries) { + const label = `v${target.version}`; + if (!force && stagingHasVersion(existingContent, target.version)) { + skipped++; + continue; + } + + const enBlock = blockForVersion(simplifiedEnContent, target.version); + if (!enBlock) { + console.log(` skip ${locale.code}/v${target.version} — no simplified en block`); + continue; + } + + if (dryRun) { + console.log(` would translate ${locale.code}/v${target.version} → ${relPath}`); + translated++; + continue; + } + + const glossary = glossaryBlockFor(enBlock.updateBlock, locale.code, locale.name); + let block = await translateChangelogUpdateBlock( + enBlock.updateBlock, + locale.name, + locale.code, + glossary + ); + + const localized = applyChangelogBlockLocalizations( + [{ label, content: block }], + parseDocument(enBlock.updateBlock, "update_blocks").blocks, + locale.code + ); + block = localized[0]?.content ?? block; + + if (!block.includes(" wrapper`); + } + + existingContent = await writeStagingCheckpoint(relPath, existingContent, { + label, + content: block, + }); + translated++; + console.log(` translated ${locale.code}/v${target.version} → saved`); + } + + return { translated, skipped }; +} diff --git a/.github/scripts/cms/cms-strapi-utils.ts b/.github/scripts/cms/cms-strapi-utils.ts new file mode 100644 index 000000000..62d3c2727 --- /dev/null +++ b/.github/scripts/cms/cms-strapi-utils.ts @@ -0,0 +1,23 @@ +import { meetsMinVersion } from "./changelog-parse.ts"; +import type { CmsConfig } from "./cms-config.ts"; +import type { StrapiDocument } from "./strapi-client.ts"; + +export function versionFromContent(content: unknown): string { + if (typeof content !== "string") return ""; + const m = content.match(/^#\s*(?:ComfyUI|Cloud)\s+v(\d+\.\d+(?:\.\d+)?)/im); + return m?.[1] ?? ""; +} + +export function docVersion(doc: StrapiDocument, config: CmsConfig): string { + const versionField = String(doc[config.version_field] ?? "").trim(); + if (versionField) return versionField; + return versionFromContent(doc[config.content_field]); +} + +export function docProject(doc: StrapiDocument, config: CmsConfig): string { + return String(doc[config.project_field] ?? config.default_project).trim(); +} + +export function isValidCmsVersion(version: string, minVersion: string): boolean { + return Boolean(version) && meetsMinVersion(version, minVersion); +} diff --git a/.github/scripts/cms/cms-translate-client.ts b/.github/scripts/cms/cms-translate-client.ts new file mode 100644 index 000000000..da9c34a3c --- /dev/null +++ b/.github/scripts/cms/cms-translate-client.ts @@ -0,0 +1,95 @@ +/** + * Minimal OpenAI-compatible client for CMS changelog block translation. + * Uses TRANSLATE_API_* env vars — decoupled from sync-to-strapi.ts. + */ + +const BASE_URL = + process.env.TRANSLATE_API_BASE_URL ?? + process.env.TRANSLATE_CJK_BASE_URL ?? + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; + +const API_KEY = + process.env.TRANSLATE_API_KEY ?? + process.env.DEEPSEEK_API_KEY ?? + process.env.TRANSLATE_CJK_API_KEY ?? + process.env.DASHSCOPE_API_KEY ?? + ""; + +const MODEL = + process.env.TRANSLATE_API_MODEL ?? + process.env.TRANSLATE_CJK_MODEL ?? + "qwen-mt-plus"; + +const IS_QWEN_MT = MODEL.startsWith("qwen-mt"); + +export function requireTranslateApiKey(): string { + if (!API_KEY.trim()) { + throw new Error( + "Missing TRANSLATE_API_KEY (or DEEPSEEK_API_KEY). Required for cms:prepare translate locales." + ); + } + return API_KEY; +} + +export async function callTranslateApi( + messages: Array<{ role: string; content: string }>, + extra?: Record +): Promise { + const key = requireTranslateApiKey(); + const res = await fetch(`${BASE_URL.replace(/\/+$/, "")}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: MODEL, + messages, + temperature: 0.2, + ...extra, + }), + }); + if (!res.ok) { + throw new Error(`Translate API ${res.status}: ${(await res.text()).slice(0, 300)}`); + } + const json = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }; + let text = json.choices?.[0]?.message?.content?.trim() ?? ""; + text = text.replace(/[\s\S]*?<\/think>\s*/g, ""); + text = text.replace(/^```(?:mdx|markdown)?\n/, "").replace(/\n```$/, ""); + return text.trim(); +} + +export async function translateChangelogUpdateBlock( + enBlock: string, + langName: string, + langCode: string, + glossaryBlock?: string +): Promise { + const instructions = [ + `Translate this ComfyUI changelog MDX block to ${langName}.`, + "Keep the exact structure.", + "Preserve URLs, version labels, and product names (ComfyUI, model names).", + "Output ONLY the translated block — no commentary.", + ].join("\n"); + + const parts = [instructions, "", enBlock]; + if (glossaryBlock) parts.push("", glossaryBlock); + + if (IS_QWEN_MT) { + return callTranslateApi([{ role: "user", content: parts.join("\n") }], { + translation_options: { source_lang: "English", target_lang: langName }, + }); + } + + return callTranslateApi([ + { + role: "system", + content: `Expert ${langName} technical translator for ComfyUI release notes.`, + }, + { role: "user", content: parts.join("\n") }, + ]); +} + +export function translateEnvSummary(): string { + return `model=${MODEL} base=${BASE_URL}`; +} diff --git a/.github/scripts/cms/cms-versions.ts b/.github/scripts/cms/cms-versions.ts new file mode 100644 index 000000000..b1078e590 --- /dev/null +++ b/.github/scripts/cms/cms-versions.ts @@ -0,0 +1,66 @@ +import { execSync } from "child_process"; +import { + meetsMinVersion, + parseChangelogUpdates, + versionsFromGitDiff, + type ReleaseNoteEntry, +} from "./changelog-parse.ts"; +import { getSectionSyncStatus } from "../i18n/chunked-translate.ts"; +import { ROOT } from "./cms-env.ts"; + +export function gitDiffChangelog(before: string, after: string): string { + try { + return execSync(`git diff ${before} ${after} -- changelog/index.mdx`, { + encoding: "utf-8", + cwd: ROOT, + }); + } catch { + return ""; + } +} + +export function incrementalVersionSet(): Set | null { + const before = process.env.CMS_SYNC_BEFORE?.trim(); + const after = process.env.CMS_SYNC_AFTER?.trim(); + if (!before || !after) return null; + const diff = gitDiffChangelog(before, after); + const versions = versionsFromGitDiff(diff); + return versions.length > 0 ? new Set(versions) : new Set(); +} + +export function resolveTargetVersions( + entries: ReleaseNoteEntry[], + minVersion: string, + explicitVersions: string[], + incremental: Set | null +): ReleaseNoteEntry[] { + let filtered = entries.filter((e) => meetsMinVersion(e.version, minVersion)); + + if (explicitVersions.length > 0) { + const wanted = new Set(explicitVersions); + filtered = filtered.filter((e) => wanted.has(e.version)); + } else if (incremental && incremental.size > 0) { + filtered = filtered.filter((e) => incremental.has(e.version)); + } + + return filtered; +} + +export function versionLabelsFromEntries(entries: ReleaseNoteEntry[]): string[] { + return entries.map((e) => e.version); +} + +/** Pending `` labels (e.g. v0.25.1) for target semver versions. */ +export function pendingLabelsForVersions( + enContent: string, + targetContent: string, + targetVersions: string[], + langCode: string +): string[] { + const status = getSectionSyncStatus(enContent, targetContent, "update_blocks", false, langCode); + const wanted = new Set(targetVersions); + return status.pendingBlocks.filter((label) => { + const v = label.replace(/^v/i, ""); + return wanted.has(v); + }); +} diff --git a/.github/scripts/cms/delete-cms-drafts.ts b/.github/scripts/cms/delete-cms-drafts.ts new file mode 100644 index 000000000..35bb4744c --- /dev/null +++ b/.github/scripts/cms/delete-cms-drafts.ts @@ -0,0 +1,166 @@ +#!/usr/bin/env bun +/** + * Delete draft release-notes from Strapi CMS (never touches published entries). + * + * Usage: + * pnpm cms:delete-drafts --preview # list what would be deleted + * pnpm cms:delete-drafts # delete draft documents / localizations + */ + +import { meetsMinVersion } from "./changelog-parse.ts"; +import { resolveProject, stripProjectArg } from "./cms-args.ts"; +import { loadCmsConfig } from "./cms-config.ts"; +import { loadEnvLocal } from "./cms-env.ts"; +import { loadPublishedVersions } from "./published-versions.ts"; +import { StrapiClient, type StrapiDocument } from "./strapi-client.ts"; + +function parseArgs(argv: string[]): { dryRun: boolean } { + return { dryRun: argv.some((a) => a === "--dry-run" || a === "--preview") }; +} + +function requireEnv(name: string): string { + const val = process.env[name]?.trim(); + if (!val) { + console.error(`Missing ${name}. See .github/scripts/cms/README.md`); + process.exit(1); + } + return val; +} + +type DraftRow = { + documentId: string; + locale: string; + version: string; + project: string; + content?: string; +}; + +function versionFromContent(content: unknown): string { + if (typeof content !== "string") return ""; + const m = content.match(/^#\s*(?:ComfyUI|Cloud)\s+v(\d+\.\d+(?:\.\d+)?)/im); + return m?.[1] ?? ""; +} + +function rowFromDoc(doc: StrapiDocument, config: Awaited>): DraftRow | null { + const versionField = String(doc[config.version_field] ?? "").trim(); + const content = doc[config.content_field]; + const version = versionField || versionFromContent(content); + const project = String(doc[config.project_field] ?? config.default_project).trim(); + if (!doc.documentId || !version) return null; + return { + documentId: doc.documentId, + locale: String(doc.locale ?? "en"), + version, + project, + content: typeof content === "string" ? content : undefined, + }; +} + +async function main(): Promise { + await loadEnvLocal(); + const { project: cliProject, rest } = stripProjectArg(process.argv.slice(2)); + const { dryRun } = parseArgs(rest); + const config = await loadCmsConfig(); + const project = resolveProject(config, cliProject); + const registry = await loadPublishedVersions(); + const client = new StrapiClient(requireEnv("CMS_BASE_URL"), requireEnv("CMS_API_TOKEN")); + + const draftRows: DraftRow[] = []; + for (const locale of config.locales) { + const docs = await client.listAll(config.content_type_plural, { + status: "draft", + locale: locale.code, + filters: { [config.project_field]: project }, + pageSize: 100, + }); + for (const doc of docs) { + const row = rowFromDoc(doc, config); + if (row && meetsMinVersion(row.version, config.min_version)) draftRows.push(row); + } + } + + const publishedVersions = new Set( + registry.published.filter((e) => e.project === project).map((e) => e.version) + ); + + const enPublishedCache = new Map(); + async function enIsPublished(version: string): Promise { + if (publishedVersions.has(version)) return true; + const cached = enPublishedCache.get(version); + if (cached != null) return cached; + const enPublishedInCms = await client.findOne( + config.content_type_plural, + { + [config.project_field]: project, + [config.version_field]: version, + }, + { locale: "en", status: "published" } + ); + const val = Boolean(enPublishedInCms?.publishedAt); + enPublishedCache.set(version, val); + return val; + } + + const byDocument = new Map(); + for (const row of draftRows) { + const list = byDocument.get(row.documentId) ?? []; + list.push(row); + byDocument.set(row.documentId, list); + } + + const toDeleteWhole: DraftRow[] = []; + const toDeleteLocale: DraftRow[] = []; + + for (const [documentId, rows] of byDocument) { + const enRow = rows.find((r) => r.locale === "en"); + const version = enRow?.version ?? rows[0]?.version ?? ""; + const enPublished = await enIsPublished(version); + + if (enPublished) { + for (const row of rows) { + if (row.locale !== "en") toDeleteLocale.push(row); + } + continue; + } + + if (enRow) { + toDeleteWhole.push(enRow); + } else { + toDeleteWhole.push(rows[0]!); + } + void documentId; + } + + console.log( + `${dryRun ? "Would delete" : "Deleting"} CMS drafts (project=${project}):\n` + + ` whole documents: ${toDeleteWhole.length}\n` + + ` draft localizations only: ${toDeleteLocale.length}\n` + ); + + for (const row of toDeleteWhole.sort((a, b) => a.version.localeCompare(b.version, undefined, { numeric: true }))) { + console.log(` DELETE doc ${row.project}/v${row.version} [all locales] (${row.documentId})`); + if (!dryRun) { + for (const locale of [...config.locales.map((l) => l.code).filter((c) => c !== "en"), "en"]) { + try { + await client.delete(config.content_type_plural, row.documentId, { locale }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("404")) console.warn(` warn ${row.version} [${locale}]: ${msg}`); + } + } + } + } + + for (const row of toDeleteLocale.sort((a, b) => + a.version.localeCompare(b.version, undefined, { numeric: true }) || a.locale.localeCompare(b.locale) + )) { + console.log(` DELETE loc ${row.project}/v${row.version} [${row.locale}] (${row.documentId})`); + if (!dryRun) { + await client.delete(config.content_type_plural, row.documentId, { locale: row.locale }); + } + } + + console.log(`\nDone${dryRun ? " (preview)" : ""}.`); +} + +main(); diff --git a/.github/scripts/cms/format-cms-content.ts b/.github/scripts/cms/format-cms-content.ts new file mode 100644 index 000000000..f4e25673c --- /dev/null +++ b/.github/scripts/cms/format-cms-content.ts @@ -0,0 +1,15 @@ +/** + * Format staging changelog body for Strapi CMS (no further stripping). + */ + +const LEGACY_HEADERS = ["ComfyUI", "Cloud"]; + +export function formatCmsReleaseContent(projectLabel: string, version: string, body: string): string { + const trimmed = body.trim(); + const prefix = `# ${projectLabel} v${version}`; + if (trimmed.startsWith(prefix)) return trimmed; + for (const label of LEGACY_HEADERS) { + if (trimmed.startsWith(`# ${label} v${version}`)) return trimmed; + } + return `${prefix}\n\n${trimmed}`; +} diff --git a/.github/scripts/cms/prepare-cms-changelog.ts b/.github/scripts/cms/prepare-cms-changelog.ts new file mode 100644 index 000000000..10ee87db4 --- /dev/null +++ b/.github/scripts/cms/prepare-cms-changelog.ts @@ -0,0 +1,184 @@ +#!/usr/bin/env bun +/** + * Prepare CMS changelog: LLM-simplify EN → staging, then translate to all locales. + * + * Usage: + * pnpm cms:prepare # comfyui + cloud (cloud copies comfyui staging) + * pnpm cms:prepare --preview + * pnpm cms:prepare -- v0.25.1 + * pnpm cms:prepare -- --project cloud # single project only + * + * Staging output: + * comfyui → .github/scripts/cms/staging/{en,zh,…}/changelog/index.mdx + * cloud → .github/scripts/cms/staging/cloud/{en,zh,…}/changelog/index.mdx + * Docs changelog (full) is NOT modified — only staging copies for CMS. + */ + +import { readFile } from "fs/promises"; +import { join } from "path"; +import { compareSemver, parseChangelogUpdates } from "./changelog-parse.ts"; +import { resolveProjects, stripProjectArg } from "./cms-args.ts"; +import { + configForProject, + englishStagingLocale, + loadCmsConfig, + translateLocales, +} from "./cms-config.ts"; +import { loadEnvLocal, ROOT } from "./cms-env.ts"; +import { + filterUnpublishedEnEntries, + loadPublishedVersions, +} from "./published-versions.ts"; +import { prepareSimplifiedEnglish } from "./cms-simplify-en.ts"; +import { readStaging, copyProjectStaging } from "./cms-staging-io.ts"; +import { prepareStagingLocale } from "./cms-staging-translate.ts"; +import { translateEnvSummary } from "./cms-translate-client.ts"; +import { + incrementalVersionSet, + resolveTargetVersions, + versionLabelsFromEntries, +} from "./cms-versions.ts"; + +function parseArgs(argv: string[]): { + dryRun: boolean; + force: boolean; + enOnly: boolean; + versions: string[]; +} { + let dryRun = false; + let force = false; + let enOnly = false; + const versions: string[] = []; + for (const arg of argv) { + if (arg === "--dry-run" || arg === "--preview") dryRun = true; + else if (arg === "--force") force = true; + else if (arg === "--en-only") enOnly = true; + else if (!arg.startsWith("-")) versions.push(arg.replace(/^v/i, "")); + } + return { dryRun, force, enOnly, versions }; +} + +async function prepareForProject( + baseConfig: Awaited>, + projectId: string, + dryRun: boolean, + force: boolean, + enOnly: boolean, + explicitVersions: string[] +): Promise { + const config = configForProject(baseConfig, projectId); + + const docsEnPath = join(ROOT, config.docs_en); + const docsEnContent = await readFile(docsEnPath, "utf-8"); + const allEnEntries = parseChangelogUpdates(docsEnContent); + + const incremental = incrementalVersionSet(); + if (incremental && incremental.size === 0 && explicitVersions.length === 0) { + console.log(`[${projectId}] No new blocks — nothing to prepare.`); + return; + } + + const targetEntries = resolveTargetVersions( + allEnEntries, + config.min_version, + explicitVersions, + incremental + ); + + let entriesToPrepare = targetEntries; + if (!incremental && explicitVersions.length === 0 && !process.env.CMS_SYNC_ALL?.trim()) { + const registry = await loadPublishedVersions(); + const unpublished = filterUnpublishedEnEntries(registry, projectId, targetEntries); + if (unpublished.length > 0) { + entriesToPrepare = unpublished; + console.log( + `[${projectId}] Local prepare: ${unpublished.length} unpublished EN version(s): ` + + `${versionLabelsFromEntries(unpublished).join(", ")}. ` + + `Use CMS_SYNC_ALL=1 to include already-published versions.` + ); + } else { + const pending = [...targetEntries].sort((a, b) => compareSemver(b.version, a.version)); + const newest = pending[0]; + if (newest) { + entriesToPrepare = [newest]; + console.log( + `[${projectId}] Local prepare: all EN published in registry — latest v${newest.version} only.` + ); + } + } + } + + if (entriesToPrepare.length === 0) { + console.log(`[${projectId}] No versions to prepare (min=${config.min_version}).`); + return; + } + + const targetVersions = versionLabelsFromEntries(entriesToPrepare); + console.log( + `[${projectId}] ${dryRun ? "Would prepare" : "Preparing"} CMS staging for: ${targetVersions.join(", ")}` + ); + + const { simplified, skipped: enSkipped } = await prepareSimplifiedEnglish( + config, + entriesToPrepare, + docsEnContent, + dryRun, + force + ); + + const enStagingPath = englishStagingLocale(config).changelog!; + const simplifiedEnContent = dryRun ? docsEnContent : await readStaging(enStagingPath); + + let translated = 0; + let translateSkipped = 0; + + if (!enOnly) { + for (const locale of translateLocales(config)) { + console.log(`[${projectId}/${locale.code}] translate ← simplified en`); + const result = await prepareStagingLocale( + locale, + entriesToPrepare, + simplifiedEnContent, + dryRun, + force + ); + translated += result.translated; + translateSkipped += result.skipped; + } + } else { + console.log(`[${projectId}] [en-only] skipping locale translation`); + } + + console.log( + `[${projectId}] Done: en simplified=${simplified} (skipped ${enSkipped})` + + (enOnly ? "" : `, locales translated=${translated} (skipped ${translateSkipped})`) + ); + console.log(`[${projectId}] Staging: ${englishStagingLocale(config).changelog}`); +} + +async function main(): Promise { + await loadEnvLocal(); + const { project: cliProject, rest } = stripProjectArg(process.argv.slice(2)); + const { dryRun, force, enOnly, versions: explicitVersions } = parseArgs(rest); + const baseConfig = await loadCmsConfig(); + const projects = resolveProjects(baseConfig, cliProject); + + console.log(`${translateEnvSummary()}\n`); + console.log(`Preparing projects: ${projects.join(", ")}\n`); + + const [primary, ...secondary] = projects; + + await prepareForProject(baseConfig, primary!, dryRun, force, enOnly, explicitVersions); + + for (const projectId of secondary) { + if (dryRun) { + console.log(`[${projectId}] Would copy staging from ${primary}`); + } else { + const copied = await copyProjectStaging(baseConfig, primary!, projectId); + console.log(`[${projectId}] Copied ${copied} staging file(s) from ${primary}`); + } + if (projects.length > 1) console.log(""); + } +} + +main(); diff --git a/.github/scripts/cms/publish-cms-drafts.ts b/.github/scripts/cms/publish-cms-drafts.ts new file mode 100644 index 000000000..4e37c2802 --- /dev/null +++ b/.github/scripts/cms/publish-cms-drafts.ts @@ -0,0 +1,310 @@ +#!/usr/bin/env bun +/** + * Publish Strapi release-note drafts → live (after manual review). + * + * Usage: + * pnpm cms:publish --preview -- v0.25.1 + * pnpm cms:publish -- v0.25.1 # publishes + refreshes published-versions.json + * pnpm cms:publish -- v0.25.1 --no-registry # skip JSON refresh + * pnpm cms:publish -- --yes # all unpublished versions with EN drafts + */ + +import { compareSemver } from "./changelog-parse.ts"; +import { resolveProjects, stripProjectArg } from "./cms-args.ts"; +import { loadCmsConfig, type CmsConfig } from "./cms-config.ts"; +import { loadEnvLocal } from "./cms-env.ts"; +import { + docProject, + docVersion, + isValidCmsVersion, +} from "./cms-strapi-utils.ts"; +import { + filterUnpublishedEnEntries, + loadPublishedVersions, +} from "./published-versions.ts"; +import { + printRegistrySyncResult, + syncPublishedVersionsFromCms, + writePublishedVersionsRegistry, +} from "./published-versions-sync.ts"; +import { StrapiClient } from "./strapi-client.ts"; + +function requireEnv(name: string): string { + const val = process.env[name]?.trim(); + if (!val) { + console.error(`Missing ${name}. See .github/scripts/cms/README.md`); + process.exit(1); + } + return val; +} + +function parseArgs(argv: string[]): { + dryRun: boolean; + skipRegistry: boolean; + yes: boolean; + versions: string[]; +} { + let dryRun = false; + let skipRegistry = false; + let yes = false; + const versions: string[] = []; + for (const arg of argv) { + if (arg === "--dry-run" || arg === "--preview") dryRun = true; + else if (arg === "--no-registry" || arg === "--skip-registry") skipRegistry = true; + else if (arg === "--write-registry") { + // Back-compat: registry refresh is now default; flag is ignored. + } + else if (arg === "--yes" || arg === "-y") yes = true; + else if (!arg.startsWith("-")) versions.push(arg.replace(/^v/i, "")); + } + return { dryRun, skipRegistry, yes, versions }; +} + +async function discoverEnDrafts( + client: StrapiClient, + config: CmsConfig, + project: string +): Promise> { + const docs = await client.listAll(config.content_type_plural, { + status: "draft", + locale: "en", + filters: { [config.project_field]: project }, + pageSize: 100, + }); + + const byVersion = new Map(); + for (const doc of docs) { + const version = docVersion(doc, config); + if (!isValidCmsVersion(version, config.min_version)) continue; + if (docProject(doc, config) !== project) continue; + byVersion.set(version, doc.documentId); + } + return byVersion; +} + +interface PublishTask { + project: string; + version: string; + locale: string; + documentId: string; + action: "publish" | "skip"; + reason?: string; +} + +async function planPublish( + client: StrapiClient, + config: CmsConfig, + project: string, + versionDocumentIds: Map +): Promise { + const tasks: PublishTask[] = []; + + for (const version of [...versionDocumentIds.keys()].sort((a, b) => compareSemver(a, b))) { + const documentId = versionDocumentIds.get(version)!; + + for (const locale of config.locales) { + const draft = await client.getDocument(config.content_type_plural, documentId, { + locale: locale.code, + status: "draft", + }); + + if (!draft) { + const published = await client.getDocument(config.content_type_plural, documentId, { + locale: locale.code, + status: "published", + }); + tasks.push({ + project, + version, + locale: locale.code, + documentId, + action: "skip", + reason: published?.publishedAt ? "already published" : "no draft", + }); + continue; + } + + tasks.push({ + project, + version, + locale: locale.code, + documentId, + action: "publish", + }); + } + } + + return tasks; +} + +function sortTasks(tasks: PublishTask[]): PublishTask[] { + return [...tasks].sort((a, b) => { + if (a.action === "skip" && b.action !== "skip") return 1; + if (b.action === "skip" && a.action !== "skip") return -1; + const v = compareSemver(a.version, b.version); + if (v !== 0) return v; + if (a.locale === "en" && b.locale !== "en") return -1; + if (b.locale === "en" && a.locale !== "en") return 1; + return a.locale.localeCompare(b.locale); + }); +} + +async function resolveTargetVersions( + client: StrapiClient, + config: CmsConfig, + project: string, + explicitVersions: string[] +): Promise> { + const enDrafts = await discoverEnDrafts(client, config, project); + + if (explicitVersions.length > 0) { + const filtered = new Map(); + for (const v of explicitVersions) { + const documentId = enDrafts.get(v); + if (documentId) filtered.set(v, documentId); + } + return filtered; + } + + if (process.env.CMS_PUBLISH_ALL?.trim()) { + return enDrafts; + } + + const registry = await loadPublishedVersions(); + const unpublished = filterUnpublishedEnEntries( + registry, + project, + [...enDrafts.entries()].map(([version]) => ({ version })) + ); + const unpublishedSet = new Set(unpublished.map((e) => e.version)); + const filtered = new Map(); + for (const [version, documentId] of enDrafts) { + if (unpublishedSet.has(version)) filtered.set(version, documentId); + } + return filtered; +} + +async function main(): Promise { + await loadEnvLocal(); + const { project: cliProject, rest } = stripProjectArg(process.argv.slice(2)); + const { dryRun, skipRegistry, yes, versions: explicitVersions } = parseArgs(rest); + const config = await loadCmsConfig(); + const projects = resolveProjects(config, cliProject); + const client = new StrapiClient(requireEnv("CMS_BASE_URL"), requireEnv("CMS_API_TOKEN")); + + const ping = await client.ping(config.content_type_plural); + if (!ping.ok) { + console.error(`CMS unreachable: ${ping.error}`); + process.exit(1); + } + + console.log(`Publishing projects: ${projects.join(", ")}\n`); + + let allActionable: PublishTask[] = []; + let allSkipped: PublishTask[] = []; + let totalVersions = 0; + + for (const project of projects) { + const versionDocumentIds = await resolveTargetVersions( + client, + config, + project, + explicitVersions + ); + + if (versionDocumentIds.size === 0) { + console.log(`[${project}] No release-note drafts to publish.`); + continue; + } + + totalVersions += versionDocumentIds.size; + const tasks = await planPublish(client, config, project, versionDocumentIds); + allActionable.push(...tasks.filter((t) => t.action === "publish")); + allSkipped.push(...tasks.filter((t) => t.action === "skip")); + } + + if (allActionable.length === 0 && allSkipped.length === 0) { + console.log("No release-note drafts to publish."); + return; + } + + const actionable = sortTasks(allActionable); + const skipped = allSkipped; + + if ( + !dryRun && + explicitVersions.length === 0 && + totalVersions > 1 && + !yes && + !process.env.CMS_PUBLISH_CONFIRM?.trim() + ) { + const versions = [...new Set(actionable.map((t) => `${t.project}/v${t.version}`))]; + console.error( + `Will publish ${versions.length} version(s) across ${projects.length} project(s).\n` + + `Re-run with --yes or CMS_PUBLISH_CONFIRM=1 to confirm.` + ); + process.exit(1); + } + + console.log( + `${dryRun ? "Would publish" : "Publishing"} ${actionable.length} locale draft(s)\n` + ); + + for (const t of actionable) { + console.log(` PUBLISH ${t.project}/v${t.version} [${t.locale}]`); + } + if (skipped.length > 0) { + console.log(`\nSkipped ${skipped.length}:`); + for (const t of skipped) { + console.log(` SKIP ${t.project}/v${t.version} [${t.locale}] — ${t.reason}`); + } + } + + if (dryRun) return; + + let ok = 0; + let failed = 0; + for (const task of actionable) { + try { + await client.publishLocale(config.content_type_plural, task.documentId, task.locale); + ok++; + console.log(`Published ${task.project}/v${task.version} [${task.locale}]`); + } catch (err) { + failed++; + console.error( + `Failed ${task.project}/v${task.version} [${task.locale}]: ${ + err instanceof Error ? err.message : err + }` + ); + } + } + + console.log(`\nDone: ${ok} published, ${failed} failed`); + + if (!skipRegistry && failed === 0 && ok > 0) { + console.log("\nRefreshing published-versions.json from CMS (all projects)…"); + try { + const result = await syncPublishedVersionsFromCms(client, config); + printRegistrySyncResult( + result, + config.projects?.map((p) => p.id) ?? [config.default_project] + ); + await writePublishedVersionsRegistry(result.merged); + console.log("Commit published-versions.json if it changed."); + } catch (err) { + console.error( + `Registry refresh failed: ${err instanceof Error ? err.message : err}\n` + + "Run: bun .github/scripts/cms/update-published-versions.ts --write" + ); + process.exit(1); + } + } else if (skipRegistry && ok > 0) { + console.log("\nSkipped registry refresh (--no-registry)."); + } else if (failed > 0) { + console.warn("\nSkipped registry refresh because some publishes failed."); + } + + if (failed > 0) process.exit(1); +} + +main(); diff --git a/.github/scripts/cms/published-versions-sync.ts b/.github/scripts/cms/published-versions-sync.ts new file mode 100644 index 000000000..0c213102a --- /dev/null +++ b/.github/scripts/cms/published-versions-sync.ts @@ -0,0 +1,123 @@ +import type { CmsConfig } from "./cms-config.ts"; +import { listProjectIds, projectMinVersion } from "./cms-config.ts"; +import { meetsMinVersion } from "./changelog-parse.ts"; +import { + loadPublishedVersions, + mergePublishedEntries, + savePublishedVersions, + type PublishedEntry, +} from "./published-versions.ts"; +import type { StrapiClient } from "./strapi-client.ts"; + +export function entryKey(e: PublishedEntry): string { + return `${e.project}:${e.version}:${(e.locales ?? []).join(",")}`; +} + +export async function fetchPublishedFromCms( + client: StrapiClient, + config: CmsConfig, + project: string +): Promise { + const minVersion = projectMinVersion(config, project); + const localeCodes = config.locales.map((l) => l.code); + const byVersion = new Map(); + + for (const locale of localeCodes) { + const docs = await client.listAll(config.content_type_plural, { + status: "published", + locale, + filters: { [config.project_field]: project }, + sort: `${config.version_field}:desc`, + }); + + for (const doc of docs) { + const version = String(doc[config.version_field] ?? ""); + if (!version || !meetsMinVersion(version, minVersion)) continue; + + const key = `${project}:${version}`; + const existing = byVersion.get(key); + const publishedAt = doc.publishedAt ? String(doc.publishedAt) : undefined; + + if (!existing) { + byVersion.set(key, { + project, + version, + locales: [locale], + published_at: publishedAt, + }); + continue; + } + + const locales = new Set(existing.locales ?? []); + locales.add(locale); + byVersion.set(key, { + ...existing, + locales: [...locales].sort(), + published_at: publishedAt ?? existing.published_at, + }); + } + } + + return [...byVersion.values()].sort((a, b) => + a.version.localeCompare(b.version, undefined, { numeric: true }) + ); +} + +export interface RegistrySyncResult { + fromCms: PublishedEntry[]; + merged: PublishedEntry[]; + added: PublishedEntry[]; + removed: PublishedEntry[]; + previousCount: number; +} + +export async function syncPublishedVersionsFromCms( + client: StrapiClient, + config: CmsConfig, + options: { projects?: string[] } = {} +): Promise { + const projects = options.projects?.length ? options.projects : listProjectIds(config); + const fromCms: PublishedEntry[] = []; + + for (const projectId of projects) { + const entries = await fetchPublishedFromCms(client, config, projectId); + fromCms.push(...entries); + } + + const current = await loadPublishedVersions(); + const merged = mergePublishedEntries(current.published, fromCms); + + const currentKeys = new Set(current.published.map(entryKey)); + const mergedKeys = new Set(merged.map(entryKey)); + const added = merged.filter((e) => !currentKeys.has(entryKey(e))); + const removed = current.published.filter((e) => !mergedKeys.has(entryKey(e))); + + return { fromCms, merged, added, removed, previousCount: current.published.length }; +} + +export function printRegistrySyncResult(result: RegistrySyncResult, projects: string[]): void { + console.log(`CMS published scanned: ${result.fromCms.length} (projects: ${projects.join(", ")})`); + console.log(`Registry entries: ${result.previousCount} → ${result.merged.length}`); + + if (result.added.length > 0) { + console.log("\nNew entries to add:"); + for (const e of result.added) { + const loc = e.locales?.join(", ") ?? "all"; + console.log(` + ${e.project}/v${e.version} [${loc}]`); + } + } + if (result.removed.length > 0) { + console.log("\nEntries no longer in registry merge:"); + for (const e of result.removed) { + console.log(` - ${e.project}/v${e.version}`); + } + } + if (result.added.length === 0 && result.removed.length === 0) { + console.log("\nRegistry is up to date with CMS."); + } +} + +export async function writePublishedVersionsRegistry(merged: PublishedEntry[]): Promise { + await savePublishedVersions({ published: merged }); + console.log(`\nWrote ${merged.length} entries → published-versions.json`); +} diff --git a/.github/scripts/cms/published-versions.json b/.github/scripts/cms/published-versions.json new file mode 100644 index 000000000..553e2d64d --- /dev/null +++ b/.github/scripts/cms/published-versions.json @@ -0,0 +1,282 @@ +{ + "published": [ + { + "project": "cloud", + "version": "0.21.0", + "locales": [ + "en" + ], + "published_at": "2026-05-12T00:36:01.426Z" + }, + { + "project": "comfyui", + "version": "0.21.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-05-12T00:21:42.919Z" + }, + { + "project": "cloud", + "version": "0.21.1", + "locales": [ + "en" + ], + "published_at": "2026-05-14T01:57:13.619Z" + }, + { + "project": "comfyui", + "version": "0.21.1", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-05-14T01:40:01.349Z" + }, + { + "project": "cloud", + "version": "0.22.0", + "locales": [ + "en" + ], + "published_at": "2026-05-21T03:40:04.727Z" + }, + { + "project": "comfyui", + "version": "0.22.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-05-20T19:26:34.822Z" + }, + { + "project": "cloud", + "version": "0.22.1", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:59:11.364Z" + }, + { + "project": "comfyui", + "version": "0.22.1", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:35:04.397Z" + }, + { + "project": "cloud", + "version": "0.22.2", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:59:19.075Z" + }, + { + "project": "comfyui", + "version": "0.22.2", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:35:12.630Z" + }, + { + "project": "cloud", + "version": "0.22.3", + "locales": [ + "en" + ], + "published_at": "2026-05-28T12:06:14.649Z" + }, + { + "project": "comfyui", + "version": "0.22.3", + "locales": [ + "en" + ], + "published_at": "2026-05-28T12:05:27.799Z" + }, + { + "project": "cloud", + "version": "0.23.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:59:26.772Z" + }, + { + "project": "comfyui", + "version": "0.23.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:35:19.781Z" + }, + { + "project": "cloud", + "version": "0.24.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:59:34.866Z" + }, + { + "project": "comfyui", + "version": "0.24.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-04T05:42:39.876Z" + }, + { + "project": "cloud", + "version": "0.24.1", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:59:45.126Z" + }, + { + "project": "comfyui", + "version": "0.24.1", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:35:28.448Z" + }, + { + "project": "cloud", + "version": "0.25.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:59:54.950Z" + }, + { + "project": "comfyui", + "version": "0.25.0", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:35:36.814Z" + }, + { + "project": "cloud", + "version": "0.25.1", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T13:00:04.082Z" + }, + { + "project": "comfyui", + "version": "0.25.1", + "locales": [ + "en", + "es", + "fr", + "ja", + "ko", + "ru", + "zh" + ], + "published_at": "2026-06-19T12:35:44.508Z" + } + ] +} diff --git a/.github/scripts/cms/published-versions.schema.json b/.github/scripts/cms/published-versions.schema.json new file mode 100644 index 000000000..2a9ee158f --- /dev/null +++ b/.github/scripts/cms/published-versions.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["published"], + "properties": { + "published": { + "type": "array", + "items": { + "type": "object", + "required": ["project", "version"], + "properties": { + "project": { "type": "string" }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+" }, + "locales": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional. When omitted, all locales for this version are treated as published." + }, + "published_at": { "type": "string", "format": "date-time" }, + "note": { "type": "string" } + } + } + } + } +} diff --git a/.github/scripts/cms/published-versions.ts b/.github/scripts/cms/published-versions.ts new file mode 100644 index 000000000..f56195252 --- /dev/null +++ b/.github/scripts/cms/published-versions.ts @@ -0,0 +1,97 @@ +import { readFile, writeFile } from "fs/promises"; +import { join } from "path"; + +export interface PublishedEntry { + project: string; + version: string; + locales?: string[]; + published_at?: string; + note?: string; +} + +export interface PublishedVersionsFile { + published: PublishedEntry[]; +} + +const CMS_DIR = import.meta.dir; + +export function publishedVersionsPath(): string { + return join(CMS_DIR, "published-versions.json"); +} + +export async function loadPublishedVersions(): Promise { + const raw = await readFile(publishedVersionsPath(), "utf-8"); + return JSON.parse(raw) as PublishedVersionsFile; +} + +export async function savePublishedVersions(data: PublishedVersionsFile): Promise { + await writeFile(publishedVersionsPath(), `${JSON.stringify(data, null, 2)}\n`, "utf-8"); +} + +export function isEnPublishedInRegistry( + registry: PublishedVersionsFile, + project: string, + version: string +): boolean { + return isPublishedInRegistry(registry, project, version, "en"); +} + +/** Changelog entries whose English release note is not in published-versions.json. */ +export function filterUnpublishedEnEntries( + registry: PublishedVersionsFile, + project: string, + entries: T[] +): T[] { + return entries.filter((e) => !isEnPublishedInRegistry(registry, project, e.version)); +} + +export function isPublishedInRegistry( + registry: PublishedVersionsFile, + project: string, + version: string, + locale: string +): boolean { + for (const entry of registry.published) { + if (entry.project !== project || entry.version !== version) continue; + if (!entry.locales || entry.locales.length === 0) return true; + if (entry.locales.includes(locale)) return true; + } + return false; +} + +/** Merge entries; newer published_at wins for same project+version. */ +export function mergePublishedEntries( + existing: PublishedEntry[], + incoming: PublishedEntry[] +): PublishedEntry[] { + const byKey = new Map(); + for (const e of existing) { + byKey.set(`${e.project}:${e.version}`, e); + } + for (const e of incoming) { + const key = `${e.project}:${e.version}`; + const prev = byKey.get(key); + if (!prev) { + byKey.set(key, e); + continue; + } + const prevLocales = new Set(prev.locales ?? []); + const nextLocales = new Set(e.locales ?? []); + const mergedLocales = + prevLocales.size === 0 || nextLocales.size === 0 + ? undefined + : [...new Set([...prevLocales, ...nextLocales])].sort(); + byKey.set(key, { + project: e.project, + version: e.version, + locales: mergedLocales, + published_at: e.published_at ?? prev.published_at, + note: e.note ?? prev.note, + }); + } + return [...byKey.values()].sort((a, b) => { + const v = a.version.localeCompare(b.version, undefined, { numeric: true }); + if (v !== 0) return v; + return a.project.localeCompare(b.project); + }); +} diff --git a/.github/scripts/cms/set-cms-attention.ts b/.github/scripts/cms/set-cms-attention.ts new file mode 100644 index 000000000..0c0cd6fbe --- /dev/null +++ b/.github/scripts/cms/set-cms-attention.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env bun +/** + * Set attention level (low | high) for a release note in Strapi CMS. + * + * Usage: + * pnpm cms:set-attention -- cloud v0.24.0 high + * pnpm cms:set-attention -- --project cloud v0.24.0 high + * pnpm cms:set-attention -- --preview comfyui v0.25.0 low + * pnpm cms:set-attention -- --save cloud v0.24.0 high # persist to attention-overrides.json + * + * Requires CMS_BASE_URL / CMS_API_TOKEN. + */ + +import { resolveProject, stripProjectArg } from "./cms-args.ts"; +import { + loadAttentionOverrides, + saveAttentionOverride, + type AttentionLevel, +} from "./cms-attention.ts"; +import { loadCmsConfig, listProjectIds, projectLabel } from "./cms-config.ts"; +import { loadEnvLocal } from "./cms-env.ts"; +import { StrapiClient } from "./strapi-client.ts"; + +function requireEnv(name: string): string { + const val = process.env[name]?.trim(); + if (!val) { + console.error(`Missing ${name}. See .github/scripts/cms/README.md`); + process.exit(1); + } + return val; +} + +function parseArgs( + argv: string[], + knownProjects: string[] +): { + dryRun: boolean; + save: boolean; + project?: string; + version?: string; + attention?: AttentionLevel; +} { + let dryRun = false; + let save = false; + const { project: cliProject, rest } = stripProjectArg(argv); + let project = cliProject; + let attention: AttentionLevel | undefined; + const positional: string[] = []; + + for (const arg of rest) { + if (arg === "--dry-run" || arg === "--preview") dryRun = true; + else if (arg === "--save") save = true; + else if (arg === "low" || arg === "high") attention = arg; + else if (!arg.startsWith("-")) positional.push(arg); + } + + let version: string | undefined; + if (!project && positional.length >= 2 && knownProjects.includes(positional[0]!)) { + project = positional[0]; + version = positional[1]!.replace(/^v/i, ""); + } else if (positional.length >= 1) { + version = positional[0]!.replace(/^v/i, ""); + } + + return { dryRun, save, project, version, attention }; +} + +async function updateAttentionInStrapi( + client: StrapiClient, + contentType: string, + attentionField: string, + project: string, + version: string, + attention: AttentionLevel, + dryRun: boolean +): Promise { + const filters = { project, version }; + const payload = { [attentionField]: attention }; + + const draft = await client.findOne(contentType, filters, { locale: "en", status: "draft" }); + const published = await client.findOne(contentType, filters, { + locale: "en", + status: "published", + }); + + if (!draft?.documentId && !published?.documentId) { + throw new Error(`No release note found for ${project}/v${version}`); + } + + const docId = draft?.documentId ?? published!.documentId; + + if (dryRun) { + console.log( + `Would set ${project}/v${version} attention=${attention}` + + (draft ? " (draft)" : "") + + (published ? " (published)" : "") + ); + return; + } + + if (draft?.documentId) { + await client.update(contentType, docId, payload, { locale: "en", status: "draft" }); + console.log(`Updated ${project}/v${version} [en] draft → attention=${attention}`); + } + + if (published?.documentId) { + await client.update(contentType, docId, payload, { locale: "en", status: "published" }); + console.log(`Updated ${project}/v${version} [en] published → attention=${attention}`); + } +} + +async function main(): Promise { + await loadEnvLocal(); + const config = await loadCmsConfig(); + const { dryRun, save, project: projectArg, version, attention } = parseArgs( + process.argv.slice(2), + listProjectIds(config) + ); + + if (!version || !attention) { + console.error( + "Usage: pnpm cms:set-attention -- [cloud] v0.24.0 high [--save] [--preview]\n" + + " pnpm cms:set-attention -- --project cloud v0.24.0 high" + ); + process.exit(1); + } + + const project = resolveProject(config, projectArg); + const overrides = await loadAttentionOverrides(); + const current = overrides[project]?.[version] ?? "low (default)"; + + console.log( + `${dryRun ? "Would set" : "Setting"} ${project}/v${version} attention: ${current} → ${attention}` + ); + + if (save && !dryRun) { + await saveAttentionOverride(project, version, attention); + console.log(`Saved override to attention-overrides.json`); + } else if (save && dryRun) { + console.log(`Would save ${project}/v${version}=${attention} to attention-overrides.json`); + } + + const client = new StrapiClient(requireEnv("CMS_BASE_URL"), requireEnv("CMS_API_TOKEN")); + const ping = await client.ping(config.content_type_plural); + if (!ping.ok) { + console.error(`CMS unreachable: ${ping.error}`); + process.exit(1); + } + + await updateAttentionInStrapi( + client, + config.content_type_plural, + config.attention_field, + project, + version, + attention, + dryRun + ); + + void projectLabel(config, project); +} + +main(); diff --git a/.github/scripts/cms/simplify-changelog.ts b/.github/scripts/cms/simplify-changelog.ts new file mode 100644 index 000000000..0f869a36c --- /dev/null +++ b/.github/scripts/cms/simplify-changelog.ts @@ -0,0 +1,34 @@ +/** + * Rule-based simplification of docs changelog blocks → CMS release-note body. + * Keeps top bullets; strips section headers; normalizes list markers. + */ + +const MAX_BULLETS = 12; + +export function simplifyChangelogBody(version: string, body: string): string { + const lines = body.split("\n"); + const bullets: string[] = []; + + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + if (line.startsWith("**") && line.endsWith("**")) continue; + const bulletMatch = line.match(/^[*-]\s+(.+)$/); + if (bulletMatch) { + bullets.push(`- ${bulletMatch[1]!.trim()}`); + if (bullets.length >= MAX_BULLETS) break; + } + } + + if (bullets.length === 0) { + const plain = body + .replace(/\*\*[^*]+\*\*/g, "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 3); + for (const p of plain) bullets.push(`- ${p}`); + } + + return `# ComfyUI v${version}\n\n${bullets.join("\n")}`.trim(); +} diff --git a/.github/scripts/cms/strapi-client.ts b/.github/scripts/cms/strapi-client.ts new file mode 100644 index 000000000..b89a6d16c --- /dev/null +++ b/.github/scripts/cms/strapi-client.ts @@ -0,0 +1,251 @@ +/** + * Minimal Strapi Content API client (Strapi 5, documentId-based REST). + */ + +export type StrapiStatus = "draft" | "published"; + +export interface StrapiListResponse { + data: T[]; + meta?: { pagination?: { pageSize?: number; pageCount?: number; total?: number } }; +} + +export interface StrapiDocument { + id?: number; + documentId: string; + locale?: string; + publishedAt?: string | null; + [key: string]: unknown; +} + +export class StrapiClient { + constructor( + private readonly baseUrl: string, + private readonly apiToken: string + ) {} + + private url(path: string, query?: URLSearchParams): string { + const base = this.baseUrl.replace(/\/+$/, ""); + const p = path.startsWith("/") ? path : `/${path}`; + const qs = query?.toString(); + return qs ? `${base}${p}?${qs}` : `${base}${p}`; + } + + private headers(): HeadersInit { + return { + Authorization: `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }; + } + + private withStatus(params: URLSearchParams, status?: StrapiStatus): URLSearchParams { + if (status) params.set("status", status); + return params; + } + + async ping(contentTypePlural: string): Promise<{ ok: boolean; total?: number; error?: string }> { + try { + const params = new URLSearchParams({ "pagination[pageSize]": "1" }); + const res = await fetch(this.url(`/api/${contentTypePlural}`, params), { + headers: this.headers(), + }); + if (!res.ok) { + const text = await res.text(); + return { ok: false, error: `${res.status} ${res.statusText}: ${text.slice(0, 200)}` }; + } + const json = (await res.json()) as StrapiListResponse; + return { ok: true, total: json.meta?.pagination?.total }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + async list( + contentTypePlural: string, + opts?: { + pageSize?: number; + page?: number; + sort?: string; + locale?: string; + status?: StrapiStatus; + filters?: Record; + } + ): Promise<{ data: StrapiDocument[]; total?: number }> { + const params = new URLSearchParams({ + "pagination[pageSize]": String(opts?.pageSize ?? 25), + "pagination[page]": String(opts?.page ?? 1), + }); + if (opts?.sort) params.set("sort", opts.sort); + if (opts?.locale) params.set("locale", opts.locale); + this.withStatus(params, opts?.status); + for (const [field, value] of Object.entries(opts?.filters ?? {})) { + params.set(`filters[${field}][$eq]`, value); + } + const res = await fetch(this.url(`/api/${contentTypePlural}`, params), { + headers: this.headers(), + }); + if (!res.ok) { + throw new Error(`list failed (${res.status}): ${await res.text()}`); + } + const json = (await res.json()) as StrapiListResponse; + return { data: json.data, total: json.meta?.pagination?.total }; + } + + async listAll( + contentTypePlural: string, + opts?: { + sort?: string; + locale?: string; + status?: StrapiStatus; + filters?: Record; + pageSize?: number; + } + ): Promise { + const pageSize = opts?.pageSize ?? 100; + const all: StrapiDocument[] = []; + let page = 1; + let total = Infinity; + while (all.length < total) { + const { data, total: t } = await this.list(contentTypePlural, { + ...opts, + page, + pageSize, + }); + if (t != null) total = t; + if (data.length === 0) break; + all.push(...data); + page++; + if (data.length < pageSize) break; + } + return all; + } + + async findOne( + contentTypePlural: string, + filters: Record, + opts?: { locale?: string; status?: StrapiStatus } + ): Promise { + const params = new URLSearchParams({ "pagination[pageSize]": "1" }); + for (const [field, value] of Object.entries(filters)) { + params.set(`filters[${field}][$eq]`, value); + } + if (opts?.locale) params.set("locale", opts.locale); + this.withStatus(params, opts?.status); + const res = await fetch(this.url(`/api/${contentTypePlural}`, params), { + headers: this.headers(), + }); + if (!res.ok) { + throw new Error(`findOne failed (${res.status}): ${await res.text()}`); + } + const json = (await res.json()) as StrapiListResponse; + return json.data[0] ?? null; + } + + async create( + contentTypePlural: string, + data: Record, + opts?: { locale?: string; status?: StrapiStatus } + ): Promise { + const params = new URLSearchParams(); + if (opts?.locale) params.set("locale", opts.locale); + this.withStatus(params, opts?.status ?? "draft"); + const res = await fetch(this.url(`/api/${contentTypePlural}`, params), { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ data }), + }); + if (!res.ok) { + throw new Error(`create failed (${res.status}): ${await res.text()}`); + } + const json = (await res.json()) as { data: StrapiDocument }; + return json.data; + } + + async update( + contentTypePlural: string, + documentId: string, + data: Record, + opts?: { locale?: string; status?: StrapiStatus } + ): Promise { + const params = new URLSearchParams(); + if (opts?.locale) params.set("locale", opts.locale); + this.withStatus(params, opts?.status ?? "draft"); + const res = await fetch(this.url(`/api/${contentTypePlural}/${documentId}`, params), { + method: "PUT", + headers: this.headers(), + body: JSON.stringify({ data }), + }); + if (!res.ok) { + throw new Error(`update failed (${res.status}): ${await res.text()}`); + } + const json = (await res.json()) as { data: StrapiDocument }; + return json.data; + } + + async getDocument( + contentTypePlural: string, + documentId: string, + opts: { locale: string; status?: StrapiStatus } + ): Promise { + const params = new URLSearchParams({ locale: opts.locale }); + this.withStatus(params, opts.status ?? "draft"); + const res = await fetch(this.url(`/api/${contentTypePlural}/${documentId}`, params), { + headers: this.headers(), + }); + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`getDocument failed (${res.status}): ${await res.text()}`); + } + const json = (await res.json()) as { data: StrapiDocument | null }; + return json.data ?? null; + } + + /** Publish the draft locale version (Strapi 5 Content API). */ + async publishLocale( + contentTypePlural: string, + documentId: string, + locale: string + ): Promise { + const draft = await this.getDocument(contentTypePlural, documentId, { + locale, + status: "draft", + }); + + const data: Record = {}; + if (draft) { + for (const key of ["content", "project", "version", "attention"] as const) { + if (draft[key] != null && draft[key] !== "") data[key] = draft[key]; + } + } + + const params = new URLSearchParams({ locale, status: "published" }); + const res = await fetch(this.url(`/api/${contentTypePlural}/${documentId}`, params), { + method: "PUT", + headers: this.headers(), + body: JSON.stringify({ data }), + }); + if (!res.ok) { + throw new Error(`publish failed (${res.status}): ${await res.text()}`); + } + const json = (await res.json()) as { data: StrapiDocument }; + return json.data; + } + + async delete( + contentTypePlural: string, + documentId: string, + opts?: { locale?: string; status?: StrapiStatus } + ): Promise { + const params = new URLSearchParams(); + if (opts?.locale) params.set("locale", opts.locale); + // Strapi 5 returns 500 when status=draft is passed on DELETE; locale-only works. + if (opts?.status) this.withStatus(params, opts.status); + const res = await fetch(this.url(`/api/${contentTypePlural}/${documentId}`, params), { + method: "DELETE", + headers: this.headers(), + }); + if (!res.ok) { + throw new Error(`delete failed (${res.status}): ${await res.text()}`); + } + } +} diff --git a/.github/scripts/cms/sync-to-strapi.ts b/.github/scripts/cms/sync-to-strapi.ts new file mode 100644 index 000000000..ddde05bc3 --- /dev/null +++ b/.github/scripts/cms/sync-to-strapi.ts @@ -0,0 +1,490 @@ +#!/usr/bin/env bun +/** + * Sync changelog release notes to Strapi CMS as drafts (incremental). + * + * Usage: + * pnpm cms:preview # dry-run — show what would sync + * pnpm cms:sync # push drafts to CMS + * + * Requires Bun + CMS_BASE_URL / CMS_API_TOKEN (see README.md in this folder). + */ + +import { readFile } from "fs/promises"; +import { join } from "path"; +import { compareSemver, parseChangelogUpdates, type ReleaseNoteEntry } from "./changelog-parse.ts"; +import { loadAttentionOverrides, resolveAttention } from "./cms-attention.ts"; +import { resolveProjects, stripProjectArg } from "./cms-args.ts"; +import { loadCmsConfig, configForProject, projectLabel, type CmsConfig, type LocaleConfig } from "./cms-config.ts"; +import { loadEnvLocal, ROOT } from "./cms-env.ts"; +import { incrementalVersionSet, resolveTargetVersions } from "./cms-versions.ts"; +import { + filterUnpublishedEnEntries, + isPublishedInRegistry, + loadPublishedVersions, + type PublishedVersionsFile, +} from "./published-versions.ts"; +import { formatCmsReleaseContent } from "./format-cms-content.ts"; +import { StrapiClient, type StrapiDocument } from "./strapi-client.ts"; + +interface SyncTask { + project: string; + version: string; + locale: string; + date: string; + body: string; + action: "create" | "update" | "skip"; + reason?: string; +} + +function requireEnv(name: string): string { + const val = process.env[name]?.trim(); + if (!val) { + console.error(`Missing ${name}. See .github/scripts/cms/README.md`); + process.exit(1); + } + return val; +} + +function parseArgs(argv: string[]): { dryRun: boolean; versions: string[] } { + let dryRun = false; + const versions: string[] = []; + for (const arg of argv) { + if (arg === "--dry-run" || arg === "--preview") dryRun = true; + else if (!arg.startsWith("-")) versions.push(arg.replace(/^v/i, "")); + } + return { dryRun, versions }; +} + +async function loadAllLocaleEntries( + locales: LocaleConfig[], + versionFilter: Set | null +): Promise>> { + const byLocale = new Map>(); + for (const locale of locales) { + if (!locale.changelog) continue; + let raw = ""; + try { + raw = await readFile(join(ROOT, locale.changelog), "utf-8"); + } catch { + byLocale.set(locale.code, new Map()); + continue; + } + const map = new Map(); + for (const entry of parseChangelogUpdates(raw)) { + if (versionFilter && !versionFilter.has(entry.version)) continue; + map.set(entry.version, entry); + } + byLocale.set(locale.code, map); + } + return byLocale; +} + +async function resolveDocState( + client: StrapiClient, + config: CmsConfig, + project: string, + version: string, + locale: string +): Promise<{ published: boolean; draft: StrapiDocument | null; enDocumentId?: string }> { + const filters = { + [config.project_field]: project, + [config.version_field]: version, + }; + + const published = await client.findOne(config.content_type_plural, filters, { + locale, + status: "published", + }); + if (published?.publishedAt) { + return { published: true, draft: null, enDocumentId: published.documentId }; + } + + const draft = await client.findOne(config.content_type_plural, filters, { + locale, + status: "draft", + }); + + let enDocumentId: string | undefined; + if (locale !== "en") { + const enDraft = await client.findOne(config.content_type_plural, filters, { + locale: "en", + status: "draft", + }); + const enPublished = await client.findOne(config.content_type_plural, filters, { + locale: "en", + status: "published", + }); + enDocumentId = enDraft?.documentId ?? enPublished?.documentId; + } else { + enDocumentId = draft?.documentId; + } + + return { published: false, draft, enDocumentId }; +} + +async function findEnglishDocumentId( + client: StrapiClient, + config: CmsConfig, + project: string, + version: string, + cache: Map +): Promise { + const cached = cache.get(version); + if (cached) return cached; + + const filters = { + [config.project_field]: project, + [config.version_field]: version, + }; + const enDraft = await client.findOne(config.content_type_plural, filters, { + locale: "en", + status: "draft", + }); + if (enDraft?.documentId) { + cache.set(version, enDraft.documentId); + return enDraft.documentId; + } + const enPublished = await client.findOne(config.content_type_plural, filters, { + locale: "en", + status: "published", + }); + if (enPublished?.documentId) { + cache.set(version, enPublished.documentId); + return enPublished.documentId; + } + return undefined; +} + +function buildPayload( + config: CmsConfig, + task: SyncTask, + content: string, + attention: string +): Record { + const payload: Record = { + [config.content_field]: content, + [config.project_field]: task.project, + [config.version_field]: task.version, + }; + if (task.locale === "en") { + payload[config.attention_field] = attention; + } + return payload; +} + +function sortSyncTasks(tasks: SyncTask[]): SyncTask[] { + return [...tasks].sort((a, b) => { + if (a.action === "skip" && b.action !== "skip") return 1; + if (b.action === "skip" && a.action !== "skip") return -1; + // Oldest version first; within each version: en → other locales + const v = compareSemver(a.version, b.version); + if (v !== 0) return v; + if (a.locale === "en" && b.locale !== "en") return -1; + if (b.locale === "en" && a.locale !== "en") return 1; + return a.locale.localeCompare(b.locale); + }); +} + +async function planSync( + client: StrapiClient, + config: CmsConfig, + project: string, + registry: PublishedVersionsFile, + targetVersions: ReleaseNoteEntry[], + byLocale: Map> +): Promise { + const tasks: SyncTask[] = []; + + for (const base of targetVersions) { + for (const locale of config.locales) { + if (!locale.changelog) continue; + + const entry = byLocale.get(locale.code)?.get(base.version); + if (!entry) { + tasks.push({ + project, + version: base.version, + locale: locale.code, + date: base.date, + body: "", + action: "skip", + reason: "no changelog entry for locale", + }); + continue; + } + + if (isPublishedInRegistry(registry, project, base.version, locale.code)) { + tasks.push({ + project, + version: base.version, + locale: locale.code, + date: entry.date, + body: entry.body, + action: "skip", + reason: "published (registry)", + }); + continue; + } + + const state = await resolveDocState(client, config, project, base.version, locale.code); + if (state.published) { + tasks.push({ + project, + version: base.version, + locale: locale.code, + date: entry.date, + body: entry.body, + action: "skip", + reason: "published (CMS)", + }); + continue; + } + + const enInChangelog = byLocale.get("en")?.has(base.version) ?? false; + + if (locale.code !== "en" && !state.enDocumentId && !enInChangelog) { + tasks.push({ + project, + version: base.version, + locale: locale.code, + date: entry.date, + body: entry.body, + action: "skip", + reason: "no English changelog entry", + }); + continue; + } + + tasks.push({ + project, + version: base.version, + locale: locale.code, + date: entry.date, + body: entry.body, + action: state.draft ? "update" : "create", + }); + } + } + + return tasks; +} + +function printTasks(tasks: SyncTask[], dryRun: boolean): void { + const actionable = tasks.filter((t) => t.action !== "skip"); + const skipped = tasks.filter((t) => t.action === "skip"); + + console.log(`${dryRun ? "Would sync" : "Syncing"} ${actionable.length} draft(s):\n`); + for (const t of actionable) { + console.log(` ${t.action.toUpperCase().padEnd(6)} ${t.project}/v${t.version} [${t.locale}]`); + } + + if (skipped.length > 0) { + console.log(`\nSkipped ${skipped.length}:`); + for (const t of skipped) { + console.log(` SKIP ${t.project}/v${t.version} [${t.locale}] — ${t.reason}`); + } + } +} + +async function executeTasks( + client: StrapiClient, + config: CmsConfig, + tasks: SyncTask[], + attentionOverrides: Awaited> +): Promise<{ ok: number; failed: number }> { + let ok = 0; + let failed = 0; + + // Oldest version first; en before other locales within each version + const sorted = sortSyncTasks(tasks); + + const enDocumentIds = new Map(); + + for (const task of sorted) { + if (task.action === "skip") continue; + + const content = formatCmsReleaseContent( + projectLabel(config, task.project), + task.version, + task.body + ); + const attention = resolveAttention(config, task.project, task.version, attentionOverrides); + const payload = buildPayload(config, task, content, attention); + + try { + if (task.action === "create") { + if (task.locale === "en") { + const doc = await client.create(config.content_type_plural, payload, { + locale: "en", + status: "draft", + }); + enDocumentIds.set(task.version, doc.documentId); + } else { + const enId = await findEnglishDocumentId( + client, + config, + task.project, + task.version, + enDocumentIds + ); + if (!enId) throw new Error("English base missing (no draft or published en entry)"); + await client.update(config.content_type_plural, enId, payload, { + locale: task.locale, + status: "draft", + }); + } + ok++; + console.log(`Created ${task.project}/v${task.version} [${task.locale}] (draft)`); + } else { + const draft = await client.findOne( + config.content_type_plural, + { + [config.project_field]: task.project, + [config.version_field]: task.version, + }, + { locale: task.locale, status: "draft" } + ); + if (!draft?.documentId) throw new Error("draft not found for update"); + await client.update(config.content_type_plural, draft.documentId, payload, { + locale: task.locale, + status: "draft", + }); + ok++; + console.log(`Updated ${task.project}/v${task.version} [${task.locale}] (draft)`); + } + } catch (err) { + failed++; + console.error( + `Failed ${task.project}/v${task.version} [${task.locale}]: ${ + err instanceof Error ? err.message : err + }` + ); + } + } + + return { ok, failed }; +} + +async function syncProject( + baseConfig: CmsConfig, + project: string, + explicitVersions: string[], + dryRun: boolean, + client: StrapiClient, + registry: PublishedVersionsFile, + attentionOverrides: Awaited> +): Promise<{ ok: number; failed: number }> { + const config = configForProject(baseConfig, project); + + const enRaw = await readFile(join(ROOT, config.docs_en), "utf-8"); + const allEnEntries = parseChangelogUpdates(enRaw); + + const incrementalVersions = incrementalVersionSet(); + if (incrementalVersions && incrementalVersions.size === 0 && explicitVersions.length === 0) { + console.log(`[${project}] No new blocks — nothing to sync.`); + return { ok: 0, failed: 0 }; + } + + const targetEntries = resolveTargetVersions( + allEnEntries, + config.min_version, + explicitVersions, + incrementalVersions + ); + + let entriesToSync = targetEntries; + if ( + !incrementalVersions && + explicitVersions.length === 0 && + !process.env.CMS_SYNC_ALL?.trim() + ) { + const unpublished = filterUnpublishedEnEntries(registry, project, targetEntries); + if (unpublished.length > 0) { + entriesToSync = unpublished; + console.log( + `[${project}] ${unpublished.length} unpublished EN version(s): ` + + `${unpublished.map((e) => e.version).join(", ")}` + ); + } else { + const pending = [...targetEntries].sort((a, b) => compareSemver(b.version, a.version)); + const newest = pending[0]; + if (newest) { + entriesToSync = [newest]; + console.log(`[${project}] all EN published — latest v${newest.version} only.`); + } + } + } + + if (entriesToSync.length === 0) { + console.log(`[${project}] No versions to sync (min=${config.min_version}).`); + return { ok: 0, failed: 0 }; + } + + const versionFilter = new Set(entriesToSync.map((e) => e.version)); + const byLocale = await loadAllLocaleEntries(config.locales, versionFilter); + + console.log( + `[${project}] ${dryRun ? "Would sync" : "Syncing"} versions=[${[...versionFilter].join(", ")}], ` + + `locales=[${config.locales.filter((l) => l.changelog).map((l) => l.code).join(", ")}]\n` + ); + + const tasks = await planSync(client, config, project, registry, entriesToSync, byLocale); + printTasks(tasks, dryRun); + + if (dryRun) return { ok: 0, failed: 0 }; + + return executeTasks( + client, + config, + tasks.filter((t) => t.action !== "skip"), + attentionOverrides + ); +} + +async function main(): Promise { + await loadEnvLocal(); + const { project: cliProject, rest } = stripProjectArg(process.argv.slice(2)); + const { dryRun, versions: explicitVersions } = parseArgs(rest); + const baseConfig = await loadCmsConfig(); + const projects = resolveProjects(baseConfig, cliProject); + const registry = await loadPublishedVersions(); + const attentionOverrides = await loadAttentionOverrides(); + + const client = new StrapiClient(requireEnv("CMS_BASE_URL"), requireEnv("CMS_API_TOKEN")); + + const ping = await client.ping(baseConfig.content_type_plural); + if (!ping.ok) { + console.error(`CMS unreachable: ${ping.error}`); + process.exit(1); + } + + console.log( + `CMS ${baseConfig.content_type_plural} — projects=[${projects.join(", ")}], ` + + `min=${baseConfig.min_version}\n` + ); + + let totalOk = 0; + let totalFailed = 0; + + for (const projectId of projects) { + const { ok, failed } = await syncProject( + baseConfig, + projectId, + explicitVersions, + dryRun, + client, + registry, + attentionOverrides + ); + totalOk += ok; + totalFailed += failed; + if (projects.length > 1) console.log(""); + } + + if (!dryRun) { + console.log(`\nDone: ${totalOk} synced, ${totalFailed} failed`); + if (totalFailed > 0) process.exit(1); + } +} + +main(); diff --git a/.github/scripts/cms/update-published-versions.ts b/.github/scripts/cms/update-published-versions.ts new file mode 100644 index 000000000..b0ea7379b --- /dev/null +++ b/.github/scripts/cms/update-published-versions.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env bun +/** + * Refresh published-versions.json from Strapi CMS (read-only scan). + * + * Usage: + * bun .github/scripts/cms/update-published-versions.ts # print diff (all projects) + * bun .github/scripts/cms/update-published-versions.ts --write # update JSON + * bun .github/scripts/cms/update-published-versions.ts --check # CI: fail if stale + * bun .github/scripts/cms/update-published-versions.ts cloud # single project only + */ + +import { resolveProject, stripProjectArg } from "./cms-args.ts"; +import { loadCmsConfig } from "./cms-config.ts"; +import { loadEnvLocal } from "./cms-env.ts"; +import { + printRegistrySyncResult, + syncPublishedVersionsFromCms, + writePublishedVersionsRegistry, +} from "./published-versions-sync.ts"; +import { StrapiClient } from "./strapi-client.ts"; + +function requireEnv(name: string): string { + const val = process.env[name]?.trim(); + if (!val) { + console.error(`Missing ${name}. Set in .env.local or GitHub Actions secrets.`); + process.exit(1); + } + return val; +} + +function parseArgs(argv: string[]): { write: boolean; check: boolean; project?: string } { + const { project: cliProject, rest } = stripProjectArg(argv); + let write = false; + let check = false; + let project = cliProject; + + for (const arg of rest) { + if (arg === "--write") write = true; + else if (arg === "--check") check = true; + else if (!arg.startsWith("-") && !project) project = arg; + } + + return { write, check, project }; +} + +async function main(): Promise { + await loadEnvLocal(); + const { write, check, project: projectArg } = parseArgs(process.argv.slice(2)); + const config = await loadCmsConfig(); + const client = new StrapiClient(requireEnv("CMS_BASE_URL"), requireEnv("CMS_API_TOKEN")); + + const projects = projectArg + ? [resolveProject(config, projectArg)] + : undefined; + + const label = projects ? projects.join(", ") : "all projects"; + console.log(`Scanning published release-notes in CMS (${label})…`); + + const result = await syncPublishedVersionsFromCms(client, config, { projects }); + printRegistrySyncResult(result, projects ?? config.projects?.map((p) => p.id) ?? [config.default_project]); + + if (check && (result.added.length > 0 || result.removed.length > 0)) { + console.error( + "\npublished-versions.json is out of date. Run:\n bun .github/scripts/cms/update-published-versions.ts --write" + ); + process.exit(1); + } + + if (write) { + await writePublishedVersionsRegistry(result.merged); + } else if (!check && (result.added.length > 0 || result.removed.length > 0)) { + console.log("\nDry run. Pass --write to update published-versions.json"); + } +} + +main(); diff --git a/.github/workflows/cms-changelog-sync.yml b/.github/workflows/cms-changelog-sync.yml new file mode 100644 index 000000000..2c1619442 --- /dev/null +++ b/.github/workflows/cms-changelog-sync.yml @@ -0,0 +1,49 @@ +name: CMS Changelog Sync + +on: + push: + branches: + - main + paths: + - 'changelog/index.mdx' + - 'zh/changelog/index.mdx' + - 'ja/changelog/index.mdx' + - 'ko/changelog/index.mdx' + - '.github/scripts/cms/**' + +jobs: + sync-drafts: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Prepare changelog translations + env: + TRANSLATE_API_KEY: ${{ secrets.TRANSLATE_API_KEY }} + TRANSLATE_API_BASE_URL: ${{ vars.TRANSLATE_API_BASE_URL }} + TRANSLATE_API_MODEL: ${{ secrets.TRANSLATE_API_MODEL }} + CMS_SYNC_BEFORE: ${{ github.event.before }} + CMS_SYNC_AFTER: ${{ github.sha }} + run: pnpm cms:prepare + + - name: Preview CMS sync + env: + CMS_BASE_URL: ${{ vars.CMS_BASE_URL }} + CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }} + CMS_SYNC_BEFORE: ${{ github.event.before }} + CMS_SYNC_AFTER: ${{ github.sha }} + run: pnpm cms:preview + + - name: Sync changelog drafts to CMS + env: + CMS_BASE_URL: ${{ vars.CMS_BASE_URL }} + CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }} + CMS_SYNC_BEFORE: ${{ github.event.before }} + CMS_SYNC_AFTER: ${{ github.sha }} + run: pnpm cms:sync diff --git a/.github/workflows/cms-published-versions-check.yml b/.github/workflows/cms-published-versions-check.yml new file mode 100644 index 000000000..dc27f982a --- /dev/null +++ b/.github/workflows/cms-published-versions-check.yml @@ -0,0 +1,24 @@ +name: CMS Published Versions Check + +on: + pull_request: + paths: + - 'changelog/index.mdx' + - '.github/scripts/cms/published-versions.json' + - '.github/scripts/cms/cms-config.json' + +jobs: + check-published-registry: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Verify published-versions.json matches CMS + env: + CMS_BASE_URL: ${{ vars.CMS_BASE_URL }} + CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }} + run: bun .github/scripts/cms/update-published-versions.ts --check diff --git a/.gitignore b/.gitignore index ce333acba..c68bec8bb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ docs.bak !.env.local.example # Translation run logs (translate-i18n.ts) .github/i18n-logs/ +# CMS staging translations (generated by cms:prepare) +.github/scripts/cms/staging/ tmp/ # Python bytecode __pycache__/ diff --git a/.mintignore b/.mintignore index 84ed96845..9feb7580e 100644 --- a/.mintignore +++ b/.mintignore @@ -1,6 +1,7 @@ # Dependencies (pnpm nests packages under node_modules/.pnpm) node_modules/ **/node_modules/ +AGENTS.md # Local tooling / logs — not part of the docs site .github/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8447e6379 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# Agent guide — ComfyUI docs repo + +English is the source of truth for documentation. This repo has **three separate pipelines** for content that leaves the Mintlify site — do not mix them. + +## Skills (read when relevant) + +| Skill | Path | Use when | +|-------|------|----------| +| **docs-i18n-translate** | [.cursor/skills/docs-i18n-translate/SKILL.md](.cursor/skills/docs-i18n-translate/SKILL.md) | Translating MDX to ja/zh/ko, `pnpm translate`, glossary, changelog docs | +| **docs-i18n-review** | [.cursor/skills/docs-i18n-review/SKILL.md](.cursor/skills/docs-i18n-review/SKILL.md) | Reviewing translation quality, `pnpm translate:review` | +| **cms-changelog-sync** | [.cursor/skills/cms-changelog-sync/SKILL.md](.cursor/skills/cms-changelog-sync/SKILL.md) | Strapi release notes, popup simplify, `pnpm cms:prepare`, `pnpm cms:sync` | + +Always load the matching skill before changing that pipeline. + +## Three pipelines + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ changelog/index.mdx (full English — edit here for releases) │ +└────────────┬───────────────────────────────┬────────────────────┘ + │ │ + ▼ ▼ + DOCS (Mintlify) CMS (Strapi popup) + pnpm translate pnpm cms:prepare:en + → zh/ ja/ ko/ → staging/en/ (gitignored) + COMMIT to git pnpm cms:prepare → staging/{lang}/ + pnpm cms:sync → Strapi draft + │ │ + ▼ ▼ + Mintlify site In-app notification + (full changelog) (3–5 bullets, PR links) +``` + +| | Docs translation | CMS sync | +|--|------------------|----------| +| Command | `pnpm translate` | `pnpm cms:prepare` / `cms:sync` | +| Output | `{lang}/**/*.mdx` | `.github/scripts/cms/staging/` | +| English input | Full docs MDX | LLM-simplified popup copy | +| Commit? | **Yes** | **No** (staging gitignored) | +| Locales | ja, zh, ko | en, zh, ja, ko, fr, ru, es | + +## Quick commands + +### Docs — after editing English MDX + +```bash +pnpm translate:dry-run +pnpm translate -- changelog/index.mdx # or specific paths +pnpm translate:check-truncation # long pages / changelog +pnpm translate:review # optional quality pass +``` + +### CMS — after editing `changelog/index.mdx` + +```bash +pnpm cms:prepare:en -- --force v0.25.1 # simplify EN (comfyui + cloud copy) +pnpm cms:prepare -- v0.25.1 # translate all locales (both projects) +pnpm cms:preview -- v0.25.1 # dry-run (both projects) +pnpm cms:sync -- v0.25.1 # push drafts (both projects) +pnpm cms:publish -- v0.25.1 # publish + refresh published-versions.json +pnpm cms:set-attention -- cloud v0.24.0 high --save # optional high priority +``` + +Default: **comfyui + cloud** together. Single project only: `--project cloud`. + +Local default for prepare/sync: **all unpublished EN versions** per `published-versions.json`. Use `CMS_SYNC_ALL=1` for full backfill. + +## Environment + +Copy `.env.local.example` → `.env.local` (never commit). + +| Variable | Docs translate | CMS prepare | CMS sync | +|----------|----------------|-------------|----------| +| `TRANSLATE_API_KEY` | ✓ | ✓ | | +| `TRANSLATE_API_BASE_URL` | ✓ | ✓ | | +| `TRANSLATE_API_MODEL` | ✓ | ✓ | | +| `CMS_BASE_URL` | | | ✓ | +| `CMS_API_TOKEN` | | | ✓ | + +## Agent rules + +1. **Do not shorten** `changelog/index.mdx` for CMS — use the staging + simplify pipeline. +2. **Do not use** `pnpm translate` to fill CMS staging — use `pnpm cms:prepare`. +3. **Do not commit** `.github/scripts/cms/staging/` or `.github/i18n-logs/`. +4. **Do commit** translated docs (`zh/`, `ja/`, `ko/`) and `published-versions.json` after Strapi publish. +5. Get user approval on **staging EN** before running full `cms:prepare` (translations cost API calls). +5. Strapi publish is **manual by default** — use `pnpm cms:publish` after review (not automatic on sync). + +## Reference docs + +- i18n tooling: [.github/scripts/i18n/README.md](.github/scripts/i18n/README.md) +- CMS tooling: [.github/scripts/cms/README.md](.github/scripts/cms/README.md) diff --git a/changelog/index.mdx b/changelog/index.mdx index d2bb11493..0684da4cf 100644 --- a/changelog/index.mdx +++ b/changelog/index.mdx @@ -14,7 +14,6 @@ icon: "clock-rotate-left" **New Open-Source Model Support** -* [**SeedVR2**](https://github.com/Comfy-Org/ComfyUI/pull/14110): Support for SeedVR2 video super-resolution model * [**Depth Anything 3**](https://github.com/Comfy-Org/ComfyUI/pull/13853): Monocular depth estimation model from LiheYoung * [**SCAIL-2**](https://github.com/Comfy-Org/ComfyUI/pull/14373): Character replacement model for enhanced video editing * [**Bernini-R**](https://github.com/Comfy-Org/ComfyUI/pull/14216): Wan video model support diff --git a/package.json b/package.json index 4fc506222..46a13745a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,14 @@ "translate:sync-docs-json": "bun .github/scripts/i18n/translate-i18n.ts --sync-docs-json", "glossary:sync": "bun .github/scripts/i18n/sync-glossary.mjs", "glossary:sync:dry-run": "bun .github/scripts/i18n/sync-glossary.mjs --dry-run", - "translate:review": "bun .github/scripts/i18n/review-i18n.ts" + "translate:review": "bun .github/scripts/i18n/review-i18n.ts", + "cms:prepare": "bun .github/scripts/cms/prepare-cms-changelog.ts", + "cms:prepare:en": "bun .github/scripts/cms/prepare-cms-changelog.ts --en-only", + "cms:delete-drafts": "bun .github/scripts/cms/delete-cms-drafts.ts", + "cms:preview": "bun .github/scripts/cms/sync-to-strapi.ts --preview", + "cms:sync": "bun .github/scripts/cms/sync-to-strapi.ts", + "cms:publish": "bun .github/scripts/cms/publish-cms-drafts.ts", + "cms:set-attention": "bun .github/scripts/cms/set-cms-attention.ts" }, "devDependencies": { "@executeautomation/playwright-mcp-server": "^1.0.5" From fa420f02888e6a3df920a7bae247275a30083612 Mon Sep 17 00:00:00 2001 From: ComfyUI Wiki Date: Fri, 19 Jun 2026 21:14:53 +0800 Subject: [PATCH 2/5] Update changelog translations for version 0.25.0, correcting block hashes and enhancing descriptions for new features, nodes, and performance improvements across Japanese, Korean, and Chinese versions. --- ja/changelog/index.mdx | 42 +++++++++++++++---------------- ko/changelog/index.mdx | 32 ++++++++++++------------ zh/changelog/index.mdx | 56 +++++++++++++++++++++--------------------- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/ja/changelog/index.mdx b/ja/changelog/index.mdx index 2fa108727..636fcbdee 100644 --- a/ja/changelog/index.mdx +++ b/ja/changelog/index.mdx @@ -1,4 +1,5 @@ --- +--- title: "変更履歴" description: "ComfyUI の最新機能、改善点、およびバグ修正を追跡します。詳細なリリースノートについては、[GitHub リリースページ](https://github.com/Comfy-Org/ComfyUI/releases) をご覧ください。" icon: "clock-rotate-left" @@ -6,7 +7,7 @@ translationSourceHash: 19292673 translationFrom: changelog/index.mdx translationBlockHashes: "v0.25.1": b23dd37e - "v0.25.0": 4b295314 + "v0.25.0": c322196d "v0.24.1": 3091a9fd "v0.24.0": 6df070aa "v0.23.0": af9616ad @@ -110,38 +111,37 @@ translationBlockHashes: **新しいオープンソースモデルのサポート** -* [**SeedVR2**](https://github.com/Comfy-Org/ComfyUI/pull/14110): SeedVR2 ビデオ超解像度モデルのサポート * [**Depth Anything 3**](https://github.com/Comfy-Org/ComfyUI/pull/13853): LiheYoung による単眼深度推定モデル -* [**SCAIL-2**](https://github.com/Comfy-Org/ComfyUI/pull/14373): 強化されたビデオ編集のためのキャラクター置換モデル -* [**Bernini-R**](https://github.com/Comfy-Org/ComfyUI/pull/14216): Wan ビデオモデルのサポート -* **Ideogram 4**: 安定性向上のためのfp8 dtype問題の修正 -* **10ビットビデオサポート**: 10ビットビデオの入出力用コアノードサポートを追加 +* [**SCAIL-2**](https://github.com/Comfy-Org/ComfyUI/pull/14373): ビデオ編集を強化するキャラクター置換モデル +* [**Bernini-R**](https://github.com/Comfy-Org/ComfyUI/pull/14216): Wanビデオモデルのサポート +* **Ideogram 4**: 安定性向上のため fp8 dtype の問題を修正 +* **10ビットビデオサポート**: 10ビットビデオの入出力に対応するコアノードサポートを追加 -**新ノード** -* [**PreviewGaussianSplat + PreviewPointCloud**](https://github.com/Comfy-Org/ComfyUI/pull/14194): ガウススプラットとポイントクラウド可視化のための新しい3Dプレビューノード +**新しいノード** +* [**PreviewGaussianSplat + PreviewPointCloud**](https://github.com/Comfy-Org/ComfyUI/pull/14194): ガウシアンスプラットとポイントクラウドを可視化する新しい3Dプレビューノード * [**Color Primitive**](https://github.com/Comfy-Org/ComfyUI/pull/14260): ワークフロー向けの色タイプとユーティリティ -* **改良された ResolutionSelector** ([#14309](https://github.com/Comfy-Org/ComfyUI/pull/14309)): より良いデフォルト値による解像度選択の強化 +* **改良された ResolutionSelector** ([#14309](https://github.com/Comfy-Org/ComfyUI/pull/14309)): デフォルト設定を改善し、解像度選択を強化 **パートナーノードの更新** * **Bria Transparent Video Background + Green Background + Replace Background** ノード -* **Gemini Text Node** ([#14299](https://github.com/Comfy-Org/ComfyUI/pull/14299)): 新しいLLMパートナーノード +* **Gemini Text Node** ([#14299](https://github.com/Comfy-Org/ComfyUI/pull/14299)): 新しい LLM パートナーノード * **Runway Aleph2** ([#14306](https://github.com/Comfy-Org/ComfyUI/pull/14306)): 新しいビデオ生成ノード * **Krea 2 Medium Turbo** モデルサポート * **Tripo3D Import 3D** ノード -* **Flux Erase**: seed入力を追加 -* **NanoBanana**: temperatureとtop_pの制御を追加 +* **Flux Erase**: シード入力を追加 +* **NanoBanana**: temperature と top_p の制御を追加 * **Flux KVキャッシュ**: 分割バッチでのクラッシュを修正 **パフォーマンスと安定性** -* **Aimdo 0.4.10** + `--reserve-vram` と `--vram-headroom` オプション -* Comfy Aimdo 0.4.9(信頼性の向上) -* メディア読み込み: `--high-ram` オプションの一般化 -* **Assets API**: カーソルベースのページネーション、取り込み時の画像寸法抽出、WebSocketメッセージ内のアセットID -* CUDA 130+で常にCUDA mallocを有効化 -* 決定論的な結果のためにcudnn.benchmarkを強制的にfalseに -* 奇数高さのクラッシュと、幅が整列されていない画像/ビデオのデコード時のエッジブリードを修正 -* 幅が整列されていない場合の非決定論的なビデオデコードを修正 -* **Ideogram 4**: cleanup_models_gc() でキャストバッファがリセットされない問題を修正 +* **Aimdo 0.4.10** + `--reserve-vram` および `--vram-headroom` オプション +* 信頼性が向上した Comfy Aimdo 0.4.9 +* メディア読み込み: `--high-ram` オプションを汎用化 +* **アセット API**: カーソルベースのページネーション、インジェスト時の画像ディメンション抽出、WebSocket メッセージへのアセット ID 追加 +* CUDA 130+ では常に CUDA malloc を有効化 +* 決定論的な結果を得るために cudnn.benchmark を false に強制 +* 幅がアラインされていない画像/ビデオのデコードで、奇数高さのクラッシュとエッジのにじみを修正 +* 幅がアラインされていないビデオのデコードでの非決定論的な動作を修正 +* **Ideogram 4**: cleanup_models_gc() でキャストバッファをリセットしない問題を修正 * **Trainer**: トレーニングノードで条件がトレーニング可能になる問題を修正 diff --git a/ko/changelog/index.mdx b/ko/changelog/index.mdx index 88fb593cb..ed5d8ae55 100644 --- a/ko/changelog/index.mdx +++ b/ko/changelog/index.mdx @@ -1,4 +1,5 @@ --- +--- title: "변경 로그" description: "ComfyUI의 최신 기능, 개선 사항 및 버그 수정을 추적하세요. 자세한 릴리스 노트는 [Github 릴리스](https://github.com/Comfy-Org/ComfyUI/releases) 페이지를 참조하세요." icon: "clock-rotate-left" @@ -6,7 +7,7 @@ translationSourceHash: 19292673 translationFrom: changelog/index.mdx translationBlockHashes: "v0.25.1": b23dd37e - "v0.25.0": 4b295314 + "v0.25.0": c322196d "v0.24.1": 3091a9fd "v0.24.0": 6df070aa "v0.23.0": af9616ad @@ -109,18 +110,17 @@ translationBlockHashes: -**새로운 오픈소스 모델 지원** -* [**SeedVR2**](https://github.com/Comfy-Org/ComfyUI/pull/14110): SeedVR2 비디오 초해상도 모델 지원 +**신규 오픈소스 모델 지원** * [**Depth Anything 3**](https://github.com/Comfy-Org/ComfyUI/pull/13853): LiheYoung의 단안 깊이 추정 모델 * [**SCAIL-2**](https://github.com/Comfy-Org/ComfyUI/pull/14373): 향상된 비디오 편집을 위한 캐릭터 교체 모델 * [**Bernini-R**](https://github.com/Comfy-Org/ComfyUI/pull/14216): Wan 비디오 모델 지원 -* **Ideogram 4**: 안정성 향상을 위한 fp8 dtype 문제 수정 +* **Ideogram 4**: 안정성 향상을 위해 fp8 dtype 문제 수정 * **10비트 비디오 지원**: 10비트 비디오 입출력을 위한 코어 노드 지원 추가 -**새로운 노드** -* [**PreviewGaussianSplat + PreviewPointCloud**](https://github.com/Comfy-Org/ComfyUI/pull/14194): 가우시안 스플랫 및 포인트 클라우드 시각화를 위한 새로운 3D 미리보기 노드 -* [**Color Primitive**](https://github.com/Comfy-Org/ComfyUI/pull/14260): 워크플로용 색상 유형 및 유틸리티 -* **개선된 ResolutionSelector** ([#14309](https://github.com/Comfy-Org/ComfyUI/pull/14309)): 더 나은 기본값으로 해상도 선택 향상 +**신규 노드** +* [**PreviewGaussianSplat + PreviewPointCloud**](https://github.com/Comfy-Org/ComfyUI/pull/14194): Gaussian splat 및 포인트 클라우드 시각화를 위한 새로운 3D 미리보기 노드 +* [**Color Primitive**](https://github.com/Comfy-Org/ComfyUI/pull/14260): 워크플로를 위한 색상 유형 및 유틸리티 +* **개선된 ResolutionSelector** ([#14309](https://github.com/Comfy-Org/ComfyUI/pull/14309)): 더 나은 기본값으로 해상도 선택 기능 향상 **파트너 노드 업데이트** * **Bria Transparent Video Background + Green Background + Replace Background** 노드 @@ -130,18 +130,18 @@ translationBlockHashes: * **Tripo3D Import 3D** 노드 * **Flux Erase**: 시드 입력 추가 * **NanoBanana**: temperature 및 top_p 제어 추가 -* **Flux KV 캐시**: 분할 배치에서의 충돌 수정 +* **Flux KV 캐시**: 분할 배치 관련 충돌 수정 **성능 및 안정성** * **Aimdo 0.4.10** + `--reserve-vram` 및 `--vram-headroom` 옵션 -* Comfy Aimdo 0.4.9, 안정성 개선 사항 포함 -* 미디어 로딩: 일반화된 `--high-ram` 옵션 -* **Assets API**: 커서 기반 페이지네이션, 수집 시 이미지 크기 추출, WebSocket 메시지의 애셋 ID +* 안정성 개선이 포함된 Comfy Aimdo 0.4.9 +* 미디어 로딩: `--high-ram` 옵션 일반화 +* **에셋 API**: 커서 기반 페이지네이션, 수집 시 이미지 크기 추출, WebSocket 메시지 내 에셋 ID 포함 * CUDA 130+에서 항상 CUDA malloc 활성화 -* 결정적 결과를 위해 `cudnn.benchmark`를 `false`로 강제 설정 -* 정렬되지 않은 너비의 이미지/비디오 디코드에서 홀수 높이 충돌 및 테두리 번짐 수정 -* 정렬되지 않은 너비에서 비결정적 비디오 디코드 수정 -* **Ideogram 4**: `cleanup_models_gc()`에서 cast 버퍼를 재설정하지 않도록 수정 +* 결정적 결과를 위해 cudnn.benchmark를 false로 강제 +* 정렬되지 않은 너비의 이미지/비디오 디코딩에서 홀수 높이 충돌 및 가장자리 번짐 수정 +* 정렬되지 않은 너비에서 비결정적 비디오 디코딩 수정 +* **Ideogram 4**: cleanup_models_gc()에서 cast 버퍼를 초기화하지 않도록 수정 * **Trainer**: 학습 노드에서 조건이 학습 가능해지는 문제 수정 diff --git a/zh/changelog/index.mdx b/zh/changelog/index.mdx index 837bee7d6..43ea3468b 100644 --- a/zh/changelog/index.mdx +++ b/zh/changelog/index.mdx @@ -1,4 +1,5 @@ --- +--- title: "更新日志" description: "跟踪 ComfyUI 的最新功能、改进和错误修复。详细的发布说明请查看 [Github releases](https://github.com/Comfy-Org/ComfyUI/releases) 页面。" icon: "clock-rotate-left" @@ -6,7 +7,7 @@ translationSourceHash: 19292673 translationFrom: changelog/index.mdx translationBlockHashes: "v0.25.1": b23dd37e - "v0.25.0": 4b295314 + "v0.25.0": c322196d "v0.24.1": 3091a9fd "v0.24.0": 6df070aa "v0.23.0": af9616ad @@ -110,39 +111,38 @@ translationBlockHashes: **新的开源模型支持** -* [**SeedVR2**](https://github.com/Comfy-Org/ComfyUI/pull/14110):SeedVR2视频超分辨率模型支持 -* [**Depth Anything 3**](https://github.com/Comfy-Org/ComfyUI/pull/13853):来自LiheYoung的单目深度估计模型 -* [**SCAIL-2**](https://github.com/Comfy-Org/ComfyUI/pull/14373):用于增强视频编辑的角色替换模型 -* [**Bernini-R**](https://github.com/Comfy-Org/ComfyUI/pull/14216):Wan视频模型支持 -* **Ideogram 4**:修复了fp8数据类型问题以提高稳定性 -* **10位视频支持**:为核心节点添加了10位视频输入/输出支持 +* [**Depth Anything 3**](https://github.com/Comfy-Org/ComfyUI/pull/13853): 来自 LiheYoung 的单目深度估计模型 +* [**SCAIL-2**](https://github.com/Comfy-Org/ComfyUI/pull/14373): 用于增强视频编辑的角色替换模型 +* [**Bernini-R**](https://github.com/Comfy-Org/ComfyUI/pull/14216): Wan 万相视频模型支持 +* **Ideogram 4**: 修复 fp8 dtype 问题以提高稳定性 +* **10 位视频支持**: 为核心节点新增 10 位视频输入/输出支持 **新节点** -* [**PreviewGaussianSplat + PreviewPointCloud**](https://github.com/Comfy-Org/ComfyUI/pull/14194):用于高斯泼溅和点云可视化的新3D预览节点 -* [**Color Primitive**](https://github.com/Comfy-Org/ComfyUI/pull/14260):工作流中的颜色类型和实用工具 -* **改进的ResolutionSelector** ([#14309](https://github.com/Comfy-Org/ComfyUI/pull/14309)):增强的分辨率选择,具有更好的默认值 +* [**PreviewGaussianSplat + PreviewPointCloud**](https://github.com/Comfy-Org/ComfyUI/pull/14194): 用于高斯泼溅和点云可视化的新 3D 预览节点 +* [**Color Primitive**](https://github.com/Comfy-Org/ComfyUI/pull/14260): 工作流用的颜色类型和实用工具 +* **Improved ResolutionSelector** ([#14309](https://github.com/Comfy-Org/ComfyUI/pull/14309)): 增强的分辨率选择与更好的默认设置 **合作伙伴节点更新** -* **Bria透明视频背景 + 绿色背景 + 替换背景** 节点 -* **Gemini文本节点** ([#14299](https://github.com/Comfy-Org/ComfyUI/pull/14299)):新的LLM合作伙伴节点 -* **Runway Aleph2** ([#14306](https://github.com/Comfy-Org/ComfyUI/pull/14306)):新的视频生成节点 -* **Krea 2 Medium Turbo** 模型支持 -* **Tripo3D导入3D** 节点 -* **Flux擦除**:添加了种子输入 -* **NanoBanana**:添加了温度(temperature)和top_p控制 -* **Flux KV缓存**:修复了分割批次时的崩溃 +* Bria Transparent Video Background + Green Background + Replace Background 节点 +* Gemini Text Node ([#14299](https://github.com/Comfy-Org/ComfyUI/pull/14299)): 新增的大语言模型合作伙伴节点 +* Runway Aleph2 ([#14306](https://github.com/Comfy-Org/ComfyUI/pull/14306)): 新增的视频生成节点 +* Krea 2 Medium Turbo 模型支持 +* Tripo3D Import 3D 节点 +* Flux Erase: 新增种子输入 +* NanoBanana: 新增 temperature 和 top_p 控制 +* Flux KV 缓存: 修复拆分批次时的崩溃问题 **性能与稳定性** -* **Aimdo 0.4.10** + `--reserve-vram` 和 `--vram-headroom` 选项 -* Comfy Aimdo 0.4.9,具有可靠性改进 -* 媒体加载:泛化的 `--high-ram` 选项 -* **资产API**:基于游标的分页、摄取时提取图像尺寸、WebSocket消息中的资产ID -* 在CUDA 130+上始终启用CUDA malloc -* 强制将cudnn.benchmark设为否以获得确定性结果 -* 修复了未对齐宽度图像/视频解码中的奇数高度崩溃和边缘溢出 -* 修复了未对齐宽度下的非确定性视频解码 -* **Ideogram 4**:修复了在cleanup_models_gc()中不要重置转换缓冲区的问题 -* **训练器**:修复了训练节点中条件变为可训练的问题 +* Aimdo 0.4.10 及 `--reserve-vram` 和 `--vram-headroom` 选项 +* Comfy Aimdo 0.4.9 带可靠性改进 +* 媒体加载: 通用化的 `--high-ram` 选项 +* **资产 API**: 基于游标的分页、摄入时提取图像尺寸、WebSocket 消息中的资产 ID +* 在 CUDA 130+ 上始终启用 CUDA malloc +* 将 cudnn.benchmark 强制设为 false 以获得确定性结果 +* 修复了非对齐宽度图像/视频解码中的奇数高度崩溃和边缘渗色问题 +* 修复了非对齐宽度视频解码的非确定性问题 +* **Ideogram 4**: 修复 cleanup_models_gc() 中不重置 cast 缓冲区的问题 +* **训练器**: 修复训练节点中条件变为可训练的问题 From 4ee9188391c0bbbe234c29bb2c7ea1fed22f9e46 Mon Sep 17 00:00:00 2001 From: ComfyUI Wiki Date: Fri, 19 Jun 2026 21:19:18 +0800 Subject: [PATCH 3/5] Update CMS changelog sync workflow to trigger only on changelog paths. Adjusted documentation to reflect changes in CI workflow for clarity. --- .cursor/skills/cms-changelog-sync/SKILL.md | 2 +- .github/workflows/cms-changelog-sync.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.cursor/skills/cms-changelog-sync/SKILL.md b/.cursor/skills/cms-changelog-sync/SKILL.md index 3e4a27ce3..aeca30139 100644 --- a/.cursor/skills/cms-changelog-sync/SKILL.md +++ b/.cursor/skills/cms-changelog-sync/SKILL.md @@ -186,7 +186,7 @@ Re-run with `--force`. Staging without `--force` **skips** existing `` b | `.github/scripts/cms/sync-to-strapi.ts` | Strapi draft sync | | `.github/scripts/cms/publish-cms-drafts.ts` | Draft → published | | `.github/scripts/cms/delete-cms-drafts.ts` | Clean bad drafts | -| `.github/workflows/cms-changelog-sync.yml` | CI: prepare → preview → sync on main | +| `.github/workflows/cms-changelog-sync.yml` | CI: prepare → preview → sync on main (changelog paths only) | ## Agent checklist diff --git a/.github/workflows/cms-changelog-sync.yml b/.github/workflows/cms-changelog-sync.yml index 2c1619442..458e2ae61 100644 --- a/.github/workflows/cms-changelog-sync.yml +++ b/.github/workflows/cms-changelog-sync.yml @@ -5,11 +5,11 @@ on: branches: - main paths: + # Changelog content only — script-only merges should not trigger CMS sync - 'changelog/index.mdx' - 'zh/changelog/index.mdx' - 'ja/changelog/index.mdx' - 'ko/changelog/index.mdx' - - '.github/scripts/cms/**' jobs: sync-drafts: From 584ccac39a40ef80ea0ecfcf05270814cc67613e Mon Sep 17 00:00:00 2001 From: ComfyUI Wiki Date: Fri, 19 Jun 2026 21:33:53 +0800 Subject: [PATCH 4/5] Address CodeRabbit review feedback on CMS Strapi sync pipeline. Harden error handling, use CMS as source of truth for sync skips, fix CI to run with bun, and tighten registry validation. --- .github/scripts/cms/cms-attention.ts | 6 ++-- .github/scripts/cms/cms-env.ts | 11 ++++-- .github/scripts/cms/cms-staging-io.ts | 7 ++-- .github/scripts/cms/cms-staging-translate.ts | 1 + .github/scripts/cms/cms-translate-client.ts | 35 ++++++++++++------- .github/scripts/cms/cms-versions.ts | 14 +++----- .github/scripts/cms/delete-cms-drafts.ts | 8 ++--- .../scripts/cms/published-versions-sync.ts | 4 ++- .../cms/published-versions.schema.json | 2 +- .github/scripts/cms/published-versions.ts | 32 ++++++++++++++++- .github/scripts/cms/set-cms-attention.ts | 6 +++- .github/scripts/cms/strapi-client.ts | 19 ++++++++-- .github/scripts/cms/sync-to-strapi.ts | 19 ++-------- .github/workflows/cms-changelog-sync.yml | 14 ++++++-- .../cms-published-versions-check.yml | 5 +++ AGENTS.md | 2 +- 16 files changed, 123 insertions(+), 62 deletions(-) diff --git a/.github/scripts/cms/cms-attention.ts b/.github/scripts/cms/cms-attention.ts index 43955752b..2b93a5e19 100644 --- a/.github/scripts/cms/cms-attention.ts +++ b/.github/scripts/cms/cms-attention.ts @@ -2,6 +2,7 @@ import { readFile, writeFile } from "fs/promises"; import { join } from "path"; import type { CmsConfig } from "./cms-config.ts"; import { getProjectConfig } from "./cms-config.ts"; +import { isEnoent } from "./cms-env.ts"; export type AttentionLevel = "low" | "high"; @@ -17,8 +18,9 @@ export async function loadAttentionOverrides(): Promise { try { const raw = await readFile(attentionOverridesPath(), "utf-8"); return JSON.parse(raw) as AttentionOverrides; - } catch { - return {}; + } catch (error) { + if (isEnoent(error)) return {}; + throw error; } } diff --git a/.github/scripts/cms/cms-env.ts b/.github/scripts/cms/cms-env.ts index 62ed54ab6..8c4df573e 100644 --- a/.github/scripts/cms/cms-env.ts +++ b/.github/scripts/cms/cms-env.ts @@ -3,6 +3,10 @@ import { join } from "path"; export const ROOT = join(import.meta.dir, "../../.."); +export function isEnoent(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === "ENOENT"; +} + export async function loadEnvLocal(): Promise { try { const content = await readFile(join(ROOT, ".env.local"), "utf-8"); @@ -13,7 +17,10 @@ export async function loadEnvLocal(): Promise { if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); const val = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = val; + if (process.env[key] === undefined) process.env[key] = val; } - } catch {} + } catch (error) { + if (isEnoent(error)) return; + throw error; + } } diff --git a/.github/scripts/cms/cms-staging-io.ts b/.github/scripts/cms/cms-staging-io.ts index dc83f1c11..2f555c3f3 100644 --- a/.github/scripts/cms/cms-staging-io.ts +++ b/.github/scripts/cms/cms-staging-io.ts @@ -11,7 +11,7 @@ import { } from "../i18n/chunked-translate.ts"; import { parseChangelogUpdates } from "./changelog-parse.ts"; import { configForProject, type CmsConfig } from "./cms-config.ts"; -import { ROOT } from "./cms-env.ts"; +import { isEnoent, ROOT } from "./cms-env.ts"; export const STAGING_FRONTMATTER = `--- title: "Changelog (CMS staging)" @@ -48,8 +48,9 @@ export function mergeStagingBlocks( export async function readStaging(relPath: string): Promise { try { return await readFile(join(ROOT, relPath), "utf-8"); - } catch { - return ""; + } catch (error) { + if (isEnoent(error)) return ""; + throw error; } } diff --git a/.github/scripts/cms/cms-staging-translate.ts b/.github/scripts/cms/cms-staging-translate.ts index fc470ba81..734eb4af4 100644 --- a/.github/scripts/cms/cms-staging-translate.ts +++ b/.github/scripts/cms/cms-staging-translate.ts @@ -55,6 +55,7 @@ export async function prepareStagingLocale( const enBlock = blockForVersion(simplifiedEnContent, target.version); if (!enBlock) { console.log(` skip ${locale.code}/v${target.version} — no simplified en block`); + skipped++; continue; } diff --git a/.github/scripts/cms/cms-translate-client.ts b/.github/scripts/cms/cms-translate-client.ts index da9c34a3c..e88def4b7 100644 --- a/.github/scripts/cms/cms-translate-client.ts +++ b/.github/scripts/cms/cms-translate-client.ts @@ -36,19 +36,28 @@ export async function callTranslateApi( extra?: Record ): Promise { const key = requireTranslateApiKey(); - const res = await fetch(`${BASE_URL.replace(/\/+$/, "")}/chat/completions`, { - method: "POST", - headers: { - Authorization: `Bearer ${key}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: MODEL, - messages, - temperature: 0.2, - ...extra, - }), - }); + const controller = new AbortController(); + const timeoutMs = Number(process.env.TRANSLATE_API_TIMEOUT_MS ?? 45_000); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let res: Response; + try { + res = await fetch(`${BASE_URL.replace(/\/+$/, "")}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + }, + signal: controller.signal, + body: JSON.stringify({ + model: MODEL, + messages, + temperature: 0.2, + ...extra, + }), + }); + } finally { + clearTimeout(timer); + } if (!res.ok) { throw new Error(`Translate API ${res.status}: ${(await res.text()).slice(0, 300)}`); } diff --git a/.github/scripts/cms/cms-versions.ts b/.github/scripts/cms/cms-versions.ts index b1078e590..6302df740 100644 --- a/.github/scripts/cms/cms-versions.ts +++ b/.github/scripts/cms/cms-versions.ts @@ -1,4 +1,4 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { meetsMinVersion, parseChangelogUpdates, @@ -9,14 +9,10 @@ import { getSectionSyncStatus } from "../i18n/chunked-translate.ts"; import { ROOT } from "./cms-env.ts"; export function gitDiffChangelog(before: string, after: string): string { - try { - return execSync(`git diff ${before} ${after} -- changelog/index.mdx`, { - encoding: "utf-8", - cwd: ROOT, - }); - } catch { - return ""; - } + return execFileSync("git", ["diff", before, after, "--", "changelog/index.mdx"], { + encoding: "utf-8", + cwd: ROOT, + }); } export function incrementalVersionSet(): Set | null { diff --git a/.github/scripts/cms/delete-cms-drafts.ts b/.github/scripts/cms/delete-cms-drafts.ts index 35bb4744c..e6c5e5f98 100644 --- a/.github/scripts/cms/delete-cms-drafts.ts +++ b/.github/scripts/cms/delete-cms-drafts.ts @@ -11,7 +11,7 @@ import { meetsMinVersion } from "./changelog-parse.ts"; import { resolveProject, stripProjectArg } from "./cms-args.ts"; import { loadCmsConfig } from "./cms-config.ts"; import { loadEnvLocal } from "./cms-env.ts"; -import { loadPublishedVersions } from "./published-versions.ts"; +import { loadPublishedVersions, isPublishedInRegistry } from "./published-versions.ts"; import { StrapiClient, type StrapiDocument } from "./strapi-client.ts"; function parseArgs(argv: string[]): { dryRun: boolean } { @@ -79,13 +79,9 @@ async function main(): Promise { } } - const publishedVersions = new Set( - registry.published.filter((e) => e.project === project).map((e) => e.version) - ); - const enPublishedCache = new Map(); async function enIsPublished(version: string): Promise { - if (publishedVersions.has(version)) return true; + if (isPublishedInRegistry(registry, project, version, "en")) return true; const cached = enPublishedCache.get(version); if (cached != null) return cached; const enPublishedInCms = await client.findOne( diff --git a/.github/scripts/cms/published-versions-sync.ts b/.github/scripts/cms/published-versions-sync.ts index 0c213102a..35081d957 100644 --- a/.github/scripts/cms/published-versions-sync.ts +++ b/.github/scripts/cms/published-versions-sync.ts @@ -85,7 +85,9 @@ export async function syncPublishedVersionsFromCms( } const current = await loadPublishedVersions(); - const merged = mergePublishedEntries(current.published, fromCms); + const projectSet = new Set(projects); + const unchangedOutsideScan = current.published.filter((e) => !projectSet.has(e.project)); + const merged = mergePublishedEntries(unchangedOutsideScan, fromCms); const currentKeys = new Set(current.published.map(entryKey)); const mergedKeys = new Set(merged.map(entryKey)); diff --git a/.github/scripts/cms/published-versions.schema.json b/.github/scripts/cms/published-versions.schema.json index 2a9ee158f..deb961c3e 100644 --- a/.github/scripts/cms/published-versions.schema.json +++ b/.github/scripts/cms/published-versions.schema.json @@ -10,7 +10,7 @@ "required": ["project", "version"], "properties": { "project": { "type": "string" }, - "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+" }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+(?:\\.\\d+)?$" }, "locales": { "type": "array", "items": { "type": "string" }, diff --git a/.github/scripts/cms/published-versions.ts b/.github/scripts/cms/published-versions.ts index f56195252..dbe873cb2 100644 --- a/.github/scripts/cms/published-versions.ts +++ b/.github/scripts/cms/published-versions.ts @@ -19,9 +19,39 @@ export function publishedVersionsPath(): string { return join(CMS_DIR, "published-versions.json"); } +function validatePublishedVersionsFile(data: unknown): PublishedVersionsFile { + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new Error("published-versions.json: expected object"); + } + const published = (data as { published?: unknown }).published; + if (!Array.isArray(published)) { + throw new Error("published-versions.json: expected { published: [...] }"); + } + for (const entry of published) { + if (!entry || typeof entry !== "object") { + throw new Error("published-versions.json: invalid entry"); + } + const e = entry as Record; + if (typeof e.project !== "string" || typeof e.version !== "string") { + throw new Error("published-versions.json: entry missing project/version"); + } + if (e.locales != null) { + if (!Array.isArray(e.locales)) { + throw new Error("published-versions.json: entry locales must be array"); + } + for (const loc of e.locales) { + if (typeof loc !== "string") { + throw new Error("published-versions.json: locale must be string"); + } + } + } + } + return data as PublishedVersionsFile; +} + export async function loadPublishedVersions(): Promise { const raw = await readFile(publishedVersionsPath(), "utf-8"); - return JSON.parse(raw) as PublishedVersionsFile; + return validatePublishedVersionsFile(JSON.parse(raw)); } export async function savePublishedVersions(data: PublishedVersionsFile): Promise { diff --git a/.github/scripts/cms/set-cms-attention.ts b/.github/scripts/cms/set-cms-attention.ts index 0c0cd6fbe..c34651250 100644 --- a/.github/scripts/cms/set-cms-attention.ts +++ b/.github/scripts/cms/set-cms-attention.ts @@ -68,13 +68,15 @@ function parseArgs( async function updateAttentionInStrapi( client: StrapiClient, contentType: string, + projectField: string, + versionField: string, attentionField: string, project: string, version: string, attention: AttentionLevel, dryRun: boolean ): Promise { - const filters = { project, version }; + const filters = { [projectField]: project, [versionField]: version }; const payload = { [attentionField]: attention }; const draft = await client.findOne(contentType, filters, { locale: "en", status: "draft" }); @@ -150,6 +152,8 @@ async function main(): Promise { await updateAttentionInStrapi( client, config.content_type_plural, + config.project_field, + config.version_field, config.attention_field, project, version, diff --git a/.github/scripts/cms/strapi-client.ts b/.github/scripts/cms/strapi-client.ts index b89a6d16c..ee4eb3bba 100644 --- a/.github/scripts/cms/strapi-client.ts +++ b/.github/scripts/cms/strapi-client.ts @@ -213,8 +213,21 @@ export class StrapiClient { const data: Record = {}; if (draft) { - for (const key of ["content", "project", "version", "attention"] as const) { - if (draft[key] != null && draft[key] !== "") data[key] = draft[key]; + const metadata = new Set([ + "id", + "documentId", + "locale", + "publishedAt", + "createdAt", + "updatedAt", + "createdBy", + "updatedBy", + "localizations", + "status", + ]); + for (const [key, value] of Object.entries(draft)) { + if (metadata.has(key)) continue; + if (value != null && value !== "") data[key] = value; } } @@ -239,7 +252,7 @@ export class StrapiClient { const params = new URLSearchParams(); if (opts?.locale) params.set("locale", opts.locale); // Strapi 5 returns 500 when status=draft is passed on DELETE; locale-only works. - if (opts?.status) this.withStatus(params, opts.status); + if (opts?.status && opts.status !== "draft") this.withStatus(params, opts.status); const res = await fetch(this.url(`/api/${contentTypePlural}/${documentId}`, params), { method: "DELETE", headers: this.headers(), diff --git a/.github/scripts/cms/sync-to-strapi.ts b/.github/scripts/cms/sync-to-strapi.ts index ddde05bc3..abba98e54 100644 --- a/.github/scripts/cms/sync-to-strapi.ts +++ b/.github/scripts/cms/sync-to-strapi.ts @@ -19,7 +19,6 @@ import { loadEnvLocal, ROOT } from "./cms-env.ts"; import { incrementalVersionSet, resolveTargetVersions } from "./cms-versions.ts"; import { filterUnpublishedEnEntries, - isPublishedInRegistry, loadPublishedVersions, type PublishedVersionsFile, } from "./published-versions.ts"; @@ -189,7 +188,6 @@ async function planSync( client: StrapiClient, config: CmsConfig, project: string, - registry: PublishedVersionsFile, targetVersions: ReleaseNoteEntry[], byLocale: Map> ): Promise { @@ -213,19 +211,6 @@ async function planSync( continue; } - if (isPublishedInRegistry(registry, project, base.version, locale.code)) { - tasks.push({ - project, - version: base.version, - locale: locale.code, - date: entry.date, - body: entry.body, - action: "skip", - reason: "published (registry)", - }); - continue; - } - const state = await resolveDocState(client, config, project, base.version, locale.code); if (state.published) { tasks.push({ @@ -240,6 +225,8 @@ async function planSync( continue; } + // CMS is source of truth — do not skip on registry alone (stale registry strand). + const enInChangelog = byLocale.get("en")?.has(base.version) ?? false; if (locale.code !== "en" && !state.enDocumentId && !enInChangelog) { @@ -428,7 +415,7 @@ async function syncProject( `locales=[${config.locales.filter((l) => l.changelog).map((l) => l.code).join(", ")}]\n` ); - const tasks = await planSync(client, config, project, registry, entriesToSync, byLocale); + const tasks = await planSync(client, config, project, entriesToSync, byLocale); printTasks(tasks, dryRun); if (dryRun) return { ok: 0, failed: 0 }; diff --git a/.github/workflows/cms-changelog-sync.yml b/.github/workflows/cms-changelog-sync.yml index 458e2ae61..49c62aa31 100644 --- a/.github/workflows/cms-changelog-sync.yml +++ b/.github/workflows/cms-changelog-sync.yml @@ -11,6 +11,13 @@ on: - 'ja/changelog/index.mdx' - 'ko/changelog/index.mdx' +concurrency: + group: cms-changelog-sync-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: sync-drafts: runs-on: ubuntu-latest @@ -19,6 +26,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -30,7 +38,7 @@ jobs: TRANSLATE_API_MODEL: ${{ secrets.TRANSLATE_API_MODEL }} CMS_SYNC_BEFORE: ${{ github.event.before }} CMS_SYNC_AFTER: ${{ github.sha }} - run: pnpm cms:prepare + run: bun .github/scripts/cms/prepare-cms-changelog.ts - name: Preview CMS sync env: @@ -38,7 +46,7 @@ jobs: CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }} CMS_SYNC_BEFORE: ${{ github.event.before }} CMS_SYNC_AFTER: ${{ github.sha }} - run: pnpm cms:preview + run: bun .github/scripts/cms/sync-to-strapi.ts --preview - name: Sync changelog drafts to CMS env: @@ -46,4 +54,4 @@ jobs: CMS_API_TOKEN: ${{ secrets.CMS_API_TOKEN }} CMS_SYNC_BEFORE: ${{ github.event.before }} CMS_SYNC_AFTER: ${{ github.sha }} - run: pnpm cms:sync + run: bun .github/scripts/cms/sync-to-strapi.ts diff --git a/.github/workflows/cms-published-versions-check.yml b/.github/workflows/cms-published-versions-check.yml index dc27f982a..2b203ff39 100644 --- a/.github/workflows/cms-published-versions-check.yml +++ b/.github/workflows/cms-published-versions-check.yml @@ -7,12 +7,17 @@ on: - '.github/scripts/cms/published-versions.json' - '.github/scripts/cms/cms-config.json' +permissions: + contents: read + jobs: check-published-registry: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/AGENTS.md b/AGENTS.md index 8447e6379..af73d4194 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ Copy `.env.local.example` → `.env.local` (never commit). 3. **Do not commit** `.github/scripts/cms/staging/` or `.github/i18n-logs/`. 4. **Do commit** translated docs (`zh/`, `ja/`, `ko/`) and `published-versions.json` after Strapi publish. 5. Get user approval on **staging EN** before running full `cms:prepare` (translations cost API calls). -5. Strapi publish is **manual by default** — use `pnpm cms:publish` after review (not automatic on sync). +6. Strapi publish is **manual by default** — use `bun run cms:publish` after review (not automatic on sync). ## Reference docs From 24082ba6ccf7327e05bfe9342f2b165ca27caee3 Mon Sep 17 00:00:00 2001 From: ComfyUI Wiki Date: Fri, 19 Jun 2026 21:37:21 +0800 Subject: [PATCH 5/5] Refresh published-versions.json from Strapi CMS. Align registry locale lists and entries with the live CMS published state so the PR registry check passes. --- .github/scripts/cms/published-versions.json | 40 +++------------------ 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/.github/scripts/cms/published-versions.json b/.github/scripts/cms/published-versions.json index 553e2d64d..529be9abb 100644 --- a/.github/scripts/cms/published-versions.json +++ b/.github/scripts/cms/published-versions.json @@ -12,13 +12,7 @@ "project": "comfyui", "version": "0.21.0", "locales": [ - "en", - "es", - "fr", - "ja", - "ko", - "ru", - "zh" + "en" ], "published_at": "2026-05-12T00:21:42.919Z" }, @@ -34,13 +28,7 @@ "project": "comfyui", "version": "0.21.1", "locales": [ - "en", - "es", - "fr", - "ja", - "ko", - "ru", - "zh" + "en" ], "published_at": "2026-05-14T01:40:01.349Z" }, @@ -56,13 +44,7 @@ "project": "comfyui", "version": "0.22.0", "locales": [ - "en", - "es", - "fr", - "ja", - "ko", - "ru", - "zh" + "en" ], "published_at": "2026-05-20T19:26:34.822Z" }, @@ -130,14 +112,6 @@ ], "published_at": "2026-05-28T12:06:14.649Z" }, - { - "project": "comfyui", - "version": "0.22.3", - "locales": [ - "en" - ], - "published_at": "2026-05-28T12:05:27.799Z" - }, { "project": "cloud", "version": "0.23.0", @@ -184,13 +158,7 @@ "project": "comfyui", "version": "0.24.0", "locales": [ - "en", - "es", - "fr", - "ja", - "ko", - "ru", - "zh" + "en" ], "published_at": "2026-06-04T05:42:39.876Z" },