Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ac718b9
feat(store): add product readme column for long-form documentation
uberware Aug 18, 2026
a7e6241
feat(product): add readme field to product definitions
uberware Aug 18, 2026
9a2e027
feat(product): add metadata length limits and validate before templates
uberware Aug 18, 2026
559d7a5
fix(presets): move segment-transcode description overflow into readme
uberware Aug 18, 2026
3337d6e
fix(presets): flatten nested lists in segment-transcode readmes
uberware Aug 18, 2026
5765c12
feat(api): expose product and preset readme over REST with length caps
uberware Aug 18, 2026
ac85637
docs(api): align PresetDetail readme cap and wording with Product sch…
uberware Aug 18, 2026
376f5f6
feat(web): add hand-written Markdown renderer for product readmes
uberware Aug 18, 2026
dd8e0b4
feat(web): wire product readme through types, form, and detail pages
uberware Aug 18, 2026
7653202
feat(python-sdk): add product readme field
uberware Aug 18, 2026
d34d384
fix(web): render the exact validated href from safeHref, case-preserved
uberware Aug 18, 2026
c67a028
docs(products): document the readme field, its markdown subset and si…
uberware Aug 18, 2026
e140551
fix(web): apply CommonMark lazy continuation to the readme list parser
uberware Aug 18, 2026
b1f39fc
fix(web,api): wire remaining product limits, fix doc comment, add Pre…
uberware Aug 18, 2026
4560649
feat(web): show product and preset readme and template as tabs
uberware Aug 19, 2026
c6d36a3
feat(web): add readme buttons to product and preset lists and the picker
uberware Aug 19, 2026
8470d46
docs(products): give the built-in products readmes demonstrating the …
uberware Aug 19, 2026
5e0c307
fix(web): show the server's reason when a preset fails to load
uberware Aug 19, 2026
bfb0a7e
fix(web): distinguish product and parameter load failures on the subm…
uberware Aug 19, 2026
448804b
ci: validate the published preset library against the current validator
uberware Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/preset-library.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# SPDX-FileCopyrightText: 2026 Uberware Inc. <https://uberware.net>
# SPDX-License-Identifier: AGPL-3.0-or-later

name: Preset library

# Validates the PUBLISHED preset library against the validator on this branch.
#
# Why this exists: a validator change can silently invalidate content that is
# already published. Commit 2cdef4f tightened parameter-control validation to
# match the base spec and corrected every preset in this repo in the same
# change -- but the copy at uberware.github.io/sqi-presets is only regenerated
# by the release workflow, so from that commit until the next release every
# published preset failed to load. Nothing reported it: the list page renders
# from the index, which needs no validation, and only the detail page parses.
# The first signal was a user clicking a preset and getting an error.
#
# Deliberately NOT part of ci.yml. It reaches the network, so putting it on
# every pull request would make unrelated work hostage to an outage. Instead it
# runs where it is actually informative:
#
# - on a schedule, so drift is found within a day rather than by a user;
# - when the presets or the validator change, which is exactly when
# already-published content can become invalid;
# - on demand.
on:
schedule:
# 07:00 UTC daily. Drift here is never urgent-to-the-minute; it just must
# not wait for someone to click a preset.
- cron: "0 7 * * *"
push:
branches: [main]
paths:
- "presets/**"
- "internal/openjd/**"
- "internal/product/**"
- "internal/presetlib/**"
- "test/presetlib/**"
- ".github/workflows/preset-library.yml"
pull_request:
paths:
- "presets/**"
- "internal/openjd/**"
- "internal/product/**"
- "internal/presetlib/**"
- "test/presetlib/**"
- ".github/workflows/preset-library.yml"
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
published-presets:
name: Published presets validate against this tree
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod

- name: Validate the published preset library
shell: bash
run: make test-preset-library 2>&1 | tee /tmp/preset-library.log

# The target exits 0 when the library is unreachable, because an offline
# runner is not evidence that the content is bad. That means a green step
# alone proves nothing -- assert the test actually ran and passed. A SKIP
# fails here on purpose: it means this check verified nothing, and the
# cause (network, or a repointed URL) needs a human.
- name: Assert the check actually ran
run: |
grep -q -- '--- PASS: TestPublishedPresets_ValidateAgainstThisTree' /tmp/preset-library.log
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ run:
- integration
- conformance
- oracle
- presetlib

# ── Linters ───────────────────────────────────────────────────────────────────
linters:
Expand Down
17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,23 @@ test-expr-oracle: ## Differential-test the EXPR evaluator against the OpenJD ref
fi
go test $(TEST_FLAGS) -tags oracle -run 'TestExprOracle' -v -timeout 5m ./test/oracle/

