diff --git a/.github/workflows/mobile-app-release.yml b/.github/workflows/mobile-app-release.yml index cd0c2df..c760558 100644 --- a/.github/workflows/mobile-app-release.yml +++ b/.github/workflows/mobile-app-release.yml @@ -155,7 +155,9 @@ jobs: path: ${{ steps.stage_apk.outputs.apk_path }} - name: Ensure GitHub Release exists - if: ${{ github.event_name == 'push' || github.event.inputs.upload_to_release != 'false' }} + # Tagged APKs have one publisher: android-apk.yml. This combined + # workflow may still publish Android when explicitly dispatched. + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.upload_to_release != 'false' }} env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name || 'v0.1.78' }} @@ -172,7 +174,7 @@ jobs: fi - name: Upload APK to GitHub Release - if: ${{ github.event_name == 'push' || github.event.inputs.upload_to_release != 'false' }} + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.upload_to_release != 'false' }} env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name || 'v0.1.78' }} diff --git a/mobile_agent/tooling/test_verify_ios_app.py b/mobile_agent/tooling/test_verify_ios_app.py index 1563bce..f53d5e0 100644 --- a/mobile_agent/tooling/test_verify_ios_app.py +++ b/mobile_agent/tooling/test_verify_ios_app.py @@ -5,18 +5,22 @@ import unittest from pathlib import Path -from verify_ios_app import verify +from verify_ios_app import _read_version, verify class VerifyIosAppTest(unittest.TestCase): + def setUp(self) -> None: + self.semantic, self.build = _read_version() + self.expected_tag = f'v{self.semantic}' + def _app(self, directory: Path) -> Path: app = directory / 'Runner.app' app.mkdir() (app / 'Info.plist').write_bytes( plistlib.dumps( { - 'CFBundleShortVersionString': '0.1.73', - 'CFBundleVersion': '63', + 'CFBundleShortVersionString': self.semantic, + 'CFBundleVersion': self.build, 'NSMicrophoneUsageDescription': 'Microphone prompt', 'NSSpeechRecognitionUsageDescription': 'Speech prompt', } @@ -31,7 +35,7 @@ def test_valid_bundle_and_clean_log_pass(self) -> None: log = directory / 'runner.log' log.write_text('Runner launched normally.', encoding='utf-8') - verify(app, 'v0.1.73', log) + verify(app, self.expected_tag, log) def test_nested_usage_description_does_not_pass(self) -> None: with tempfile.TemporaryDirectory() as directory_name: @@ -45,7 +49,7 @@ def test_nested_usage_description_does_not_pass(self) -> None: (app / 'Info.plist').write_bytes(plistlib.dumps(document)) with self.assertRaisesRegex(ValueError, 'top-level'): - verify(app, 'v0.1.73', None) + verify(app, self.expected_tag, None) def test_privacy_crash_signature_fails(self) -> None: with tempfile.TemporaryDirectory() as directory_name: @@ -59,7 +63,7 @@ def test_privacy_crash_signature_fails(self) -> None: ) with self.assertRaisesRegex(ValueError, 'crash signature'): - verify(app, 'v0.1.73', log) + verify(app, self.expected_tag, log) if __name__ == '__main__': diff --git a/scripts/test_verify_public_release_workflows.py b/scripts/test_verify_public_release_workflows.py index 776d59d..78984fe 100644 --- a/scripts/test_verify_public_release_workflows.py +++ b/scripts/test_verify_public_release_workflows.py @@ -7,7 +7,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) -from verify_public_release_workflows import find_violations, verify +from verify_public_release_workflows import ( + ANDROID_WORKFLOW, + COMBINED_WORKFLOW, + find_apk_publisher_violations, + find_violations, + verify, +) class PublicReleaseWorkflowPolicyTest(unittest.TestCase): @@ -50,6 +56,37 @@ def test_missing_workflow_fails_closed(self) -> None: self.assertEqual(["missing.yml: required public release workflow is missing"], violations) + def test_tagged_apk_has_one_release_publisher(self) -> None: + contents = { + ANDROID_WORKFLOW: "- name: Upload APK to GitHub Release\n run: gh release upload", + COMBINED_WORKFLOW: "\n".join( + ( + " - name: Upload APK to GitHub Release", + " if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.upload_to_release != 'false' }}", + " run: gh release upload", + ) + ), + } + + self.assertEqual([], find_apk_publisher_violations(contents)) + + def test_combined_tagged_apk_publisher_is_rejected(self) -> None: + contents = { + ANDROID_WORKFLOW: "- name: Upload APK to GitHub Release\n run: gh release upload", + COMBINED_WORKFLOW: "\n".join( + ( + " - name: Upload APK to GitHub Release", + " if: ${{ github.event_name == 'push' || github.event.inputs.upload_to_release != 'false' }}", + " run: gh release upload", + ) + ), + } + + violations = find_apk_publisher_violations(contents) + + self.assertEqual(1, len(violations)) + self.assertIn("manual-only", violations[0]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify_public_release_workflows.py b/scripts/verify_public_release_workflows.py index c48ae1f..5943203 100644 --- a/scripts/verify_public_release_workflows.py +++ b/scripts/verify_public_release_workflows.py @@ -23,6 +23,14 @@ "--dart-define-from-file", ) +ANDROID_WORKFLOW = Path(".github/workflows/android-apk.yml") +COMBINED_WORKFLOW = Path(".github/workflows/mobile-app-release.yml") +APK_UPLOAD_STEP = "- name: Upload APK to GitHub Release" +MANUAL_ONLY_UPLOAD_CONDITION = ( + "if: ${{ github.event_name == 'workflow_dispatch' && " + "github.event.inputs.upload_to_release != 'false' }}" +) + def find_violations(path: Path, content: str) -> list[str]: violations: list[str] = [] @@ -34,14 +42,41 @@ def find_violations(path: Path, content: str) -> list[str]: return violations +def find_apk_publisher_violations(contents: dict[Path, str]) -> list[str]: + android = contents.get(ANDROID_WORKFLOW) + combined = contents.get(COMBINED_WORKFLOW) + if android is None or combined is None: + return [] + + violations: list[str] = [] + if APK_UPLOAD_STEP not in android: + violations.append( + f"{ANDROID_WORKFLOW}: dedicated tagged APK publisher is missing" + ) + + upload_start = combined.find(APK_UPLOAD_STEP) + upload_end = combined.find("\n - name:", upload_start + 1) + upload_block = combined[upload_start:upload_end if upload_end >= 0 else None] + if upload_start < 0 or MANUAL_ONLY_UPLOAD_CONDITION not in upload_block: + violations.append( + f"{COMBINED_WORKFLOW}: APK Release upload must be manual-only; " + f"tagged APKs are published by {ANDROID_WORKFLOW}" + ) + return violations + + def verify(root: Path, workflow_paths: tuple[Path, ...]) -> list[str]: violations: list[str] = [] + contents: dict[Path, str] = {} for relative_path in workflow_paths: path = root / relative_path if not path.is_file(): violations.append(f"{relative_path}: required public release workflow is missing") continue - violations.extend(find_violations(relative_path, path.read_text(encoding="utf-8"))) + content = path.read_text(encoding="utf-8") + contents[relative_path] = content + violations.extend(find_violations(relative_path, content)) + violations.extend(find_apk_publisher_violations(contents)) return violations