Skip to content

Sync Localization Assets #1

Sync Localization Assets

Sync Localization Assets #1

name: Sync Localization Assets
on:
workflow_dispatch:
push:
branches:
- main
paths:
- translations/manifest.json
permissions:
contents: read
concurrency:
group: localization-assets-cos
cancel-in-progress: true
env:
ASSET_RELEASE_TAG: localization-assets
COS_BUCKET: rmtool-localization-1254761827
COS_REGION: ap-shanghai
COS_PUBLIC_BASE_URL: https://rmtool-localization-1254761827.cos.ap-shanghai.myqcloud.com
jobs:
sync:
name: Verify and sync to Tencent COS
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Download fixed localization release
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p "$RUNNER_TEMP/localization-release"
gh release download "$ASSET_RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--dir "$RUNNER_TEMP/localization-release" \
--pattern manifest.json \
--pattern "*.qm"
- name: Verify release manifest and payloads
env:
ASSET_LIST_PATH: ${{ runner.temp }}/localization-assets.json
RELEASE_DIR: ${{ runner.temp }}/localization-release
run: |
python - <<'PY'
import hashlib
import json
import os
import re
from pathlib import Path
repository_manifest = Path("translations/manifest.json").read_bytes()
release_dir = Path(os.environ["RELEASE_DIR"])
release_manifest = (release_dir / "manifest.json").read_bytes()
if release_manifest != repository_manifest:
raise SystemExit(
"The localization-assets release manifest does not match "
"translations/manifest.json byte-for-byte."
)
document = json.loads(repository_manifest.decode("utf-8"))
firmwares = document.get("firmwares")
if (
not isinstance(document, dict)
or document.get("schema") != 1
or not isinstance(firmwares, dict)
or not firmwares
):
raise SystemExit("The localization manifest has no firmware entries.")
firmware_re = re.compile(r"^[0-9]{14}$")
sha256_re = re.compile(r"^[0-9a-f]{64}$")
asset_re = re.compile(r"^[A-Za-z0-9._-]+\.qm$")
release_re = re.compile(r"^[A-Za-z0-9._-]{1,32}$")
platform_re = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$")
max_payload_bytes = 16 * 1024 * 1024
assets = {}
for firmware_version, firmware in firmwares.items():
if (
not isinstance(firmware_version, str)
or not firmware_re.fullmatch(firmware_version)
or not isinstance(firmware, dict)
):
raise SystemExit("Invalid firmware entry in localization manifest.")
variants = firmware.get("variants", [])
if not isinstance(variants, list):
raise SystemExit("Invalid hardware variants in localization manifest.")
packages = [(firmware, False), *((item, True) for item in variants)]
platforms = []
stock_digests = []
localized_digests = set()
for package, require_platform in packages:
if not isinstance(package, dict):
raise SystemExit("Invalid package entry in localization manifest.")
name = package.get("asset")
size = package.get("size")
digest = package.get("sha256")
stock_digest = package.get("stock_french_sha256")
release_version = package.get("release_version")
channel = package.get("channel")
platform = package.get("platform", "")
if (
not isinstance(name, str)
or not asset_re.fullmatch(name)
or type(size) is not int
or size <= 0
or size > max_payload_bytes
or not isinstance(digest, str)
or not sha256_re.fullmatch(digest)
or not isinstance(stock_digest, str)
or not sha256_re.fullmatch(stock_digest)
or not isinstance(release_version, str)
or not release_re.fullmatch(release_version)
or channel not in ("stable", "beta")
or not isinstance(platform, str)
or (platform and not platform_re.fullmatch(platform))
or (require_platform and not platform)
):
raise SystemExit(f"Invalid localization asset metadata: {name!r}")
metadata = (size, digest)
if name in assets and assets[name] != metadata:
raise SystemExit(
f"Conflicting size or SHA-256 metadata for {name}."
)
assets[name] = metadata
platforms.append(platform.casefold())
stock_digests.append(stock_digest)
localized_digests.add(digest)
if variants and not firmware.get("platform"):
raise SystemExit("A variant manifest requires a base platform.")
if len(platforms) != len(set(platforms)):
raise SystemExit("Duplicate hardware platform in firmware entry.")
if len(stock_digests) != len(set(stock_digests)):
raise SystemExit("Duplicate stock carrier digest in firmware entry.")
if any(item in localized_digests for item in stock_digests):
raise SystemExit("Stock and localized digests conflict.")
for name, (expected_size, expected_digest) in sorted(assets.items()):
path = release_dir / name
payload = path.read_bytes()
actual_digest = hashlib.sha256(payload).hexdigest()
if len(payload) != expected_size or actual_digest != expected_digest:
raise SystemExit(f"Release asset validation failed for {name}.")
Path(os.environ["ASSET_LIST_PATH"]).write_text(
json.dumps(sorted(assets), ensure_ascii=True),
encoding="utf-8",
)
print(f"Validated {len(assets)} unique localization payloads.")
PY
- name: Install Tencent COS SDK
run: |
python -m pip --isolated install \
--disable-pip-version-check \
--no-input \
"cos-python-sdk-v5==1.9.44"
- name: Upload payloads, then manifest
env:
ASSET_LIST_PATH: ${{ runner.temp }}/localization-assets.json
RELEASE_DIR: ${{ runner.temp }}/localization-release
TENCENT_CLOUD_SECRET_ID: ${{ secrets.TENCENT_CLOUD_SECRET_ID }}
TENCENT_CLOUD_SECRET_KEY: ${{ secrets.TENCENT_CLOUD_SECRET_KEY }}
run: |
python - <<'PY'
import json
import os
from pathlib import Path
from qcloud_cos import CosConfig, CosS3Client
secret_id = os.environ.get("TENCENT_CLOUD_SECRET_ID")
secret_key = os.environ.get("TENCENT_CLOUD_SECRET_KEY")
if not secret_id or not secret_key:
raise SystemExit("Tencent Cloud GitHub Secrets are not configured.")
release_dir = Path(os.environ["RELEASE_DIR"])
assets = json.loads(Path(os.environ["ASSET_LIST_PATH"]).read_text())
client = CosS3Client(
CosConfig(
Region=os.environ["COS_REGION"],
SecretId=secret_id,
SecretKey=secret_key,
Scheme="https",
)
)
for name in assets:
client.put_object(
Bucket=os.environ["COS_BUCKET"],
Key=name,
Body=(release_dir / name).read_bytes(),
ContentType="application/octet-stream",
)
print(f"Uploaded payload: {name}")
client.put_object(
Bucket=os.environ["COS_BUCKET"],
Key="manifest.json",
Body=(release_dir / "manifest.json").read_bytes(),
ContentType="application/json",
)
print("Uploaded manifest.json last.")
PY
- name: Verify public COS objects
env:
ASSET_LIST_PATH: ${{ runner.temp }}/localization-assets.json
RELEASE_DIR: ${{ runner.temp }}/localization-release
run: |
python - <<'PY'
import hashlib
import json
import os
import time
import urllib.parse
import urllib.request
from pathlib import Path
release_dir = Path(os.environ["RELEASE_DIR"])
names = json.loads(Path(os.environ["ASSET_LIST_PATH"]).read_text())
names.append("manifest.json")
cache_buster = (
f"{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}"
)
for name in names:
expected = (release_dir / name).read_bytes()
expected_digest = hashlib.sha256(expected).hexdigest()
url = (
f"{os.environ['COS_PUBLIC_BASE_URL']}/"
f"{urllib.parse.quote(name, safe='')}?verify={cache_buster}"
)
last_error = None
for attempt in range(5):
try:
with urllib.request.urlopen(url, timeout=30) as response:
actual = response.read()
if (
len(actual) == len(expected)
and hashlib.sha256(actual).hexdigest() == expected_digest
):
print(f"Verified public object: {name}")
break
last_error = RuntimeError("size or SHA-256 mismatch")
except Exception as exc:
last_error = exc
if attempt < 4:
time.sleep(2)
else:
raise SystemExit(
f"Public COS verification failed for {name}: {last_error}"
)
PY