Files
FamilyMealPlanner/scripts/check_cloudkit_schema.py
T
alexandrev-tibco 75c41b12ec comprobar el esquema de CloudKit antes de publicar
Un campo que esta en el @Model pero no en el esquema desplegado no sincroniza,
y no falla nada: los datos se quedan en el dispositivo que los escribio. Asi se
colaron nueve campos fuera de Production entre la 2.0 y la 2.1.2 sin que nadie
lo notara, incluidas las reglas de dia de las etiquetas y el desayuno/merienda
activados.

scripts/check_cloudkit_schema.py compara las propiedades almacenadas de cada
@Model con los CD_<campo> del esquema real (exportado con cktool), saltando
relaciones, computadas y el sufijo _ckAsset de los binarios.

fastlane submit y release abortan si falta algo; beta solo avisa, porque en
TestFlight es normal que el deploy a Production aun no se haya hecho. La lane
check_schema lo ejecuta suelto.

El deploy a Production sigue siendo manual: cktool no tiene subcomando y Apple
bloquea el endpoint de esquema en ese entorno.

Refs #38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
2026-09-14 17:23:22 +02:00

135 lines
4.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Compares the SwiftData models against the deployed CloudKit schema.
Every stored property of a @Model becomes a CD_<name> field in CloudKit, and a
field missing from an environment simply doesn't sync: the app keeps working
and the data silently stays on the device that wrote it. That is how nine
fields drifted out of Production between 2.0 and 2.1.2 (issue #38).
The schema only picks up new fields when the app runs against the development
environment, and promoting them to Production is a manual step in CloudKit
Console. So this check has to run before shipping, not after.
python3 scripts/check_cloudkit_schema.py [--environment production]
Exits non-zero when a stored property has no field in the schema.
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
MODELS_DIR = REPO / "MealMood" / "Models"
CONTAINER = "iCloud.com.alexandrev.mealmood"
# Properties CloudKit does not mirror as a field of their own record type.
IGNORED = {
# To-one relationships live on the child as CD_<name>; to-many ones have no
# field at all (the inverse carries the link).
"slots",
"weekPlan",
}
def pass_show(entry: str) -> str:
return subprocess.run(
["pass", "show", entry], capture_output=True, text=True, check=True
).stdout.splitlines()[0].strip()
def export_schema(environment: str) -> str:
"""Pulls the live schema. Needs the management token from `pass`."""
token = pass_show("apple/mealmood/cloudkit-token")
team = pass_show("apple/mealmood/developer-team-id")
result = subprocess.run(
[
"xcrun", "cktool", "export-schema",
"--token", token,
"--team-id", team,
"--container-id", CONTAINER,
"--environment", environment,
],
capture_output=True, text=True,
)
if result.returncode != 0:
raise SystemExit(f"cktool export-schema failed:\n{result.stderr.strip()}")
return result.stdout
def model_names() -> set:
names = set()
for path in MODELS_DIR.glob("*.swift"):
names.update(re.findall(r"@Model\s+final class (\w+)", path.read_text()))
return names
def stored_properties(source: str, models: set) -> list:
"""Stored `var`s of a @Model: computed ones end in `{`, and relationships to
other models are not fields of this record type."""
body = source.split("init(")[0]
properties = []
for match in re.finditer(r"^\s*(?:@Attribute\([^)]*\)\s*)?var (\w+)\s*:\s*([^\n=]+?)(\s*=|\s*\{|$)", body, re.M):
name, type_name, tail = match.group(1), match.group(2).strip(), match.group(3)
if tail.strip() == "{":
continue # computed
if name in IGNORED:
continue
bare = type_name.strip("[]?").strip()
if bare in models:
continue # relationship
properties.append(name)
return properties
def schema_fields(schema: str, record_type: str) -> set:
block = re.search(rf"RECORD TYPE CD_{record_type} \((.*?)\);", schema, re.S)
if not block:
return set()
# Binary attributes land as CD_<name>_ckAsset.
return {
field.removesuffix("_ckAsset")
for field in re.findall(r"CD_(\w+)", block.group(1))
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--environment", default="production", choices=["production", "development"])
args = parser.parse_args()
schema = export_schema(args.environment)
models = model_names()
problems = []
for path in sorted(MODELS_DIR.glob("*.swift")):
source = path.read_text()
for model in re.findall(r"@Model\s+final class (\w+)", source):
fields = schema_fields(schema, model)
if not fields:
problems.append(f"{model}: record type CD_{model} missing from the schema")
continue
for prop in stored_properties(source, models):
if prop not in fields:
problems.append(f"{model}.{prop} → CD_{prop} missing")
if problems:
print(f"CloudKit schema ({args.environment}) is behind the models:\n")
for problem in problems:
print(f"{problem}")
print(
"\nThese fields will not sync. Run the app once against development so the\n"
"schema picks them up (or import it with `cktool import-schema`), then deploy\n"
"to Production from CloudKit Console → Schema → Deploy Schema Changes."
)
return 1
print(f"✅ CloudKit schema ({args.environment}) matches the models.")
return 0
if __name__ == "__main__":
sys.exit(main())