@@ -120,8 +120,23 @@ def v2_full_package_names(channel: str, dataset_version: str) -> tuple[str, str]
120120 return (f"{ V2_FULL_PACKAGE_BASENAME } -{ dataset_version } .zip" , f"{ V2_FULL_PACKAGE_BASENAME } -{ channel } .zip" )
121121
122122
123- def v2_app_package_names (dataset_version : str ) -> tuple [str , str ]:
124- return (f"{ dataset_version } .zip" , "latest.zip" )
123+ def _app_version_from_hash (app_hash : str ) -> str :
124+ """Derive a stable, content-addressed version identifier from an app's fingerprint hash.
125+
126+ Using the content hash means the same app content always produces the same
127+ versioned filename, so unchanged apps never need to be rebuilt or re-uploaded.
128+ """
129+ return app_hash [:16 ]
130+
131+
132+ def v2_app_package_names (app_version : str ) -> tuple [str , str ]:
133+ """Return (versioned_zip_name, latest_zip_name) for a single app.
134+
135+ ``app_version`` should be a per-app content-derived identifier, not the
136+ global publish timestamp, so that only actually-changed apps get new
137+ versioned packages.
138+ """
139+ return (f"{ app_version } .zip" , "latest.zip" )
125140
126141
127142def sha256_file (path : Path ) -> str :
@@ -181,17 +196,17 @@ def current_app_fingerprint(app_dir: Path) -> str:
181196 return digest .hexdigest ()
182197
183198
184- def build_app_package_entry (app_name : str , dataset_version : str ) -> dict :
185- versioned_name , latest_name = v2_app_package_names (dataset_version )
199+ def build_app_package_entry (app_name : str , app_version : str ) -> dict :
200+ versioned_name , latest_name = v2_app_package_names (app_version )
186201 app_base = f"apps/{ app_name } "
187202 return {
188203 "versioned" : f"{ app_base } /{ versioned_name } " ,
189204 "latest" : f"{ app_base } /{ latest_name } " ,
190205 }
191206
192207
193- def build_app_checksum_entry (app_name : str , dataset_version : str ) -> dict :
194- package_entry = build_app_package_entry (app_name , dataset_version )
208+ def build_app_checksum_entry (app_name : str , app_version : str ) -> dict :
209+ package_entry = build_app_package_entry (app_name , app_version )
195210 return {
196211 "versioned" : f"{ package_entry ['versioned' ]} .sha256" ,
197212 "latest" : f"{ package_entry ['latest' ]} .sha256" ,
@@ -203,6 +218,8 @@ def build_apps_index(dataset_version: str, channel: str, generated_at: str) -> d
203218 for app_dir in sorted (path for path in APPS_DIR .iterdir () if path .is_dir ()):
204219 variables = load_variables_json (app_dir )
205220 app_name = app_dir .name
221+ app_hash = current_app_fingerprint (app_dir )
222+ app_version = _app_version_from_hash (app_hash )
206223 apps .append (
207224 {
208225 "app" : app_name ,
@@ -211,9 +228,9 @@ def build_apps_index(dataset_version: str, channel: str, generated_at: str) -> d
211228 "release" : variables .get ("release" ),
212229 "versions" : summarize_versions (variables .get ("edition" , [])),
213230 "path" : f"apps/{ app_name } " ,
214- "hash" : current_app_fingerprint ( app_dir ) ,
215- "package" : build_app_package_entry (app_name , dataset_version ),
216- "checksum" : build_app_checksum_entry (app_name , dataset_version ),
231+ "hash" : app_hash ,
232+ "package" : build_app_package_entry (app_name , app_version ),
233+ "checksum" : build_app_checksum_entry (app_name , app_version ),
217234 }
218235 )
219236
@@ -402,7 +419,7 @@ def validate_catalog_artifacts(output_dir: Path, manifest: dict) -> None:
402419 raise SystemExit ("missing catalog manifest.json" )
403420
404421
405- def validate_library_artifacts (output_dir : Path , manifest : dict ) -> None :
422+ def validate_library_artifacts (output_dir : Path , manifest : dict , changed_app_names : set [ str ] | None = None ) -> None :
406423 for name in (
407424 manifest ["fullPackage" ]["versioned" ],
408425 manifest ["fullPackage" ]["latest" ],
@@ -423,7 +440,13 @@ def validate_library_artifacts(output_dir: Path, manifest: dict) -> None:
423440 raise SystemExit (f"invalid apps delta structure: { key } " )
424441
425442 apps_index = json .loads ((output_dir / manifest ["appsIndex" ]).read_text (encoding = "utf-8" ))
443+ # Only validate per-app packages for apps that were actually (re)built
444+ # in this publish run. Unchanged apps keep their existing packages on R2.
445+ validate_set = changed_app_names if changed_app_names is not None else {e ["app" ] for e in apps_index .get ("apps" , [])}
446+
426447 for entry in apps_index .get ("apps" , []):
448+ if entry .get ("app" ) not in validate_set :
449+ continue
427450 package = entry .get ("package" ) or {}
428451 checksum = entry .get ("checksum" ) or {}
429452 for path in (package .get ("versioned" ), package .get ("latest" ), checksum .get ("versioned" ), checksum .get ("latest" )):
@@ -561,11 +584,39 @@ def build_v2_appstore_artifacts(
561584
562585 shutil .copy2 (full_dir / full_versioned_name , full_dir / full_latest_name )
563586
587+ # ── library – compute index & delta BEFORE per-app zips ──
588+ apps_index = build_apps_index (dataset_version , channel , generated_at )
589+ apps_delta = build_apps_delta (
590+ apps_index = apps_index ,
591+ from_ref = from_ref ,
592+ from_version = from_version ,
593+ to_version = dataset_version ,
594+ channel = channel ,
595+ generated_at = generated_at ,
596+ )
597+
598+ # Determine which apps actually need (re)built packages.
599+ # On the very first publish (from_ref is None) every app is new.
600+ if from_ref is None :
601+ changed_app_names : set [str ] = {entry ["app" ] for entry in apps_index ["apps" ]}
602+ else :
603+ changed_app_names = set (apps_delta .get ("addedApps" , [])) | set (apps_delta .get ("changedApps" , []))
604+
605+ # ── library – per-app packages (only for changed apps) ───
564606 for app_dir in sorted (path for path in APPS_DIR .iterdir () if path .is_dir ()):
565607 app_name = app_dir .name
608+ if app_name not in changed_app_names :
609+ continue
610+
611+ # Look up the app's content-derived version identifier from the index.
612+ app_entry = next ((e for e in apps_index ["apps" ] if e ["app" ] == app_name ), None )
613+ if app_entry is None :
614+ continue
615+ app_version = _app_version_from_hash (app_entry ["hash" ])
616+ app_versioned_name , app_latest_name = v2_app_package_names (app_version )
617+
566618 app_output_dir = apps_packages_dir / app_name
567619 app_output_dir .mkdir (parents = True , exist_ok = True )
568- app_versioned_name , app_latest_name = v2_app_package_names (dataset_version )
569620
570621 with tempfile .TemporaryDirectory () as tmp_dir_name :
571622 tmp_dir = Path (tmp_dir_name )
@@ -577,15 +628,7 @@ def build_v2_appstore_artifacts(
577628 write_checksum_file (app_output_dir / app_versioned_name )
578629 write_checksum_file (app_output_dir / app_latest_name )
579630
580- apps_index = build_apps_index (dataset_version , channel , generated_at )
581- apps_delta = build_apps_delta (
582- apps_index = apps_index ,
583- from_ref = from_ref ,
584- from_version = from_version ,
585- to_version = dataset_version ,
586- channel = channel ,
587- generated_at = generated_at ,
588- )
631+ # ── library – write index, delta, manifest ───────────────
589632 write_json (library_dir / apps_index_name , apps_index )
590633 write_json (library_dir / apps_delta_name , apps_delta )
591634
@@ -610,7 +653,7 @@ def build_v2_appstore_artifacts(
610653 )
611654 write_json (library_dir / "manifest.json" , library_manifest )
612655 write_checksum_file (library_dir / "manifest.json" )
613- validate_library_artifacts (library_dir , library_manifest )
656+ validate_library_artifacts (library_dir , library_manifest , changed_app_names )
614657
615658 appstore_manifest = build_appstore_manifest (dataset_version , channel , generated_at )
616659 write_json (manifests_dir / "appstore-manifest.json" , appstore_manifest )
0 commit comments