Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cbf5fa26 | ||
|
|
fc920b4d3c | ||
|
|
d604deb6a5 | ||
|
|
8cf40241cf | ||
|
|
72e96ec73f | ||
|
|
5d593a1700 | ||
|
|
81495ea530 | ||
|
|
494b53192d | ||
|
|
8c0f3e418e | ||
|
|
d2f548d0e2 | ||
|
|
7e36bb02c6 | ||
|
|
3a5e46cd9d | ||
|
|
757116dc54 | ||
|
|
05334d3e05 | ||
|
|
2d68c61f4a | ||
|
|
c6d8834a42 | ||
|
|
99d3b21334 | ||
|
|
e9a9d51ea3 | ||
|
|
0572eec472 | ||
|
|
c7f4d91a10 | ||
|
|
45a9092bbc | ||
|
|
2dfed26c1e | ||
|
|
5585583765 | ||
|
|
c4a761d071 |
@@ -1,7 +1,12 @@
|
||||
name: Discord announce
|
||||
|
||||
# Fires when semantic-release publishes a GitHub release, and can be run by
|
||||
# hand for the catch-up case (announcements skipped for a few versions).
|
||||
# Two channels, deliberately asymmetric.
|
||||
#
|
||||
# staff — posts AUTOMATICALLY when semantic-release publishes a release.
|
||||
# The post carries a link back to this workflow, so the review and
|
||||
# the publish button are the same artefact.
|
||||
# public — only ever from a manual run. A release can never announce itself
|
||||
# to everyone without someone having read it first.
|
||||
#
|
||||
# Deliberately a SEPARATE workflow from release.yml: a Discord outage, a
|
||||
# rotated webhook or a rate limit must never fail a release that already
|
||||
@@ -11,6 +16,12 @@ on:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Where to post."
|
||||
required: true
|
||||
type: choice
|
||||
default: staff
|
||||
options: [staff, public]
|
||||
since:
|
||||
description: "Roll up every release after this tag (e.g. v2.10.2). Blank = latest release only."
|
||||
required: false
|
||||
@@ -32,22 +43,40 @@ jobs:
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
# setup-node sniffs the lockfile and tries to prime a pnpm cache,
|
||||
# which fails because pnpm is never installed here. It is not needed:
|
||||
# the announce script has no dependencies at all — plain ESM and the
|
||||
# global fetch built into Node 18+. Nothing is installed, so there is
|
||||
# nothing to cache.
|
||||
package-manager-cache: false
|
||||
|
||||
# `release` events always post. `workflow_dispatch` honours the dry_run
|
||||
# box, which defaults to true so a manual run cannot surprise anyone.
|
||||
# A `release` event always posts, and always to staff. A dispatch honours
|
||||
# both inputs, with dry_run defaulting to true so a manual run cannot
|
||||
# surprise anyone.
|
||||
- name: Announce
|
||||
env:
|
||||
DISCORD_WEBHOOK_STAFF: ${{ secrets.DISCORD_WEBHOOK_STAFF }}
|
||||
DISCORD_WEBHOOK_PUBLIC: ${{ secrets.DISCORD_WEBHOOK_PUBLIC }}
|
||||
# Kept so an existing single-webhook setup keeps working as staff.
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_ROLE_ID: ${{ secrets.DISCORD_ROLE_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARGS=""
|
||||
if [ -n "${{ inputs.since }}" ]; then
|
||||
ARGS="--since ${{ inputs.since }}"
|
||||
elif [ -n "${{ github.event.release.tag_name }}" ]; then
|
||||
ARGS="--version ${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
if [ "${{ github.event_name }}" = "release" ] || [ "${{ inputs.dry_run }}" = "false" ]; then
|
||||
ARGS="$ARGS --post"
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
ARGS="--version ${{ github.event.release.tag_name }} --channel staff --post"
|
||||
else
|
||||
ARGS="--channel ${{ inputs.channel }}"
|
||||
if [ -n "${{ inputs.since }}" ]; then
|
||||
ARGS="$ARGS --since ${{ inputs.since }}"
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "false" ]; then
|
||||
ARGS="$ARGS --post"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Running: discord-announce.mjs $ARGS"
|
||||
node scripts/discord-announce.mjs $ARGS
|
||||
|
||||
@@ -3,6 +3,25 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Tooling-only pushes must not be able to cut a release, whatever their
|
||||
# commit type says.
|
||||
#
|
||||
# 2.13.0 shipped as an empty release because three commits touching only
|
||||
# `scripts/` and `.github/` were typed `feat(announce)` / `fix(announce)`.
|
||||
# Both are release-triggering, so semantic-release did exactly what it was
|
||||
# told — every user on 2.12.0 was offered an update with a blank changelog.
|
||||
# Relying on remembering to type those `ci:` had already failed once.
|
||||
#
|
||||
# Neither path is part of what a user installs: `scripts/` is dev tooling
|
||||
# and `.github/` is CI. GitHub skips the run only when EVERY changed file
|
||||
# matches, so a push that touches shipped code alongside a workflow still
|
||||
# releases correctly.
|
||||
#
|
||||
# `docs/**` is deliberately NOT here — `docs(readme)` is configured to bump
|
||||
# a patch, and `docs/release-notes/next.md` is the release body itself.
|
||||
paths-ignore:
|
||||
- 'scripts/**'
|
||||
- '.github/**'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
+201
@@ -1,3 +1,204 @@
|
||||
## 🎉 Wildcard Pipeline 2.13.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Firefox: the editor no longer dies when you insert a nested reference
|
||||
|
||||
Picking a wildcard from the `@` autocomplete could take down the whole editor —
|
||||
the field stopped accepting text, Backspace did nothing, and Save and Cancel
|
||||
stopped responding until the page was reloaded. Select-all then cut inside an
|
||||
option value did the same thing.
|
||||
|
||||
Both were the same cause. Firefox removes the empty text nodes that Vue uses to
|
||||
mark where each chip and text run begins and ends, and once they are gone any
|
||||
edit that adds or removes a chip runs off the end of the field's contents.
|
||||
Chromium keeps those markers, which is why this only ever happened on Firefox
|
||||
and never reproduced during development.
|
||||
|
||||
The editor now notices when those markers have been removed and rebuilds the
|
||||
field instead of tripping over them. This affects every rich text field —
|
||||
option values, combine templates, derivation rules and injector bindings, on
|
||||
the canvas and in the manager alike.
|
||||
|
||||
### Your library is protected against version mismatches
|
||||
|
||||
If a database has been through migrations that this build does not recognise —
|
||||
after downgrading, hand-installing an older copy, or switching versions in
|
||||
ComfyUI Manager — Wildcard Pipeline now stops with a clear message instead of
|
||||
quietly reading a library it does not understand. Previously it carried on, and
|
||||
every later edit was written through assumptions that no longer held.
|
||||
|
||||
### Release archives now include the interface translations
|
||||
|
||||
The release zip was missing `locales/`, so node names, descriptions and setting
|
||||
labels fell back to raw identifiers for anyone installing from it. Installs
|
||||
from ComfyUI Manager and the Comfy Registry were unaffected.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.1…v2.13.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.1...v2.13.2)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
A security fix for the database location setting. Update when you can.
|
||||
|
||||
> **Already on 2.13.0?** This is the same release. 2.13.0 reached GitHub but not the
|
||||
> ComfyUI Registry, so it never arrived for anyone installing through ComfyUI Manager.
|
||||
> 2.13.1 is that same build under a version number the registry will accept — nothing
|
||||
> in it differs.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
|
||||
|
||||
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
|
||||
|
||||
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
|
||||
7. Security fixes say what an attacker could have done, in plain language, and
|
||||
whether the reader must act. Never ship one as "hardening" or "internal
|
||||
improvements" — someone deciding whether to update tonight needs the facts,
|
||||
and vagueness reads as a cover-up once the diff is public anyway.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.0…v2.13.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.0...v2.13.1)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
A security fix for the database location setting. Update when you can.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
|
||||
|
||||
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
|
||||
|
||||
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
|
||||
7. Security fixes say what an attacker could have done, in plain language, and
|
||||
whether the reader must act. Never ship one as "hardening" or "internal
|
||||
improvements" — someone deciding whether to update tonight needs the facts,
|
||||
and vagueness reads as a cover-up once the diff is public anyway.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.12.0…v2.13.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.12.0...v2.13.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.12.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
+107
-9
@@ -14,6 +14,7 @@ import graph.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -26,6 +27,24 @@ _SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
|
||||
|
||||
|
||||
class SchemaAheadError(RuntimeError):
|
||||
"""The database was migrated by a build this one cannot safely read.
|
||||
|
||||
Raised instead of letting the runner's "skip anything already applied"
|
||||
rule quietly hand a foreign schema to code that expects its own.
|
||||
"""
|
||||
|
||||
|
||||
def _checksum(path: Path) -> str:
|
||||
"""sha256 of a migration file's bytes.
|
||||
|
||||
Bytes, not parsed content: a `.py` migration's behaviour lives in code the
|
||||
runner `exec`s, so any edit at all is a different migration regardless of
|
||||
whether the SQL it emits looks the same.
|
||||
"""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
@@ -75,8 +94,93 @@ def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
|
||||
return up # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def head_version() -> int:
|
||||
"""Highest migration version this build ships."""
|
||||
pairs = _discover()
|
||||
return pairs[-1][0] if pairs else 0
|
||||
|
||||
|
||||
def _has_checksum_column(conn: sqlite3.Connection) -> bool:
|
||||
cur = conn.execute("PRAGMA table_info(migrations);")
|
||||
return any(row[1] == "checksum" for row in cur.fetchall())
|
||||
|
||||
|
||||
def _record(conn: sqlite3.Connection, version: int, mig_file: Path) -> None:
|
||||
"""Insert the applied-migration row, with a checksum when we can store one.
|
||||
|
||||
Migration 017 is what adds the column, so on a fresh database every
|
||||
migration before it inserts without one. Those rows stay NULL forever —
|
||||
see the 017 docblock for why backfilling them would defeat the check.
|
||||
"""
|
||||
if _has_checksum_column(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at, checksum) VALUES(?, ?, ?);",
|
||||
(version, now_iso(), _checksum(mig_file)),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def assert_schema_compatible(conn: sqlite3.Connection) -> None:
|
||||
"""Refuse to touch a database some other build's migrations shaped.
|
||||
|
||||
Two distinct failures, both silent before this existed:
|
||||
|
||||
1. The database is AHEAD — migrated by a newer build. Every migration is
|
||||
`<= MAX(version)`, so the runner does nothing and the caller reads a
|
||||
schema it has never seen.
|
||||
2. The database is at the SAME version but a DIFFERENT shape, because a
|
||||
migration file changed after it had already run somewhere. Version
|
||||
numbers match, so nothing compares anything.
|
||||
|
||||
Both are cumulative: each subsequent write goes through code whose
|
||||
assumptions about the row shape are wrong. Raising is recoverable;
|
||||
continuing is not.
|
||||
"""
|
||||
if not _migrations_table_exists(conn):
|
||||
return
|
||||
|
||||
applied = current_version(conn)
|
||||
head = head_version()
|
||||
if applied > head:
|
||||
raise SchemaAheadError(
|
||||
f"This database was migrated to version {applied}, but this build only "
|
||||
f"knows up to {head}. It was almost certainly written by a newer "
|
||||
f"version of Wildcard Pipeline. Reading it here could corrupt your "
|
||||
f"library — install the newer version again, or restore a backup."
|
||||
)
|
||||
|
||||
if not _has_checksum_column(conn):
|
||||
return
|
||||
|
||||
known = dict(_discover())
|
||||
rows = conn.execute(
|
||||
"SELECT version, checksum FROM migrations WHERE checksum IS NOT NULL;"
|
||||
).fetchall()
|
||||
for version, checksum in rows:
|
||||
mig_file = known.get(int(version))
|
||||
# Unknown version is the `applied > head` case, already handled above.
|
||||
if mig_file is None:
|
||||
continue
|
||||
if _checksum(mig_file) != checksum:
|
||||
raise SchemaAheadError(
|
||||
f"Migration {int(version):03d} was applied by a build whose copy of "
|
||||
f"it differs from this one's. The version numbers match but the "
|
||||
f"schemas may not, so this build cannot safely read your library. "
|
||||
f"Restore a backup taken before that build ran."
|
||||
)
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply all pending migrations. Safe to call repeatedly."""
|
||||
"""Apply all pending migrations. Safe to call repeatedly.
|
||||
|
||||
Guards first: applying this build's migrations on top of a schema another
|
||||
build shaped is how a mismatch becomes permanent.
|
||||
"""
|
||||
assert_schema_compatible(conn)
|
||||
applied = current_version(conn)
|
||||
for version, mig_file in _discover():
|
||||
if version <= applied:
|
||||
@@ -85,10 +189,7 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
up = _load_py_migration(mig_file)
|
||||
with conn:
|
||||
up(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
else:
|
||||
sql = mig_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
@@ -96,7 +197,4 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Migration 017 — record WHICH migration ran, not just how many.
|
||||
|
||||
``migrations`` tracked only ``version``, and the runner skips anything
|
||||
``<= MAX(version)``. That makes an integer the entire identity of a
|
||||
schema, which it is not:
|
||||
|
||||
- A build whose head is 015 opened against a DB migrated to 016 skips
|
||||
every migration, finds nothing to do, and reads a schema it has never
|
||||
seen. Additive columns survive; a shape change does not.
|
||||
- Worse, two builds can both call something "016" and mean different
|
||||
things — a reworked migration, a hand-edited file, a branch that never
|
||||
shipped. The version matches, so nothing anywhere notices.
|
||||
|
||||
Both are silent, and both corrupt cumulatively: every subsequent write
|
||||
goes through code whose assumptions about the row shape are wrong.
|
||||
|
||||
``checksum`` fixes it by storing the sha256 of the migration file that
|
||||
actually ran. ``assert_schema_compatible`` then compares applied
|
||||
checksums against the files this build ships, so a divergence is a
|
||||
loud, recoverable error instead of silence.
|
||||
|
||||
Nullable on purpose: rows applied before this migration existed have no
|
||||
recorded checksum and cannot retroactively grow one. The guard skips
|
||||
them rather than guessing — a NULL means "unknown", never "mismatched".
|
||||
|
||||
Idempotent via ``PRAGMA table_info`` so tests that rewind the
|
||||
``migrations`` table can re-run it against a populated DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
if not _has_column(conn, "migrations", "checksum"):
|
||||
conn.execute("ALTER TABLE migrations ADD COLUMN checksum TEXT;")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.12.0",
|
||||
"version": "2.13.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.12.0"
|
||||
version = "2.13.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 # dry run
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 --post
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 --post # → staff
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 --post --channel public
|
||||
* node scripts/discord-announce.mjs --since v2.10.2 --post # catch-up
|
||||
* node scripts/discord-announce.mjs --notes-file docs/release-notes/next.md \
|
||||
* --version v2.12.0 # preview
|
||||
@@ -17,15 +18,39 @@
|
||||
* an announcement can never accidentally publish one.
|
||||
*
|
||||
* Environment:
|
||||
* DISCORD_WEBHOOK_URL required with --post
|
||||
* DISCORD_ROLE_ID optional; pinged as <@&ID>
|
||||
* DISCORD_WEBHOOK_STAFF staff / pre-announcement channel (the default)
|
||||
* DISCORD_WEBHOOK_PUBLIC public announcement channel (--channel public only)
|
||||
* DISCORD_WEBHOOK_URL legacy fallback for the staff webhook
|
||||
* DISCORD_ROLE_ID optional; pinged as <@&ID>, PUBLIC channel only
|
||||
* DISCORD_USERNAME optional; overrides the posting name
|
||||
* DISCORD_AVATAR_URL optional; overrides the posting avatar (raster only)
|
||||
* GITHUB_TOKEN optional; raises the API rate limit for --since
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { buildPayload, buildRollupPayload } from "./lib/discord-notes.mjs";
|
||||
import { buildPayload, buildRollupPayload, normalizeRoleId } from "./lib/discord-notes.mjs";
|
||||
|
||||
const REPO = "DumiFlex/ComfyUI-Wildcard-Pipeline";
|
||||
|
||||
/**
|
||||
* Two channels, deliberately asymmetric.
|
||||
*
|
||||
* `staff` posts automatically on every release and carries a link to publish
|
||||
* the same message onward — it is the review step. `public` only ever runs
|
||||
* from a deliberate manual dispatch, so a release can never announce itself to
|
||||
* everyone without someone having read it first.
|
||||
*/
|
||||
const CHANNELS = {
|
||||
staff: ["DISCORD_WEBHOOK_STAFF", "DISCORD_WEBHOOK_URL"],
|
||||
public: ["DISCORD_WEBHOOK_PUBLIC"],
|
||||
};
|
||||
|
||||
function webhookFor(channel) {
|
||||
for (const name of CHANNELS[channel]) {
|
||||
if (process.env[name]) return { url: process.env[name], via: name };
|
||||
}
|
||||
return { url: null, via: CHANNELS[channel][0] };
|
||||
}
|
||||
|
||||
function arg(name, fallback = undefined) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
@@ -62,7 +87,34 @@ async function fetchReleasesSince(sinceTag) {
|
||||
|
||||
async function main() {
|
||||
const post = arg("post", false) === true;
|
||||
const roleId = process.env.DISCORD_ROLE_ID || undefined;
|
||||
const channel = arg("channel", "staff");
|
||||
if (channel !== "staff" && channel !== "public") {
|
||||
console.error(`Unknown --channel "${channel}". Use staff or public.`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Belt to the workflow's braces. The workflow only passes `--channel public`
|
||||
// on a manual dispatch, but a release event reaching this line with public
|
||||
// selected would announce to everyone with nobody having read it.
|
||||
if (channel === "public" && process.env.GITHUB_EVENT_NAME === "release") {
|
||||
console.error("Refusing to post to the public channel from a release event.");
|
||||
process.exit(1);
|
||||
}
|
||||
// Only the public post pings. A role ping in the staff channel on every
|
||||
// release is noise in a room that already exists to watch releases.
|
||||
const rawRole = channel === "public" ? process.env.DISCORD_ROLE_ID : undefined;
|
||||
const roleId = normalizeRoleId(rawRole);
|
||||
// `buildPayload` drops a malformed id either way, but silence would leave
|
||||
// someone wondering why the ping never fired.
|
||||
if (rawRole && rawRole.trim() && !roleId) {
|
||||
console.warn(
|
||||
`DISCORD_ROLE_ID is not a valid role id (${JSON.stringify(rawRole)}) — posting without a ping.`,
|
||||
);
|
||||
}
|
||||
// Undefined falls through to the defaults in discord-notes.mjs — an empty
|
||||
// env var must not blank the identity and hand the post back to the
|
||||
// webhook's own name.
|
||||
const username = process.env.DISCORD_USERNAME || undefined;
|
||||
const avatarUrl = process.env.DISCORD_AVATAR_URL || undefined;
|
||||
const since = arg("since");
|
||||
const notesFile = arg("notes-file");
|
||||
let version = arg("version");
|
||||
@@ -80,6 +132,9 @@ async function main() {
|
||||
sinceVersion: since,
|
||||
releaseUrl: releases[0].html_url,
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
} else if (typeof notesFile === "string") {
|
||||
// Local preview against unreleased notes. No network, so the compare link
|
||||
@@ -88,6 +143,9 @@ async function main() {
|
||||
version: version || "(unreleased)",
|
||||
body: readFileSync(notesFile, "utf8"),
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
} else {
|
||||
const rel = await fetchRelease(typeof version === "string" ? version : undefined);
|
||||
@@ -97,11 +155,20 @@ async function main() {
|
||||
body: rel.body ?? "",
|
||||
releaseUrl: rel.html_url,
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
console.log("--- DRY RUN (pass --post to send) ---\n");
|
||||
// Identity is part of what a preview is for: it is the thing that was
|
||||
// wrong on the first real post, and it is invisible in the embed body.
|
||||
console.log(`channel: ${channel}`);
|
||||
console.log(`posting as: ${payload.username}`);
|
||||
console.log(`avatar: ${payload.avatar_url}`);
|
||||
console.log("");
|
||||
console.log(payload.embeds[0].title);
|
||||
console.log("-".repeat(60));
|
||||
console.log(payload.embeds[0].description);
|
||||
@@ -110,9 +177,9 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhook = process.env.DISCORD_WEBHOOK_URL;
|
||||
const { url: webhook, via } = webhookFor(channel);
|
||||
if (!webhook) {
|
||||
console.error("DISCORD_WEBHOOK_URL is not set — refusing to post.");
|
||||
console.error(`${via} is not set — refusing to post to the ${channel} channel.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -125,7 +192,7 @@ async function main() {
|
||||
console.error(`Discord ${res.status}: ${await res.text()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Announced ${payload.embeds[0].title}`);
|
||||
console.log(`Announced ${payload.embeds[0].title} → ${channel}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -26,6 +26,54 @@ export const EMBED_DESCRIPTION_LIMIT = 4096;
|
||||
/** Accent purple, matching `--wp-accent-500` in the manager. */
|
||||
export const BRAND_COLOR = 0x8b5cf6;
|
||||
|
||||
/**
|
||||
* Who the post appears to be from.
|
||||
*
|
||||
* A webhook posts under whatever name and avatar it was created with — the
|
||||
* Discord default is a generic name like "Captain Hook" and a placeholder
|
||||
* image, which is what an announcement went out as. `username` and
|
||||
* `avatar_url` override that PER MESSAGE, so the identity lives here rather
|
||||
* than in a webhook's settings: it stays right if the webhook is ever rotated
|
||||
* or recreated, and it is the same in every channel the automation posts to.
|
||||
*/
|
||||
export const WEBHOOK_USERNAME = "Wildcard Pipeline";
|
||||
|
||||
/**
|
||||
* Discord fetches this server-side, so it has to be a public raster URL — an
|
||||
* SVG will not render, which rules out the repo's `favicon.svg`. Pinned to
|
||||
* `main` rather than a tag so replacing the logo does not need a code change.
|
||||
*/
|
||||
export const WEBHOOK_AVATAR_URL =
|
||||
"https://raw.githubusercontent.com/DumiFlex/ComfyUI-Wildcard-Pipeline/main/public/images/web-app-manifest-512x512.png";
|
||||
|
||||
/**
|
||||
* A role id we are willing to turn into a ping, or `undefined`.
|
||||
*
|
||||
* An unset secret was always safe, but a MALFORMED one was not: any truthy
|
||||
* string went straight into `<@&…>`, so a stray space produced a literal
|
||||
* `<@& >` at the top of the announcement and a pasted mention produced
|
||||
* `<@&<@&123>>`. Both render as visible junk in the most public message the
|
||||
* project sends.
|
||||
*
|
||||
* Accepts a bare snowflake or the `<@&123>` form, since "Copy ID" and copying
|
||||
* the mention text are equally easy mistakes to make. Anything else is
|
||||
* rejected rather than guessed at — a ping that silently does not happen is a
|
||||
* much smaller problem than a broken one that does.
|
||||
*/
|
||||
export function normalizeRoleId(raw) {
|
||||
if (typeof raw !== "string" && typeof raw !== "number") return undefined;
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return undefined;
|
||||
const unwrapped = trimmed.replace(/^<@&(\d+)>$/, "$1");
|
||||
// Discord snowflakes are 17-20 digits today; the range leaves room without
|
||||
// accepting arbitrary numbers.
|
||||
return /^\d{17,20}$/.test(unwrapped) ? unwrapped : undefined;
|
||||
}
|
||||
|
||||
/** Where the manual "publish to the public channel" run is started. */
|
||||
export const DISPATCH_URL =
|
||||
"https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions/workflows/discord-announce.yml";
|
||||
|
||||
/**
|
||||
* Split a release body into the part worth announcing and what is left behind.
|
||||
*
|
||||
@@ -162,11 +210,19 @@ export function buildPayload({
|
||||
body,
|
||||
releaseUrl,
|
||||
compareUrl,
|
||||
roleId,
|
||||
roleId: rawRoleId,
|
||||
username = WEBHOOK_USERNAME,
|
||||
avatarUrl = WEBHOOK_AVATAR_URL,
|
||||
channel = "staff",
|
||||
installHint = 'ComfyUI Manager → search "Wildcard Pipeline" → Update → restart ComfyUI',
|
||||
}) {
|
||||
const description = buildDescription(body, { compareUrl });
|
||||
// Normalised HERE rather than at the call site so every caller — CLI,
|
||||
// roll-up, future ones — gets the same guarantee.
|
||||
const roleId = normalizeRoleId(rawRoleId);
|
||||
return {
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
content: roleId ? `<@&${roleId}>` : "",
|
||||
allowed_mentions: { parse: [], roles: roleId ? [roleId] : [] },
|
||||
embeds: [
|
||||
@@ -175,12 +231,37 @@ export function buildPayload({
|
||||
url: releaseUrl || undefined,
|
||||
description,
|
||||
color: BRAND_COLOR,
|
||||
fields: reviewFields(channel),
|
||||
footer: { text: installHint },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff copy carries its own publish button; the public copy does not.
|
||||
*
|
||||
* This is what makes the staff channel a review step rather than a duplicate
|
||||
* feed: the post you are reading is the post that will go out, and the link to
|
||||
* send it is attached to it. Without that, "go and publish it" is a separate
|
||||
* instruction living in someone's head.
|
||||
*
|
||||
* Returned as `undefined` for the public channel rather than an empty array —
|
||||
* Discord rejects `fields: []` on some client versions, and an empty field
|
||||
* block would render as dead space in the announcement everyone sees.
|
||||
*/
|
||||
function reviewFields(channel) {
|
||||
if (channel !== "staff") return undefined;
|
||||
return [
|
||||
{
|
||||
name: "Ready to go public?",
|
||||
value:
|
||||
`[Run the workflow](${DISPATCH_URL}) with **channel: public** and ` +
|
||||
"**dry_run: false** to post this to the announcement channel.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Several releases → one catch-up payload.
|
||||
*
|
||||
@@ -191,7 +272,10 @@ export function buildPayload({
|
||||
*
|
||||
* `releases` is newest-first: `{ version, body }`.
|
||||
*/
|
||||
export function buildRollupPayload({ releases, sinceVersion, releaseUrl, roleId, installHint }) {
|
||||
export function buildRollupPayload({
|
||||
releases, sinceVersion, releaseUrl, roleId, installHint, username, avatarUrl,
|
||||
channel = "staff",
|
||||
}) {
|
||||
const latest = releases[0];
|
||||
const sections = releases.map((r) => {
|
||||
const { head } = splitBody(r.body);
|
||||
@@ -205,6 +289,9 @@ export function buildRollupPayload({ releases, sinceVersion, releaseUrl, roleId,
|
||||
releaseUrl,
|
||||
roleId,
|
||||
installHint,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
base.embeds[0].title = sinceVersion
|
||||
? `Wildcard Pipeline ${latest?.version ?? ""} — everything since ${sinceVersion}`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DISPATCH_URL,
|
||||
normalizeRoleId,
|
||||
splitBody,
|
||||
toDiscordMarkdown,
|
||||
buildDescription,
|
||||
@@ -180,11 +182,104 @@ describe("buildPayload", () => {
|
||||
expect(p.allowed_mentions.roles).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* An unset secret was always safe. A MALFORMED one was not: any truthy string
|
||||
* went straight into `<@&…>`, so a stray space produced a literal `<@& >`
|
||||
* at the top of the most public message the project sends.
|
||||
*/
|
||||
describe("role ping", () => {
|
||||
const content = (roleId) => buildPayload({ version: "v1", body: "x", roleId }).content;
|
||||
|
||||
it("stays silent for every flavour of absent", () => {
|
||||
expect(content(undefined)).toBe("");
|
||||
expect(content(null)).toBe("");
|
||||
expect(content("")).toBe("");
|
||||
expect(content(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("refuses anything that is not a snowflake, rather than guessing", () => {
|
||||
expect(content("not-an-id")).toBe("");
|
||||
expect(content("12345")).toBe(""); // too short
|
||||
expect(content("1234567890123456789012")).toBe(""); // too long
|
||||
expect(content("123abc456def78901")).toBe("");
|
||||
});
|
||||
|
||||
it("never emits a half-formed mention", () => {
|
||||
for (const bad of [" ", "not-an-id", "<@&>", "@everyone"]) {
|
||||
expect(content(bad)).not.toContain("<@&");
|
||||
}
|
||||
});
|
||||
|
||||
// "Copy ID" and copying the mention text are equally easy mistakes.
|
||||
it("accepts a pasted mention as well as a bare id", () => {
|
||||
expect(content("123456789012345678")).toBe("<@&123456789012345678>");
|
||||
expect(content("<@&123456789012345678>")).toBe("<@&123456789012345678>");
|
||||
expect(content(" 123456789012345678 ")).toBe("<@&123456789012345678>");
|
||||
});
|
||||
|
||||
it("keeps allowed_mentions in step with the content", () => {
|
||||
const bad = buildPayload({ version: "v1", body: "x", roleId: "junk" });
|
||||
expect(bad.allowed_mentions.roles).toEqual([]);
|
||||
const good = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
|
||||
expect(good.allowed_mentions.roles).toEqual(["123456789012345678"]);
|
||||
expect(good.allowed_mentions.parse).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizeRoleId is the single decision point", () => {
|
||||
expect(normalizeRoleId("<@&123456789012345678>")).toBe("123456789012345678");
|
||||
expect(normalizeRoleId("nope")).toBeUndefined();
|
||||
expect(normalizeRoleId(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("pings only the configured role, and only when configured", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).content).toBe("");
|
||||
const p = buildPayload({ version: "v1", body: "x", roleId: "123" });
|
||||
expect(p.content).toBe("<@&123>");
|
||||
expect(p.allowed_mentions.roles).toEqual(["123"]);
|
||||
const p = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
|
||||
expect(p.content).toBe("<@&123456789012345678>");
|
||||
expect(p.allowed_mentions.roles).toEqual(["123456789012345678"]);
|
||||
});
|
||||
|
||||
// A webhook posts under whatever name and avatar it was created with —
|
||||
// Discord's default is a generic name ("Captain Hook") and a placeholder
|
||||
// image, which is exactly what v2.12.0 went out as.
|
||||
it("posts under the project's own name and avatar, not the webhook's", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x" });
|
||||
expect(p.username).toBe("Wildcard Pipeline");
|
||||
expect(p.avatar_url).toMatch(/^https:\/\/.+\.png$/);
|
||||
});
|
||||
|
||||
it("uses a raster avatar — Discord will not render an SVG", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).avatar_url).not.toMatch(/\.svg$/i);
|
||||
});
|
||||
|
||||
it("lets a caller override the identity", () => {
|
||||
const p = buildPayload({
|
||||
version: "v1", body: "x", username: "WP Staff", avatarUrl: "https://x/a.png",
|
||||
});
|
||||
expect(p.username).toBe("WP Staff");
|
||||
expect(p.avatar_url).toBe("https://x/a.png");
|
||||
});
|
||||
|
||||
/**
|
||||
* Two channels, deliberately asymmetric. The staff copy is the review step,
|
||||
* so it carries the button that publishes it onward; the public copy is the
|
||||
* announcement itself and must not tell readers how to re-send it.
|
||||
*/
|
||||
it("gives the staff copy a publish link, so review and button are one thing", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x", channel: "staff" });
|
||||
const field = p.embeds[0].fields[0];
|
||||
expect(field.value).toContain(DISPATCH_URL);
|
||||
expect(field.value).toMatch(/channel.*public/i);
|
||||
});
|
||||
|
||||
it("defaults to the staff copy — the safe one to send by accident", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).embeds[0].fields).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the public copy without the publish link", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x", channel: "public" });
|
||||
// `undefined`, not `[]` — an empty field block renders as dead space.
|
||||
expect(p.embeds[0].fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays inside the embed description cap", () => {
|
||||
@@ -210,6 +305,26 @@ describe("buildRollupPayload", () => {
|
||||
expect(p.embeds[0].description).toContain("**v2.11.0**");
|
||||
});
|
||||
|
||||
it("carries the channel through a roll-up", () => {
|
||||
const staff = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "staff",
|
||||
});
|
||||
const pub = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "public",
|
||||
});
|
||||
expect(staff.embeds[0].fields).toBeTruthy();
|
||||
expect(pub.embeds[0].fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the identity on a roll-up too", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }],
|
||||
sinceVersion: "v2.10.2",
|
||||
});
|
||||
expect(p.username).toBe("Wildcard Pipeline");
|
||||
expect(p.avatar_url).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
it("leads with the newest, which is what people decide to install", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* - web/ (built manager SPA)
|
||||
* - public/ (favicons + doc images referenced by the SPA)
|
||||
* - docs/help/ (per-node help markdown the canvas reads)
|
||||
* - locales/ (node display names + settings labels ComfyUI reads)
|
||||
* - README.md
|
||||
* - LICENSE
|
||||
*
|
||||
@@ -59,8 +60,55 @@ const SHIP_PATHS = [
|
||||
"web",
|
||||
"public",
|
||||
"docs/help",
|
||||
// ComfyUI reads `custom_nodes/<pack>/locales/<lang>/` for node display
|
||||
// names, descriptions and settings labels (app/custom_node_manager.py).
|
||||
// The registry archive picks this up automatically because it ships
|
||||
// everything git-tracked that `.comfyignore` doesn't exclude; this list is
|
||||
// hand-maintained, so it did not. The two artifacts silently disagreed —
|
||||
// see DEV_ONLY below for the guard that now stops that recurring.
|
||||
"locales",
|
||||
];
|
||||
|
||||
/**
|
||||
* Top-level tracked entries that deliberately do NOT ship.
|
||||
*
|
||||
* Exists to make the ship list total: every top-level path in git is either
|
||||
* shipped or listed here, and `assertShipListIsTotal` fails the release if a
|
||||
* new one appears in neither. Without it, adding a runtime directory and
|
||||
* forgetting this file produces a zip that installs and silently misbehaves —
|
||||
* which is exactly how `locales/` went missing.
|
||||
*/
|
||||
const DEV_ONLY = new Set([
|
||||
".comfyignore", ".gitattributes", ".github", ".gitignore", ".husky", ".npmrc",
|
||||
".releaserc.js", "CHANGELOG.md", "CLAUDE.md", "config", "conftest.py",
|
||||
"eslint.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
||||
"scripts", "src", "tests", "tsconfig.json", "tsconfig.node.json",
|
||||
"vite.config.mts", "vitest.config.ts",
|
||||
]);
|
||||
|
||||
function assertShipListIsTotal() {
|
||||
const ls = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" });
|
||||
if (ls.status !== 0) {
|
||||
console.warn("pack-release: `git ls-files` unavailable — skipping ship-list completeness check");
|
||||
return;
|
||||
}
|
||||
// A ship path may name a subtree (`docs/help`); its top-level segment is
|
||||
// what makes the parent directory "accounted for".
|
||||
const covered = new Set(SHIP_PATHS.map((p) => p.split("/")[0]));
|
||||
const tracked = new Set(
|
||||
ls.stdout.split("\n").map((line) => line.trim().split("/")[0]).filter(Boolean),
|
||||
);
|
||||
const unaccounted = [...tracked].filter((e) => !covered.has(e) && !DEV_ONLY.has(e)).sort();
|
||||
if (unaccounted.length > 0) {
|
||||
console.error("pack-release: top-level paths in git that are neither shipped nor dev-only:");
|
||||
for (const p of unaccounted) console.error(` - ${p}`);
|
||||
console.error("Add each to SHIP_PATHS (ships to users) or DEV_ONLY (stays in the repo).");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
assertShipListIsTotal();
|
||||
|
||||
// Sanity: every path must exist before zipping. If `js/` or `web/`
|
||||
// is missing the release would ship a broken bundle — catch here so the
|
||||
// release workflow fails fast rather than uploading a half-baked zip.
|
||||
@@ -93,6 +141,13 @@ const args = [
|
||||
];
|
||||
|
||||
const result = spawnSync("zip", args, { stdio: "inherit", cwd: root });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
// Windows has no `zip` by default, so a maintainer running this locally
|
||||
// otherwise gets a bare "exited with status null".
|
||||
console.error("pack-release: the `zip` CLI was not found on PATH.");
|
||||
console.error("The release workflow runs on ubuntu-latest where it is always present.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.error(`pack-release: zip exited with status ${result.status}`);
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
@@ -244,7 +244,14 @@ export function useGrowableField(
|
||||
window.removeEventListener("pointercancel", onPointerUp);
|
||||
}
|
||||
|
||||
/** Idempotent: callers re-attach when the element they own is REPLACED
|
||||
* rather than mutated (RichTextInput swaps its contenteditable host to
|
||||
* repair a browser-mangled DOM). Without the disconnect the old observer
|
||||
* would keep watching a detached node and leak, and the hint would go
|
||||
* permanently stale on the live one. */
|
||||
function attach(): void {
|
||||
obs?.disconnect();
|
||||
obs = null;
|
||||
const el = getEl();
|
||||
if (!el) return;
|
||||
lastHeight = el.getBoundingClientRect().height;
|
||||
|
||||
@@ -132,6 +132,8 @@ describe("AppTopbar.vue", () => {
|
||||
lastChecked: ref(null),
|
||||
checking: ref(false),
|
||||
rateLimitedUntil: ref(null),
|
||||
history: ref([]),
|
||||
loadHistory: vi.fn(),
|
||||
checkNow: vi.fn(),
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
|
||||
|
||||
@@ -302,6 +302,113 @@ describe("RichTextInput.vue", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("survives a blur re-parse that drops an atom after the anchors went missing", async () => {
|
||||
// The sibling of the picker-Apply crash: same missing `<template v-for>`
|
||||
// fragment anchors, reached from the plain re-parse path (no `rebuild`).
|
||||
// Shrinking the list unmounts an item's Fragment, and `removeFragment`
|
||||
// would walk off the end exactly as it does on a full re-key.
|
||||
// Here `hostDomIsStale()` DOES fire — the deleted chip drops the atom-node
|
||||
// count below the list length — so the host swap already covers it. This
|
||||
// pins that: the two repairs must not diverge, since only one of them was
|
||||
// ever reachable when the anchors are gone but the elements are intact.
|
||||
const errors: string[] = [];
|
||||
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
|
||||
window.addEventListener("error", onErr);
|
||||
const ref = "@{aabbccdd#pose}";
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: `a ${ref} b ${ref} c`,
|
||||
surface: "wildcard",
|
||||
uuidToName: new Map([["aabbccdd", "pose"]]),
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
[...host.childNodes]
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "")
|
||||
.forEach((n) => (n as Text).remove());
|
||||
// User deletes the second chip; the surviving atom nodes stay put.
|
||||
const chips = host.querySelectorAll(".wp-refchip");
|
||||
chips[1].remove();
|
||||
await wrap.find(".wp-rt__host").trigger("blur");
|
||||
await flushPromises();
|
||||
window.removeEventListener("error", onErr);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(wrap.findAll(".wp-refchip")).toHaveLength(1);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("survives Ctrl+A Ctrl+X after the anchors went missing", async () => {
|
||||
// Reported on Firefox: select-all + cut inside an option value bricked the
|
||||
// field — text input, Backspace and even Save/Cancel stopped responding —
|
||||
// with `insertBefore: Child to insert before is not a child of this node`.
|
||||
//
|
||||
// `onHostCut` already routes the deletion through `applyAtoms` (2.13.1) so
|
||||
// the browser never removes the spans itself. But the atom list still
|
||||
// SHRINKS from N to 1, and shrinking a keyed list unmounts the surplus
|
||||
// `<template v-for>` Fragments. With the fragment anchors normalised away
|
||||
// by Firefox, that teardown walks off the end of the child list and takes
|
||||
// the whole editor down — the same wound as the ref-picker crash, reached
|
||||
// through a path that never asked for a rebuild.
|
||||
const errors: string[] = [];
|
||||
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
|
||||
window.addEventListener("error", onErr);
|
||||
const ref = "@{aabbccdd#pose}";
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: `a ${ref} b ${ref} c`,
|
||||
surface: "wildcard",
|
||||
uuidToName: new Map([["aabbccdd", "pose"]]),
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
[...host.childNodes]
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "")
|
||||
.forEach((n) => (n as Text).remove());
|
||||
// Ctrl+A.
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(host);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
// Ctrl+X.
|
||||
await wrap.find(".wp-rt__host").trigger("cut");
|
||||
await flushPromises();
|
||||
window.removeEventListener("error", onErr);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
// Emptied, and still a live editor rather than a dead subtree.
|
||||
const events = wrap.emitted("update:modelValue") ?? [];
|
||||
expect(events[events.length - 1]?.[0]).toBe("");
|
||||
const fresh = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
expect(fresh.querySelectorAll("[data-atom-index]").length).toBeGreaterThan(0);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does not remount the host for a structural apply while the anchors are intact", async () => {
|
||||
// The other half of the anchor check: it must stay OFF on the browser
|
||||
// where the anchors survive. Settling a chip is a structural apply — it
|
||||
// splits one text atom into text + chip + text — and it happens on nearly
|
||||
// every keystroke that closes a token. Swapping the host there would cost
|
||||
// a caret restore per settle on Chromium for no reason at all.
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: { modelValue: "", varSuggestions: [] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const before = wrap.find(".wp-rt__host").element;
|
||||
const span = (before as HTMLElement).querySelector(".wp-rt__text") as HTMLElement;
|
||||
span.textContent = "$runtimeVar ";
|
||||
await wrap.find(".wp-rt__host").trigger("input", { inputType: "insertText", data: " " });
|
||||
await flushPromises();
|
||||
// The settle landed...
|
||||
expect(wrap.findAll(".wp-refchip--var")).toHaveLength(1);
|
||||
// ...on the very same host element.
|
||||
expect(wrap.find(".wp-rt__host").element).toBe(before);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does not remount the host for the normal typing case (orphan text node)", async () => {
|
||||
// Guard against over-triggering the stale-DOM repair: browsers routinely
|
||||
// insert user-typed characters as raw text nodes directly under the host.
|
||||
@@ -681,6 +788,61 @@ describe("RichTextInput.vue", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("survives a picker Apply after the browser dropped the v-for fragment anchors", async () => {
|
||||
// Regression (Firefox): `<template v-for>` mounts each atom inside a
|
||||
// Fragment, and a Fragment is delimited in the DOM by two EMPTY text
|
||||
// nodes. Those anchors live inside a contenteditable, and Firefox
|
||||
// normalises empty text nodes away as the user types — Chromium keeps
|
||||
// them, which is why this only ever reproduced on Firefox.
|
||||
//
|
||||
// The picker-Apply path then remounts the atom v-for, and Vue's
|
||||
// `removeFragment` walks `nextSibling` from the start anchor until it
|
||||
// reaches the end anchor. With the end anchor gone the walk runs off the
|
||||
// end of the child list and derefs null: "can't access property
|
||||
// nextSibling, e is null", followed by the unmount cascade that leaves
|
||||
// the editor dead behind the ErrorBoundary.
|
||||
//
|
||||
// `hostDomIsStale()` cannot see this — it counts `[data-atom-index]`
|
||||
// ELEMENTS, and the destroyed nodes are anchors, not atoms.
|
||||
const errors: string[] = [];
|
||||
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
|
||||
window.addEventListener("error", onErr);
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "hair ",
|
||||
surface: "wildcard",
|
||||
refSuggestions: ["aabbccdd"],
|
||||
uuidToName: new Map([["aabbccdd", "color"]]),
|
||||
uuidToSubCategories: new Map([["aabbccdd", ["warm", "cool"]]]),
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
// What Firefox does to the host while the user types `@hair`.
|
||||
const anchors = [...host.childNodes].filter(
|
||||
(n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "",
|
||||
);
|
||||
expect(anchors.length).toBeGreaterThan(0);
|
||||
anchors.forEach((n) => (n as Text).remove());
|
||||
|
||||
await (wrap.vm as unknown as { __triggerAutocompleteForTest: (t: "@" | "$") => Promise<void> })
|
||||
.__triggerAutocompleteForTest("@");
|
||||
await (wrap.vm as unknown as { __applyAutocompleteForTest: (label: string) => Promise<void> })
|
||||
.__applyAutocompleteForTest("aabbccdd");
|
||||
(document.querySelector('[data-test="subcat-chip"][data-value="warm"]') as HTMLElement).click();
|
||||
await flushPromises();
|
||||
(document.querySelector('[data-test="picker-apply"]') as HTMLElement).click();
|
||||
await flushPromises();
|
||||
window.removeEventListener("error", onErr);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
// And the insert actually landed — the editor is still alive, not a
|
||||
// half-torn subtree.
|
||||
const events = wrap.emitted("update:modelValue") ?? [];
|
||||
expect(events[events.length - 1]?.[0]).toContain("@{aabbccdd#color:warm}");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("subcat picker Skip inserts plain @{uuid#name} chip", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
|
||||
@@ -27,6 +27,8 @@ beforeEach(() => {
|
||||
lastChecked: ref("2026-07-21T10:00:00.000Z"),
|
||||
checking: ref(false),
|
||||
rateLimitedUntil: ref(null),
|
||||
history: ref([]),
|
||||
loadHistory: vi.fn(),
|
||||
checkNow,
|
||||
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
|
||||
import WhatsNew from "../../views/WhatsNew.vue";
|
||||
import * as releaseCheck from "../../composables/useReleaseCheck";
|
||||
|
||||
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.13.0";
|
||||
|
||||
const NOTES = "Headline.\n\n### Highlights\n\n- **A thing.** detail\n";
|
||||
|
||||
function stubRelease(over: Record<string, unknown> = {}) {
|
||||
return vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
|
||||
current: "2.13.0",
|
||||
latestVersion: ref("2.13.0"),
|
||||
hasUpdate: ref(false),
|
||||
severity: ref(null),
|
||||
releaseBody: ref(NOTES),
|
||||
releaseUrl: ref("https://github.com/o/r/releases/tag/v2.13.0"),
|
||||
lastChecked: ref(null),
|
||||
checking: ref(false),
|
||||
rateLimitedUntil: ref(null),
|
||||
history: ref([
|
||||
{ version: "2.13.0", body: NOTES, url: "https://x/13", publishedAt: "2026-08-04T00:00:00Z" },
|
||||
{ version: "2.12.0", body: "The big one.\n\n- **Nodes 2.0.** yes", url: "https://x/12", publishedAt: "2026-08-03T00:00:00Z" },
|
||||
{ version: "2.11.0", body: "Older.\n\n- **Held modules.** yes", url: "https://x/11", publishedAt: "2026-07-24T00:00:00Z" },
|
||||
]),
|
||||
loadHistory: vi.fn(),
|
||||
checkNow: vi.fn(),
|
||||
...over,
|
||||
} as unknown as ReturnType<typeof releaseCheck.useReleaseCheck>);
|
||||
}
|
||||
|
||||
async function mountPage() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: "/whats-new", component: WhatsNew }],
|
||||
});
|
||||
router.push("/whats-new");
|
||||
await router.isReady();
|
||||
const w = mount(WhatsNew, { global: { plugins: [router] } });
|
||||
await flushPromises();
|
||||
return w;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("WhatsNew — earlier releases", () => {
|
||||
/**
|
||||
* 2.12.0 was current for about an hour before 2.13.0 replaced it. Anyone
|
||||
* updating from 2.11.0 lands on 2.13.0, so showing only the newest tag makes
|
||||
* the largest release in months invisible.
|
||||
*/
|
||||
it("shows the releases behind the current one", async () => {
|
||||
stubRelease();
|
||||
const w = await mountPage();
|
||||
expect(w.find('[data-test="whats-new-older"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="whats-new-older-2.12.0"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="whats-new-older-2.11.0"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not repeat the release already shown in full above", async () => {
|
||||
stubRelease();
|
||||
const w = await mountPage();
|
||||
expect(w.find('[data-test="whats-new-older-2.13.0"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders each earlier release's notes, not just its number", async () => {
|
||||
stubRelease();
|
||||
const w = await mountPage();
|
||||
expect(w.find('[data-test="whats-new-older-2.12.0"]').text()).toContain("Nodes 2.0");
|
||||
});
|
||||
|
||||
// Collapsed: the newest release is why the page was opened.
|
||||
it("keeps earlier releases collapsed", async () => {
|
||||
stubRelease();
|
||||
const w = await mountPage();
|
||||
const el = w.find('[data-test="whats-new-older-2.12.0"]').element as HTMLDetailsElement;
|
||||
expect(el.open).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for the history on mount", async () => {
|
||||
const loadHistory = vi.fn();
|
||||
stubRelease({ loadHistory });
|
||||
await mountPage();
|
||||
expect(loadHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a release with an empty body rather than showing a bare heading", async () => {
|
||||
stubRelease({
|
||||
history: ref([
|
||||
{ version: "2.12.0", body: " ", url: null, publishedAt: null },
|
||||
]),
|
||||
});
|
||||
const w = await mountPage();
|
||||
expect(w.find('[data-test="whats-new-older"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders nothing extra when there is no history", async () => {
|
||||
stubRelease({ history: ref([]) });
|
||||
const w = await mountPage();
|
||||
expect(w.find('[data-test="whats-new-older"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
// The button says "All releases".
|
||||
it("links to the releases index, not to one release", async () => {
|
||||
stubRelease();
|
||||
const w = await mountPage();
|
||||
const href = w.find('[data-test="whats-new-github"]').attributes("href");
|
||||
expect(href).toMatch(/\/releases$/);
|
||||
expect(href).not.toContain("/tag/");
|
||||
});
|
||||
});
|
||||
@@ -542,20 +542,27 @@ function parseForSurface(text: string): Atom[] {
|
||||
}
|
||||
|
||||
const atoms = ref<Atom[]>(padAtoms(parseForSurface(props.modelValue || "")));
|
||||
// Bumped only on structural-insert applies (autocomplete / ref-picker) to
|
||||
// force a clean teardown+remount of the atom v-for instead of patching the
|
||||
// new structure over a hand-mutated, out-of-sync contenteditable DOM. Folded
|
||||
// into the v-for :key. See applyAtoms() for why the typing path never bumps it.
|
||||
const renderEpoch = ref(0);
|
||||
// Bumped when the browser destroyed rendered atom nodes behind Vue's back
|
||||
// (see `hostDomIsStale`). Keyed on the HOST ELEMENT, not the atom v-for:
|
||||
// once Vue's child vnodes hold detached `el`s, ANY patch of that subtree —
|
||||
// including the full key-churn teardown `renderEpoch` triggers — derefs a
|
||||
// null `el` and throws `insertBefore: Child to insert before is not a child
|
||||
// of this node`. Replacing the host element itself is the only safe repair:
|
||||
// Vue unmounts an element vnode with a single `hostRemove(el)` on a node
|
||||
// that IS still attached, tearing children down with `doRemove: false` so
|
||||
// no stale child el is ever touched.
|
||||
// Bumped whenever the atom v-for has to be rebuilt rather than patched:
|
||||
// the browser destroyed rendered nodes behind Vue's back (see
|
||||
// `hostDomIsStale`), or a structural insert is landing on a hand-mutated
|
||||
// contenteditable (autocomplete / ref-picker apply).
|
||||
//
|
||||
// Keyed on the HOST ELEMENT, never on the atom v-for. Re-keying the v-for
|
||||
// looks like the lighter option and is not: the host is a contenteditable,
|
||||
// so every node inside it — including the two EMPTY TEXT NODES Vue uses to
|
||||
// delimit each `<template v-for>` Fragment — belongs to the browser, which
|
||||
// deletes and merges them at will (Firefox normalises empty text nodes away
|
||||
// as you type; Chromium keeps them, which is why this only ever reproduced
|
||||
// on Firefox). Once an anchor is gone, tearing the v-for down runs Vue's
|
||||
// `removeFragment` walk off the end of the child list and throws
|
||||
// `nextSibling of null`, and patching over detached child els throws
|
||||
// `insertBefore: Child to insert before is not a child of this node`.
|
||||
//
|
||||
// Replacing the host element sidesteps both: Vue unmounts an element vnode
|
||||
// with a single `hostRemove(el)` on a node that IS still attached, tearing
|
||||
// its children down with `doRemove: false` so no stale child el or missing
|
||||
// anchor is ever touched, then mounts the new tree into an empty element
|
||||
// with no anchor to insert before.
|
||||
const hostEpoch = ref(0);
|
||||
let lastEmittedValue = props.modelValue || "";
|
||||
|
||||
@@ -1359,6 +1366,17 @@ onMounted(() => {
|
||||
attach();
|
||||
});
|
||||
|
||||
/** A host swap replaces the observed element, so the ResizeObserver has to
|
||||
* follow it — otherwise the overflow fade and grip-follow silently stop
|
||||
* working for the rest of the session on any field that ever had to repair
|
||||
* its DOM. */
|
||||
watch(hostEpoch, () => {
|
||||
void nextTick(() => {
|
||||
attach();
|
||||
scheduleOverflowHint();
|
||||
});
|
||||
});
|
||||
|
||||
/** True when the live host has FEWER rendered atom nodes than `atoms.value`
|
||||
* expects — i.e. the browser destroyed nodes Vue still holds `el` pointers
|
||||
* for. Contenteditable hands the browser ownership of these children, and
|
||||
@@ -1377,24 +1395,87 @@ function hostDomIsStale(): boolean {
|
||||
return host.querySelectorAll("[data-atom-index]").length < atoms.value.length;
|
||||
}
|
||||
|
||||
/** True while every Fragment anchor Vue needs is still in the host.
|
||||
*
|
||||
* Vue delimits each Fragment with two EMPTY TEXT NODES, and `<template
|
||||
* v-for>` builds one Fragment per atom plus one for the list itself — so a
|
||||
* healthy host holds `2N + 2` empty text nodes as direct children. They sit
|
||||
* inside a contenteditable, which makes them the browser's to destroy:
|
||||
* Firefox normalises empty text nodes away as the user types, Chromium keeps
|
||||
* them. That difference is why every crash in this family has been
|
||||
* Firefox-only.
|
||||
*
|
||||
* Each anchor is load-bearing. Removing a list item walks `nextSibling` from
|
||||
* its start anchor until it reaches its end anchor (`removeFragment`), and
|
||||
* appending one inserts before the LIST's end anchor. With either gone the
|
||||
* render throws — `nextSibling of null`, or `insertBefore: Child to insert
|
||||
* before is not a child of this node` — and leaves a half-torn subtree that
|
||||
* no longer responds to anything, Save and Cancel included.
|
||||
*
|
||||
* `hostDomIsStale` cannot stand in for this: it counts `[data-atom-index]`
|
||||
* ELEMENTS, and anchors are neither elements nor atoms.
|
||||
*
|
||||
* Counts EMPTY text nodes only — characters the browser drops directly under
|
||||
* the host are non-empty (see `reconcileOrphanTextNodes`), and a surplus is
|
||||
* harmless, so the test is `>=`. */
|
||||
function hostAnchorsIntact(): boolean {
|
||||
const host = hostEl.value;
|
||||
if (!host) return true;
|
||||
let empties = 0;
|
||||
for (const node of host.childNodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE && (node as Text).data === "") empties += 1;
|
||||
}
|
||||
return empties >= 2 * atoms.value.length + 2;
|
||||
}
|
||||
|
||||
/** True when applying `next` would ADD or REMOVE atom nodes, or flip an atom
|
||||
* between its chip and its text branch.
|
||||
*
|
||||
* Those are exactly the renders that make Vue insert or unmount list items,
|
||||
* and therefore the only ones that touch a fragment anchor. A same-shape
|
||||
* apply patches text and props in place and cannot trip over a missing one,
|
||||
* which is what keeps ordinary typing on the cheap path. */
|
||||
function isStructuralApply(next: Atom[]): boolean {
|
||||
const cur = atoms.value;
|
||||
if (cur.length !== next.length) return true;
|
||||
return cur.some((atom, i) => (atom.kind === "text") !== (next[i].kind === "text"));
|
||||
}
|
||||
|
||||
function applyAtoms(next: Atom[], opts?: { rebuild?: boolean }): void {
|
||||
// `rebuild` forces a full teardown+remount of the atom v-for (via the
|
||||
// renderEpoch key) instead of an in-place patch. Used ONLY on the
|
||||
// structural-insert paths (autocomplete / ref-picker apply): there the
|
||||
// contenteditable already lost focus to the popover/modal, so no live
|
||||
// caret or typing is disturbed, AND the host DOM may have been hand-mutated
|
||||
// (orphan text nodes, in-place span textContent) out of sync with Vue's
|
||||
// vdom. Patching the new atom structure over that stale DOM is what throws
|
||||
// `insertBefore: Child to insert before is not a child of this node`.
|
||||
// `rebuild` forces a full teardown+remount instead of an in-place patch.
|
||||
// Used ONLY on the structural-insert paths (autocomplete / ref-picker
|
||||
// apply): there the contenteditable already lost focus to the popover/modal,
|
||||
// so no live caret or typing is disturbed, AND the host DOM may have been
|
||||
// hand-mutated (orphan text nodes, in-place span textContent, fragment
|
||||
// anchors normalised away) out of sync with Vue's vdom. Patching the new
|
||||
// atom structure over that stale DOM is what throws `insertBefore: Child to
|
||||
// insert before is not a child of this node`.
|
||||
// The raw typing / Backspace paths deliberately do NOT rebuild — they keep
|
||||
// the imperative syncTextSpansToAtoms approach for caret stability (the
|
||||
// removed global renderTick churned those paths; this scopes it to inserts).
|
||||
// Stale-DOM repair takes precedence: bumping `renderEpoch` here would NOT
|
||||
// help (verified — key churn still walks the destroyed els and throws on
|
||||
// `nextSibling` of null). Replace the host element instead.
|
||||
if (hostDomIsStale()) hostEpoch.value += 1;
|
||||
else if (opts?.rebuild) renderEpoch.value += 1;
|
||||
atoms.value = padAtoms(next);
|
||||
//
|
||||
// Both routes go through the HOST ELEMENT swap, never a v-for re-key:
|
||||
// re-keying was verified NOT to help — the key churn still walks destroyed
|
||||
// els and missing `<template v-for>` fragment anchors and throws on
|
||||
// `nextSibling` of null. See the `hostEpoch` docblock.
|
||||
//
|
||||
// The third trigger is the general one, and the reason `rebuild` is no
|
||||
// longer load-bearing on its own: ANY apply that adds, removes or re-branches
|
||||
// an atom needs the fragment anchors, and the browser may have taken them.
|
||||
// Marking individual call sites `rebuild` was a whitelist, and the whitelist
|
||||
// kept missing entries — the ref picker, then Ctrl+A Ctrl+X, each crashing
|
||||
// the same way from a path nobody had flagged. Asking the DOM instead covers
|
||||
// all thirteen call sites at once, and costs nothing on Chromium, where the
|
||||
// anchors are never missing in the first place.
|
||||
const padded = padAtoms(next);
|
||||
if (
|
||||
hostDomIsStale() ||
|
||||
opts?.rebuild ||
|
||||
(isStructuralApply(padded) && !hostAnchorsIntact())
|
||||
) {
|
||||
hostEpoch.value += 1;
|
||||
}
|
||||
atoms.value = padded;
|
||||
isEmpty.value = serialiseAtomsLocal(next).length === 0;
|
||||
void nextTick(() => {
|
||||
syncTextSpansToAtoms();
|
||||
@@ -2640,7 +2721,7 @@ function onHostKeydown(ev: KeyboardEvent): void {
|
||||
@copy="onHostCopy"
|
||||
@cut="onHostCut"
|
||||
>
|
||||
<template v-for="(atom, idx) in atoms" :key="renderEpoch + ':' + idx">
|
||||
<template v-for="(atom, idx) in atoms" :key="idx">
|
||||
<RefChip
|
||||
v-if="atom.kind === 'ref' || atom.kind === 'var'"
|
||||
:kind="atom.kind"
|
||||
|
||||
@@ -32,6 +32,19 @@ import { GITHUB_REPO } from "../config/links";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
|
||||
const STORAGE_KEY = "wp.releaseCheck";
|
||||
/** Separate key: the history is a different shape and a different cadence. */
|
||||
const HISTORY_KEY = "wp.releaseHistory";
|
||||
|
||||
/**
|
||||
* How many past releases the What's new page offers.
|
||||
*
|
||||
* Three, because the gap being covered is small but real: a user who updates
|
||||
* infrequently — or who was on 2.12.0 for the hour before 2.13.0 replaced it —
|
||||
* would otherwise never see the notes for the release they actually skipped
|
||||
* past. More than three and the page becomes a changelog, which the compare
|
||||
* link already does better.
|
||||
*/
|
||||
export const HISTORY_COUNT = 3;
|
||||
|
||||
/**
|
||||
* How stale the cache must be before a launch check goes back to the network.
|
||||
@@ -70,6 +83,15 @@ const lastChecked = ref<string | null>(null);
|
||||
const checking = ref<boolean>(false);
|
||||
/** Epoch ms until which GitHub has refused us, or null when we are clear. */
|
||||
const rateLimitedUntil = ref<number | null>(null);
|
||||
/** Recent releases, newest first. Empty until `loadHistory` resolves. */
|
||||
const history = ref<ReleaseSummary[]>([]);
|
||||
|
||||
export interface ReleaseSummary {
|
||||
version: string;
|
||||
body: string;
|
||||
url: string | null;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
/** Guard so the once-per-session launch fetch fires at most once across all
|
||||
* consumers. `checkNow` ignores it. */
|
||||
@@ -97,6 +119,75 @@ export function resetReleaseCheckSession(): void {
|
||||
lastChecked.value = null;
|
||||
checking.value = false;
|
||||
rateLimitedUntil.value = null;
|
||||
history.value = [];
|
||||
historyFetched = false;
|
||||
}
|
||||
|
||||
/** Once per page: the history does not change while someone reads it. */
|
||||
let historyFetched = false;
|
||||
|
||||
function readHistoryCache(): { at: string; items: ReleaseSummary[] } | null {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as { at: string; items: ReleaseSummary[] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the last few releases for the What's new page.
|
||||
*
|
||||
* Subject to the same throttle and the same rate-limit lockout as the update
|
||||
* check — this is a second GitHub call on a shared 60/hr anonymous quota, and
|
||||
* the whole point of the caching work was that a page someone opens repeatedly
|
||||
* must not spend a request each time.
|
||||
*/
|
||||
async function loadHistory(): Promise<void> {
|
||||
const cached = readHistoryCache();
|
||||
if (cached?.items?.length) history.value = cached.items;
|
||||
|
||||
const fresh = cached ? Date.now() - Date.parse(cached.at) < LAUNCH_TTL_MS : false;
|
||||
if (historyFetched || fresh || isRateLimited()) return;
|
||||
historyFetched = true;
|
||||
|
||||
const slug = repoSlug();
|
||||
if (!slug) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${slug}/releases?per_page=${HISTORY_COUNT}`,
|
||||
{ headers: { Accept: "application/vnd.github+json" } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 403 || res.status === 429) {
|
||||
const reset = Number.parseInt(res.headers?.get?.("X-RateLimit-Reset") ?? "", 10);
|
||||
writeRateLimit(Number.isFinite(reset) ? reset * 1000 : Date.now() + 60 * 60 * 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rows = (await res.json()) as Array<Record<string, unknown>>;
|
||||
if (!Array.isArray(rows)) return;
|
||||
const items: ReleaseSummary[] = rows
|
||||
.filter((r) => r.draft !== true && r.prerelease !== true)
|
||||
.map((r) => ({
|
||||
version: normalizeTag(String(r.tag_name ?? "")),
|
||||
body: typeof r.body === "string" ? r.body : "",
|
||||
url: typeof r.html_url === "string" ? r.html_url : null,
|
||||
publishedAt: typeof r.published_at === "string" ? r.published_at : null,
|
||||
}))
|
||||
.filter((r) => r.version.length > 0);
|
||||
if (!items.length) return;
|
||||
history.value = items;
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify({ at: new Date().toISOString(), items }));
|
||||
} catch {
|
||||
// Storage denied — the in-memory list still serves this page view.
|
||||
}
|
||||
} catch {
|
||||
// Offline. The cached list, if any, is already painted.
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the persisted check recent enough to skip the network? */
|
||||
@@ -330,6 +421,8 @@ export function useReleaseCheck(): {
|
||||
lastChecked: ReturnType<typeof ref<string | null>>;
|
||||
checking: ReturnType<typeof ref<boolean>>;
|
||||
rateLimitedUntil: ReturnType<typeof ref<number | null>>;
|
||||
history: ReturnType<typeof ref<ReleaseSummary[]>>;
|
||||
loadHistory: () => Promise<void>;
|
||||
checkNow: () => Promise<void>;
|
||||
} {
|
||||
const current = __APP_VERSION__;
|
||||
@@ -366,6 +459,7 @@ export function useReleaseCheck(): {
|
||||
|
||||
return {
|
||||
current, latestVersion, hasUpdate, severity,
|
||||
releaseBody, releaseUrl, lastChecked, checking, rateLimitedUntil, checkNow,
|
||||
releaseBody, releaseUrl, lastChecked, checking, rateLimitedUntil,
|
||||
history, loadHistory, checkNow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useReleaseCheck } from "../composables/useReleaseCheck";
|
||||
import { useEngagementStore } from "../stores/engagementStore";
|
||||
import { renderReleaseNotes } from "../utils/releaseNotes";
|
||||
import { GITHUB_REPO } from "../config/links";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
|
||||
const release = useReleaseCheck();
|
||||
const engagement = useEngagementStore();
|
||||
@@ -43,9 +44,34 @@ const notesVersion = computed(() => release.latestVersion.value ?? release.curre
|
||||
const body = computed(() => release.releaseBody.value ?? "");
|
||||
const notesHtml = computed(() => renderReleaseNotes(body.value, { full: true }));
|
||||
const hasNotes = computed(() => body.value.trim().length > 0);
|
||||
const fullUrl = computed(() => release.releaseUrl.value ?? `${GITHUB_REPO}/releases`);
|
||||
/** The button says "All releases", so it goes to all of them. It used to
|
||||
* prefer this release's own URL, which made the label a lie whenever the
|
||||
* release check had resolved — the common case. The single release is still
|
||||
* one click away from the index, and each earlier entry below links to its
|
||||
* own page directly. */
|
||||
const allReleasesUrl = `${GITHUB_REPO}/releases`;
|
||||
|
||||
/**
|
||||
* Releases older than the one shown above.
|
||||
*
|
||||
* Without these, skipping a version means never seeing its notes: 2.12.0 was
|
||||
* the current release for about an hour before 2.13.0 replaced it, so anyone
|
||||
* updating from 2.11.0 lands on 2.13.0 and the largest release in months is
|
||||
* simply invisible to them. The page already exists to answer "what changed";
|
||||
* it should not answer only for the single most recent tag.
|
||||
*
|
||||
* Collapsed by default — the newest release is the reason someone opened the
|
||||
* page, and the older ones are there to be found, not to compete with it.
|
||||
*/
|
||||
const older = computed(() =>
|
||||
(release.history.value ?? [])
|
||||
.filter((r) => r.version !== notesVersion.value)
|
||||
.map((r) => ({ ...r, html: renderReleaseNotes(r.body, { full: true }) }))
|
||||
.filter((r) => r.body.trim().length > 0),
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void release.loadHistory();
|
||||
// Reaching the page IS reading it, whether or not the notes had loaded —
|
||||
// otherwise a slow network leaves the dot lit after a deliberate visit.
|
||||
engagement.markReleaseSeen(release.current);
|
||||
@@ -68,7 +94,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<a
|
||||
class="wp-btn wp-btn--outline wp-btn--sm wp-whatsnew-hero__link"
|
||||
:href="fullUrl"
|
||||
:href="allReleasesUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-test="whats-new-github"
|
||||
@@ -90,6 +116,31 @@ onMounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Previous releases. Rendered as native <details> so each stays
|
||||
collapsed, findable, and costs nothing when unopened. -->
|
||||
<Card v-if="older.length" data-test="whats-new-older">
|
||||
<h3 class="wp-relnotes__older-head">Earlier releases</h3>
|
||||
<details
|
||||
v-for="rel in older"
|
||||
:key="rel.version"
|
||||
class="wp-relnotes__older"
|
||||
:data-test="`whats-new-older-${rel.version}`"
|
||||
>
|
||||
<summary class="wp-relnotes__older-summary">
|
||||
<span class="wp-relnotes__older-version">v{{ rel.version }}</span>
|
||||
<RelativeDate v-if="rel.publishedAt" :value="rel.publishedAt" />
|
||||
</summary>
|
||||
<div class="wp-relnotes" v-html="rel.html" />
|
||||
<a
|
||||
v-if="rel.url"
|
||||
class="wp-relnotes__older-link"
|
||||
:href="rel.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Release page →</a>
|
||||
</details>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -143,4 +194,32 @@ onMounted(() => {
|
||||
}
|
||||
.wp-relnotes__none .pi { font-size: 26px; color: var(--wp-text-dim); }
|
||||
.wp-relnotes__none p { margin: 0; max-width: 46ch; }
|
||||
|
||||
.wp-relnotes__older-head {
|
||||
margin: 0 0 var(--wp-space-3);
|
||||
font-size: var(--wp-text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wp-relnotes__older { border-top: 1px solid var(--wp-border); }
|
||||
.wp-relnotes__older:first-of-type { border-top: 0; }
|
||||
.wp-relnotes__older-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--wp-space-3);
|
||||
padding: var(--wp-space-3) 0;
|
||||
cursor: pointer;
|
||||
font-size: var(--wp-text-sm);
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
.wp-relnotes__older-summary:hover { color: var(--wp-text); }
|
||||
.wp-relnotes__older-version { font-weight: 600; color: var(--wp-text); }
|
||||
.wp-relnotes__older[open] .wp-relnotes__older-summary { color: var(--wp-text); }
|
||||
.wp-relnotes__older > .wp-relnotes { padding: 0 0 var(--wp-space-4); }
|
||||
.wp-relnotes__older-link {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--wp-space-4);
|
||||
font-size: var(--wp-text-xs);
|
||||
color: var(--wp-accent-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Migration 017 — checksums, and the guard they enable.
|
||||
|
||||
The bug being fixed: ``migrate()`` skips every migration ``<= MAX(version)``,
|
||||
so an integer was the entire identity of a schema. A database migrated by
|
||||
another build was either silently skipped past (it is "ahead") or silently
|
||||
accepted (same number, different shape). Both let code read and then write a
|
||||
schema it does not actually understand.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.db.connection import get_connection
|
||||
from engine.db.migrations import (
|
||||
SchemaAheadError,
|
||||
_discover,
|
||||
assert_schema_compatible,
|
||||
current_version,
|
||||
head_version,
|
||||
migrate,
|
||||
)
|
||||
|
||||
|
||||
def test_checksum_column_exists_after_migrate(tmp_path):
|
||||
conn = get_connection(tmp_path / "c.db")
|
||||
migrate(conn)
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(migrations);").fetchall()}
|
||||
assert "checksum" in cols
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_rerunning_migrate_is_a_noop(tmp_path):
|
||||
"""The guard runs on every migrate(); a healthy DB must sail through it."""
|
||||
db = tmp_path / "idem.db"
|
||||
conn = get_connection(db)
|
||||
migrate(conn)
|
||||
first = current_version(conn)
|
||||
migrate(conn)
|
||||
assert current_version(conn) == first
|
||||
rows = conn.execute("SELECT COUNT(*) FROM migrations;").fetchone()[0]
|
||||
assert rows == first
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migration_017_records_its_own_checksum(tmp_path):
|
||||
"""017 adds the column inside its own transaction, so its row can carry one.
|
||||
|
||||
Everything before it cannot — the column does not exist yet — which is why
|
||||
the guard treats NULL as "unknown" rather than "mismatched".
|
||||
"""
|
||||
conn = get_connection(tmp_path / "own.db")
|
||||
migrate(conn)
|
||||
row = conn.execute("SELECT checksum FROM migrations WHERE version = 17;").fetchone()
|
||||
assert row is not None
|
||||
assert isinstance(row[0], str) and len(row[0]) == 64
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_pre_017_migrations_have_null_checksum(tmp_path):
|
||||
conn = get_connection(tmp_path / "null.db")
|
||||
migrate(conn)
|
||||
nulls = conn.execute(
|
||||
"SELECT COUNT(*) FROM migrations WHERE version < 17 AND checksum IS NULL;"
|
||||
).fetchone()[0]
|
||||
assert nulls == 16
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_database_ahead_of_this_build_is_refused(tmp_path):
|
||||
"""A DB migrated by a newer build must not be read as if it were ours."""
|
||||
db = tmp_path / "ahead.db"
|
||||
conn = get_connection(db)
|
||||
migrate(conn)
|
||||
# A future build applied something this one has never heard of.
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(head_version() + 1, "2026-08-09T00:00:00Z"),
|
||||
)
|
||||
with pytest.raises(SchemaAheadError) as exc:
|
||||
assert_schema_compatible(conn)
|
||||
assert "newer version" in str(exc.value)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrate_refuses_an_ahead_database(tmp_path):
|
||||
"""The guard is inside migrate(), so no caller can forget it."""
|
||||
conn = get_connection(tmp_path / "ahead2.db")
|
||||
migrate(conn)
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(head_version() + 5, "2026-08-09T00:00:00Z"),
|
||||
)
|
||||
with pytest.raises(SchemaAheadError):
|
||||
migrate(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_same_version_different_shape_is_refused(tmp_path):
|
||||
"""The case an integer version cannot catch.
|
||||
|
||||
A migration is reworked after it has already run somewhere — a pre-release
|
||||
that got revised before shipping, a hand-edited file, an abandoned branch.
|
||||
The version still matches, so before checksums nothing compared anything.
|
||||
"""
|
||||
conn = get_connection(tmp_path / "diverged.db")
|
||||
migrate(conn)
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE migrations SET checksum = ? WHERE version = 17;",
|
||||
("0" * 64,),
|
||||
)
|
||||
with pytest.raises(SchemaAheadError) as exc:
|
||||
assert_schema_compatible(conn)
|
||||
assert "017" in str(exc.value)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_null_checksums_are_not_treated_as_mismatched(tmp_path):
|
||||
"""NULL means "we never recorded one", which is not evidence of divergence.
|
||||
|
||||
Every database that existed before 017 is in this state for versions 1-16;
|
||||
treating unknown as bad would lock every existing user out of their library.
|
||||
"""
|
||||
conn = get_connection(tmp_path / "unknown.db")
|
||||
migrate(conn)
|
||||
with conn:
|
||||
conn.execute("UPDATE migrations SET checksum = NULL;")
|
||||
assert_schema_compatible(conn) # must not raise
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_guard_is_silent_on_a_database_with_no_migrations_table(tmp_path):
|
||||
"""A fresh file has nothing to be incompatible with."""
|
||||
conn = sqlite3.connect(tmp_path / "empty.db")
|
||||
assert_schema_compatible(conn) # must not raise
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_every_shipped_migration_hashes_distinctly():
|
||||
"""Guards the guard: identical files would make divergence undetectable."""
|
||||
from engine.db.migrations import _checksum
|
||||
|
||||
sums = [_checksum(path) for _, path in _discover()]
|
||||
assert len(set(sums)) == len(sums)
|
||||
@@ -2,7 +2,7 @@
|
||||
import sqlite3
|
||||
|
||||
from engine.db.connection import get_connection
|
||||
from engine.db.migrations import _discover, current_version, migrate
|
||||
from engine.db.migrations import _discover, current_version, head_version, migrate
|
||||
|
||||
|
||||
def test_initial_version_is_zero(tmp_path):
|
||||
@@ -30,9 +30,10 @@ def test_migrate_records_version(tmp_path):
|
||||
conn = get_connection(tmp_path / "v2.db")
|
||||
migrate(conn)
|
||||
# Keep this assertion in sync with the highest-numbered migration in
|
||||
# ``engine/db/migrations_sql``. (016_wildcard_multi_subcategory_v2.py
|
||||
# is current head.)
|
||||
assert current_version(conn) == 16
|
||||
# ``engine/db/migrations_sql``. (017_migration_checksums.py is current
|
||||
# head.)
|
||||
assert current_version(conn) == 17
|
||||
assert head_version() == 17
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -282,7 +283,7 @@ def test_004_is_idempotent(tmp_path):
|
||||
conn = get_connection(tmp_path / "i.db")
|
||||
migrate(conn)
|
||||
migrate(conn) # second call should be a no-op
|
||||
assert current_version(conn) == 16
|
||||
assert current_version(conn) == head_version()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
+115
-13
@@ -3,6 +3,21 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wp_api.database import _allowed_move_paths
|
||||
|
||||
|
||||
def _two_allowed_locations():
|
||||
"""Two distinct real DB locations, or skip.
|
||||
|
||||
`pending_move` only accepts the plugin's own known database locations, so
|
||||
a test cannot invent paths under tmp_path any more — that is the point of
|
||||
the allowlist.
|
||||
"""
|
||||
allowed = sorted(str(p) for p in _allowed_move_paths())
|
||||
if len(allowed) < 2:
|
||||
pytest.skip("needs at least two resolvable DB locations")
|
||||
return allowed[0], allowed[1]
|
||||
|
||||
|
||||
async def test_get_info_returns_200_with_expected_shape(wp_client):
|
||||
resp = await wp_client.get("/wp/api/database/info")
|
||||
@@ -115,9 +130,8 @@ async def test_put_config_rejects_invalid_preference(wp_client):
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_put_config_sets_pending_move(wp_client, tmp_path):
|
||||
src = str(tmp_path / "old.db")
|
||||
dst = str(tmp_path / "new.db")
|
||||
async def test_put_config_sets_pending_move(wp_client):
|
||||
src, dst = _two_allowed_locations()
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {"from": src, "to": dst, "mode": "copy"}},
|
||||
@@ -135,12 +149,11 @@ async def test_put_config_rejects_relative_paths(wp_client):
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
async def test_put_config_rejects_bad_mode(wp_client, tmp_path):
|
||||
async def test_put_config_rejects_bad_mode(wp_client):
|
||||
src, dst = _two_allowed_locations()
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {"from": str(tmp_path / "a.db"),
|
||||
"to": str(tmp_path / "b.db"),
|
||||
"mode": "rename"}},
|
||||
json={"pending_move": {"from": src, "to": dst, "mode": "rename"}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
@@ -174,10 +187,9 @@ async def test_put_config_null_clears_field(wp_client):
|
||||
assert body["preference"] is None
|
||||
|
||||
|
||||
async def test_put_config_preserves_unmentioned_fields(wp_client, tmp_path):
|
||||
async def test_put_config_preserves_unmentioned_fields(wp_client):
|
||||
"""Omitting a field in the body should preserve its existing value."""
|
||||
src = str(tmp_path / "a.db")
|
||||
dst = str(tmp_path / "b.db")
|
||||
src, dst = _two_allowed_locations()
|
||||
# Set both preference + pending_move.
|
||||
await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
@@ -193,9 +205,8 @@ async def test_put_config_preserves_unmentioned_fields(wp_client, tmp_path):
|
||||
assert body["pending_move"] == {"from": src, "to": dst, "mode": "copy"}
|
||||
|
||||
|
||||
async def test_delete_pending_move(wp_client, tmp_path):
|
||||
src = str(tmp_path / "a.db")
|
||||
dst = str(tmp_path / "b.db")
|
||||
async def test_delete_pending_move(wp_client):
|
||||
src, dst = _two_allowed_locations()
|
||||
await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"preference": "user",
|
||||
@@ -213,3 +224,94 @@ async def test_delete_pending_move_when_absent_is_noop(wp_client):
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["pending_move"] is None
|
||||
|
||||
|
||||
class TestPendingMoveContainment:
|
||||
"""`pending_move` must not be usable as a file-transfer primitive.
|
||||
|
||||
`execute_pending_move` runs `shutil.copy2` / `shutil.move` at plugin load,
|
||||
as whoever runs ComfyUI. Requiring merely an ABSOLUTE path — which is all
|
||||
validation used to do — meant an unauthenticated PUT could schedule a copy
|
||||
or move between any two paths on the host:
|
||||
|
||||
* read anything, by copying it into `web/`, which this plugin serves
|
||||
publicly at `/wp/<path>`;
|
||||
* destroy anything, by moving it elsewhere.
|
||||
|
||||
Every ComfyUI route is unauthenticated by default, so "an attacker who can
|
||||
reach the API" is anyone who can reach the port.
|
||||
"""
|
||||
|
||||
async def test_rejects_arbitrary_source(self, wp_client, tmp_path):
|
||||
_, dst = _two_allowed_locations()
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {
|
||||
"from": str(tmp_path / "secrets.key"), "to": dst, "mode": "copy",
|
||||
}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
async def test_rejects_arbitrary_destination(self, wp_client, tmp_path):
|
||||
src, _ = _two_allowed_locations()
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {
|
||||
"from": src, "to": str(tmp_path / "anywhere.db"), "mode": "copy",
|
||||
}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
async def test_rejects_exfiltration_into_the_served_web_dir(self, wp_client):
|
||||
"""The chain that made this critical rather than merely untidy."""
|
||||
src, _ = _two_allowed_locations()
|
||||
from wp_api.spa import WEB_DIR
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {
|
||||
"from": src, "to": str((WEB_DIR / "leak.db").resolve()), "mode": "copy",
|
||||
}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
async def test_rejects_traversal_dressed_as_an_allowed_path(self, wp_client):
|
||||
src, dst = _two_allowed_locations()
|
||||
from pathlib import Path
|
||||
escaped = str(Path(dst).parent / ".." / ".." / "escaped.db")
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {"from": src, "to": escaped, "mode": "copy"}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
async def test_rejects_a_no_op_move(self, wp_client):
|
||||
src, _ = _two_allowed_locations()
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {"from": src, "to": src, "mode": "move"}},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
async def test_a_legitimate_relocation_still_works(self, wp_client):
|
||||
"""The guard must not break the feature it protects."""
|
||||
src, dst = _two_allowed_locations()
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {"from": src, "to": dst, "mode": "move"}},
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["pending_move"]["from"] == src
|
||||
assert body["pending_move"]["to"] == dst
|
||||
|
||||
async def test_stores_the_resolved_paths_that_were_validated(self, wp_client):
|
||||
"""What gets executed must be what passed the check, not the raw input."""
|
||||
from pathlib import Path
|
||||
src, dst = _two_allowed_locations()
|
||||
noisy = str(Path(dst).parent / "." / Path(dst).name)
|
||||
resp = await wp_client.put(
|
||||
"/wp/api/database/config",
|
||||
json={"pending_move": {"from": src, "to": noisy, "mode": "copy"}},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert (await resp.json())["pending_move"]["to"] == dst
|
||||
|
||||
+55
-2
@@ -95,12 +95,50 @@ def _build_config_response() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _allowed_move_paths() -> set[Path]:
|
||||
"""The only paths a deferred move may read from or write to.
|
||||
|
||||
An allowlist rather than a sanitiser. The DB can legitimately live in
|
||||
exactly three places, all computed here on the server, under one fixed
|
||||
filename — so there is no reason to accept a client-supplied path at all,
|
||||
and every reason not to: `execute_pending_move` runs `shutil.copy2` /
|
||||
`shutil.move` at plugin load, as whatever user runs ComfyUI.
|
||||
|
||||
The live path is included because `WP_DB_PATH` / `COMFYUI_USER_DIR` can
|
||||
place the database outside the three defaults, and a user who set those
|
||||
must still be able to move off them.
|
||||
"""
|
||||
out: set[Path] = set()
|
||||
for fn in (user_location_path, global_location_path, root_location_path):
|
||||
try:
|
||||
p = fn()
|
||||
except Exception: # a location that cannot be resolved is simply not offered
|
||||
continue
|
||||
if p:
|
||||
out.add(Path(p).resolve())
|
||||
try:
|
||||
out.add(resolve_db_path_with_source()[0].resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _validate_pending_move(pm: Any) -> tuple[dict[str, str] | None, str | None]:
|
||||
"""Validate a pending_move payload.
|
||||
|
||||
Returns ``(value, None)`` on success where ``value`` is either a
|
||||
normalized dict or ``None`` (explicit clear). Returns ``(None, err)``
|
||||
on validation failure."""
|
||||
on validation failure.
|
||||
|
||||
SECURITY: `from` and `to` must each be one of the known database
|
||||
locations. Requiring merely an ABSOLUTE path — which is all this used to
|
||||
do — let an unauthenticated `PUT /wp/api/database/config` schedule a copy
|
||||
or move between any two paths on the host, executed on the next ComfyUI
|
||||
start. Every ComfyUI route is unauthenticated by default, so that was a
|
||||
full arbitrary-read and arbitrary-overwrite primitive: copy a private key
|
||||
into `web/`, which this plugin serves at `/wp/<path>`, or move any file
|
||||
anywhere and destroy it.
|
||||
"""
|
||||
if pm is None:
|
||||
return None, None
|
||||
if not isinstance(pm, dict):
|
||||
@@ -114,7 +152,22 @@ def _validate_pending_move(pm: Any) -> tuple[dict[str, str] | None, str | None]:
|
||||
return None, f"pending_move.mode must be one of: {sorted(_VALID_MODES)}"
|
||||
if not Path(src).is_absolute() or not Path(dst).is_absolute():
|
||||
return None, "pending_move.from and pending_move.to must be absolute paths"
|
||||
return {"from": src, "to": dst, "mode": mode}, None
|
||||
|
||||
allowed = _allowed_move_paths()
|
||||
# `resolve()` collapses `..` and follows symlinks, so neither traversal nor
|
||||
# a symlink planted at an allowed path can smuggle a different target past
|
||||
# the membership test.
|
||||
src_r = Path(src).resolve()
|
||||
dst_r = Path(dst).resolve()
|
||||
if src_r not in allowed or dst_r not in allowed:
|
||||
return None, (
|
||||
"pending_move.from and pending_move.to must each be one of this "
|
||||
"plugin's known database locations"
|
||||
)
|
||||
if src_r == dst_r:
|
||||
return None, "pending_move.from and pending_move.to must differ"
|
||||
# Store the RESOLVED paths: what was validated is what gets executed.
|
||||
return {"from": str(src_r), "to": str(dst_r), "mode": mode}, None
|
||||
|
||||
|
||||
async def get_config(request: web.Request) -> web.Response:
|
||||
|
||||
Reference in New Issue
Block a user