Skip to content

Commit 6c093f8

Browse files
authored
Merge pull request #2118 from Websoft9/feature/appstore-publish-migration
fix
2 parents 1f72fb3 + 8729f88 commit 6c093f8

2 files changed

Lines changed: 178 additions & 28 deletions

File tree

build/library_publish.py

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"rc": "library-rc.zip",
2323
"release": "library-latest.zip",
2424
}
25+
V2_FULL_PACKAGE_BASENAME = "library"
2526
CATALOG_FILE_NAMES = (
2627
"catalog_en.json",
2728
"catalog_zh.json",
@@ -115,6 +116,14 @@ def summarize_versions(edition_list: list[dict]) -> list[str]:
115116
return versions
116117

117118

119+
def v2_full_package_names(channel: str, dataset_version: str) -> tuple[str, str]:
120+
return (f"{V2_FULL_PACKAGE_BASENAME}-{dataset_version}.zip", f"{V2_FULL_PACKAGE_BASENAME}-{channel}.zip")
121+
122+
123+
def v2_app_package_names(dataset_version: str) -> tuple[str, str]:
124+
return (f"{dataset_version}.zip", "latest.zip")
125+
126+
118127
def sha256_file(path: Path) -> str:
119128
digest = hashlib.sha256()
120129
with path.open("rb") as handle:
@@ -172,19 +181,39 @@ def current_app_fingerprint(app_dir: Path) -> str:
172181
return digest.hexdigest()
173182

174183

184+
def build_app_package_entry(app_name: str, dataset_version: str) -> dict:
185+
versioned_name, latest_name = v2_app_package_names(dataset_version)
186+
app_base = f"apps/{app_name}"
187+
return {
188+
"versioned": f"{app_base}/{versioned_name}",
189+
"latest": f"{app_base}/{latest_name}",
190+
}
191+
192+
193+
def build_app_checksum_entry(app_name: str, dataset_version: str) -> dict:
194+
package_entry = build_app_package_entry(app_name, dataset_version)
195+
return {
196+
"versioned": f"{package_entry['versioned']}.sha256",
197+
"latest": f"{package_entry['latest']}.sha256",
198+
}
199+
200+
175201
def build_apps_index(dataset_version: str, channel: str, generated_at: str) -> dict:
176202
apps = []
177203
for app_dir in sorted(path for path in APPS_DIR.iterdir() if path.is_dir()):
178204
variables = load_variables_json(app_dir)
205+
app_name = app_dir.name
179206
apps.append(
180207
{
181-
"app": app_dir.name,
182-
"name": variables.get("name", app_dir.name),
183-
"trademark": variables.get("trademark", variables.get("name", app_dir.name)),
208+
"app": app_name,
209+
"name": variables.get("name", app_name),
210+
"trademark": variables.get("trademark", variables.get("name", app_name)),
184211
"release": variables.get("release"),
185212
"versions": summarize_versions(variables.get("edition", [])),
186-
"path": f"apps/{app_dir.name}",
213+
"path": f"apps/{app_name}",
187214
"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),
188217
}
189218
)
190219

@@ -260,7 +289,7 @@ def build_apps_delta(
260289
def build_library_manifest(
261290
dataset_version: str,
262291
channel: str,
263-
package_name: str,
292+
full_package_names: dict,
264293
apps_index_name: str,
265294
apps_delta_name: str,
266295
checksum_names: dict,
@@ -270,9 +299,11 @@ def build_library_manifest(
270299
"schemaVersion": "1",
271300
"datasetVersion": dataset_version,
272301
"channel": channel,
273-
"libraryPackage": package_name,
302+
"fullPackage": full_package_names,
274303
"appsIndex": apps_index_name,
275304
"appsDelta": apps_delta_name,
305+
"appPackagesBase": "apps/",
306+
"supportsPartialUpdate": True,
276307
"checksum": checksum_names,
277308
"generatedAt": generated_at,
278309
}
@@ -373,7 +404,8 @@ def validate_catalog_artifacts(output_dir: Path, manifest: dict) -> None:
373404

374405
def validate_library_artifacts(output_dir: Path, manifest: dict) -> None:
375406
for name in (
376-
manifest["libraryPackage"],
407+
manifest["fullPackage"]["versioned"],
408+
manifest["fullPackage"]["latest"],
377409
manifest["appsIndex"],
378410
manifest["appsDelta"],
379411
"manifest.json",
@@ -390,6 +422,14 @@ def validate_library_artifacts(output_dir: Path, manifest: dict) -> None:
390422
if key not in apps_delta or not isinstance(apps_delta[key], list):
391423
raise SystemExit(f"invalid apps delta structure: {key}")
392424

425+
apps_index = json.loads((output_dir / manifest["appsIndex"]).read_text(encoding="utf-8"))
426+
for entry in apps_index.get("apps", []):
427+
package = entry.get("package") or {}
428+
checksum = entry.get("checksum") or {}
429+
for path in (package.get("versioned"), package.get("latest"), checksum.get("versioned"), checksum.get("latest")):
430+
if not path or not (output_dir / path).exists():
431+
raise SystemExit(f"missing app package artifact: {entry.get('app')} -> {path}")
432+
393433

394434
def validate_appstore_artifacts(appstore_dir: Path) -> None:
395435
required_paths = (
@@ -506,12 +546,36 @@ def build_v2_appstore_artifacts(
506546

507547
apps_index_name = f"apps-index-{dataset_version}.json"
508548
apps_delta_name = f"apps-delta-{from_version}-to-{dataset_version}.json"
549+
full_dir = library_dir / "full"
550+
apps_packages_dir = library_dir / "apps"
551+
full_dir.mkdir(parents=True, exist_ok=True)
552+
apps_packages_dir.mkdir(parents=True, exist_ok=True)
553+
554+
full_versioned_name, full_latest_name = v2_full_package_names(channel, dataset_version)
509555

510556
with tempfile.TemporaryDirectory() as tmp_dir_name:
511557
tmp_dir = Path(tmp_dir_name)
512558
package_dir = tmp_dir / PACKAGE_ROOT_NAME
513559
copy_package_contents(package_dir, packaged_library_json)
514-
create_zip_from_directory(package_dir, library_dir / package_name)
560+
create_zip_from_directory(package_dir, full_dir / full_versioned_name)
561+
562+
shutil.copy2(full_dir / full_versioned_name, full_dir / full_latest_name)
563+
564+
for app_dir in sorted(path for path in APPS_DIR.iterdir() if path.is_dir()):
565+
app_name = app_dir.name
566+
app_output_dir = apps_packages_dir / app_name
567+
app_output_dir.mkdir(parents=True, exist_ok=True)
568+
app_versioned_name, app_latest_name = v2_app_package_names(dataset_version)
569+
570+
with tempfile.TemporaryDirectory() as tmp_dir_name:
571+
tmp_dir = Path(tmp_dir_name)
572+
app_package_root = tmp_dir / app_name
573+
shutil.copytree(app_dir, app_package_root)
574+
create_zip_from_directory(app_package_root, app_output_dir / app_versioned_name)
575+
576+
shutil.copy2(app_output_dir / app_versioned_name, app_output_dir / app_latest_name)
577+
write_checksum_file(app_output_dir / app_versioned_name)
578+
write_checksum_file(app_output_dir / app_latest_name)
515579

516580
apps_index = build_apps_index(dataset_version, channel, generated_at)
517581
apps_delta = build_apps_delta(
@@ -526,15 +590,19 @@ def build_v2_appstore_artifacts(
526590
write_json(library_dir / apps_delta_name, apps_delta)
527591

528592
library_checksums = {
529-
"libraryPackage": write_checksum_file(library_dir / package_name),
593+
"fullPackageVersioned": f"full/{write_checksum_file(full_dir / full_versioned_name)}",
594+
"fullPackageLatest": f"full/{write_checksum_file(full_dir / full_latest_name)}",
530595
"appsIndex": write_checksum_file(library_dir / apps_index_name),
531596
"appsDelta": write_checksum_file(library_dir / apps_delta_name),
532597
}
533598

534599
library_manifest = build_library_manifest(
535600
dataset_version=dataset_version,
536601
channel=channel,
537-
package_name=package_name,
602+
full_package_names={
603+
"versioned": f"full/{full_versioned_name}",
604+
"latest": f"full/{full_latest_name}",
605+
},
538606
apps_index_name=apps_index_name,
539607
apps_delta_name=apps_delta_name,
540608
checksum_names=library_checksums,
@@ -558,7 +626,11 @@ def build_v2_appstore_artifacts(
558626
},
559627
"library": {
560628
"manifest": "library/manifest.json",
561-
"libraryPackage": package_name,
629+
"fullPackage": {
630+
"versioned": f"full/{full_versioned_name}",
631+
"latest": f"full/{full_latest_name}",
632+
},
633+
"appPackagesBase": "apps/",
562634
"appsIndex": apps_index_name,
563635
"appsDelta": apps_delta_name,
564636
"checksum": library_checksums,

docs/devops.md

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,17 @@
266266
1. `manifest.json`
267267
2. `apps-index-<datasetVersion>.json`
268268
3. `apps-delta-<fromVersion>-to-<toVersion>.json`
269-
4. `library` 主制品
270-
5. 上述文件对应 checksum
269+
4. `library` 全量主制品
270+
5. `apps/<app>` 级独立制品集合
271+
6. 上述文件对应 checksum
272+
273+
补充要求:
274+
275+
1. `library` 必须同时保留全量包与单 app 包两类输出
276+
2. 全量包用于首装、回退、灾备与旧客户端兼容
277+
3. 单 app 包用于新增 app 和变更 app 的网络增量分发
278+
4. 第一阶段单 app 制品粒度固定到 `apps/<app>`,不继续细化到 app 内文件级制品
279+
5. 单 app 制品应同时提供可覆盖的最新别名与不可变的版本化对象
271280

272281
### 6.4 legacy 输出
273282

@@ -370,6 +379,27 @@ v2 workflow 的职责应为:
370379
4. `appstore` 根级所有子数据集都必须能通过根 manifest 追溯
371380
5. `catalog``library` 都必须有各自 manifest
372381

382+
### 10.1 library 子目录建议结构
383+
384+
为支持真正的 app 级增量分发,`library/` 下建议采用以下结构:
385+
386+
1. `artifact/websoft9/v2/<channel>/appstore/library/manifest.json`
387+
2. `artifact/websoft9/v2/<channel>/appstore/library/apps-index-<datasetVersion>.json`
388+
3. `artifact/websoft9/v2/<channel>/appstore/library/apps-delta-<fromVersion>-to-<toVersion>.json`
389+
4. `artifact/websoft9/v2/<channel>/appstore/library/full/library-<datasetVersion>.zip`
390+
5. `artifact/websoft9/v2/<channel>/appstore/library/full/library-<channel>.zip`
391+
6. `artifact/websoft9/v2/<channel>/appstore/library/apps/<app>/<datasetVersion>.zip`
392+
7. `artifact/websoft9/v2/<channel>/appstore/library/apps/<app>/latest.zip`
393+
394+
说明:
395+
396+
1. `full/library-<datasetVersion>.zip` 是不可变的全量快照包
397+
2. `full/library-<channel>.zip` 是该通道下的全量最新别名,可被覆盖
398+
3. `apps/<app>/<datasetVersion>.zip` 是不可变的单 app 快照包
399+
4. `apps/<app>/latest.zip` 是该 app 在该通道下的最新别名,可被覆盖
400+
5. `latest` 类对象只承担最新指针语义,不承担审计与回滚语义
401+
6. 真正的审计、回滚与复现必须依赖版本化对象
402+
373403
## 11. 更新粒度
374404

375405
第一阶段的更新粒度按子数据集分别定义。
@@ -401,6 +431,20 @@ v2 workflow 的职责应为:
401431
2. `changedApps`
402432
3. `removedApps`
403433

434+
但需要明确区分两层语义:
435+
436+
1. `apps-delta` 只负责表达 app 级变化范围
437+
2. 真正的增量载荷必须由单 app 制品承担
438+
439+
因此客户端更新应采用如下优先级:
440+
441+
1. 首装或本地状态损坏时,下载 `library` 全量包
442+
2. 已有旧版本且服务端提供单 app 制品时,只下载 `addedApps``changedApps` 对应的 app 包
443+
3.`removedApps` 直接执行本地删除
444+
4. 任一单 app 更新失败时,可回退到全量包恢复
445+
446+
结论:`apps-delta` 不是增量载荷本身;只有在服务端同时发布单 app 制品时,app 级增量更新才成立。
447+
404448
## 12. 版本语义
405449

406450
发布规范中至少区分三类版本语义:
@@ -513,7 +557,7 @@ v2 workflow 的职责应为:
513557
1. `schemaVersion`
514558
2. `datasetVersion`
515559
3. `channel`
516-
4. `libraryPackage`
560+
4. `fullPackage`
517561
5. `appsIndex`
518562
6. `appsDelta`
519563
7. `checksum`
@@ -524,6 +568,9 @@ v2 workflow 的职责应为:
524568
1. `library` 必须被定义为独立可版本化的数据集
525569
2. 第一阶段即使输出仍较简,也必须通过 `library/manifest.json` 明确其契约版本边界
526570
3. 后续 `apps-index``apps-delta` 或包命名结构变化只能通过 `schemaVersion` 管理
571+
4. `library manifest` 必须同时暴露全量包入口与单 app 包基准路径
572+
5. `supportsPartialUpdate=true` 才表示客户端可以按 app 制品做网络增量更新
573+
6. 即使支持单 app 更新,仍必须保留全量包作为兜底恢复路径
527574

528575
建议样例如下:
529576

@@ -532,18 +579,42 @@ v2 workflow 的职责应为:
532579
"schemaVersion": "1",
533580
"datasetVersion": "2026.06.09.120000",
534581
"channel": "release",
535-
"libraryPackage": "library-latest.zip",
582+
"fullPackage": {
583+
"versioned": "full/library-2026.06.09.120000.zip",
584+
"latest": "full/library-release.zip"
585+
},
536586
"appsIndex": "apps-index-2026.06.09.120000.json",
537587
"appsDelta": "apps-delta-2026.06.08.120000-to-2026.06.09.120000.json",
588+
"appPackagesBase": "apps/",
589+
"supportsPartialUpdate": true,
538590
"checksum": {
539-
"libraryPackage": "library-latest.zip.sha256",
591+
"fullPackageVersioned": "full/library-2026.06.09.120000.zip.sha256",
592+
"fullPackageLatest": "full/library-release.zip.sha256",
540593
"appsIndex": "apps-index-2026.06.09.120000.json.sha256",
541594
"appsDelta": "apps-delta-2026.06.08.120000-to-2026.06.09.120000.json.sha256"
542595
},
543596
"generatedAt": "2026-06-09T12:00:00Z"
544597
}
545598
```
546599

600+
### 11.4 apps-index 扩展要求
601+
602+
为支持单 app 下载,`apps-index` 中每个 app 条目建议最少包含:
603+
604+
1. `app`
605+
2. `hash`
606+
3. `package.versioned`
607+
4. `package.latest`
608+
5. `checksum.versioned`
609+
6. `checksum.latest`
610+
611+
说明:
612+
613+
1. `package.versioned` 指向不可变单 app 包
614+
2. `package.latest` 指向该 app 的最新别名
615+
3. 新客户端优先读取 `package.versioned`,以获得更强一致性
616+
4. 只关心当前通道最新值的轻量客户端可读取 `package.latest`
617+
547618
## 14. 校验门禁
548619

549620
第一阶段至少执行以下校验:
@@ -556,20 +627,22 @@ v2 workflow 的职责应为:
556627
6. `library.schemaVersion``library.datasetVersion` 可追溯
557628
7. `apps-index` 可生成
558629
8. `apps-delta` 可生成
559-
9. appstore 根 manifest 可生成
560-
10. appstore 根级 `schemaVersion``datasetVersion` 可追溯
561-
11. 所有 checksum 与实际文件一致
562-
12. R2 上传后的文件可访问
563-
13. legacy catalog 兼容输出可被完整生成
564-
14. legacy library 兼容输出可被完整生成
630+
9. 单 app 制品可按 app 级生成
631+
10. appstore 根 manifest 可生成
632+
11. appstore 根级 `schemaVersion``datasetVersion` 可追溯
633+
12. 所有 checksum 与实际文件一致
634+
13. R2 上传后的文件可访问
635+
14. legacy catalog 兼容输出可被完整生成
636+
15. legacy library 兼容输出可被完整生成
565637

566638
建议以下情况直接失败:
567639

568640
1. 任一必须制品缺失
569641
2. 任一 manifest 缺关键字段
570642
3. `apps-delta` 不符合 app 级结构
571-
4. checksum 不一致
572-
5. 根 manifest 无法追溯到 `catalog``library`
643+
4. 单 app 制品缺失或校验信息不可追溯
644+
5. checksum 不一致
645+
6. 根 manifest 无法追溯到 `catalog``library`
573646

574647
## 15. 回滚要求
575648

@@ -580,8 +653,10 @@ v2 workflow 的职责应为:
580653
1. 每次发布保留历史 `appstore-manifest.json`
581654
2. 每次发布保留历史 `catalog` manifest 与对应 JSON
582655
3. 每次发布保留历史 `library` manifest、`apps-index``apps-delta`
583-
4. 每次发布保留 legacy catalog 与 legacy library 兼容制品
584-
5. 能根据历史根 manifest 找回同一次发布对应的 `catalog``library` 组合
656+
4. 每次发布保留历史全量 `library` 版本包
657+
5. 每次发布保留历史单 app 版本包
658+
6. 每次发布保留 legacy catalog 与 legacy library 兼容制品
659+
7. 能根据历史根 manifest 找回同一次发布对应的 `catalog``library` 组合
585660

586661
## 16. 与下游的契约
587662

@@ -595,7 +670,7 @@ v2 workflow 的职责应为:
595670

596671
## 17. 第一阶段最小改造清单
597672

598-
第一阶段最少只做下面十一件事
673+
第一阶段最少只做下面十三件事
599674

600675
1. 保留 legacy workflow 并允许其在迁移窗口内继续运行
601676
2. 新增独立的 v2 workflow,专门发布 `appstore`
@@ -608,6 +683,8 @@ v2 workflow 的职责应为:
608683
9. 继续保留 legacy 兼容制品
609684
10.`catalog``library``appstore` 根级补充 manifest 与 checksum
610685
11. 保持 `library` 更新粒度为 app 级
686+
12.`library` 增加单 app 制品输出能力
687+
13. 为全量包与单 app 包同时保留版本化对象与最新别名
611688

612689
## 18. 结论
613690

@@ -625,4 +702,5 @@ v2 workflow 的职责应为:
625702
8. 新增统一的 `appstore/catalog/library/manifests` 目录结构
626703
9. `catalog``library` 具有清晰的 `schemaVersion``datasetVersion` 边界
627704
10. `library` 更新粒度固定为 app 级
628-
11. 发布结果可通过根 manifest 追溯到完整发布集合
705+
11. `library` 同时提供全量包与单 app 包两类制品
706+
12. 发布结果可通过根 manifest 追溯到完整发布集合

0 commit comments

Comments
 (0)