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:
parent
147cf8acda
commit
f87f4e5e4d
9 changed files with 153 additions and 17 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -49,16 +49,30 @@ def _fetch_value(client: OpenBaoClient, entry: CatalogEntry, field: str) -> str:
|
|||
return data[field]
|
||||
|
||||
|
||||
def _registry_authkey(registry: str) -> str:
|
||||
"""Turn a registry URL into the npm `//host/path/:_authToken` config key."""
|
||||
no_scheme = registry.split("://", 1)[-1]
|
||||
if not no_scheme.endswith("/"):
|
||||
no_scheme += "/"
|
||||
return "//" + no_scheme
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _npm_userconfig(token: str) -> Iterator[Path]:
|
||||
"""Write a mode-0600 temp .npmrc, yield its path, delete it unconditionally."""
|
||||
def _npm_userconfig(registry: str, scope: str) -> Iterator[Path]:
|
||||
"""Write a mode-0600 temp .npmrc for the configured registry/scope.
|
||||
|
||||
The token itself is NOT written to the file — npm expands ${SE_NPM_TOKEN}
|
||||
from the child environment, so the value never touches disk.
|
||||
"""
|
||||
fd, name = tempfile.mkstemp(prefix="se-npmrc-", suffix=".ini")
|
||||
path = Path(name)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
# Registry-scoped auth token; child npm reads this via NPM_CONFIG_USERCONFIG.
|
||||
authkey = _registry_authkey(registry)
|
||||
with os.fdopen(fd, "w") as fh:
|
||||
fh.write("//registry.npmjs.org/:_authToken=${SE_NPM_TOKEN}\n")
|
||||
# e.g. @whynot:registry=https://gitea.coulomb.social/api/packages/coulomb/npm/
|
||||
fh.write(f"{scope}:registry={registry}\n")
|
||||
fh.write(f"{authkey}:_authToken=${{SE_NPM_TOKEN}}\n")
|
||||
yield path
|
||||
finally:
|
||||
try:
|
||||
|
|
@ -110,7 +124,15 @@ def exec_with_secret(
|
|||
child_env = dict(os.environ)
|
||||
|
||||
if mode == "npm-config":
|
||||
with _npm_userconfig(value) as npmrc:
|
||||
npm = entry.npm
|
||||
registry = npm.get("registry", "")
|
||||
scope = npm.get("scope", "")
|
||||
if not registry or not scope:
|
||||
raise DeliveryError(
|
||||
f"lane '{entry.id}' npm-config delivery needs "
|
||||
"delivery_config.npm.registry and .scope"
|
||||
)
|
||||
with _npm_userconfig(registry, scope) as npmrc:
|
||||
child_env["NPM_CONFIG_USERCONFIG"] = str(npmrc)
|
||||
child_env["SE_NPM_TOKEN"] = value
|
||||
rc = _spawn(command, child_env, value)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue