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
|
||||
```
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
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