refactor(catalog): explicit org/repo terminology; npm targets coulomb Gitea registry

Gitea's "project/package/release" terms are overloaded, so the catalog now uses
the most explicit words:
- org  = coulomb (the Gitea organisation)
- repo = whynot-design (the Gitea repository/product) — not an org, not a scope
- npm scope @whynot and package @whynot/design are distinct from both

Changes:
- catalog schema: replace conflated `owner` with required `org` + `repo`; `owner`
  is now a derived `org/repo` slug property
- npm-config delivery is data-driven: registry + scope live in
  delivery_config.npm and are validated; engine no longer hardcodes a registry
- exec delivery writes `<scope>:registry=<url>` + scoped `:_authToken` for the
  configured Gitea registry (token still env-expanded, never written to disk)
- pilot lane points at https://gitea.coulomb.social/api/packages/coulomb/npm/,
  scope @whynot, KV path coulomb/whynot-design/npm/publish
- npm-publish-demo uses @whynot scope so dry-run resolves the Gitea registry
- docs: terminology table; routing owner shown as coulomb/whynot-design
- tests: org/repo required, npm-config validation, registry authkey mapping

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-06-28 12:44:55 +02:00
parent 147cf8acda
commit f87f4e5e4d
9 changed files with 153 additions and 17 deletions

View file

@ -22,7 +22,8 @@ VALID_APPROVAL_MODELS = ("decision", "ccr", "dual-control", "bootstrap-only")
REQUIRED_FIELDS = (
"id",
"owner",
"org",
"repo",
"stage",
"mount",
"path",
@ -40,7 +41,11 @@ REQUIRED_FIELDS = (
@dataclass(frozen=True)
class CatalogEntry:
id: str
owner: str
# Gitea coordinates, kept explicit to avoid the overloaded word "project".
# org = the Gitea organisation (e.g. "coulomb")
# repo = the Gitea repository / product (e.g. "whynot-design")
org: str
repo: str
stage: str
mount: str
path: str
@ -52,9 +57,20 @@ class CatalogEntry:
rotation: dict[str, Any]
deactivation: dict[str, Any]
audit: dict[str, Any]
delivery_config: dict[str, Any] = field(default_factory=dict)
description: str = ""
raw: dict[str, Any] = field(default_factory=dict)
@property
def owner(self) -> str:
"""The lane's owning repo as the explicit ``org/repo`` slug."""
return f"{self.org}/{self.repo}"
@property
def npm(self) -> dict[str, Any]:
"""npm delivery config (registry, scope, package) when present."""
return self.delivery_config.get("npm", {})
@property
def kv_data_path(self) -> str:
"""Full KV v2 *data* path used for read/write of the value."""
@ -123,6 +139,20 @@ def validate_entry(data: dict[str, Any], *, source: str = "<memory>") -> Catalog
f"allowed {VALID_APPROVAL_MODELS}"
)
# npm-config delivery must declare WHERE it publishes (registry + scope), so
# the registry is catalog data, never hardcoded in the engine.
if "npm-config" in modes:
npm = (data.get("delivery_config") or {}).get("npm")
if not isinstance(npm, dict) or not npm.get("registry") or not npm.get("scope"):
raise CatalogError(
f"{source}: npm-config delivery requires "
"delivery_config.npm.registry and .scope"
)
if not str(npm["registry"]).startswith(("http://", "https://")):
raise CatalogError(
f"{source}: delivery_config.npm.registry must be an http(s) URL"
)
# A path must never leak a value through a field name suggesting inline secrets.
if any(looks_secret(k) and data.get(k) for k in ("value", "secret", "token", "password")):
raise CatalogError(f"{source}: catalog entries must not contain secret values")