From 9b156af27432c71c1ab31f6fa03673f7362a99de Mon Sep 17 00:00:00 2001 From: abhishekmadan30 Date: Thu, 25 Jun 2026 17:49:45 -0400 Subject: [PATCH 1/8] feat!: add support to publish to huawei store --- pushapkscript/docker.d/init_worker.sh | 6 ++ pushapkscript/docker.d/worker.yml | 4 ++ pushapkscript/examples/config.example.json | 3 + pushapkscript/pyproject.toml | 1 + .../src/pushapkscript/data/config_schema.json | 12 ++++ pushapkscript/src/pushapkscript/publish.py | 3 +- .../src/pushapkscript/publish_config.py | 20 ++++-- .../integration/test_integration_script.py | 41 +++++++++++ pushapkscript/tests/test_config.py | 2 + pushapkscript/tests/test_publish.py | 2 + pushapkscript/tests/test_publish_config.py | 68 +++++++++++++++++++ 11 files changed, 157 insertions(+), 5 deletions(-) diff --git a/pushapkscript/docker.d/init_worker.sh b/pushapkscript/docker.d/init_worker.sh index 441bccaaf..ef5535402 100644 --- a/pushapkscript/docker.d/init_worker.sh +++ b/pushapkscript/docker.d/init_worker.sh @@ -73,6 +73,8 @@ case $COT_PRODUCT in export GOOGLE_CREDENTIALS_FOCUS_DEP_PATH=$CONFIG_DIR/fake_cert.json export SGS_SERVICE_ACCOUNT_ID_DEP="0123456" export SGS_ACCESS_TOKEN_DEP="dummy" + echo '{"key_id": "dummy", "sub_account": "dummy", "private_key": "dummy"}' > $CONFIG_DIR/huawei_dep.json + export HUAWEI_CREDENTIALS_DEP_PATH=$CONFIG_DIR/huawei_dep.json import_cert fenix $CERT_DIR/fenix_dep.pem import_cert focus $CERT_DIR/focus_dep.pem @@ -85,6 +87,7 @@ case $COT_PRODUCT in test_var_set 'GOOGLE_SERVICE_ACCOUNT_FENIX_RELEASE' test_var_set 'SGS_SERVICE_ACCOUNT_ID' test_var_set 'SGS_ACCESS_TOKEN' + test_var_set 'HUAWEI_SERVICE_ACCOUNT' export GOOGLE_CREDENTIALS_FOCUS_PATH=$CONFIG_DIR/focus.json export GOOGLE_CREDENTIALS_FENIX_NIGHTLY_PATH=$CONFIG_DIR/fenix_nightly.json @@ -96,6 +99,9 @@ case $COT_PRODUCT in echo $GOOGLE_SERVICE_ACCOUNT_FENIX_BETA | base64 -d > $GOOGLE_CREDENTIALS_FENIX_BETA_PATH echo $GOOGLE_SERVICE_ACCOUNT_FENIX_RELEASE | base64 -d > $GOOGLE_CREDENTIALS_FENIX_RELEASE_PATH + export HUAWEI_CREDENTIALS_PATH=$CONFIG_DIR/huawei.json + echo $HUAWEI_SERVICE_ACCOUNT | base64 -d > $HUAWEI_CREDENTIALS_PATH + import_cert fenix-nightly $CERT_DIR/fenix_nightly.pem import_cert fenix-beta $CERT_DIR/fenix_beta.pem import_cert fenix-release $CERT_DIR/fenix_release.pem diff --git a/pushapkscript/docker.d/worker.yml b/pushapkscript/docker.d/worker.yml index d89b72646..eba3cce40 100644 --- a/pushapkscript/docker.d/worker.yml +++ b/pushapkscript/docker.d/worker.yml @@ -51,6 +51,8 @@ products: samsung: service_account_id: { "$eval": "SGS_SERVICE_ACCOUNT_ID" } access_token: { "$eval": "SGS_ACCESS_TOKEN" } + huawei: + credentials_file: { "$eval": "HUAWEI_CREDENTIALS_PATH" } - product_names: ["focus-android" ] digest_algorithm: 'SHA1' skip_check_ordered_version_codes: true @@ -113,6 +115,8 @@ products: samsung: service_account_id: { "$eval": "SGS_SERVICE_ACCOUNT_ID_DEP" } access_token: { "$eval": "SGS_ACCESS_TOKEN_DEP" } + huawei: + credentials_file: { "$eval": "HUAWEI_CREDENTIALS_DEP_PATH" } - product_names: ["focus-android" ] digest_algorithm: "SHA1" skip_check_ordered_version_codes: true diff --git a/pushapkscript/examples/config.example.json b/pushapkscript/examples/config.example.json index e131a3c8a..917097cf8 100644 --- a/pushapkscript/examples/config.example.json +++ b/pushapkscript/examples/config.example.json @@ -68,6 +68,9 @@ "samsung": { "sgs_service_account_id": "0123456", "sgs_access_token": "abcdef" + }, + "huawei": { + "credentials_file": "/huawei.json" } } } diff --git a/pushapkscript/pyproject.toml b/pushapkscript/pyproject.toml index 8e9f2576d..f7c6a0085 100644 --- a/pushapkscript/pyproject.toml +++ b/pushapkscript/pyproject.toml @@ -13,6 +13,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", ] dependencies = [ + # TODO: bump to >=12.0.0 once mozapkpublisher 12.0.0 (huawei store) is on PyPI "mozapkpublisher", "scriptworker", ] diff --git a/pushapkscript/src/pushapkscript/data/config_schema.json b/pushapkscript/src/pushapkscript/data/config_schema.json index 381f909aa..2aa914624 100644 --- a/pushapkscript/src/pushapkscript/data/config_schema.json +++ b/pushapkscript/src/pushapkscript/data/config_schema.json @@ -141,6 +141,18 @@ "type": "string" } } + }, + "huawei": { + "type": "object", + "required": [ + "credentials_file" + ], + "additionalProperties": false, + "properties": { + "credentials_file": { + "type": "string" + } + } } } } diff --git a/pushapkscript/src/pushapkscript/publish.py b/pushapkscript/src/pushapkscript/publish.py index 34c9ca2b3..8d9da44a7 100644 --- a/pushapkscript/src/pushapkscript/publish.py +++ b/pushapkscript/src/pushapkscript/publish.py @@ -25,7 +25,8 @@ async def publish(product_config, publish_config, apk_files, contact_server): skip_checks_fennec=bool(product_config.get("skip_checks_fennec")), sgs_service_account_id=publish_config.get("sgs_service_account_id"), sgs_access_token=publish_config.get("sgs_access_token"), - # Note that this only has an effect on SGS submissions, not google play. + huawei_credentials=publish_config.get("huawei_credentials"), + # Note that this only has an effect on SGS and Huawei submissions, not google play. submit=publish_config.get("submit", False), ) diff --git a/pushapkscript/src/pushapkscript/publish_config.py b/pushapkscript/src/pushapkscript/publish_config.py index 91eea87f4..b913b7748 100644 --- a/pushapkscript/src/pushapkscript/publish_config.py +++ b/pushapkscript/src/pushapkscript/publish_config.py @@ -2,6 +2,18 @@ log = logging.getLogger(__name__) +# Non-Google stores produce the same publish-config shape and differ only in which +# credential keys they expose. Maps target_store -> {output_key: store_config_key}. +_NON_GOOGLE_STORE_CREDENTIALS = { + "samsung": { + "sgs_service_account_id": "service_account_id", + "sgs_access_token": "access_token", + }, + "huawei": { + "huawei_credentials": "credentials_file", + }, +} + def _should_do_dry_run(task): # Don't commit anything by default. Committed APKs can't be unpublished, @@ -70,19 +82,19 @@ def _get_channel_publish_config(product_config, task): store_config = publish_config[target_store] rollout_percentage = task.get("rollout_percentage") - if target_store == "samsung": + if target_store in _NON_GOOGLE_STORE_CREDENTIALS: if task.get("google_play_track"): - raise ValueError("`google_play_track` is not allowed on the task if the target store is samsung") + raise ValueError(f"`google_play_track` is not allowed on the task if the target store is {target_store}") + credentials = {output_key: store_config[config_key] for output_key, config_key in _NON_GOOGLE_STORE_CREDENTIALS[target_store].items()} return { "target_store": target_store, "dry_run": _should_do_dry_run(task), "certificate_alias": publish_config.get("certificate_alias"), "package_names": publish_config["package_names"], "rollout_percentage": rollout_percentage, - "sgs_service_account_id": store_config["service_account_id"], - "sgs_access_token": store_config["access_token"], "submit": task.get("submit", False), + **credentials, } google_track = task.get("google_play_track", store_config["default_track"]) diff --git a/pushapkscript/tests/integration/test_integration_script.py b/pushapkscript/tests/integration/test_integration_script.py index 97de3bd63..55d577678 100644 --- a/pushapkscript/tests/integration/test_integration_script.py +++ b/pushapkscript/tests/integration/test_integration_script.py @@ -206,6 +206,9 @@ def generate_fenix_config(self): "service_account_id": "123", "access_token": "456", }, + "huawei": { + "credentials_file": "huawei.json", + }, }, }, } @@ -287,6 +290,7 @@ def test_main_fennec_style(self, push_apk): skip_checks_fennec=False, sgs_service_account_id=None, sgs_access_token=None, + huawei_credentials=None, submit=False, ) @@ -316,6 +320,7 @@ def test_main_focus_style(self, push_apk): skip_checks_fennec=True, sgs_service_account_id=None, sgs_access_token=None, + huawei_credentials=None, submit=False, ) @@ -345,6 +350,7 @@ def test_main_fenix_style(self, push_apk): skip_checks_fennec=True, sgs_service_account_id=None, sgs_access_token=None, + huawei_credentials=None, submit=False, ) @@ -374,6 +380,7 @@ def test_main_downloads_verifies_signature_and_gives_the_right_config_to_mozapkp skip_checks_fennec=False, sgs_service_account_id=None, sgs_access_token=None, + huawei_credentials=None, submit=False, ) @@ -403,6 +410,7 @@ def test_main_allows_rollout_percentage(self, push_apk): skip_checks_fennec=False, sgs_service_account_id=None, sgs_access_token=None, + huawei_credentials=None, submit=False, ) @@ -433,6 +441,7 @@ def test_main_allows_commit_transaction(self, push_apk): skip_checks_fennec=False, sgs_service_account_id=None, sgs_access_token=None, + huawei_credentials=None, submit=False, ) @@ -463,6 +472,38 @@ def test_main_with_samsung_store(self, push_apk): skip_checks_fennec=True, sgs_service_account_id="123", sgs_access_token="456", + huawei_credentials=None, + submit=False, + ) + + @unittest.mock.patch("pushapkscript.publish.push_apk") + def test_main_with_huawei_store(self, push_apk): + task_generator = TaskGenerator(should_commit_transaction=True, store="huawei") + + self.write_task_file(task_generator.generate_task("fenix", channel="release")) + + self._prepare_apks(task_generator, "fenix-production") + main(config_path=self.config_generator.generate_fenix_config()) + + push_apk.assert_called_with( + apks=[ + MockFile("{}/work/cot/{}/public/build/target.apk".format(self.test_temp_dir, task_generator.arm_task_id)), + MockFile("{}/work/cot/{}/public/build/target.apk".format(self.test_temp_dir, task_generator.x86_task_id)), + ], + secret=None, + track=None, + expected_package_names=["org.mozilla.fenix"], + store="huawei", + rollout_percentage=None, + dry_run=False, + contact_server=True, + skip_check_multiple_locales=True, + skip_check_ordered_version_codes=False, + skip_check_same_locales=True, + skip_checks_fennec=True, + sgs_service_account_id=None, + sgs_access_token=None, + huawei_credentials="huawei.json", submit=False, ) diff --git a/pushapkscript/tests/test_config.py b/pushapkscript/tests/test_config.py index ccb11b610..ad4ac8ed1 100644 --- a/pushapkscript/tests/test_config.py +++ b/pushapkscript/tests/test_config.py @@ -67,6 +67,7 @@ def test_firefox_fake_prod(): "GOOGLE_CREDENTIALS_FOCUS_DEP_PATH": "focus", "SGS_SERVICE_ACCOUNT_ID_DEP": "123456", "SGS_ACCESS_TOKEN_DEP": "abcdef", + "HUAWEI_CREDENTIALS_DEP_PATH": "huawei", } _validate_config(context) @@ -81,5 +82,6 @@ def test_firefox_prod(): "GOOGLE_CREDENTIALS_FOCUS_PATH": "focus", "SGS_SERVICE_ACCOUNT_ID": "123456", "SGS_ACCESS_TOKEN": "abcdef", + "HUAWEI_CREDENTIALS_PATH": "huawei", } _validate_config(context) diff --git a/pushapkscript/tests/test_publish.py b/pushapkscript/tests/test_publish.py index 8b0227bef..47ffafdc9 100644 --- a/pushapkscript/tests/test_publish.py +++ b/pushapkscript/tests/test_publish.py @@ -43,6 +43,8 @@ async def test_publish_config(self, mock_push_aab, mock_push_apk): skip_checks_fennec=False, sgs_service_account_id=None, sgs_access_token=None, + huawei_credentials=None, + submit=False, ) async def test_publish_aab_config(self, mock_push_aab, mock_push_apk): diff --git a/pushapkscript/tests/test_publish_config.py b/pushapkscript/tests/test_publish_config.py index 8255d3f54..53d694754 100644 --- a/pushapkscript/tests/test_publish_config.py +++ b/pushapkscript/tests/test_publish_config.py @@ -1,3 +1,5 @@ +import pytest + from pushapkscript.publish_config import _should_do_dry_run, get_publish_config AURORA_CONFIG = { @@ -28,6 +30,7 @@ "certificate_alias": "fenix", "google": {"default_track": "internal", "credentials_file": "fenix.json"}, "samsung": {"service_account_id": "123456", "access_token": "abcdef"}, + "huawei": {"credentials_file": "huawei.json"}, } } } @@ -204,14 +207,79 @@ def test_target_samsung_submit(): } +def test_target_huawei(): + payload = {"channel": "production", "target_store": "huawei"} + + assert get_publish_config(FENIX_CONFIG, payload, "fenix") == { + "target_store": "huawei", + "dry_run": True, + "certificate_alias": "fenix", + "huawei_credentials": "huawei.json", + "package_names": ["org.mozilla.fenix"], + "rollout_percentage": None, + "submit": False, + } + + +def test_target_huawei_with_commit(): + payload = {"channel": "production", "target_store": "huawei", "commit": True} + + assert get_publish_config(FENIX_CONFIG, payload, "fenix") == { + "target_store": "huawei", + "dry_run": False, + "certificate_alias": "fenix", + "huawei_credentials": "huawei.json", + "package_names": ["org.mozilla.fenix"], + "rollout_percentage": None, + "submit": False, + } + + +def test_target_huawei_rollout(): + payload = {"channel": "production", "target_store": "huawei", "rollout_percentage": 50} + + assert get_publish_config(FENIX_CONFIG, payload, "fenix") == { + "target_store": "huawei", + "dry_run": True, + "certificate_alias": "fenix", + "huawei_credentials": "huawei.json", + "package_names": ["org.mozilla.fenix"], + "rollout_percentage": 50, + "submit": False, + } + + +def test_target_huawei_submit(): + payload = {"channel": "production", "target_store": "huawei", "submit": True} + + assert get_publish_config(FENIX_CONFIG, payload, "fenix") == { + "target_store": "huawei", + "dry_run": True, + "certificate_alias": "fenix", + "huawei_credentials": "huawei.json", + "package_names": ["org.mozilla.fenix"], + "rollout_percentage": None, + "submit": True, + } + + +def test_target_huawei_rejects_google_play_track(): + payload = {"channel": "production", "target_store": "huawei", "google_play_track": "production"} + + with pytest.raises(ValueError, match="`google_play_track` is not allowed"): + get_publish_config(FENIX_CONFIG, payload, "fenix") + + def test_certificate_alias_does_not_depend_on_the_target_store(): # The alias identifies the certificate the incoming artifact was signed with, which is # decided by the upstream signing task, so it is the same whichever store it goes to. google = get_publish_config(FENIX_CONFIG, {"channel": "production", "target_store": "google"}, "fenix") samsung = get_publish_config(FENIX_CONFIG, {"channel": "production", "target_store": "samsung"}, "fenix") + huawei = get_publish_config(FENIX_CONFIG, {"channel": "production", "target_store": "huawei"}, "fenix") assert google["certificate_alias"] == "fenix" assert samsung["certificate_alias"] == "fenix" + assert huawei["certificate_alias"] == "fenix" def test_certificate_alias_is_none_when_nothing_configures_it(): From 920343451fda52cfc18ac54098e04f4a7fc29306 Mon Sep 17 00:00:00 2001 From: Heitor Neiva Date: Fri, 4 Sep 2026 10:12:10 -0700 Subject: [PATCH 2/8] fix(pushapkscript)!: require mozapkpublisher>=12.0.0 publish() passes `huawei_credentials` to push_apk() unconditionally, and that argument only exists as of mozapkpublisher 12.0.0. The dependency was left unpinned pending that release, so the lockfile still resolved 11.0.2 - under which every publication, google included, would have raised TypeError. The tests mock push_apk, so none of them caught it. 12.0.0 is on PyPI now, so pin it and drop the TODO. It also pulls in pyjwt[crypto], which the AppGallery client needs to sign its PS256 JWTs. --- pushapkscript/pyproject.toml | 3 +-- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pushapkscript/pyproject.toml b/pushapkscript/pyproject.toml index f7c6a0085..d81d32302 100644 --- a/pushapkscript/pyproject.toml +++ b/pushapkscript/pyproject.toml @@ -13,8 +13,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", ] dependencies = [ - # TODO: bump to >=12.0.0 once mozapkpublisher 12.0.0 (huawei store) is on PyPI - "mozapkpublisher", + "mozapkpublisher>=12.0.0", "scriptworker", ] diff --git a/uv.lock b/uv.lock index c6c6ab57c..d58db9e82 100644 --- a/uv.lock +++ b/uv.lock @@ -2520,7 +2520,7 @@ wheels = [ [[package]] name = "mozapkpublisher" -version = "11.0.2" +version = "12.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -2528,12 +2528,12 @@ dependencies = [ { name = "google-auth" }, { name = "mozilla-version" }, { name = "pyaxmlparser" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/01e47ead7dc9ddc8f0f827fd0f4f6b97502b27e3595780bec995321bae42/mozapkpublisher-11.0.2.tar.gz", hash = "sha256:9de539b6d348ace432a4f20bb9db06daf4820ff812700fdda14ce6e6088edcd6", size = 151855, upload-time = "2026-07-16T14:45:01.459Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/b7/ae15b448f826cc4d66b21540d04e4621a0b163379ac0100adbe18ffe05df/mozapkpublisher-12.0.0.tar.gz", hash = "sha256:20d8a117b40653a517bf994156966ac5aa3bc62e1ed938dbd96b9dd22a337bf8", size = 185839, upload-time = "2026-09-04T16:05:49.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/82/0b1e9c14de8dfeb3d68819496598d1bbfcfc32c075ab841eda2c61f1911d/mozapkpublisher-11.0.2-py3-none-any.whl", hash = "sha256:5f0b69af145ea21baabf07b588735568586bbe3e71d91d531a66522a26594409", size = 64369, upload-time = "2026-07-16T14:44:59.887Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d5/be4f2477047cfea4a719ba1934f6ab5d8c9b9ae5fb9bd2f11079cb731793/mozapkpublisher-12.0.0-py3-none-any.whl", hash = "sha256:b3a1f35760e0ebf22000d9f0d767649b952c0dfc0852f02b7f5e6c4111040f14", size = 94188, upload-time = "2026-09-04T16:05:48.161Z" }, ] [[package]] @@ -3038,7 +3038,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mozapkpublisher" }, + { name = "mozapkpublisher", specifier = ">=12.0.0" }, { name = "scriptworker" }, ] From d50d1d63d94f6152d0bf17689965074614b28c5d Mon Sep 17 00:00:00 2001 From: Heitor Neiva Date: Fri, 4 Sep 2026 10:10:09 -0700 Subject: [PATCH 3/8] fix: set HUAWEI_SERVICE_ACCOUNT in the init script test context The huawei store support added `test_var_set 'HUAWEI_SERVICE_ACCOUNT'` to pushapkscript's prod init_worker.sh, but not the matching entry to the cloudops-infra variables the init script test simulates, so test_init_script[pushapk-firefox-prod] exited 1 instead of 0. --- tests/test_init.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_init.py b/tests/test_init.py index 3e85c236d..fa780cc84 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -73,6 +73,7 @@ "GOOGLE_SERVICE_ACCOUNT_REFERENCE_BROWSER": "Zm9vYmFyCg==", "SGS_SERVICE_ACCOUNT_ID": "Zm9vYmFyCg==", "SGS_ACCESS_TOKEN": "Zm9vYmFyCg==", + "HUAWEI_SERVICE_ACCOUNT": "Zm9vYmFyCg==", }, re.compile(r"pushflatpak:.*"): { "FLATHUB_URL": "https://flathub.example.com", From eb4c130f7e02bf70d45b7c9d566372b7e66b66e8 Mon Sep 17 00:00:00 2001 From: Heitor Neiva Date: Fri, 4 Sep 2026 10:10:21 -0700 Subject: [PATCH 4/8] fix(pushapkscript): warn before publishing to non-Google stores _log_warning_forewords only had a message for `target_store == "google"`, so a samsung or huawei publication logged nothing at all - not even the "this action is irreversible" warning that precedes a committed upload. Drive the messages off a store name table instead, so every store the script can target gets one. This also fixes two typos in the Google wording: a missing space after "irreversible," and the double negative in "no change will not be committed". --- pushapkscript/src/pushapkscript/script.py | 26 ++++++++++++++--------- pushapkscript/tests/test_script.py | 21 +++++++++++++++--- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/pushapkscript/src/pushapkscript/script.py b/pushapkscript/src/pushapkscript/script.py index dd6d0a9a5..755fd47be 100755 --- a/pushapkscript/src/pushapkscript/script.py +++ b/pushapkscript/src/pushapkscript/script.py @@ -15,6 +15,14 @@ log = logging.getLogger(__name__) +# Human readable names for the stores mozapkpublisher can target, used in the warnings +# logged before a publication. +STORE_NAMES = { + "google": "Google Play", + "samsung": "the Samsung Galaxy Store", + "huawei": "the Huawei AppGallery", +} + async def async_main(context): android_product = task.extract_android_product_from_scopes(context) @@ -82,17 +90,15 @@ def _get_product_config(context, android_product): def _log_warning_forewords(contact_server, dry_run, target_store): - if contact_server: - if target_store == "google": - if not dry_run: - log.warning( - "You will publish APKs to Google Play. This action is irreversible,\ -if no error is detected either by this script or by Google Play." - ) - else: - log.warning("APKs will be submitted, but no change will not be committed.") + store_name = STORE_NAMES.get(target_store, target_store) + if not contact_server: + log.warning("This pushapk instance is not allowed to talk to {}. *All* requests will be mocked.".format(store_name)) + elif dry_run: + log.warning("APKs will be submitted to {}, but no change will be committed.".format(store_name)) else: - log.warning("This pushapk instance is not allowed to talk to Google Play. *All* requests will be mocked.") + log.warning( + "You will publish APKs to {}. This action is irreversible, if no error is detected either by this script or by {}.".format(store_name, store_name) + ) def get_default_config(): diff --git a/pushapkscript/tests/test_script.py b/pushapkscript/tests/test_script.py index 06e8f5922..2af9ad708 100644 --- a/pushapkscript/tests/test_script.py +++ b/pushapkscript/tests/test_script.py @@ -153,12 +153,27 @@ def test_get_product_config(): True, False, "google", - "You will publish APKs to Google Play. This action is irreversible,\ -if no error is detected either by this script or by Google Play.", + "You will publish APKs to Google Play. This action is irreversible, if no error is detected either by this script or by Google Play.", ), - (True, True, "google", "APKs will be submitted, but no change will not be committed."), + (True, True, "google", "APKs will be submitted to Google Play, but no change will be committed."), (False, True, "google", "This pushapk instance is not allowed to talk to Google Play. *All* requests will be mocked."), (False, False, "google", "This pushapk instance is not allowed to talk to Google Play. *All* requests will be mocked."), + ( + True, + False, + "samsung", + "You will publish APKs to the Samsung Galaxy Store. This action is irreversible, " + "if no error is detected either by this script or by the Samsung Galaxy Store.", + ), + ( + True, + False, + "huawei", + "You will publish APKs to the Huawei AppGallery. This action is irreversible, " + "if no error is detected either by this script or by the Huawei AppGallery.", + ), + (True, True, "huawei", "APKs will be submitted to the Huawei AppGallery, but no change will be committed."), + (False, False, "huawei", "This pushapk instance is not allowed to talk to the Huawei AppGallery. *All* requests will be mocked."), ), ) def test_log_warning_forewords(caplog, monkeypatch, is_allowed_to_push, dry_run, target_store, expected): From 1cfb4b6459e40659c641e6ed4bf3776acff38070 Mon Sep 17 00:00:00 2001 From: Heitor Neiva Date: Fri, 4 Sep 2026 10:10:31 -0700 Subject: [PATCH 5/8] fix(pushapkscript): correct the samsung keys in the config example The example's samsung block used `sgs_service_account_id`/`sgs_access_token` - the names publish_config emits for mozapkpublisher - rather than the `service_account_id`/`access_token` the config schema requires. Since the samsung block sets `additionalProperties: false`, the example did not validate against config_schema.json; it does now. --- pushapkscript/examples/config.example.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pushapkscript/examples/config.example.json b/pushapkscript/examples/config.example.json index 917097cf8..0d69b2e29 100644 --- a/pushapkscript/examples/config.example.json +++ b/pushapkscript/examples/config.example.json @@ -66,8 +66,8 @@ "credentials_file": "/fenix-production.json" }, "samsung": { - "sgs_service_account_id": "0123456", - "sgs_access_token": "abcdef" + "service_account_id": "0123456", + "access_token": "abcdef" }, "huawei": { "credentials_file": "/huawei.json" From 2405555dc1f4a3050e9da82c3e539c295dbf79c5 Mon Sep 17 00:00:00 2001 From: Heitor Neiva Date: Fri, 4 Sep 2026 10:10:32 -0700 Subject: [PATCH 6/8] test(pushapkscript): collect the publish tests `PublishTest` matched neither pytest's `Test*` class pattern nor unittest.TestCase, so tests/test_publish.py collected zero tests and had been silently dead. Rename it and swap the unused `setUp` for `setup_method`, which completes the refactor the file's TODO asked for. Two problems surfaced once the tests ran: a missing `await` on a publish_aab call, and no coverage of the huawei arguments publish() now passes through. Both are fixed here. --- pushapkscript/tests/test_publish.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/pushapkscript/tests/test_publish.py b/pushapkscript/tests/test_publish.py index 47ffafdc9..2a57af449 100644 --- a/pushapkscript/tests/test_publish.py +++ b/pushapkscript/tests/test_publish.py @@ -1,20 +1,17 @@ -import unittest from unittest.mock import patch import pytest -import asyncio from pushapkscript.publish import publish, publish_aab from .helpers.mock_file import MockFile, mock_open -# TODO: refactor to pytest instead of unittest @patch("pushapkscript.publish.open", new=mock_open) @patch("pushapkscript.publish.push_apk") @patch("pushapkscript.publish.push_aab") @pytest.mark.asyncio -class PublishTest: - def setUp(self): +class TestPublish: + def setup_method(self): self.publish_config = { "target_store": "google", "dry_run": True, @@ -47,6 +44,23 @@ async def test_publish_config(self, mock_push_aab, mock_push_apk): submit=False, ) + async def test_publish_huawei_config(self, mock_push_aab, mock_push_apk): + publish_config = { + "target_store": "huawei", + "dry_run": False, + "package_names": ["org.mozilla.fenix"], + "huawei_credentials": "/huawei.json", + "submit": True, + } + await publish({}, publish_config, self.apks, contact_server=True) + + _, args = mock_push_apk.call_args + assert args["store"] == "huawei" + assert args["huawei_credentials"] == "/huawei.json" + assert args["submit"] is True + # Huawei uses its own credentials file, so the Google Play secret is unset. + assert args["secret"] is None + async def test_publish_aab_config(self, mock_push_aab, mock_push_apk): await publish_aab({}, self.publish_config, self.aabs, contact_server=True) @@ -100,7 +114,7 @@ async def test_craft_push_aab_config_allows_to_contact_google_play_or_not(self, _, args = mock_push_aab.call_args assert args["contact_server"] is True - publish_aab({}, self.publish_config, self.aabs, False) + await publish_aab({}, self.publish_config, self.aabs, False) _, args = mock_push_aab.call_args assert args["contact_server"] is False From f191dd71fc5155e487e77d302bf4016b45299c20 Mon Sep 17 00:00:00 2001 From: Heitor Neiva Date: Fri, 4 Sep 2026 15:15:24 -0700 Subject: [PATCH 7/8] fix(pushapkscript): stop claiming non-Google dry runs upload anything Generalising these warnings across stores reused wording that is only true of Google Play. Google uploads inside an edit transaction it then declines to commit, and mocks the API when the instance may not contact the server - so "will be submitted, but no change will be committed" and "*All* requests will be mocked" describe it accurately. The Samsung and Huawei clients have no transaction to leave uncommitted. mozapkpublisher collapses dry_run and contact_server into one flag for them and returns from upload_apks before uploading anything, so both messages promised an upload that never happens. Give those stores their own message, naming which of the two reasons applies. Adds the missing parametrized cases: every branch for each store, plus an unrecognised store to pin the STORE_NAMES fallback. --- pushapkscript/src/pushapkscript/script.py | 20 +++++++++++++++----- pushapkscript/tests/test_script.py | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/pushapkscript/src/pushapkscript/script.py b/pushapkscript/src/pushapkscript/script.py index 755fd47be..44faef5a3 100755 --- a/pushapkscript/src/pushapkscript/script.py +++ b/pushapkscript/src/pushapkscript/script.py @@ -91,14 +91,24 @@ def _get_product_config(context, android_product): def _log_warning_forewords(contact_server, dry_run, target_store): store_name = STORE_NAMES.get(target_store, target_store) - if not contact_server: - log.warning("This pushapk instance is not allowed to talk to {}. *All* requests will be mocked.".format(store_name)) - elif dry_run: - log.warning("APKs will be submitted to {}, but no change will be committed.".format(store_name)) - else: + if contact_server and not dry_run: log.warning( "You will publish APKs to {}. This action is irreversible, if no error is detected either by this script or by {}.".format(store_name, store_name) ) + elif target_store == "google": + # Google Play uploads inside an edit transaction it then declines to commit, and + # mocks the API outright when this instance may not contact the server. + if contact_server: + log.warning("APKs will be submitted to {}, but no change will be committed.".format(store_name)) + else: + log.warning("This pushapk instance is not allowed to talk to {}. *All* requests will be mocked.".format(store_name)) + else: + # The other stores have no transaction to leave uncommitted, so mozapkpublisher + # skips the upload rather than performing or mocking it. + if contact_server: + log.warning("Nothing will be uploaded to {}, since this is a dry run.".format(store_name)) + else: + log.warning("Nothing will be uploaded to {}, since this pushapk instance is not allowed to talk to it.".format(store_name)) def get_default_config(): diff --git a/pushapkscript/tests/test_script.py b/pushapkscript/tests/test_script.py index 2af9ad708..a384142b4 100644 --- a/pushapkscript/tests/test_script.py +++ b/pushapkscript/tests/test_script.py @@ -165,6 +165,7 @@ def test_get_product_config(): "You will publish APKs to the Samsung Galaxy Store. This action is irreversible, " "if no error is detected either by this script or by the Samsung Galaxy Store.", ), + (True, True, "samsung", "Nothing will be uploaded to the Samsung Galaxy Store, since this is a dry run."), ( True, False, @@ -172,8 +173,22 @@ def test_get_product_config(): "You will publish APKs to the Huawei AppGallery. This action is irreversible, " "if no error is detected either by this script or by the Huawei AppGallery.", ), - (True, True, "huawei", "APKs will be submitted to the Huawei AppGallery, but no change will be committed."), - (False, False, "huawei", "This pushapk instance is not allowed to talk to the Huawei AppGallery. *All* requests will be mocked."), + # Unlike Google Play, these stores upload nothing at all when they aren't committing. + (True, True, "huawei", "Nothing will be uploaded to the Huawei AppGallery, since this is a dry run."), + ( + False, + True, + "huawei", + "Nothing will be uploaded to the Huawei AppGallery, since this pushapk instance is not allowed to talk to it.", + ), + ( + False, + False, + "huawei", + "Nothing will be uploaded to the Huawei AppGallery, since this pushapk instance is not allowed to talk to it.", + ), + # An unrecognised store falls back to its raw payload value rather than crashing. + (True, True, "amazon", "Nothing will be uploaded to amazon, since this is a dry run."), ), ) def test_log_warning_forewords(caplog, monkeypatch, is_allowed_to_push, dry_run, target_store, expected): From 20f3b1fc0f7a8f76471510f27815149632202549 Mon Sep 17 00:00:00 2001 From: Heitor Neiva Date: Fri, 4 Sep 2026 15:15:37 -0700 Subject: [PATCH 8/8] test(pushapkscript): bind the mozapkpublisher mocks to its real signature All ten push_apk/push_aab patches were bare mocks, which accept any keyword whatsoever, so nothing checked that publish.py's call matches the library it is pinned against. That is not hypothetical. Shadowing the venv with mozapkpublisher 11.0.2 - the version uv.lock actually resolved when huawei support landed, and whose push_apk has no `huawei_credentials` parameter - the suite reports 109 passed, while a real publish() call raises TypeError. Every publication, Google Play included, was broken and fully green. autospec=True turns each existing assert_called_with into a signature check: the same shadowed run now fails 16 tests, naming the unexpected keyword. --- .../tests/integration/test_integration_script.py | 16 ++++++++-------- pushapkscript/tests/test_publish.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pushapkscript/tests/integration/test_integration_script.py b/pushapkscript/tests/integration/test_integration_script.py index 55d577678..0c2ae33b8 100644 --- a/pushapkscript/tests/integration/test_integration_script.py +++ b/pushapkscript/tests/integration/test_integration_script.py @@ -264,7 +264,7 @@ def write_task_file(self, task): with open(task_file, "w") as f: json.dump(task, f) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_fennec_style(self, push_apk): task_generator = TaskGenerator() self.write_task_file(task_generator.generate_task("aurora")) @@ -294,7 +294,7 @@ def test_main_fennec_style(self, push_apk): submit=False, ) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_focus_style(self, push_apk): task_generator = TaskGenerator() self.write_task_file(task_generator.generate_task("focus", "production")) @@ -324,7 +324,7 @@ def test_main_focus_style(self, push_apk): submit=False, ) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_fenix_style(self, push_apk): task_generator = TaskGenerator() self.write_task_file(task_generator.generate_task("fenix", "nightly")) @@ -354,7 +354,7 @@ def test_main_fenix_style(self, push_apk): submit=False, ) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_downloads_verifies_signature_and_gives_the_right_config_to_mozapkpublisher(self, push_apk): task_generator = TaskGenerator() self.write_task_file(task_generator.generate_task("aurora")) @@ -384,7 +384,7 @@ def test_main_downloads_verifies_signature_and_gives_the_right_config_to_mozapkp submit=False, ) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_allows_rollout_percentage(self, push_apk): task_generator = TaskGenerator(rollout_percentage=25) self.write_task_file(task_generator.generate_task("aurora")) @@ -414,7 +414,7 @@ def test_main_allows_rollout_percentage(self, push_apk): submit=False, ) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_allows_commit_transaction(self, push_apk): task_generator = TaskGenerator(should_commit_transaction=True) @@ -445,7 +445,7 @@ def test_main_allows_commit_transaction(self, push_apk): submit=False, ) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_with_samsung_store(self, push_apk): task_generator = TaskGenerator(should_commit_transaction=True, store="samsung") @@ -476,7 +476,7 @@ def test_main_with_samsung_store(self, push_apk): submit=False, ) - @unittest.mock.patch("pushapkscript.publish.push_apk") + @unittest.mock.patch("pushapkscript.publish.push_apk", autospec=True) def test_main_with_huawei_store(self, push_apk): task_generator = TaskGenerator(should_commit_transaction=True, store="huawei") diff --git a/pushapkscript/tests/test_publish.py b/pushapkscript/tests/test_publish.py index 2a57af449..76c86d3e0 100644 --- a/pushapkscript/tests/test_publish.py +++ b/pushapkscript/tests/test_publish.py @@ -7,8 +7,8 @@ @patch("pushapkscript.publish.open", new=mock_open) -@patch("pushapkscript.publish.push_apk") -@patch("pushapkscript.publish.push_aab") +@patch("pushapkscript.publish.push_apk", autospec=True) +@patch("pushapkscript.publish.push_aab", autospec=True) @pytest.mark.asyncio class TestPublish: def setup_method(self):