mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
ci: add coordinated release pipeline
This commit is contained in:
54
packaging/android/README.md
Normal file
54
packaging/android/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Android release pipeline
|
||||
|
||||
Android releases use two independent credentials:
|
||||
|
||||
- the upload keystore signs the APK and AAB;
|
||||
- a short-lived Google access token publishes the AAB through the Play
|
||||
Developer API.
|
||||
|
||||
The GitHub release workflow expects these encrypted secrets:
|
||||
|
||||
- `ANDROID_UPLOAD_KEYSTORE_BASE64`
|
||||
- `ANDROID_UPLOAD_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_UPLOAD_KEY_ALIAS`
|
||||
- `ANDROID_UPLOAD_KEY_PASSWORD`
|
||||
|
||||
It also expects this repository variable:
|
||||
|
||||
- `ANDROID_UPLOAD_CERT_SHA256`
|
||||
|
||||
The protected `play-closed-testing` GitHub Environment supplies:
|
||||
|
||||
- `PLAY_APP_SIGNING_CERT_SHA256`
|
||||
- `GCP_WORKLOAD_IDENTITY_PROVIDER`
|
||||
- `GCP_PLAY_SERVICE_ACCOUNT`
|
||||
- `PLAY_PACKAGE_NAME` (`com.vnidrop.app`)
|
||||
- `PLAY_CLOSED_TRACK` (the existing closed-test track identifier)
|
||||
|
||||
The upload and app-signing certificate fingerprints are public identifiers from
|
||||
Play Console's App signing page. Do not store a private key in a repository
|
||||
variable.
|
||||
|
||||
`packaging/android/build-release.sh` creates an upload-signed AAB and APK,
|
||||
verifies their canonical version and upload certificate, and writes checksums.
|
||||
The release workflow uploads only the AAB to Play. It then downloads the
|
||||
universal APK generated and signed by Play for the public GitHub Release.
|
||||
|
||||
Play publishing is deliberately restricted to `draft` releases on
|
||||
`PLAY_CLOSED_TRACK`. Production promotion is not part of this pipeline.
|
||||
|
||||
## One-time setup
|
||||
|
||||
1. In Play Console, link a Google Cloud project and grant the deployment
|
||||
service account permission to manage releases for VniDrop.
|
||||
2. In Google Cloud, enable the Google Play Android Developer API and configure
|
||||
a Workload Identity Federation provider that trusts this repository's
|
||||
GitHub Actions identity. Permit the service account to receive federated
|
||||
tokens from that provider.
|
||||
3. Create the `play-closed-testing` GitHub Environment. Add the five variables
|
||||
listed above and restrict deployment branches/tags to the release policy.
|
||||
4. Add the four upload-keystore secrets and
|
||||
`ANDROID_UPLOAD_CERT_SHA256` in the repository settings.
|
||||
|
||||
No Google service-account JSON key is stored in GitHub. The workflow exchanges
|
||||
GitHub's OIDC identity for a short-lived Google access token.
|
||||
171
packaging/android/build-release.sh
Executable file
171
packaging/android/build-release.sh
Executable file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
resolver="$repo_root/packaging/version/resolve-version.sh"
|
||||
output_dir="$repo_root/build/release/android"
|
||||
required_apk_libraries=(
|
||||
"lib/arm64-v8a/libvnidrop.so"
|
||||
"lib/x86_64/libvnidrop.so"
|
||||
)
|
||||
required_aab_libraries=(
|
||||
"base/lib/arm64-v8a/libvnidrop.so"
|
||||
"base/lib/x86_64/libvnidrop.so"
|
||||
)
|
||||
|
||||
require_environment() {
|
||||
local name=$1
|
||||
[[ -n ${!name:-} ]] || {
|
||||
printf 'Missing required environment variable: %s\n' "$name" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
normalize_fingerprint() {
|
||||
printf '%s' "$1" | tr -d '[:space:]:' | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
verify_archive_entries() {
|
||||
local archive=$1
|
||||
shift
|
||||
local entry
|
||||
local size
|
||||
for entry in "$@"; do
|
||||
size="$(
|
||||
unzip -l "$archive" "$entry" |
|
||||
awk -v expected="$entry" '$4 == expected {print $1; exit}'
|
||||
)"
|
||||
[[ -n $size && $size -gt 0 ]] || {
|
||||
printf 'Missing or empty Android native library %s in %s\n' \
|
||||
"$entry" "$archive" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
find_apksigner() {
|
||||
if command -v apksigner >/dev/null 2>&1; then
|
||||
command -v apksigner
|
||||
return
|
||||
fi
|
||||
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
|
||||
[[ -n $sdk_root ]] || return 1
|
||||
find "$sdk_root/build-tools" -type f -name apksigner -perm -111 2>/dev/null |
|
||||
sort -r |
|
||||
head -1
|
||||
}
|
||||
|
||||
for name in \
|
||||
VNIDROP_ANDROID_KEYSTORE_PATH \
|
||||
VNIDROP_ANDROID_KEYSTORE_PASSWORD \
|
||||
VNIDROP_ANDROID_KEY_ALIAS \
|
||||
VNIDROP_ANDROID_KEY_PASSWORD \
|
||||
VNIDROP_ANDROID_UPLOAD_CERT_SHA256; do
|
||||
require_environment "$name"
|
||||
done
|
||||
|
||||
[[ -r $VNIDROP_ANDROID_KEYSTORE_PATH ]] || {
|
||||
printf 'Android upload keystore is not readable: %s\n' "$VNIDROP_ANDROID_KEYSTORE_PATH" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
version="$("$resolver" product)"
|
||||
version_code="$("$resolver" android-code)"
|
||||
"$resolver" verify >/dev/null
|
||||
|
||||
cd "$repo_root"
|
||||
./gradlew \
|
||||
:androidApp:check \
|
||||
:androidApp:assembleRelease \
|
||||
:androidApp:bundleRelease \
|
||||
-Pvnidrop.diagnostics.included=false \
|
||||
--no-daemon \
|
||||
--no-configuration-cache \
|
||||
--stacktrace
|
||||
|
||||
source_apk="$repo_root/androidApp/build/outputs/apk/release/androidApp-release.apk"
|
||||
source_aab="$repo_root/androidApp/build/outputs/bundle/release/androidApp-release.aab"
|
||||
metadata="$repo_root/androidApp/build/intermediates/merged_manifests/release/processReleaseManifest/output-metadata.json"
|
||||
[[ -s $source_apk && -s $source_aab && -s $metadata ]] || {
|
||||
printf 'Android release outputs are missing or empty\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
actual_version="$(jq -r '.elements[0].versionName' "$metadata")"
|
||||
actual_version_code="$(jq -r '.elements[0].versionCode' "$metadata")"
|
||||
[[ $actual_version == "$version" && $actual_version_code == "$version_code" ]] || {
|
||||
printf 'Android artifact version mismatch: expected %s (%s), got %s (%s)\n' \
|
||||
"$version" "$version_code" "$actual_version" "$actual_version_code" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
jarsigner_report="$(jarsigner -verify "$source_aab" 2>&1)" || {
|
||||
printf 'AAB signature verification failed:\n%s\n' "$jarsigner_report" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -F 'jar verified.' <<< "$jarsigner_report" >/dev/null || {
|
||||
printf 'jarsigner did not confirm the AAB signature\n' >&2
|
||||
exit 1
|
||||
}
|
||||
verify_archive_entries "$source_apk" "${required_apk_libraries[@]}"
|
||||
verify_archive_entries "$source_aab" "${required_aab_libraries[@]}"
|
||||
apksigner_path="$(find_apksigner)" || {
|
||||
printf 'apksigner was not found in PATH or the Android SDK\n' >&2
|
||||
exit 1
|
||||
}
|
||||
signature_report="$("$apksigner_path" verify --verbose --print-certs "$source_apk")"
|
||||
actual_fingerprint="$(
|
||||
printf '%s\n' "$signature_report" |
|
||||
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}'
|
||||
)"
|
||||
[[ -n $actual_fingerprint ]] || {
|
||||
printf 'Could not read the APK signing certificate fingerprint\n' >&2
|
||||
exit 1
|
||||
}
|
||||
actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")"
|
||||
expected_fingerprint="$(normalize_fingerprint "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")"
|
||||
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
|
||||
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
|
||||
"$expected_fingerprint" "$actual_fingerprint" >&2
|
||||
exit 1
|
||||
}
|
||||
aab_fingerprint="$(
|
||||
keytool -printcert -jarfile "$source_aab" |
|
||||
awk -F': ' '/SHA256:/ {print $2; exit}'
|
||||
)"
|
||||
aab_fingerprint="$(normalize_fingerprint "$aab_fingerprint")"
|
||||
[[ $aab_fingerprint == "$expected_fingerprint" ]] || {
|
||||
printf 'AAB signing certificate mismatch: expected %s, got %s\n' \
|
||||
"$expected_fingerprint" "$aab_fingerprint" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
rm -f \
|
||||
"$output_dir"/VniDrop-*-upload-signed.apk \
|
||||
"$output_dir"/VniDrop-*.aab \
|
||||
"$output_dir"/SHA256SUMS
|
||||
apk_name="VniDrop-${version}-${version_code}-upload-signed.apk"
|
||||
aab_name="VniDrop-${version}-${version_code}.aab"
|
||||
cp "$source_apk" "$output_dir/$apk_name"
|
||||
cp "$source_aab" "$output_dir/$aab_name"
|
||||
|
||||
{
|
||||
printf '%s %s\n' "$(sha256_file "$output_dir/$apk_name")" "$apk_name"
|
||||
printf '%s %s\n' "$(sha256_file "$output_dir/$aab_name")" "$aab_name"
|
||||
} > "$output_dir/SHA256SUMS"
|
||||
|
||||
printf 'Created signed Android release artifacts:\n'
|
||||
printf ' %s\n' "$output_dir/$aab_name"
|
||||
printf ' %s\n' "$output_dir/$apk_name"
|
||||
printf ' upload certificate SHA-256: %s\n' "$actual_fingerprint"
|
||||
410
packaging/android/publish_play.py
Executable file
410
packaging/android/publish_play.py
Executable file
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
API_ROOT = "https://androidpublisher.googleapis.com/androidpublisher/v3"
|
||||
UPLOAD_ROOT = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3"
|
||||
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
class PlayApiError(RuntimeError):
|
||||
def __init__(self, status: int | None, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class PlayClient:
|
||||
def __init__(self, token: str) -> None:
|
||||
if not token:
|
||||
raise ValueError("Google Play access token is required")
|
||||
self.token = token
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
body: bytes | None = None,
|
||||
content_type: str | None = None,
|
||||
timeout: int = 180,
|
||||
attempts: int = 5,
|
||||
) -> bytes:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if content_type is not None:
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
error_body = error.read().decode("utf-8", errors="replace")
|
||||
if error.code not in RETRYABLE_STATUS or attempt == attempts:
|
||||
raise PlayApiError(
|
||||
error.code,
|
||||
f"Google Play API returned HTTP {error.code}: {error_body}",
|
||||
) from error
|
||||
except urllib.error.URLError as error:
|
||||
if attempt == attempts:
|
||||
raise PlayApiError(
|
||||
None,
|
||||
f"Google Play API request failed: {error.reason}",
|
||||
) from error
|
||||
time.sleep(2 ** (attempt - 1))
|
||||
raise AssertionError("request retry loop exited unexpectedly")
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
value: Any | None = None,
|
||||
timeout: int = 180,
|
||||
) -> dict[str, Any]:
|
||||
body = None
|
||||
content_type = None
|
||||
if value is not None:
|
||||
body = json.dumps(value, separators=(",", ":")).encode()
|
||||
content_type = "application/json"
|
||||
response = self.request(
|
||||
method,
|
||||
url,
|
||||
body=body,
|
||||
content_type=content_type,
|
||||
timeout=timeout,
|
||||
)
|
||||
return json.loads(response) if response else {}
|
||||
|
||||
|
||||
def normalize_fingerprint(value: str) -> str:
|
||||
return "".join(character for character in value.lower() if character.isalnum())
|
||||
|
||||
|
||||
def validate_closed_track(track: str) -> None:
|
||||
normalized = track.strip().casefold()
|
||||
if not normalized:
|
||||
raise ValueError("Play track is required")
|
||||
if normalized == "production" or normalized.endswith(":production"):
|
||||
raise ValueError(
|
||||
"production tracks are forbidden by this closed-testing pipeline"
|
||||
)
|
||||
|
||||
|
||||
def find_universal_apk(
|
||||
response: dict[str, Any],
|
||||
expected_fingerprint: str,
|
||||
) -> tuple[str, str] | None:
|
||||
expected = normalize_fingerprint(expected_fingerprint)
|
||||
for signing_key in response.get("generatedApks", []):
|
||||
fingerprint = normalize_fingerprint(
|
||||
str(signing_key.get("certificateSha256Hash", ""))
|
||||
)
|
||||
if fingerprint != expected:
|
||||
continue
|
||||
universal = signing_key.get("generatedUniversalApk") or {}
|
||||
download_id = universal.get("downloadId")
|
||||
if download_id:
|
||||
return fingerprint, str(download_id)
|
||||
return None
|
||||
|
||||
|
||||
def build_track_payload(
|
||||
track: dict[str, Any],
|
||||
version_code: int,
|
||||
release_name: str,
|
||||
) -> dict[str, Any]:
|
||||
releases = list(track.get("releases") or [])
|
||||
expected_code = str(version_code)
|
||||
if any(
|
||||
expected_code in [str(code) for code in release.get("versionCodes", [])]
|
||||
for release in releases
|
||||
):
|
||||
raise ValueError(
|
||||
f"version code {version_code} is already present in track "
|
||||
f"{track.get('track', '<unknown>')}"
|
||||
)
|
||||
releases.append(
|
||||
{
|
||||
"name": release_name,
|
||||
"versionCodes": [expected_code],
|
||||
"status": "draft",
|
||||
}
|
||||
)
|
||||
return {"track": track["track"], "releases": releases}
|
||||
|
||||
|
||||
def find_track_release(
|
||||
track: dict[str, Any],
|
||||
version_code: int,
|
||||
) -> dict[str, Any] | None:
|
||||
expected_code = str(version_code)
|
||||
return next(
|
||||
(
|
||||
release
|
||||
for release in track.get("releases") or []
|
||||
if expected_code
|
||||
in [str(code) for code in release.get("versionCodes", [])]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def generated_apks_url(package_name: str, version_code: int) -> str:
|
||||
package = urllib.parse.quote(package_name, safe="")
|
||||
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}"
|
||||
|
||||
|
||||
def get_generated_apks(
|
||||
client: PlayClient,
|
||||
package_name: str,
|
||||
version_code: int,
|
||||
) -> dict[str, Any] | None:
|
||||
try:
|
||||
return client.request_json(
|
||||
"GET",
|
||||
generated_apks_url(package_name, version_code),
|
||||
)
|
||||
except PlayApiError as error:
|
||||
if error.status == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def download_universal_apk(
|
||||
client: PlayClient,
|
||||
package_name: str,
|
||||
version_code: int,
|
||||
expected_fingerprint: str,
|
||||
output: Path,
|
||||
*,
|
||||
attempts: int,
|
||||
interval_seconds: int,
|
||||
) -> str:
|
||||
for attempt in range(1, attempts + 1):
|
||||
response = get_generated_apks(client, package_name, version_code)
|
||||
if response is not None:
|
||||
selected = find_universal_apk(response, expected_fingerprint)
|
||||
if selected is not None:
|
||||
fingerprint, download_id = selected
|
||||
package = urllib.parse.quote(package_name, safe="")
|
||||
download = urllib.parse.quote(download_id, safe="")
|
||||
url = (
|
||||
f"{API_ROOT}/applications/{package}/generatedApks/"
|
||||
f"{version_code}/downloads/{download}:download"
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(client.request("GET", url))
|
||||
if output.stat().st_size == 0:
|
||||
raise RuntimeError("Google Play returned an empty universal APK")
|
||||
return fingerprint
|
||||
if attempt < attempts:
|
||||
time.sleep(interval_seconds)
|
||||
raise RuntimeError(
|
||||
"Google Play did not provide a universal APK signed with the expected "
|
||||
f"certificate after {attempts} attempts"
|
||||
)
|
||||
|
||||
|
||||
def publish_bundle(args: argparse.Namespace) -> dict[str, Any]:
|
||||
validate_closed_track(args.track)
|
||||
if args.version_code < 1:
|
||||
raise ValueError("version code must be positive")
|
||||
if not args.bundle.is_file() or args.bundle.stat().st_size == 0:
|
||||
raise ValueError(f"AAB is missing or empty: {args.bundle}")
|
||||
|
||||
client = PlayClient(args.access_token)
|
||||
existing = get_generated_apks(client, args.package_name, args.version_code)
|
||||
if existing is not None:
|
||||
selected = find_universal_apk(existing, args.expected_app_certificate)
|
||||
if selected is None:
|
||||
raise RuntimeError(
|
||||
"version code already exists in Play, but no universal APK matches "
|
||||
"the expected app-signing certificate"
|
||||
)
|
||||
package = urllib.parse.quote(args.package_name, safe="")
|
||||
edit = client.request_json(
|
||||
"POST",
|
||||
f"{API_ROOT}/applications/{package}/edits",
|
||||
value={},
|
||||
)
|
||||
edit_id = str(edit["id"])
|
||||
edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}"
|
||||
try:
|
||||
track_id = urllib.parse.quote(args.track, safe="")
|
||||
track = client.request_json(
|
||||
"GET",
|
||||
f"{edit_base}/tracks/{track_id}",
|
||||
)
|
||||
release = find_track_release(track, args.version_code)
|
||||
if release is None or release.get("status") != "draft":
|
||||
raise RuntimeError(
|
||||
"version code already exists in Play but is not a draft on "
|
||||
f"the configured track {args.track}"
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
client.request("DELETE", edit_base, attempts=1)
|
||||
except PlayApiError:
|
||||
pass
|
||||
source = "existing"
|
||||
else:
|
||||
package = urllib.parse.quote(args.package_name, safe="")
|
||||
edit = client.request_json(
|
||||
"POST",
|
||||
f"{API_ROOT}/applications/{package}/edits",
|
||||
value={},
|
||||
)
|
||||
edit_id = str(edit["id"])
|
||||
committed = False
|
||||
edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}"
|
||||
try:
|
||||
upload_url = (
|
||||
f"{UPLOAD_ROOT}/applications/{package}/edits/{edit_id}/bundles"
|
||||
"?uploadType=media"
|
||||
)
|
||||
try:
|
||||
uploaded = json.loads(
|
||||
client.request(
|
||||
"POST",
|
||||
upload_url,
|
||||
body=args.bundle.read_bytes(),
|
||||
content_type="application/octet-stream",
|
||||
attempts=1,
|
||||
)
|
||||
)
|
||||
except PlayApiError:
|
||||
bundles = client.request_json("GET", f"{edit_base}/bundles")
|
||||
matches = [
|
||||
bundle
|
||||
for bundle in bundles.get("bundles", [])
|
||||
if int(bundle.get("versionCode", 0)) == args.version_code
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise
|
||||
uploaded = matches[0]
|
||||
|
||||
uploaded_code = int(uploaded["versionCode"])
|
||||
if uploaded_code != args.version_code:
|
||||
raise RuntimeError(
|
||||
f"Play accepted version code {uploaded_code}, expected "
|
||||
f"{args.version_code}"
|
||||
)
|
||||
|
||||
track_id = urllib.parse.quote(args.track, safe="")
|
||||
track_url = f"{edit_base}/tracks/{track_id}"
|
||||
track = client.request_json("GET", track_url)
|
||||
payload = build_track_payload(track, args.version_code, args.release_name)
|
||||
client.request_json("PUT", track_url, value=payload)
|
||||
commit_url = (
|
||||
f"{edit_base}:commit"
|
||||
"?changesInReviewBehavior=ERROR_IF_IN_REVIEW"
|
||||
)
|
||||
client.request("POST", commit_url, body=b"")
|
||||
committed = True
|
||||
source = "uploaded"
|
||||
finally:
|
||||
if not committed:
|
||||
try:
|
||||
client.request("DELETE", edit_base, attempts=1)
|
||||
except PlayApiError:
|
||||
pass
|
||||
|
||||
fingerprint = download_universal_apk(
|
||||
client,
|
||||
args.package_name,
|
||||
args.version_code,
|
||||
args.expected_app_certificate,
|
||||
args.apk_output,
|
||||
attempts=args.poll_attempts,
|
||||
interval_seconds=args.poll_interval,
|
||||
)
|
||||
return {
|
||||
"packageName": args.package_name,
|
||||
"track": args.track,
|
||||
"releaseStatus": "draft",
|
||||
"releaseName": args.release_name,
|
||||
"versionCode": args.version_code,
|
||||
"bundleSha256": sha256_file(args.bundle),
|
||||
"universalApk": args.apk_output.name,
|
||||
"universalApkSha256": sha256_file(args.apk_output),
|
||||
"appSigningCertificateSha256": fingerprint,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Stage a signed AAB on a closed Play track and download Play's "
|
||||
"app-signed universal APK"
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--access-token",
|
||||
default=os.environ.get("GOOGLE_PLAY_ACCESS_TOKEN"),
|
||||
)
|
||||
parser.add_argument("--bundle", type=Path, required=True)
|
||||
parser.add_argument("--package-name", required=True)
|
||||
parser.add_argument("--track", required=True)
|
||||
parser.add_argument("--version-code", type=int, required=True)
|
||||
parser.add_argument("--release-name", required=True)
|
||||
parser.add_argument("--expected-app-certificate", required=True)
|
||||
parser.add_argument("--apk-output", type=Path, required=True)
|
||||
parser.add_argument("--metadata-output", type=Path, required=True)
|
||||
parser.add_argument("--poll-attempts", type=int, default=18)
|
||||
parser.add_argument("--poll-interval", type=int, default=10)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
metadata = publish_bundle(args)
|
||||
except (KeyError, ValueError, RuntimeError, PlayApiError) as error:
|
||||
print(f"Play closed-testing publication failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
args.metadata_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.metadata_output.write_text(
|
||||
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(
|
||||
f"Staged {args.release_name} ({args.version_code}) as a draft on "
|
||||
f"{args.track}"
|
||||
)
|
||||
print(f"Downloaded Play-signed APK: {args.apk_output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
100
packaging/android/tests/test_publish_play.py
Normal file
100
packaging/android/tests/test_publish_play.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "publish_play.py"
|
||||
SPEC = importlib.util.spec_from_file_location("publish_play", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
publish_play = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(publish_play)
|
||||
|
||||
|
||||
class PublishPlayTests(unittest.TestCase):
|
||||
def test_rejects_phone_and_form_factor_production_tracks(self):
|
||||
for track in ("production", "wear:production", " Production "):
|
||||
with self.subTest(track=track):
|
||||
with self.assertRaisesRegex(ValueError, "production"):
|
||||
publish_play.validate_closed_track(track)
|
||||
|
||||
def test_accepts_custom_closed_track(self):
|
||||
publish_play.validate_closed_track("closed-beta")
|
||||
|
||||
def test_normalizes_certificate_fingerprint(self):
|
||||
self.assertEqual(
|
||||
publish_play.normalize_fingerprint("AA:bb 01"),
|
||||
"aabb01",
|
||||
)
|
||||
|
||||
def test_selects_universal_apk_for_expected_signing_key(self):
|
||||
response = {
|
||||
"generatedApks": [
|
||||
{
|
||||
"certificateSha256Hash": "11:22",
|
||||
"generatedUniversalApk": {"downloadId": "wrong"},
|
||||
},
|
||||
{
|
||||
"certificateSha256Hash": "AA:BB",
|
||||
"generatedUniversalApk": {"downloadId": "correct"},
|
||||
},
|
||||
]
|
||||
}
|
||||
self.assertEqual(
|
||||
publish_play.find_universal_apk(response, "aa:bb"),
|
||||
("aabb", "correct"),
|
||||
)
|
||||
|
||||
def test_track_update_preserves_existing_releases_and_adds_draft(self):
|
||||
track = {
|
||||
"track": "closed-beta",
|
||||
"releases": [
|
||||
{
|
||||
"name": "0.1.0",
|
||||
"versionCodes": ["1"],
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
}
|
||||
updated = publish_play.build_track_payload(track, 2, "0.2.0")
|
||||
self.assertEqual(updated["releases"][0], track["releases"][0])
|
||||
self.assertEqual(
|
||||
updated["releases"][1],
|
||||
{
|
||||
"name": "0.2.0",
|
||||
"versionCodes": ["2"],
|
||||
"status": "draft",
|
||||
},
|
||||
)
|
||||
|
||||
def test_track_update_rejects_duplicate_version_code(self):
|
||||
track = {
|
||||
"track": "closed-beta",
|
||||
"releases": [{"versionCodes": ["2"], "status": "draft"}],
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "already present"):
|
||||
publish_play.build_track_payload(track, 2, "0.2.0")
|
||||
|
||||
def test_finds_existing_release_by_version_code(self):
|
||||
expected = {"versionCodes": ["2"], "status": "draft"}
|
||||
track = {
|
||||
"track": "closed-beta",
|
||||
"releases": [
|
||||
{"versionCodes": ["1"], "status": "completed"},
|
||||
expected,
|
||||
],
|
||||
}
|
||||
self.assertIs(
|
||||
publish_play.find_track_release(track, 2),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_returns_none_when_version_is_not_on_track(self):
|
||||
track = {
|
||||
"track": "closed-beta",
|
||||
"releases": [{"versionCodes": ["1"], "status": "completed"}],
|
||||
}
|
||||
self.assertIsNone(publish_play.find_track_release(track, 2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user