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
This commit is contained in:
alexandrev-tibco
2026-09-14 17:23:22 +02:00
parent 8b75f496f8
commit 75c41b12ec
3 changed files with 194 additions and 0 deletions
+37
View File
@@ -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_<campo> que falten (Bool/Int → INT64, String → STRING,
# Date → TIMESTAMP, Data → CD_<campo>_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)
+23
View File
@@ -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:<number>") 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",
+134
View File
@@ -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_<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())