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:
@@ -52,6 +52,43 @@ pass show admob/mealmood/app-id
|
|||||||
pass show admob/mealmood/banner-home-unit-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
|
### TestFlight upload
|
||||||
```bash
|
```bash
|
||||||
export FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=$(pass show apple/mealmood/app-specific-password-fastlane)
|
export FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=$(pass show apple/mealmood/app-specific-password-fastlane)
|
||||||
|
|||||||
@@ -25,8 +25,29 @@ platform :ios do
|
|||||||
)
|
)
|
||||||
end
|
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"
|
desc "Push a new beta build to TestFlight"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
|
check_cloudkit_schema(blocking: false)
|
||||||
increment_build_number(xcodeproj: "MealMood.xcodeproj")
|
increment_build_number(xcodeproj: "MealMood.xcodeproj")
|
||||||
# No `sdk:` here: forcing one SDK globally breaks the watchOS targets,
|
# No `sdk:` here: forcing one SDK globally breaks the watchOS targets,
|
||||||
# which must build against watchos within the same archive.
|
# 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)"
|
desc "Submit an already-uploaded build for App Store review (automatic release)"
|
||||||
lane :submit do |options|
|
lane :submit do |options|
|
||||||
UI.user_error!("pass build:<number>") unless options[:build]
|
UI.user_error!("pass build:<number>") unless options[:build]
|
||||||
|
check_cloudkit_schema(blocking: true)
|
||||||
version = options[:version] || shipped_app_version
|
version = options[:version] || shipped_app_version
|
||||||
ensure_editable_version(version)
|
ensure_editable_version(version)
|
||||||
upload_to_app_store(
|
upload_to_app_store(
|
||||||
@@ -239,6 +261,7 @@ platform :ios do
|
|||||||
|
|
||||||
desc "Push a new release build to the App Store"
|
desc "Push a new release build to the App Store"
|
||||||
lane :release do
|
lane :release do
|
||||||
|
check_cloudkit_schema(blocking: true)
|
||||||
increment_build_number(xcodeproj: "MealMood.xcodeproj")
|
increment_build_number(xcodeproj: "MealMood.xcodeproj")
|
||||||
build_app(
|
build_app(
|
||||||
scheme: "MealMood",
|
scheme: "MealMood",
|
||||||
|
|||||||
Executable
+134
@@ -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())
|
||||||
Reference in New Issue
Block a user