diff --git a/.cursor/skills/cms-changelog-sync/SKILL.md b/.cursor/skills/cms-changelog-sync/SKILL.md new file mode 100644 index 000000000..aeca30139 --- /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 (changelog paths only) | + +## 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 (error) { + if (isEnoent(error)) return {}; + throw error; + } +} + +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..8c4df573e --- /dev/null +++ b/.github/scripts/cms/cms-env.ts @@ -0,0 +1,26 @@ +import { readFile } from "fs/promises"; +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"); + 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] === undefined) process.env[key] = val; + } + } catch (error) { + if (isEnoent(error)) return; + throw error; + } +} 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..2f555c3f3 --- /dev/null +++ b/.github/scripts/cms/cms-staging-io.ts @@ -0,0 +1,109 @@ +/** + * 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 { isEnoent, 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 (error) { + if (isEnoent(error)) return ""; + throw error; + } +} + +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..734eb4af4 --- /dev/null +++ b/.github/scripts/cms/cms-staging-translate.ts @@ -0,0 +1,96 @@ +/** + * 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`); + skipped++; + 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..e88def4b7 --- /dev/null +++ b/.github/scripts/cms/cms-translate-client.ts @@ -0,0 +1,104 @@ +/** + * 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 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)}`); + } + 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..6302df740 --- /dev/null +++ b/.github/scripts/cms/cms-versions.ts @@ -0,0 +1,62 @@ +import { execFileSync } 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 { + return execFileSync("git", ["diff", before, after, "--", "changelog/index.mdx"], { + encoding: "utf-8", + cwd: ROOT, + }); +} + +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..e6c5e5f98 --- /dev/null +++ b/.github/scripts/cms/delete-cms-drafts.ts @@ -0,0 +1,162 @@ +#!/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, isPublishedInRegistry } 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 enPublishedCache = new Map(); + async function enIsPublished(version: string): Promise { + if (isPublishedInRegistry(registry, project, version, "en")) 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..35081d957 --- /dev/null +++ b/.github/scripts/cms/published-versions-sync.ts @@ -0,0 +1,125 @@ +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 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)); + 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..529be9abb --- /dev/null +++ b/.github/scripts/cms/published-versions.json @@ -0,0 +1,250 @@ +{ + "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" + ], + "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" + ], + "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" + ], + "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": "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" + ], + "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..deb961c3e --- /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..dbe873cb2 --- /dev/null +++ b/.github/scripts/cms/published-versions.ts @@ -0,0 +1,127 @@ +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"); +} + +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 validatePublishedVersionsFile(JSON.parse(raw)); +} + +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..c34651250 --- /dev/null +++ b/.github/scripts/cms/set-cms-attention.ts @@ -0,0 +1,167 @@ +#!/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, + projectField: string, + versionField: string, + attentionField: string, + project: string, + version: string, + attention: AttentionLevel, + dryRun: boolean +): Promise { + const filters = { [projectField]: project, [versionField]: 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.project_field, + config.version_field, + 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..ee4eb3bba --- /dev/null +++ b/.github/scripts/cms/strapi-client.ts @@ -0,0 +1,264 @@ +/** + * 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) { + 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; + } + } + + 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 && opts.status !== "draft") 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..abba98e54 --- /dev/null +++ b/.github/scripts/cms/sync-to-strapi.ts @@ -0,0 +1,477 @@ +#!/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, + 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, + 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; + } + + 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; + } + + // 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) { + 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, 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..49c62aa31 --- /dev/null +++ b/.github/workflows/cms-changelog-sync.yml @@ -0,0 +1,57 @@ +name: CMS Changelog Sync + +on: + push: + 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' + +concurrency: + group: cms-changelog-sync-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sync-drafts: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - 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: bun .github/scripts/cms/prepare-cms-changelog.ts + + - 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: bun .github/scripts/cms/sync-to-strapi.ts --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: 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 new file mode 100644 index 000000000..2b203ff39 --- /dev/null +++ b/.github/workflows/cms-published-versions-check.yml @@ -0,0 +1,29 @@ +name: CMS Published Versions Check + +on: + pull_request: + paths: + - 'changelog/index.mdx' + - '.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 + + - 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..af73d4194 --- /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). +6. Strapi publish is **manual by default** — use `bun run 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/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/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" 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 缓冲区的问题 +* **训练器**: 修复训练节点中条件变为可训练的问题