diff --git a/backend/app/registry.py b/backend/app/registry.py index 1dfee01..a04343f 100644 --- a/backend/app/registry.py +++ b/backend/app/registry.py @@ -32,7 +32,7 @@ _TYPE_DEFAULT_INITIAL_STATE = { "specification": "super-draft", "bdd": "active", } -_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") class RegistryError(Exception): @@ -72,6 +72,8 @@ def parse_registry(text: str) -> RegistryDoc: seen: set[str] = set() entries: list[ProjectEntry] = [] for p in projects_raw: + if not isinstance(p, dict): + raise RegistryError(f"each project entry must be a mapping, got {type(p).__name__}") pid = str(p.get("id") or "").strip() if not _SLUG_RE.match(pid): raise RegistryError(f"project id {pid!r} is not a valid slug") @@ -119,46 +121,46 @@ def apply_registry(doc: RegistryDoc, registry_sha: str) -> None: (skip + log), never applied. Projects absent from the registry are left in place (archival is out of scope for M3; they simply stop refreshing). """ - conn = db.conn() - for e in doc.projects: - existing = conn.execute( - "SELECT type FROM projects WHERE id = ?", (e.id,) - ).fetchone() - if existing is not None and existing["type"] not in (None, "", e.type): - log.error( - "registry: refusing immutable type change on project %s (%s -> %s)", - e.id, existing["type"], e.type, + with db.tx() as conn: + for e in doc.projects: + existing = conn.execute( + "SELECT type FROM projects WHERE id = ?", (e.id,) + ).fetchone() + if existing is not None and existing["type"] != e.type: + log.error( + "registry: refusing immutable type change on project %s (%s -> %s)", + e.id, existing["type"], e.type, + ) + continue + conn.execute( + """ + INSERT INTO projects + (id, name, type, content_repo, visibility, initial_state, + config_json, registry_sha, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + type = excluded.type, + content_repo = excluded.content_repo, + visibility = excluded.visibility, + initial_state = excluded.initial_state, + config_json = excluded.config_json, + registry_sha = excluded.registry_sha, + updated_at = datetime('now') + """, + ( + e.id, e.name, e.type, e.content_repo, e.visibility, + e.initial_state, json.dumps(e.config), registry_sha, + ), ) - continue conn.execute( """ - INSERT INTO projects - (id, name, type, content_repo, visibility, initial_state, - config_json, registry_sha, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - type = excluded.type, - content_repo = excluded.content_repo, - visibility = excluded.visibility, - initial_state = excluded.initial_state, - config_json = excluded.config_json, - registry_sha = excluded.registry_sha, - updated_at = datetime('now') + UPDATE deployment + SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now') + WHERE id = 1 """, - ( - e.id, e.name, e.type, e.content_repo, e.visibility, - e.initial_state, json.dumps(e.config), registry_sha, - ), + (doc.deployment_name, doc.deployment_tagline, registry_sha), ) - conn.execute( - """ - UPDATE deployment - SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now') - WHERE id = 1 - """, - (doc.deployment_name, doc.deployment_tagline, registry_sha), - ) async def refresh_registry(config: Config, gitea: Gitea) -> None: @@ -175,6 +177,8 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None: f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found" ) text = base64.b64decode(item["content"]).decode("utf-8") + # Prefer the file's last commit sha for provenance (production Gitea + # includes it on the contents response); fall back to the blob sha. sha = item.get("last_commit_sha") or item.get("sha") or "" doc = parse_registry(text) apply_registry(doc, sha) diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py index be1ecf8..8272997 100644 --- a/backend/tests/test_registry.py +++ b/backend/tests/test_registry.py @@ -58,6 +58,7 @@ def test_parse_initial_state_defaults_per_type(): @pytest.mark.parametrize("bad,msg", [ ("projects: []\n", "at least one"), + ("projects:\n - just-a-string\n", "must be a mapping"), ("projects:\n - {id: 'Bad Slug', name: A, type: document, content_repo: a}\n", "valid slug"), ("projects:\n - {id: a, name: A, type: nope, content_repo: a}\n", "invalid type"), ("projects:\n - {id: a, name: A, type: document}\n", "content_repo"), @@ -92,3 +93,6 @@ def test_apply_rejects_type_change_on_existing_project(): registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"] assert t == "document" # immutable — unchanged + # The deployment row IS still advanced even though the project upsert was skipped. + drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone() + assert drow["registry_sha"] == "s2"