# Validates the PUBLISHED preset library against the validator in this working
# tree. It exists because a validator change can silently invalidate content
# already published: 2cdef4f tightened parameter-control validation and fixed
# every preset in this repo, but the copy at uberware.github.io/sqi-presets is
# only refreshed on release, so every preset there failed to load in between --
# with no signal until a user clicked one.
#
# Needs the network. SKIPS when the library is unreachable and FAILS when it is
# reachable but invalid, so an offline runner never masks a real breakage. A
# SKIP VERIFIES NOTHING -- look for the "--- PASS: TestPublishedPresets" line.
# CI asserts it by name for that reason.
#
# SQI_TEST_PRESET_LIBRARY_URL points it at a staging index instead.
.PHONY: test-preset-library
test-preset-library: ## Validate the published preset library against this tree (needs network)
go test $(TEST_FLAGS) -tags presetlib -run 'TestPublishedPresets' -v -timeout 5m ./test/presetlib/

.PHONY: test-ldap
test-ldap: ## Run the LDAP tests against a real directory in a container (needs Docker)
go test $(TEST_FLAGS) -tags integration -run 'TestLDAP_' -v -timeout 15m ./test/integration/
Expand Down
12 changes: 12 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ with SqiClient("http://localhost:8080") as sqi:
template="specificationVersion: jobtemplate-2023-09\nname: My Renderer\nsteps: []\n",
format="yaml",
description="Render a frame range.",
readme="# My Renderer\n\nLonger usage notes in Markdown go here.\n",
category="Rendering",
version="1.0.0",
)
Expand Down Expand Up @@ -166,6 +167,17 @@ with SqiClient("http://localhost:8080") as sqi:
print("submitted job", job.id)
```

A product has two independent text fields: `description` is a short
(max 500 character) plain-text blurb -- not Markdown, since it reaches
consumers that cannot render markup, such as the Blender addon's tooltip --
and it is the field product search matches on. `readme` is long-form Markdown
(max 8000 characters), rendered only on the product's detail page in the web
UI; it is never searched. Both caps are enforced server-side (HTTP 400 on
create/update) and are not validated client-side.

`update_product` is a full PUT replacement: any field omitted from the call,
including `readme`, is cleared on the server rather than left unchanged.

`get_product_parameters` raises `NotFoundError` when the product does not exist
and `ValidationError` when the stored template cannot be parsed (HTTP 422).
`submit_product_job` uses the keyword argument `job_name=` (not `name=`) to
Expand Down
56 changes: 48 additions & 8 deletions clients/python/src/sqi_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,7 @@ def create_product(
format: str,
title: str | None = None,
description: str | None = None,
readme: str | None = None,
category: str | None = None,
version: str | None = None,
) -> Product:
Expand All @@ -1499,10 +1500,26 @@ def create_product(
name: Stable product name (slug).
template: Raw OpenJD template text.
format: ``yaml`` or ``json``.
title, description, category, version: Optional metadata.
title, category, version: Optional metadata.
description: Short plain-text catalog blurb (max 500 characters).
Plain text, not Markdown -- it reaches consumers that cannot
render markup, such as the Blender addon's tooltip. It is also
the field product search matches on.
readme: Long-form Markdown documentation (max 8000 characters),
rendered on the product's detail page in the web UI. It is NOT
searched.
"""
return self._products.create(
_product_body(name, title, description, category, version, template, format)
_product_body(
name,
template,
format,
title=title,
description=description,
readme=readme,
category=category,
version=version,
)
)

def update_product(
Expand All @@ -1513,12 +1530,27 @@ def update_product(
format: str,
title: str | None = None,
description: str | None = None,
readme: str | None = None,
category: str | None = None,
version: str | None = None,
) -> Product:
"""Replace a custom product's fields (PUT, full replacement) and return it."""
"""Replace a custom product's fields (PUT, full replacement) and return it.

Full replace: an omitted ``readme`` is CLEARED, not preserved, exactly
as ``description`` already behaves.
"""
return self._products.update(
name, _product_body(name, title, description, category, version, template, format)
name,
_product_body(
name,
template,
format,
title=title,
description=description,
readme=readme,
category=category,
version=version,
),
)

def delete_product(self, name: str) -> None:
Expand Down Expand Up @@ -1932,18 +1964,26 @@ def _compute_location_body(

def _product_body(
name: str,
title: str | None,
description: str | None,
category: str | None,
version: str | None,
template: str,
template_format: str,
*,
title: str | None = None,
description: str | None = None,
readme: str | None = None,
category: str | None = None,
version: str | None = None,
) -> dict[str, Any]:
# description and readme are both str | None: with positional arguments a
# transposition of the two would pass mypy silently and ship a bug, so
# every optional field beyond the first three positional ones is
# keyword-only.
body: dict[str, Any] = {"name": name, "template": template, "format": template_format}
if title is not None:
body["title"] = title
if description is not None:
body["description"] = description
if readme is not None:
body["readme"] = readme
if category is not None:
body["category"] = category
if version is not None:
Expand Down
7 changes: 7 additions & 0 deletions clients/python/src/sqi_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,12 @@ class Product:
name: str
title: str = ""
description: str = ""
"""Short plain-text catalog blurb (max 500 characters). Not Markdown — it
reaches consumers that cannot render markup, such as the Blender addon's
tooltip. It is also the field product search matches on."""
readme: str = ""
"""Long-form Markdown documentation (max 8000 characters), rendered on the
product's detail page in the web UI. It is NOT searched."""
category: str = ""
version: str = ""
source: str = ""
Expand All @@ -1344,6 +1350,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> Product:
name=_as_str(data.get("name")),
title=_as_str(data.get("title")),
description=_as_str(data.get("description")),
readme=_as_str(data.get("readme")),
category=_as_str(data.get("category")),
version=_as_str(data.get("version")),
source=_as_str(data.get("source")),
Expand Down
21 changes: 21 additions & 0 deletions clients/python/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,27 @@ def test_product_from_dict() -> None:
assert p.format == "yaml"


def test_product_parses_readme() -> None:
from sqi_client.models import Product

p = Product.from_dict({"name": "probe", "readme": "# Docs\n\nBody.\n"})
assert p.readme == "# Docs\n\nBody.\n"


def test_product_readme_defaults_empty() -> None:
from sqi_client.models import Product

assert Product.from_dict({"name": "probe"}).readme == ""


def test_product_readme_tolerates_mistyped_value() -> None:
# Matches the module's tolerant-parsing contract: a mistyped field falls
# back to a type-appropriate default rather than raising.
from sqi_client.models import Product

assert Product.from_dict({"name": "probe", "readme": 17}).readme == ""


def test_product_parameter_from_dict_with_user_interface() -> None:
from sqi_client.models import ProductParameter

Expand Down
22 changes: 22 additions & 0 deletions clients/python/tests/test_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ def test_create_product(make_client: ClientFactory) -> None:
assert sent["template"] == "tmpl"


@respx.mock
def test_create_product_sends_readme(make_client: ClientFactory) -> None:
route = respx.post(f"{API}/products").mock(
return_value=httpx.Response(201, json={"name": "custom", "source": "custom"})
)
client = make_client()
client.create_product(name="custom", template="t", format="yaml", readme="# Docs")
sent = json.loads(route.calls.last.request.content)
assert sent["readme"] == "# Docs"


@respx.mock
def test_create_product_omits_readme_when_unset(make_client: ClientFactory) -> None:
route = respx.post(f"{API}/products").mock(
return_value=httpx.Response(201, json={"name": "custom", "source": "custom"})
)
client = make_client()
client.create_product(name="custom", template="t", format="yaml")
sent = json.loads(route.calls.last.request.content)
assert "readme" not in sent


@respx.mock
def test_update_product(make_client: ClientFactory) -> None:
respx.put(f"{API}/products/custom").mock(
Expand Down
1 change: 1 addition & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ Run `make` (no arguments) to see all available targets with descriptions.
| `make test-oidc` | Run the SSO tests against a real Keycloak in a container (needs Docker; **skips** without it) |
| `make test-isolation` | Run run-as-user task-isolation tests as real root against real OS accounts in a container (needs Docker; **skips** without it) |
| `make test-expr-oracle` | Differential-test the EXPR evaluator against the OpenJD reference implementation (needs `python3`; **skips** without it) |
| `make test-preset-library` | Validate the **published** preset library against the validator in your tree (needs network; **skips** when the library is unreachable, **fails** when it is reachable but invalid) |
| `make expr-oracle-venv` | Create `.venv-oracle/` with the pinned reference implementation (`make test-expr-oracle` does this on demand) |
| `make smoke` | End-to-end smoke test against the real binaries (REST + WebSocket) |
| `make bench` | Run benchmarks |
Expand Down
5 changes: 5 additions & 0 deletions docs/preset-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ promised) and **update detection** (if the hash in the index changes, the instal
product is shown as having an update available). It is not a cryptographic authorship
signature — the trust boundary is the configured index URL itself.

The index carries `description` but **not** `readme`. `description` is there
because the preset list page searches it; `readme` is not searched, so shipping
it in the index would grow every client's cached index for nothing. A preset's
readme arrives with its definition when the detail page is opened.

---

## Configuration
Expand Down
Loading