From 75c41b12ec73961bfddbfd477b523e4b80fb4d27 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Mon, 14 Sep 2026 17:23:22 +0200 Subject: [PATCH] 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_ 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) Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246 --- CLAUDE.md | 37 +++++++++ fastlane/Fastfile | 23 ++++++ scripts/check_cloudkit_schema.py | 134 +++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100755 scripts/check_cloudkit_schema.py diff --git a/CLAUDE.md b/CLAUDE.md index 588190b..f291b1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,43 @@ pass show admob/mealmood/app-id pass show admob/mealmood/banner-home-unit-id ``` +### Antes de mandar una versión a la App Store: comprobar el esquema de CloudKit + +Un campo que está en el `@Model` pero no en el esquema de CloudKit **no +sincroniza**, y no falla nada: los datos se quedan en el dispositivo que los +escribió. Así se colaron nueve campos fuera de Production entre la 2.0 y la +2.1.2 (issue #38) — desayuno/merienda activados, sus horas, el recordatorio, +las fotos del planificador y las reglas de día de las etiquetas no viajaban +entre dispositivos. + +```bash +fastlane check_schema # o directamente: +python3 scripts/check_cloudkit_schema.py --environment production +``` + +`fastlane submit` y `fastlane release` ya lo ejecutan y **abortan** si falta +algo; `fastlane beta` solo avisa (en TestFlight aún puede faltar el deploy). + +Si faltan campos: el esquema de development solo se actualiza al ejecutar la +app contra ese entorno con iCloud activo, o importándolo a mano: + +```bash +TOKEN=$(pass show apple/mealmood/cloudkit-token) +TEAM=$(pass show apple/mealmood/developer-team-id) +xcrun cktool export-schema --token "$TOKEN" --team-id "$TEAM" \ + --container-id iCloud.com.alexandrev.mealmood --environment development \ + --output-file /tmp/schema.ckdb +# añadir los CD_ que falten (Bool/Int → INT64, String → STRING, +# Date → TIMESTAMP, Data → CD__ckAsset ASSET) y: +xcrun cktool import-schema --token "$TOKEN" --team-id "$TEAM" \ + --container-id iCloud.com.alexandrev.mealmood --environment development \ + --file /tmp/schema.ckdb +``` + +El paso a Production **no se puede automatizar**: `cktool` no tiene deploy y +Apple bloquea el endpoint de esquema en ese entorno. Lo hace Alexandre en +CloudKit Console → Schema → *Deploy Schema Changes* → Deploy to Production. + ### TestFlight upload ```bash export FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=$(pass show apple/mealmood/app-specific-password-fastlane) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 821b052..d30729a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -25,8 +25,29 @@ platform :ios do ) end + # A field missing from the CloudKit schema doesn't crash anything — it just + # never syncs, silently. Nine of them drifted out of Production between 2.0 + # and 2.1.2 before anyone noticed (issue #38), so shipping now checks first. + def check_cloudkit_schema(blocking:) + script = File.expand_path("../scripts/check_cloudkit_schema.py", __dir__) + ok = system("python3", script, "--environment", "production") + return if ok + + if blocking + UI.user_error!("CloudKit Production schema is behind the models — deploy it before shipping (see the output above).") + else + UI.important("CloudKit Production schema is behind the models. Fine for TestFlight, but deploy it before submitting to the App Store.") + end + end + + desc "Check the deployed CloudKit schema against the SwiftData models" + lane :check_schema do + check_cloudkit_schema(blocking: true) + end + desc "Push a new beta build to TestFlight" lane :beta do + check_cloudkit_schema(blocking: false) increment_build_number(xcodeproj: "MealMood.xcodeproj") # No `sdk:` here: forcing one SDK globally breaks the watchOS targets, # which must build against watchos within the same archive. @@ -166,6 +187,7 @@ platform :ios do desc "Submit an already-uploaded build for App Store review (automatic release)" lane :submit do |options| UI.user_error!("pass build:") unless options[:build] + check_cloudkit_schema(blocking: true) version = options[:version] || shipped_app_version ensure_editable_version(version) upload_to_app_store( @@ -239,6 +261,7 @@ platform :ios do desc "Push a new release build to the App Store" lane :release do + check_cloudkit_schema(blocking: true) increment_build_number(xcodeproj: "MealMood.xcodeproj") build_app( scheme: "MealMood", diff --git a/scripts/check_cloudkit_schema.py b/scripts/check_cloudkit_schema.py new file mode 100755 index 0000000..16c4a25 --- /dev/null +++ b/scripts/check_cloudkit_schema.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Compares the SwiftData models against the deployed CloudKit schema. + +Every stored property of a @Model becomes a CD_ 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_; 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__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())