From a8622d61c90f8771cde2ba79a40ed82f5562e07f Mon Sep 17 00:00:00 2001 From: Yevhen Boichuk Date: Mon, 5 Oct 2026 14:26:29 +0300 Subject: [PATCH] add: add update tool for update keiraOS under OS --- .github/workflows/firmware.yml | 37 ++++- src/apps/keiraupdate/keiraupdate.cpp | 230 +++++++++++++++++++++++++++ src/apps/keiraupdate/keiraupdate.h | 39 +++++ src/apps/launcher/launcher.cpp | 2 + src/keira/localizations/lang_en.h | 14 ++ src/keira/localizations/lang_uk.h | 14 ++ tools/gen_firmware_json.py | 158 ++++++++++++++++++ 7 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 src/apps/keiraupdate/keiraupdate.cpp create mode 100644 src/apps/keiraupdate/keiraupdate.h create mode 100644 tools/gen_firmware_json.py diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 196b5df0..fd84681b 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -40,23 +40,29 @@ jobs: - name: Extract version and generate Keira header id: version + env: + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} run: | if [[ "${GITHUB_REF}" == refs/tags/* ]]; then TAG="${GITHUB_REF#refs/tags/}" VERSION="${TAG#v}" + PRERELEASE="false" IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" # Use prerelease info from event payload if available - if [[ "${GITHUB_EVENT_NAME}" == "release" && "${GITHUB_EVENT_RELEASE_PRERELEASE}" == "true" ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "release" && "${RELEASE_PRERELEASE}" == "true" ]]; then VERSION_TYPE="KEIRA_VERSION_TYPE_PRE_RELEASE" + PRERELEASE="true" else VERSION_TYPE="KEIRA_VERSION_TYPE_RELEASE" fi else + TAG="" MAJOR=0 MINOR=0 PATCH=0 VERSION="0.0.0" + PRERELEASE="false" VERSION_TYPE="KEIRA_VERSION_TYPE_DEV" fi @@ -76,6 +82,11 @@ jobs: echo "Generated version:" cat src/keira/keira_version_auto_gen.h + echo "TAG=$TAG" >> "$GITHUB_OUTPUT" + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "VERSION_TYPE=$VERSION_TYPE" >> "$GITHUB_OUTPUT" + echo "PRERELEASE=$PRERELEASE" >> "$GITHUB_OUTPUT" + - name: Build and package firmware run: | mkdir -p out @@ -108,17 +119,37 @@ jobs: 0x10000 out/keira.bin fi + # Manifest used by Keira OTA updater, available at: + # https://github.com/lilka-dev/keira/releases/latest/download/keira_firmware.json + - name: Generate firmware manifest + if: startsWith(github.ref, 'refs/tags/') + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python tools/gen_firmware_json.py \ + --repo "${{ github.repository }}" \ + --tag "${{ steps.version.outputs.TAG }}" \ + --prerelease "${{ steps.version.outputs.PRERELEASE }}" \ + --bin-dir out \ + --history 5 \ + --output out/keira_firmware.json + cat out/keira_firmware.json + - name: Upload firmware artifacts uses: actions/upload-artifact@v4 with: name: KeiraOS-firmware - path: out/*.bin + path: | + out/*.bin + out/*.json - name: Upload KeiraOS bins to Release uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: - files: out/*.bin + files: | + out/*.bin + out/keira_firmware.json github-releases-to-discord: needs: keira diff --git a/src/apps/keiraupdate/keiraupdate.cpp b/src/apps/keiraupdate/keiraupdate.cpp new file mode 100644 index 00000000..50637a6e --- /dev/null +++ b/src/apps/keiraupdate/keiraupdate.cpp @@ -0,0 +1,230 @@ +#include "keiraupdate.h" +#include "keira/keira.h" +#include "keira/ksystem.h" +#include "keira/keira_version_auto_gen.h" +#include "keira/utils/mem.h" +#include "keira/utils/string.h" +#include "services/network/network.h" + +#include +#include +#include + +// Arduino WiFiClientSecure ships its own esp_crt_bundle.h which shadows esp-idf one, +// so declare esp-idf certificate bundle hook directly +extern "C" esp_err_t esp_crt_bundle_attach(void* conf); + +KeiraUpdateApp::KeiraUpdateApp() : App("KeiraUpdate") { + setktStackSize(16384); // TLS handshake is stack hungry +} + +String KeiraUpdateApp::currentLanguage() { + String lang = K_S_CURRENT_LANGUAGE_SHORT; + lang.toUpperCase(); + return "LANG_" + lang; +} + +String KeiraUpdateApp::currentVersion() { + return StringFormat("%d.%d.%d", KEIRA_VERSION_MAJOR, KEIRA_VERSION_MINOR, KEIRA_VERSION_PATCH); +} + +void KeiraUpdateApp::run() { + NetworkService* networkService = static_cast(ksystem.services["network"]); + if (networkService->getnetworkState() != NETWORK_STATE_ONLINE) { + alert(K_S_ERROR, K_S_LAUNCHER_ENABLE_WIFI_FIRST); + return; + } + + lilka::ProgressDialog dialog(K_S_KEIRA_UPDATE, K_S_KEIRA_UPDATE_CHECKING); + dialog.draw(canvas); + queueDraw(); + + String json; + if (!fetchManifest(json)) { + alert(K_S_ERROR, K_S_KEIRA_UPDATE_FETCH_FAILED); + return; + } + if (!parseManifest(json)) { + alert(K_S_ERROR, K_S_KEIRA_UPDATE_PARSE_FAILED); + return; + } + if (versions.empty()) { + alert(K_S_KEIRA_UPDATE, K_S_KEIRA_UPDATE_NO_VERSIONS); + return; + } + + const String current = currentVersion(); + lilka::Menu menu(K_S_KEIRA_UPDATE); + menu.addActivationButton(K_BTN_BACK); + for (size_t i = 0; i < versions.size(); i++) { + const FirmwareVersion& fw = versions[i]; + String postfix; + if (fw.version == current) postfix = K_S_KEIRA_UPDATE_CURRENT; + else if (i == 0) postfix = K_S_KEIRA_UPDATE_LATEST; + else if (fw.prerelease) postfix = K_S_KEIRA_UPDATE_PRERELEASE; + menu.addItem("v" + fw.version, nullptr, lilka::colors::White, postfix); + } + menu.addItem(K_S_MENU_BACK); + + while (1) { + while (!menu.isFinished()) { + menu.update(); + menu.draw(canvas); + queueDraw(); + } + if (menu.getButton() == K_BTN_BACK) return; + int16_t index = menu.getCursor(); + if (index < 0 || index >= static_cast(versions.size())) return; + + const FirmwareVersion& fw = versions[index]; + String description = StringFormat( + K_S_KEIRA_UPDATE_CONFIRM_FMT, + fw.version.c_str(), + current.c_str(), + fw.language.c_str(), + lilka::fileutils.getHumanFriendlySize(fw.size).c_str(), + fw.date.substring(0, 10).c_str() + ); + if (confirm(K_S_KEIRA_UPDATE, description)) { + install(fw); + } + } +} + +static esp_err_t manifestHttpEvent(esp_http_client_event_t* evt) { + // Skip bodies of redirect responses, only final response is a manifest + if (evt->event_id == HTTP_EVENT_ON_DATA && esp_http_client_get_status_code(evt->client) == 200) { + String* out = static_cast(evt->user_data); + if (out->length() + evt->data_len > KEIRA_UPDATE_MANIFEST_MAX) return ESP_FAIL; + out->concat(static_cast(evt->data), evt->data_len); + } + return ESP_OK; +} + +bool KeiraUpdateApp::fetchManifest(String& out) { + out = ""; + esp_http_client_config_t config = {}; + config.url = KEIRA_UPDATE_MANIFEST_URL; + config.crt_bundle_attach = esp_crt_bundle_attach; + config.timeout_ms = KEIRA_UPDATE_HTTP_TIMEOUT; + config.buffer_size = KEIRA_UPDATE_RX_BUFFER; + config.buffer_size_tx = KEIRA_UPDATE_TX_BUFFER; + config.event_handler = manifestHttpEvent; + config.user_data = &out; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return false; + esp_err_t err = esp_http_client_perform(client); + int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (err != ESP_OK || status != 200) { + lilka::serial.err("Keira update: manifest fetch failed, err=%s, status=%d", esp_err_to_name(err), status); + return false; + } + return !out.isEmpty(); +} + +// Missing keys are converted to "null" by as(), use empty string instead +static String jsonStr(JsonVariantConst value) { + const char* str = value.as(); + return str ? String(str) : String(); +} + +bool KeiraUpdateApp::parseManifest(const String& json) { + JsonDocument doc(&spiRamAllocator); + DeserializationError error = deserializeJson(doc, json); + if (error) { + lilka::serial.err("Keira update: manifest parse failed: %s", error.c_str()); + return false; + } + + const String lang = currentLanguage(); + versions.clear(); + for (JsonObject entry : doc["versions"].as()) { + JsonObject firmware = entry["firmware"].as(); + if (firmware.isNull() || firmware.size() == 0) continue; + + FirmwareVersion fw; + fw.version = jsonStr(entry["version"]); + fw.tag = jsonStr(entry["tag"]); + fw.prerelease = entry["prerelease"].as(); + fw.date = jsonStr(entry["date"]); + + // Prefer firmware built for the current language + JsonObject bin = firmware[lang].as(); + if (!bin.isNull()) { + fw.language = lang; + } else { + JsonPair first = *firmware.begin(); + fw.language = first.key().c_str(); + bin = first.value().as(); + } + fw.url = jsonStr(bin["url"]); + fw.size = bin["size"].as(); + + if (fw.version.isEmpty() || fw.url.isEmpty()) continue; + versions.push_back(fw); + } + return true; +} + +void KeiraUpdateApp::install(const FirmwareVersion& fw) { + lilka::ProgressDialog dialog(K_S_KEIRA_UPDATE, StringFormat(K_S_KEIRA_UPDATE_DOWNLOADING_FMT, fw.version.c_str())); + dialog.draw(canvas); + queueDraw(); + + esp_http_client_config_t httpConfig = {}; + httpConfig.url = fw.url.c_str(); + httpConfig.crt_bundle_attach = esp_crt_bundle_attach; + httpConfig.timeout_ms = KEIRA_UPDATE_HTTP_TIMEOUT; + httpConfig.buffer_size = KEIRA_UPDATE_RX_BUFFER; + httpConfig.buffer_size_tx = KEIRA_UPDATE_TX_BUFFER; + httpConfig.keep_alive_enable = true; + + esp_https_ota_config_t otaConfig = {}; + otaConfig.http_config = &httpConfig; + + esp_https_ota_handle_t handle = nullptr; + esp_err_t err = esp_https_ota_begin(&otaConfig, &handle); + if (err != ESP_OK) { + alert(K_S_ERROR, StringFormat(K_S_KEIRA_UPDATE_ERROR_FMT, esp_err_to_name(err))); + return; + } + + int total = esp_https_ota_get_image_size(handle); + if (total <= 0) total = fw.size; + int lastProgress = -1; + + while ((err = esp_https_ota_perform(handle)) == ESP_ERR_HTTPS_OTA_IN_PROGRESS) { + if (lilka::controller.getState().b.justPressed) { + esp_https_ota_abort(handle); + return; + } + if (total > 0) { + int progress = esp_https_ota_get_image_len_read(handle) * 100LL / total; + if (progress != lastProgress) { + lastProgress = progress; + dialog.setProgress(progress); + dialog.draw(canvas); + queueDraw(); + } + } + } + + if (err != ESP_OK || !esp_https_ota_is_complete_data_received(handle)) { + esp_https_ota_abort(handle); + alert(K_S_ERROR, StringFormat(K_S_KEIRA_UPDATE_ERROR_FMT, esp_err_to_name(err))); + return; + } + + // Validates image and switches boot partition, KeiraSystem::verifyOTA() approves it on next boot + err = esp_https_ota_finish(handle); + if (err != ESP_OK) { + alert(K_S_ERROR, StringFormat(K_S_KEIRA_UPDATE_ERROR_FMT, esp_err_to_name(err))); + return; + } + + alert(K_S_KEIRA_UPDATE, K_S_KEIRA_UPDATE_SUCCESS); + esp_restart(); +} diff --git a/src/apps/keiraupdate/keiraupdate.h b/src/apps/keiraupdate/keiraupdate.h new file mode 100644 index 00000000..6659179e --- /dev/null +++ b/src/apps/keiraupdate/keiraupdate.h @@ -0,0 +1,39 @@ +#pragma once +#include "keira/app.h" +#include + +// Manifest generated by firmware workflow (tools/gen_firmware_json.py) +#define KEIRA_UPDATE_MANIFEST_URL "https://github.com/lilka-dev/keira/releases/latest/download/keira_firmware.json" + +#define KEIRA_UPDATE_HTTP_TIMEOUT 15000 +#define KEIRA_UPDATE_RX_BUFFER 4096 +// GitHub redirects release downloads to long signed URLs +#define KEIRA_UPDATE_TX_BUFFER 2048 +#define KEIRA_UPDATE_MANIFEST_MAX (64 * 1024) + +// Updates Keira itself over the air using firmware published in GitHub releases +class KeiraUpdateApp : public App { +public: + KeiraUpdateApp(); + +private: + struct FirmwareVersion { + String version; + String tag; + bool prerelease = false; + String date; + String url; + String language; + uint32_t size = 0; + }; + + void run() override; + bool fetchManifest(String& out); + bool parseManifest(const String& json); + void install(const FirmwareVersion& fw); + + static String currentLanguage(); + static String currentVersion(); + + std::vector versions; +}; diff --git a/src/apps/launcher/launcher.cpp b/src/apps/launcher/launcher.cpp index eadedeb7..02ec6ecf 100644 --- a/src/apps/launcher/launcher.cpp +++ b/src/apps/launcher/launcher.cpp @@ -48,6 +48,7 @@ #include "apps/usbdrive/usbdrive.h" #include "apps/soundsettings/sound.h" #include "apps/partmanager/partmanager.h" +#include "apps/keiraupdate/keiraupdate.h" // Icons #include "apps/icons/demos.h" @@ -476,6 +477,7 @@ void LauncherApp::run() { ITEM::MENU(K_S_LAUNCHER_DEVICE_INFO, [this]() { this->info(); }), } ), + ITEM::MENU(K_S_LAUNCHER_KEIRA_UPDATE, [this]() { this->runApp(); }), ITEM::MENU(K_S_LAUNCHER_FACTORY_RESET, [this]() { this->factoryReset(); }), ITEM::MENU(K_S_LAUNCHER_REBOOT, []() { esp_restart(); }), }, diff --git a/src/keira/localizations/lang_en.h b/src/keira/localizations/lang_en.h index 1112bb22..7171e91f 100644 --- a/src/keira/localizations/lang_en.h +++ b/src/keira/localizations/lang_en.h @@ -571,5 +571,19 @@ #define K_S_PART_MGR_INTERRUPT_CONFIRM "Do you want to interrupt that operation?" #define K_S_PART_MGR_SELECTED_ENTRIES_FMT "Selected %d entries" +// Keira update ////////////////////////////////////////////////////////////////////////////////////// +#define K_S_LAUNCHER_KEIRA_UPDATE "Update Keira" +#define K_S_KEIRA_UPDATE "Keira update" +#define K_S_KEIRA_UPDATE_CHECKING "Checking for updates..." +#define K_S_KEIRA_UPDATE_FETCH_FAILED "Failed to fetch update info" +#define K_S_KEIRA_UPDATE_PARSE_FAILED "Invalid update info" +#define K_S_KEIRA_UPDATE_NO_VERSIONS "No firmware available" +#define K_S_KEIRA_UPDATE_CURRENT "current" +#define K_S_KEIRA_UPDATE_LATEST "latest" +#define K_S_KEIRA_UPDATE_PRERELEASE "pre" +#define K_S_KEIRA_UPDATE_CONFIRM_FMT "Install v%s?\n\nCurrent: v%s\nLanguage: %s\nSize: %s\nDate: %s\n\nSTART - continue\nB - exit" +#define K_S_KEIRA_UPDATE_DOWNLOADING_FMT "Installing v%s...\n\nB - cancel" +#define K_S_KEIRA_UPDATE_ERROR_FMT "Update failed\n\n%s" +#define K_S_KEIRA_UPDATE_SUCCESS "Update installed!\n\nSystem will reboot." /////////////////////////////////////////////////////////////////////////////////////////////////////// // clang-format on diff --git a/src/keira/localizations/lang_uk.h b/src/keira/localizations/lang_uk.h index a7f0bfc7..a8b5bb37 100644 --- a/src/keira/localizations/lang_uk.h +++ b/src/keira/localizations/lang_uk.h @@ -563,5 +563,19 @@ #define K_S_PART_MGR_INTERRUPT_CONFIRM "Чи бажаєте ви перервати операцію?" #define K_S_PART_MGR_SELECTED_ENTRIES_FMT "Обрано %d розділ(ів)" +// Keira update ////////////////////////////////////////////////////////////////////////////////////// +#define K_S_LAUNCHER_KEIRA_UPDATE "Оновити Keira" +#define K_S_KEIRA_UPDATE "Оновлення Keira" +#define K_S_KEIRA_UPDATE_CHECKING "Перевірка оновлень..." +#define K_S_KEIRA_UPDATE_FETCH_FAILED "Не вдалося отримати інформацію про оновлення" +#define K_S_KEIRA_UPDATE_PARSE_FAILED "Некоректна інформація про оновлення" +#define K_S_KEIRA_UPDATE_NO_VERSIONS "Немає доступних прошивок" +#define K_S_KEIRA_UPDATE_CURRENT "поточна" +#define K_S_KEIRA_UPDATE_LATEST "остання" +#define K_S_KEIRA_UPDATE_PRERELEASE "пре" +#define K_S_KEIRA_UPDATE_CONFIRM_FMT "Встановити v%s?\n\nПоточна: v%s\nМова: %s\nРозмір: %s\nДата: %s\n\nSTART - продовжити\nB - скасувати" +#define K_S_KEIRA_UPDATE_DOWNLOADING_FMT "Встановлення v%s...\n\nB - скасувати" +#define K_S_KEIRA_UPDATE_ERROR_FMT "Помилка оновлення\n\n%s" +#define K_S_KEIRA_UPDATE_SUCCESS "Оновлення встановлено!\n\nСистему буде перезавантажено." /////////////////////////////////////////////////////////////////////////////////////////////////////// // clang-format on diff --git a/tools/gen_firmware_json.py b/tools/gen_firmware_json.py new file mode 100644 index 00000000..4c57f6e3 --- /dev/null +++ b/tools/gen_firmware_json.py @@ -0,0 +1,158 @@ +#!/usr/bin/python +################################################################################## +# # +## Generates keira_firmware.json manifest for GitHub releases. ## +## Used by firmware workflow and by Keira OTA updater on device. ## +# # +################################################################################## +# +# Manifest layout: +# { +# "name": "KeiraOS", +# "repo": "lilka-dev/keira", +# ...fields of current version entry..., +# "versions": [ , ] +# } +# +# Version entry: +# { +# "version": "2.6.12", "tag": "v2.6.12", "prerelease": false, "date": "...", +# "languages": ["LANG_UK", "LANG_EN"], +# "firmware": {"LANG_UK": {"url": "...", "size": 123, "sha256": "..."}, ...}, +# "merged": {"LANG_UK": {"url": "...", "size": 123, "sha256": "..."}, ...} +# } +# +# Info about previous versions is taken from their own keira_firmware.json if it +# exists, otherwise it is generated from release assets list. + +import argparse, hashlib, json, os, re, sys, urllib.request +from datetime import datetime, timezone +from pathlib import Path + +MANIFEST_NAME = "keira_firmware.json" +FIRMWARE_RE = re.compile(r"^keira_(LANG_[A-Z]+)\.bin$") +MERGED_RE = re.compile(r"^KeiraOS_merged_(LANG_[A-Z]+)\.bin$") + + +def api_get(url, token): + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github+json") + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def download_url(repo, tag, name): + return f"https://github.com/{repo}/releases/download/{tag}/{name}" + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def make_entry(repo, tag, prerelease, date, files): + # files: list of (name, size, sha256 or None) + entry = { + "version": tag.lstrip("v"), + "tag": tag, + "prerelease": prerelease, + "date": date, + "languages": [], + "firmware": {}, + "merged": {}, + } + for name, size, sha in files: + for regex, key in ((FIRMWARE_RE, "firmware"), (MERGED_RE, "merged")): + m = regex.match(name) + if not m: + continue + info = {"url": download_url(repo, tag, name), "size": size} + if sha: + info["sha256"] = sha + entry[key][m.group(1)] = info + entry["languages"] = sorted(entry["firmware"].keys()) + return entry + + +def previous_entries(repo, current_tag, count, token): + releases = api_get(f"https://api.github.com/repos/{repo}/releases?per_page=30", token) + releases = [r for r in releases if not r["draft"] and r["tag_name"] != current_tag] + releases.sort(key=lambda r: r.get("published_at") or "", reverse=True) + + entries = [] + for rel in releases: + if len(entries) >= count: + break + tag = rel["tag_name"] + assets = rel.get("assets", []) + + entry = None + manifest = next((a for a in assets if a["name"] == MANIFEST_NAME), None) + if manifest: + try: + with urllib.request.urlopen(manifest["browser_download_url"], timeout=30) as resp: + data = json.loads(resp.read().decode("utf-8")) + entry = {k: v for k, v in data.items() if k not in ("versions", "name", "repo")} + print(f"{tag}: using existing {MANIFEST_NAME}") + except Exception as e: # noqa: BLE001 + print(f"{tag}: failed to read {MANIFEST_NAME} ({e}), generating from assets") + + if entry is None: + files = [] + for a in assets: + digest = a.get("digest") or "" + sha = digest[len("sha256:"):] if digest.startswith("sha256:") else None + files.append((a["name"], a["size"], sha)) + entry = make_entry(repo, tag, rel["prerelease"], rel.get("published_at"), files) + print(f"{tag}: generated from release assets") + + if not entry.get("firmware"): + print(f"{tag}: no firmware binaries found, skipping") + continue + entries.append(entry) + return entries + + +def main(): + parser = argparse.ArgumentParser(description=f"Generate {MANIFEST_NAME}") + parser.add_argument("--repo", required=True, help="owner/name") + parser.add_argument("--tag", required=True, help="current release tag, e.g. v2.6.12") + parser.add_argument("--prerelease", default="false") + parser.add_argument("--bin-dir", default="out", help="directory with built binaries") + parser.add_argument("--history", type=int, default=5, help="number of previous versions to include") + parser.add_argument("--output", default=f"out/{MANIFEST_NAME}") + args = parser.parse_args() + + token = os.environ.get("GITHUB_TOKEN", "") + prerelease = args.prerelease.lower() == "true" + date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + files = [(p.name, p.stat().st_size, sha256_file(p)) for p in sorted(Path(args.bin_dir).glob("*.bin"))] + current = make_entry(args.repo, args.tag, prerelease, date, files) + if not current["firmware"]: + print(f"Error: no keira_LANG_*.bin found in {args.bin_dir}") + return 1 + + try: + history = previous_entries(args.repo, args.tag, args.history, token) + except Exception as e: # noqa: BLE001 + # Manifest for the current version is still useful without history + print(f"Warning: failed to fetch previous releases: {e}") + history = [] + + manifest = {"name": "KeiraOS", "repo": args.repo, **current, "versions": [current] + history} + + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + print(f"Written {args.output} with {len(manifest['versions'])} version(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())