From e5829bcaf2b7f0dae74c121a95fd2c54ede48537 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Thu, 23 Jul 2026 23:39:56 +0300 Subject: [PATCH] Separate Android deployment identities --- .env.example | 12 +- .gitea/workflows/quality.yml | 3 + .github/workflows/quality.yml | 3 + README.md | 15 ++- android/Dockerfile | 68 +++++----- android/README.md | 53 +++++--- android/app/build.gradle.kts | 35 ++++- android/app/src/main/AndroidManifest.xml | 6 +- .../whoneedhelp/mobile/LaunchUrlResolver.java | 7 +- .../org/whoneedhelp/mobile/TrustedOrigin.java | 35 +++++ .../mobile/LaunchUrlResolverTest.java | 21 +++ .../whoneedhelp/mobile/TrustedOriginTest.java | 8 ++ docs/operations.md | 27 ++-- docs/verification.md | 50 ++++++-- scripts/android-browser-development-e2e.sh | 7 + scripts/android-browser-staging-e2e.sh | 69 +++++++--- scripts/android-development-build.sh | 64 ++++++++++ scripts/android-development-smoke.sh | 7 + scripts/android-public-ci.sh | 78 ++++++++++++ scripts/android-release-build.sh | 7 +- scripts/android-release-ci.sh | 28 +++- scripts/android-signing-fingerprint.sh | 82 ++++++++++++ scripts/android-staging-build.sh | 11 +- scripts/android-staging-smoke.sh | 80 ++++++++---- scripts/check-environment-readiness.sh | 27 +++- scripts/configure-android-development-env.sh | 50 ++++++++ scripts/init-android-development-signing.sh | 10 ++ scripts/init-production-env.sh | 3 + scripts/init-test-env.sh | 3 + scripts/quality.sh | 81 ++++++++++++ scripts/sync-env-template.sh | 120 ++++++++++++++++++ scripts/validate-android-environment.sh | 110 ++++++++++++++++ 32 files changed, 1029 insertions(+), 151 deletions(-) create mode 100755 scripts/android-browser-development-e2e.sh create mode 100755 scripts/android-development-build.sh create mode 100755 scripts/android-development-smoke.sh create mode 100755 scripts/android-public-ci.sh create mode 100755 scripts/android-signing-fingerprint.sh create mode 100755 scripts/configure-android-development-env.sh create mode 100755 scripts/init-android-development-signing.sh create mode 100755 scripts/sync-env-template.sh create mode 100755 scripts/validate-android-environment.sh diff --git a/.env.example b/.env.example index 43e4837..8481562 100644 --- a/.env.example +++ b/.env.example @@ -72,7 +72,7 @@ PHX_CHECK_ORIGINS= # Android debug builds compile this origin into BuildConfig. The Docker # emulator uses adb reverse to expose the local Compose proxy on loopback. WNH_DEBUG_BASE_URL=http://localhost:4010 -# Staging/release builds require a public HTTPS origin. Keep the value +# Development/staging/release builds require a public HTTPS origin. Keep the value # environment-specific; scripts/ensure-local-public-origin.sh can derive it # from the three PHX_* values in the ignored .env. WNH_BASE_URL= @@ -91,8 +91,9 @@ WNH_FIREBASE_API_KEY= WNH_FIREBASE_PROJECT_ID= WNH_FIREBASE_GCM_SENDER_ID= # Verified Android App Links are configured by the web deployment rather than -# embedded as secrets in the application. Use org.whoneedhelp.mobile.staging -# with the staging signing certificate on the dev checkout and +# embedded as secrets in the application. Use +# org.whoneedhelp.mobile.development with the development certificate on DEV, +# org.whoneedhelp.mobile.staging with the staging certificate on test, and # org.whoneedhelp.mobile with every active Play signing certificate on # production. Keep both empty until the matching signed APK/AAB is available. ANDROID_APP_LINKS_PACKAGE_NAME= @@ -101,7 +102,10 @@ ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS= # keystore and its randomized password live outside the repository under # ~/.config/who_need_help/android-release/. WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload -# The dev-domain staging APK uses a different stable signing identity under +# The DEV-domain APK uses a stable signing identity under +# ~/.config/who_need_help/android-development/. +WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=who-need-help-development +# The test-domain staging APK uses a different stable signing identity under # ~/.config/who_need_help/android-staging/. This keeps App Link verification # reproducible without reusing the future production upload key. WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS=who-need-help-staging diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index e0f36c7..aa60287 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -30,3 +30,6 @@ jobs: - name: Exercise signed APK and Play AAB release pipeline run: ./scripts/android-release-ci.sh + + - name: Exercise isolated signed development APK pipeline + run: ./scripts/android-public-ci.sh diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 96324c1..4306ee2 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -61,3 +61,6 @@ jobs: - name: Exercise signed APK and Play AAB release pipeline run: ./scripts/android-release-ci.sh + + - name: Exercise isolated signed development APK pipeline + run: ./scripts/android-public-ci.sh diff --git a/README.md b/README.md index 2394d18..cf4c127 100644 --- a/README.md +++ b/README.md @@ -517,14 +517,21 @@ Results and failure diagnostics are retained by API under ignored `output/android-instrumentation/`; the exact emulator container and one-run image are removed automatically. -The public-staging variant and its instrumentation APK use the explicit HTTPS -origin from the ignored `.env`. The smoke probe checks rendered WebView DOM on +The public development and staging variants and their instrumentation APKs use +the explicit HTTPS origin from each checkout's ignored `.env`. Development uses +`org.whoneedhelp.mobile.development`; the independent test checkout uses +`org.whoneedhelp.mobile.staging`. The smoke probe checks rendered WebView DOM on the home and Safety routes. The cross-client probe uses run-scoped users and a matched medicine request to verify Android login, private chat in both -directions, foreground location sharing, browser marker appearance and -removal, and exact database cleanup: +directions, foreground location sharing, browser marker appearance and removal, +and exact database cleanup: ```bash +./scripts/android-development-build.sh +./scripts/android-development-smoke.sh +./scripts/android-browser-development-e2e.sh + +# Run these only from the independent test checkout. ./scripts/android-staging-build.sh ./scripts/android-staging-smoke.sh ./scripts/android-browser-staging-e2e.sh diff --git a/android/Dockerfile b/android/Dockerfile index c1f34a4..631766d 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -146,7 +146,7 @@ COPY --from=android-sdk \ /workspace/android/app/build/reports/lint-results-debug.html \ /lint-results-debug.html -FROM android-base AS android-staging-sdk +FROM android-base AS android-public-sdk USER gradle SHELL ["/bin/bash", "-o", "pipefail", "-c"] @@ -163,14 +163,22 @@ ARG WNH_FIREBASE_APPLICATION_ID ARG WNH_FIREBASE_CLIENT_VALUE ARG WNH_FIREBASE_PROJECT_ID ARG WNH_FIREBASE_GCM_SENDER_ID +ARG WNH_PUBLIC_BUILD_TYPE +ARG WNH_EXPECTED_APPLICATION_ID RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ - --mount=type=secret,id=android_staging_keystore,required=true,uid=1000,gid=1000,mode=0400 \ - --mount=type=secret,id=android_staging_password,required=true,uid=1000,gid=1000,mode=0400 \ - --mount=type=secret,id=android_staging_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \ - WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_staging_keystore \ - WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_staging_password \ + --mount=type=secret,id=android_nonproduction_keystore,required=true,uid=1000,gid=1000,mode=0400 \ + --mount=type=secret,id=android_nonproduction_password,required=true,uid=1000,gid=1000,mode=0400 \ + --mount=type=secret,id=android_nonproduction_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \ + case "${WNH_PUBLIC_BUILD_TYPE}" in \ + development) task_name=Development ;; \ + staging) task_name=Staging ;; \ + *) echo "WNH_PUBLIC_BUILD_TYPE must be development or staging" >&2; exit 1 ;; \ + esac \ + && test -n "${WNH_EXPECTED_APPLICATION_ID}" \ + && WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_nonproduction_keystore \ + WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_nonproduction_password \ gradle --no-daemon \ "-PWNH_BASE_URL=${WNH_BASE_URL}" \ "-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \ @@ -182,38 +190,38 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ "-PWNH_FIREBASE_API_KEY=${WNH_FIREBASE_CLIENT_VALUE}" \ "-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \ "-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \ - "-PWNH_TEST_BUILD_TYPE=staging" \ - testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest \ + "-PWNH_TEST_BUILD_TYPE=${WNH_PUBLIC_BUILD_TYPE}" \ + "test${task_name}UnitTest" \ + "lint${task_name}" \ + "assemble${task_name}" \ + "assemble${task_name}AndroidTest" \ && "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \ verify --verbose --print-certs \ - app/build/outputs/apk/staging/app-staging.apk \ - >app/build/outputs/apk/staging/signing-certificate.txt \ + "app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \ + >"/tmp/signing-certificate.txt" \ && "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \ - app/build/outputs/apk/staging/app-staging.apk \ + "app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \ | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \ - >app/build/outputs/apk/staging/package-name.txt \ - && grep -Fx "org.whoneedhelp.mobile.staging" \ - app/build/outputs/apk/staging/package-name.txt + >"/tmp/package-name.txt" \ + && grep -Fx "${WNH_EXPECTED_APPLICATION_ID}" "/tmp/package-name.txt" \ + && mkdir -p /workspace/export \ + && cp \ + "app/build/outputs/apk/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}.apk" \ + "/workspace/export/who-need-help-${WNH_PUBLIC_BUILD_TYPE}.apk" \ + && cp \ + "app/build/outputs/apk/androidTest/${WNH_PUBLIC_BUILD_TYPE}/app-${WNH_PUBLIC_BUILD_TYPE}-androidTest.apk" \ + "/workspace/export/who-need-help-${WNH_PUBLIC_BUILD_TYPE}-androidTest.apk" \ + && cp \ + "app/build/reports/lint-results-${WNH_PUBLIC_BUILD_TYPE}.html" \ + "/workspace/export/lint-results-${WNH_PUBLIC_BUILD_TYPE}.html" \ + && cp /tmp/signing-certificate.txt /workspace/export/signing-certificate.txt \ + && cp /tmp/package-name.txt /workspace/export/package-name.txt -FROM scratch AS staging-artifact +FROM scratch AS public-artifact USER 65532:65532 -COPY --from=android-staging-sdk \ - /workspace/android/app/build/outputs/apk/staging/app-staging.apk \ - /who-need-help-staging.apk -COPY --from=android-staging-sdk \ - /workspace/android/app/build/outputs/apk/androidTest/staging/app-staging-androidTest.apk \ - /who-need-help-staging-androidTest.apk -COPY --from=android-staging-sdk \ - /workspace/android/app/build/reports/lint-results-staging.html \ - /lint-results-staging.html -COPY --from=android-staging-sdk \ - /workspace/android/app/build/outputs/apk/staging/signing-certificate.txt \ - /signing-certificate.txt -COPY --from=android-staging-sdk \ - /workspace/android/app/build/outputs/apk/staging/package-name.txt \ - /package-name.txt +COPY --from=android-public-sdk /workspace/export/ / FROM android-base AS android-release-base diff --git a/android/README.md b/android/README.md index 33e6fe3..378f6ca 100644 --- a/android/README.md +++ b/android/README.md @@ -87,8 +87,11 @@ build. Google/Firebase setup is environment-specific as well: -- dev/staging uses package `org.whoneedhelp.mobile.staging`, its stable staging - signing certificate, the dev Web OAuth client, and the dev Firebase project; +- development uses package `org.whoneedhelp.mobile.development`, its stable + development certificate, the development Web OAuth client, and the + development Firebase project; +- test/staging uses package `org.whoneedhelp.mobile.staging`, its independent + staging certificate, and the test environment's provider configuration; - production uses package `org.whoneedhelp.mobile`, the Play-distributed signing certificate, the production Web OAuth client, and the production Firebase project; @@ -119,38 +122,50 @@ separate app-signing key used for distributed APKs: - - -## Public staging build +## Public development and staging builds -The installable `staging` build type uses the explicit public HTTPS -`WNH_BASE_URL`, disables cleartext traffic, and has its own -`org.whoneedhelp.mobile.staging` application ID. Configure a missing local value -from the existing `PHX_HOST`, `PHX_SCHEME`, and `PHX_URL_PORT`, then build: +The installable `development` and `staging` build types use the explicit public +HTTPS `WNH_BASE_URL` and disable cleartext traffic. Development is the +`org.whoneedhelp.mobile.development` application connected to the development +origin. Generate its dedicated signing identity once, synchronize the public +identity into the checkout's single ignored `.env`, and build: ```sh -./scripts/ensure-local-public-origin.sh +./scripts/init-android-development-signing.sh +./scripts/configure-android-development-env.sh +./scripts/android-development-build.sh +sha256sum android/dist-development/who-need-help-development.apk +``` + +The separate test checkout uses `org.whoneedhelp.mobile.staging`, its own +staging key, and the same workflow with test-specific inputs: + +```sh +./scripts/init-android-staging-signing.sh ./scripts/android-staging-build.sh sha256sum android/dist-staging/who-need-help-staging.apk ``` -This variant uses the dedicated stable staging key below -`~/.config/who_need_help/android-staging/`. It is not the production upload -identity and must not be published as a production release. The manifest -accepts same-origin HTTPS deep links, while verified Android App Links require -this staging certificate fingerprint in the dev deployment's -`/.well-known/assetlinks.json`. +The two identities live below +`~/.config/who_need_help/android-development/` and +`~/.config/who_need_help/android-staging/`. Neither is the production upload +identity or suitable for publication as the production application. Each +deployment's `/.well-known/assetlinks.json` must contain the package and +certificate fingerprint of the APK connected to that exact origin. -With the temporary public origin reachable, run the API 37 emulator smoke test: +With the matching public origin reachable, run the API 37 emulator smoke test: ```sh +./scripts/android-development-smoke.sh ./scripts/android-staging-smoke.sh ``` -The script installs the exported staging APK into a fresh project-scoped -emulator container, loads the configured HTTPS home page, follows a +Each script installs the corresponding APK into a fresh project-scoped emulator +container, loads the configured HTTPS home page, follows a same-origin `/safety` deep link, verifies that the package does not claim an external HTTPS origin, and retains UI dumps, screenshots, package metadata, -and logcat diagnostics under ignored `output/android-staging-smoke/`. The -one-run container and image are removed on success or failure. +and logcat diagnostics under its ignored `output/android-*-smoke/` directory. +The one-run container and image are removed on success or failure. ## Reproducible Docker build diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8376d30..b17adf7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -161,15 +161,34 @@ android { "\"${debugBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" ) manifestPlaceholders["usesCleartextTraffic"] = "true" - manifestPlaceholders["deepLinkScheme"] = debugManifestOrigin?.scheme ?: "https" manifestPlaceholders["deepLinkHost"] = debugManifestOrigin?.host ?: "invalid.whoneedhelp.local" } - create("staging") { + create("development") { initWith(getByName("debug")) - applicationIdSuffix = ".staging" - versionNameSuffix = "-staging" + applicationIdSuffix = ".development" + versionNameSuffix = "-development" + isDebuggable = false + if (releaseSigningConfigured) { + signingConfig = signingConfigs.getByName("release") + } + buildConfigField( + "String", + "BASE_URL", + "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" + ) + manifestPlaceholders["usesCleartextTraffic"] = "false" + manifestPlaceholders["deepLinkHost"] = + releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local" + matchingFallbacks += listOf("debug") + } + + create("staging") { + initWith(getByName("debug")) + applicationIdSuffix = ".staging" + versionNameSuffix = "-staging" + isDebuggable = false if (releaseSigningConfigured) { signingConfig = signingConfigs.getByName("release") } @@ -179,7 +198,6 @@ android { "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" ) manifestPlaceholders["usesCleartextTraffic"] = "false" - manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https" manifestPlaceholders["deepLinkHost"] = releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local" matchingFallbacks += listOf("debug") @@ -197,7 +215,6 @@ android { "\"${releaseBaseUrl.get().replace("\\", "\\\\").replace("\"", "\\\"")}\"" ) manifestPlaceholders["usesCleartextTraffic"] = "false" - manifestPlaceholders["deepLinkScheme"] = releaseManifestOrigin?.scheme ?: "https" manifestPlaceholders["deepLinkHost"] = releaseManifestOrigin?.host ?: "invalid.whoneedhelp.local" proguardFiles( @@ -221,7 +238,11 @@ android { } } -tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach { +tasks.matching { + it.name == "preDevelopmentBuild" || + it.name == "preStagingBuild" || + it.name == "preReleaseBuild" +}.configureEach { doFirst { validateFirebaseConfiguration() if (!releaseSigningConfigured) { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa9f0f2..0df829a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,9 +40,9 @@ - + + + &2 + exit 2 + ;; +esac + +APK="$ROOT/android/dist-$variant/who-need-help-$variant.apk" +TEST_APK="$ROOT/android/dist-$variant/who-need-help-$variant-androidTest.apk" remote_target= remote_host= remote_root= @@ -15,13 +34,13 @@ remote_output_dir= remote_tools_image= ssh_tunnel_pid= run_id="$(date -u +%Y%m%d%H%M%S)-$$" -output_dir="$ROOT/output/android-browser-staging-e2e/$run_id" +output_root="$ROOT/output/android-browser-$variant-e2e" +output_dir="$output_root/$run_id" tools_image="who-need-help:android-e2e-tools-$run_id" browser_image="who-need-help-e2e-tests:android-$run_id" -android_image="who-need-help-android:cross-client-$run_id" -container="who-need-help-android-cross-client-$run_id" -avd_volume="who-need-help-android-avd-cross-client-$run_id" -package=org.whoneedhelp.mobile.staging +android_image="who-need-help-android:$variant-cross-client-$run_id" +container="who-need-help-android-$variant-cross-client-$run_id" +avd_volume="who-need-help-android-avd-$variant-cross-client-$run_id" service_class=org.whoneedhelp.mobile.TrackingService fixture_password="$(openssl rand -hex 24)" android_message="android-$run_id" @@ -46,6 +65,11 @@ case "$#" in esac if [[ -n "$remote_target" ]]; then + if [[ "$variant" != staging ]]; then + echo "Remote cross-client E2E is restricted to the isolated test/staging deployment." >&2 + exit 1 + fi + case "$remote_target" in *:/*) remote_host=${remote_target%%:*} @@ -85,13 +109,17 @@ if [[ ! -f "$ENV_FILE" ]]; then exit 1 fi +"$ROOT/scripts/validate-android-environment.sh" \ + "$ENV_FILE" \ + "$expected_deployment_env" + if [[ ! -s "$APK" ]]; then - echo "Missing staging APK: $APK. Run scripts/android-staging-build.sh first." >&2 + echo "Missing $variant APK: $APK. Run $build_script first." >&2 exit 1 fi if [[ ! -s "$TEST_APK" ]]; then - echo "Missing staging test APK: $TEST_APK. Run scripts/android-staging-build.sh first." >&2 + echo "Missing $variant test APK: $TEST_APK. Run $build_script first." >&2 exit 1 fi @@ -100,6 +128,11 @@ set -a . "$ENV_FILE" set +a +if [[ -z "$remote_target" && "${DEPLOYMENT_ENV:-}" != "$expected_deployment_env" ]]; then + echo "The $variant cross-client E2E requires DEPLOYMENT_ENV=$expected_deployment_env." >&2 + exit 1 +fi + if [[ -z "$remote_target" && "${DATABASE_MODE:-container}" != container ]]; then echo "This rollback-based Android/browser drill requires DATABASE_MODE=container." >&2 exit 1 @@ -202,7 +235,7 @@ esac mailpit_url="http://${mailpit_host}:${MAILPIT_PORT}" mkdir -p "$output_dir" -chmod 700 "$ROOT/output" "$ROOT/output/android-browser-staging-e2e" "$output_dir" +chmod 700 "$ROOT/output" "$output_root" "$output_dir" if [[ -n "$remote_target" ]]; then remote_runtime=$( @@ -424,7 +457,7 @@ start_android_phase() { -e request_path "$request_path" \ -e android_message "$android_message" \ -e browser_reply "$browser_reply" \ - org.whoneedhelp.mobile.staging.test/androidx.test.runner.AndroidJUnitRunner \ + "${package}.test/androidx.test.runner.AndroidJUnitRunner" \ >"$android_runner_output" 2>&1 & android_runner_pid=$! } @@ -462,7 +495,7 @@ cleanup() { if [[ "$prepared" -eq 1 ]]; then if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then - echo "Exact Android/browser staging cleanup failed; inspect $output_dir." >&2 + echo "Exact Android/browser $variant cleanup failed; inspect $output_dir." >&2 status=1 else snapshot_database "$output_dir/database-after.txt" @@ -670,10 +703,10 @@ adb shell settings put global window_animation_scale 0 adb shell settings put global transition_animation_scale 0 adb shell settings put global animator_duration_scale 0 adb shell cmd location set-location-enabled true -docker cp "$APK" "$container:/tmp/who-need-help-staging.apk" -docker cp "$TEST_APK" "$container:/tmp/who-need-help-staging-androidTest.apk" -adb install -r /tmp/who-need-help-staging.apk >"$output_dir/install.txt" -adb install -r /tmp/who-need-help-staging-androidTest.apk \ +docker cp "$APK" "$container:/tmp/who-need-help-public.apk" +docker cp "$TEST_APK" "$container:/tmp/who-need-help-public-androidTest.apk" +adb install -r /tmp/who-need-help-public.apk >"$output_dir/install.txt" +adb install -r /tmp/who-need-help-public-androidTest.apk \ >"$output_dir/test-install.txt" adb shell pm list instrumentation >"$output_dir/instrumentation.txt" adb shell pm grant "$package" android.permission.ACCESS_FINE_LOCATION @@ -691,7 +724,7 @@ for _attempt in $(seq 1 45); do done if [[ "$network_ready" -ne 1 ]]; then - echo "The Android emulator could not resolve and reach the staging host." >&2 + echo "The Android emulator could not resolve and reach the $variant host." >&2 exit 1 fi @@ -787,5 +820,5 @@ fi printf 'load_tls_browser_or_fatal_errors=0\n' } >"$output_dir/summary.txt" -echo "Android/browser public staging E2E passed." +echo "Android/browser public $variant E2E passed." echo "Evidence: $output_dir" diff --git a/scripts/android-development-build.sh b/scripts/android-development-build.sh new file mode 100755 index 0000000..722ad82 --- /dev/null +++ b/scripts/android-development-build.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +ENV_FILE="$ROOT/.env" +config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} +SIGNING_DIR=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"} +KEYSTORE="$SIGNING_DIR/who-need-help-development.p12" +PASSWORD_FILE="$SIGNING_DIR/who-need-help-development.password" + +"$ROOT/scripts/ensure-local-public-origin.sh" +"$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" development + +set -a +# shellcheck source=/dev/null +. "$ENV_FILE" +set +a + +: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}" +: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}" +: "${WNH_ANDROID_VERSION_CODE:?Set WNH_ANDROID_VERSION_CODE in .env}" +: "${WNH_ANDROID_VERSION_NAME:?Set WNH_ANDROID_VERSION_NAME in .env}" +: "${WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS:?Set WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS in .env}" + +for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do + if [ ! -f "$secret_file" ]; then + echo "Missing Android development signing file: $secret_file" >&2 + echo "Run scripts/init-android-development-signing.sh once." >&2 + exit 1 + fi + + mode=$(stat -c '%a' "$secret_file") + case "$mode" in + 400|600) ;; + *) + echo "Android development signing file must have mode 0400 or 0600: $secret_file" >&2 + exit 1 + ;; + esac +done + +docker build \ + --secret "id=android_nonproduction_keystore,src=$KEYSTORE" \ + --secret "id=android_nonproduction_password,src=$PASSWORD_FILE" \ + --secret "id=android_nonproduction_alias,env=WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS" \ + --build-arg "WNH_BASE_URL=$WNH_BASE_URL" \ + --build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ + --build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ + --build-arg "WNH_ANDROID_VERSION_CODE=$WNH_ANDROID_VERSION_CODE" \ + --build-arg "WNH_ANDROID_VERSION_NAME=$WNH_ANDROID_VERSION_NAME" \ + --build-arg "WNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID:-}" \ + --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \ + --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \ + --build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \ + --build-arg "WNH_PUBLIC_BUILD_TYPE=development" \ + --build-arg "WNH_EXPECTED_APPLICATION_ID=org.whoneedhelp.mobile.development" \ + --target public-artifact \ + --output "type=local,dest=$ROOT/android/dist-development" \ + "$ROOT/android" + +"$ROOT/scripts/android-app-links-verify.sh" \ + "$ENV_FILE" \ + "$ROOT/android/dist-development" \ + --online diff --git a/scripts/android-development-smoke.sh b/scripts/android-development-smoke.sh new file mode 100755 index 0000000..218a083 --- /dev/null +++ b/scripts/android-development-smoke.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +WNH_ANDROID_PUBLIC_VARIANT=development +export WNH_ANDROID_PUBLIC_VARIANT + +exec "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/android-staging-smoke.sh" "$@" diff --git a/scripts/android-public-ci.sh b/scripts/android-public-ci.sh new file mode 100755 index 0000000..a56dd8a --- /dev/null +++ b/scripts/android-public-ci.sh @@ -0,0 +1,78 @@ +#!/bin/sh +set -eu + +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +workspace=$(mktemp -d "${TMPDIR:-/tmp}/wnh-android-public-ci.XXXXXX") +signing_dir="$workspace/signing" +environment_file="$workspace/development.env" +output_dir="$workspace/output" +key_alias=who-need-help-ci-development +base_url=https://android-development-ci.invalid +WNH_ANDROID_SIGNING_KEY_ALIAS=$key_alias +export WNH_ANDROID_SIGNING_KEY_ALIAS + +cleanup() { + rm -rf "$workspace" +} +trap cleanup EXIT HUP INT TERM + +WNH_ANDROID_SIGNING_DIR="$signing_dir" \ +WNH_ANDROID_SIGNING_BASENAME=who-need-help-development \ +WNH_ANDROID_SIGNING_KEY_ALIAS="$key_alias" \ +WNH_ANDROID_SIGNING_SUBJECT="CN=Who Need Help CI development key" \ + "$ROOT/scripts/init-android-release-signing.sh" >/dev/null + +fingerprint=$( + "$ROOT/scripts/android-signing-fingerprint.sh" \ + "$signing_dir/who-need-help-development.p12" \ + "$signing_dir/who-need-help-development.password" \ + "$key_alias" +) + +printf '%s\n' \ + 'DEPLOYMENT_ENV=development' \ + 'PHX_HOST=android-development-ci.invalid' \ + 'PHX_SCHEME=https' \ + 'PHX_URL_PORT=443' \ + "WNH_BASE_URL=$base_url" \ + "WNH_DEBUG_BASE_URL=$base_url" \ + 'WNH_TRACKING_MIN_TIME_MS=5000' \ + 'WNH_TRACKING_HTTP_TIMEOUT_MS=15000' \ + 'WNH_ANDROID_VERSION_CODE=1' \ + 'WNH_ANDROID_VERSION_NAME=0.1.0-ci' \ + "WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=$key_alias" \ + 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development' \ + "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$fingerprint" \ + >"$environment_file" +chmod 600 "$environment_file" + +"$ROOT/scripts/validate-android-environment.sh" \ + "$environment_file" \ + development + +docker build \ + --secret "id=android_nonproduction_keystore,src=$signing_dir/who-need-help-development.p12" \ + --secret "id=android_nonproduction_password,src=$signing_dir/who-need-help-development.password" \ + --secret "id=android_nonproduction_alias,env=WNH_ANDROID_SIGNING_KEY_ALIAS" \ + --build-arg "WNH_BASE_URL=$base_url" \ + --build-arg WNH_TRACKING_MIN_TIME_MS=5000 \ + --build-arg WNH_TRACKING_HTTP_TIMEOUT_MS=15000 \ + --build-arg WNH_ANDROID_VERSION_CODE=1 \ + --build-arg WNH_ANDROID_VERSION_NAME=0.1.0-ci \ + --build-arg WNH_PUBLIC_BUILD_TYPE=development \ + --build-arg WNH_EXPECTED_APPLICATION_ID=org.whoneedhelp.mobile.development \ + --target public-artifact \ + --output "type=local,dest=$output_dir" \ + "$ROOT/android" + +"$ROOT/scripts/android-app-links-verify.sh" \ + "$environment_file" \ + "$output_dir" + +test -s "$output_dir/who-need-help-development.apk" +test -s "$output_dir/who-need-help-development-androidTest.apk" +test -s "$output_dir/lint-results-development.html" + +echo "Ephemeral signed Android development pipeline passed." diff --git a/scripts/android-release-build.sh b/scripts/android-release-build.sh index 67c853a..1d0d130 100755 --- a/scripts/android-release-build.sh +++ b/scripts/android-release-build.sh @@ -24,6 +24,8 @@ set -a . "$ENV_FILE" set +a +"$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" production + : "${WNH_BASE_URL:?Set WNH_BASE_URL in the selected environment file}" : "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in the selected environment file}" : "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in the selected environment file}" @@ -84,10 +86,7 @@ for artifact in \ fi done -if [ -n "${ANDROID_APP_LINKS_PACKAGE_NAME:-}" ] || - [ -n "${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}" ]; then - "$ROOT/scripts/android-app-links-verify.sh" "$ENV_FILE" "$OUTPUT_DIR" -fi +"$ROOT/scripts/android-app-links-verify.sh" "$ENV_FILE" "$OUTPUT_DIR" sha256sum \ "$OUTPUT_DIR/who-need-help-release.apk" \ diff --git a/scripts/android-release-ci.sh b/scripts/android-release-ci.sh index a44133f..8cf35f4 100755 --- a/scripts/android-release-ci.sh +++ b/scripts/android-release-ci.sh @@ -8,26 +8,42 @@ workspace=$(mktemp -d "${TMPDIR:-/tmp}/wnh-android-release-ci.XXXXXX") signing_dir="$workspace/signing" environment_file="$workspace/release.env" output_dir="$workspace/output" +key_alias=who-need-help-ci-upload +base_url=https://android-release-ci.invalid cleanup() { rm -rf "$workspace" } trap cleanup EXIT HUP INT TERM +WNH_ANDROID_SIGNING_DIR="$signing_dir" \ +WNH_ANDROID_SIGNING_KEY_ALIAS="$key_alias" \ + "$ROOT/scripts/init-android-release-signing.sh" >/dev/null + +fingerprint=$( + "$ROOT/scripts/android-signing-fingerprint.sh" \ + "$signing_dir/who-need-help-upload.p12" \ + "$signing_dir/who-need-help-upload.password" \ + "$key_alias" +) + printf '%s\n' \ - 'WNH_BASE_URL=https://android-release-ci.invalid' \ + 'DEPLOYMENT_ENV=production' \ + 'PHX_HOST=android-release-ci.invalid' \ + 'PHX_SCHEME=https' \ + 'PHX_URL_PORT=443' \ + "WNH_BASE_URL=$base_url" \ + "WNH_DEBUG_BASE_URL=$base_url" \ 'WNH_TRACKING_MIN_TIME_MS=5000' \ 'WNH_TRACKING_HTTP_TIMEOUT_MS=15000' \ 'WNH_ANDROID_VERSION_CODE=1' \ 'WNH_ANDROID_VERSION_NAME=0.1.0-ci' \ - 'WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-ci-upload' \ + "WNH_ANDROID_SIGNING_KEY_ALIAS=$key_alias" \ + 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile' \ + "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$fingerprint" \ >"$environment_file" chmod 600 "$environment_file" -WNH_ANDROID_SIGNING_DIR="$signing_dir" \ -WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-ci-upload \ - "$ROOT/scripts/init-android-release-signing.sh" >/dev/null - WNH_ANDROID_SIGNING_DIR="$signing_dir" \ WNH_ANDROID_RELEASE_OUTPUT_DIR="$output_dir" \ WNH_ENV_FILE="$environment_file" \ diff --git a/scripts/android-signing-fingerprint.sh b/scripts/android-signing-fingerprint.sh new file mode 100755 index 0000000..a3e53f9 --- /dev/null +++ b/scripts/android-signing-fingerprint.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +keystore=${1:-} +password_file=${2:-} +key_alias=${3:-} +key_image="gradle:9.6.1-jdk17@sha256:7364ce528f33bb6038672bcef990d524f1ad8fbc292935819c235db886d0fae7" + +if [[ -z "$keystore" || -z "$password_file" || -z "$key_alias" ]]; then + echo "Usage: $0 KEYSTORE PASSWORD_FILE KEY_ALIAS" >&2 + exit 2 +fi + +keystore=$(realpath "$keystore") +password_file=$(realpath "$password_file") +keystore_dir=$(dirname "$keystore") +password_dir=$(dirname "$password_file") + +for secret_file in "$keystore" "$password_file"; do + [[ -f "$secret_file" && ! -L "$secret_file" ]] || { + echo "Android signing input must be a regular non-symlink file: $secret_file" >&2 + exit 1 + } + case "$(stat -c '%a' "$secret_file")" in + 400 | 600) ;; + *) + echo "Android signing input must have mode 0400 or 0600: $secret_file" >&2 + exit 1 + ;; + esac +done + +case "$key_alias" in + '' | *[!A-Za-z0-9._-]*) + echo "Android signing alias contains unsupported characters." >&2 + exit 1 + ;; +esac + +mounts=( + --mount "type=bind,src=$keystore_dir,dst=/keystore,readonly" +) +password_container_dir=/password +if [[ "$password_dir" == "$keystore_dir" ]]; then + password_container_dir=/keystore +else + mounts+=(--mount "type=bind,src=$password_dir,dst=/password,readonly") +fi + +report=$( + docker run --rm \ + --user "$(id -u):$(id -g)" \ + "${mounts[@]}" \ + --entrypoint keytool \ + "$key_image" \ + -list -v \ + -keystore "/keystore/$(basename "$keystore")" \ + -storetype PKCS12 \ + -storepass:file "$password_container_dir/$(basename "$password_file")" \ + -alias "$key_alias" +) + +fingerprint=$( + awk -F': ' ' + /^[[:space:]]*SHA256:/ { + print $2 + found = 1 + exit + } + END { if (!found) exit 1 } + ' <<<"$report" +) || { + echo "The signing certificate SHA-256 fingerprint was not found." >&2 + exit 1 +} + +[[ "${fingerprint//:/}" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "The signing certificate SHA-256 fingerprint is malformed." >&2 + exit 1 +} + +printf '%s\n' "$(tr '[:lower:]' '[:upper:]' <<<"$fingerprint")" diff --git a/scripts/android-staging-build.sh b/scripts/android-staging-build.sh index f379c46..faf86e6 100755 --- a/scripts/android-staging-build.sh +++ b/scripts/android-staging-build.sh @@ -9,6 +9,7 @@ KEYSTORE="$SIGNING_DIR/who-need-help-staging.p12" PASSWORD_FILE="$SIGNING_DIR/who-need-help-staging.password" "$ROOT/scripts/ensure-local-public-origin.sh" +"$ROOT/scripts/validate-android-environment.sh" "$ENV_FILE" test set -a # shellcheck source=/dev/null @@ -40,9 +41,9 @@ for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do done docker build \ - --secret "id=android_staging_keystore,src=$KEYSTORE" \ - --secret "id=android_staging_password,src=$PASSWORD_FILE" \ - --secret "id=android_staging_alias,env=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS" \ + --secret "id=android_nonproduction_keystore,src=$KEYSTORE" \ + --secret "id=android_nonproduction_password,src=$PASSWORD_FILE" \ + --secret "id=android_nonproduction_alias,env=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS" \ --build-arg "WNH_BASE_URL=$WNH_BASE_URL" \ --build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ @@ -52,7 +53,9 @@ docker build \ --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \ --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \ --build-arg "WNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID:-}" \ - --target staging-artifact \ + --build-arg "WNH_PUBLIC_BUILD_TYPE=staging" \ + --build-arg "WNH_EXPECTED_APPLICATION_ID=org.whoneedhelp.mobile.staging" \ + --target public-artifact \ --output "type=local,dest=$ROOT/android/dist-staging" \ "$ROOT/android" diff --git a/scripts/android-staging-smoke.sh b/scripts/android-staging-smoke.sh index 7a09bd2..a7ddc28 100755 --- a/scripts/android-staging-smoke.sh +++ b/scripts/android-staging-smoke.sh @@ -4,14 +4,33 @@ umask 077 ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE="$ROOT/.env" -APK="$ROOT/android/dist-staging/who-need-help-staging.apk" -TEST_APK="$ROOT/android/dist-staging/who-need-help-staging-androidTest.apk" +variant=${WNH_ANDROID_PUBLIC_VARIANT:-staging} + +case "$variant" in + development) + package=org.whoneedhelp.mobile.development + validation_environment=development + build_script=scripts/android-development-build.sh + ;; + staging) + package=org.whoneedhelp.mobile.staging + validation_environment="test" + build_script=scripts/android-staging-build.sh + ;; + *) + echo "WNH_ANDROID_PUBLIC_VARIANT must be development or staging." >&2 + exit 2 + ;; +esac + +APK="$ROOT/android/dist-$variant/who-need-help-$variant.apk" +TEST_APK="$ROOT/android/dist-$variant/who-need-help-$variant-androidTest.apk" run_id=$(date -u +%Y%m%d%H%M%S)-$$ -image="who-need-help-android:staging-smoke-$run_id" -container="who-need-help-android-staging-smoke-$run_id" -avd_volume="who-need-help-android-avd-staging-smoke-$run_id" -output="$ROOT/output/android-staging-smoke/$run_id" -package=org.whoneedhelp.mobile.staging +image="who-need-help-android:$variant-smoke-$run_id" +container="who-need-help-android-$variant-smoke-$run_id" +avd_volume="who-need-help-android-avd-$variant-smoke-$run_id" +output_root="$ROOT/output/android-$variant-smoke" +output="$output_root/$run_id" activity=org.whoneedhelp.mobile.MainActivity if [ ! -e /dev/kvm ]; then @@ -24,13 +43,17 @@ if [ ! -f "$ENV_FILE" ]; then exit 1 fi +"$ROOT/scripts/validate-android-environment.sh" \ + "$ENV_FILE" \ + "$validation_environment" + if [ ! -s "$APK" ]; then - echo "Missing staging APK: $APK. Run scripts/android-staging-build.sh first." >&2 + echo "Missing $variant APK: $APK. Run $build_script first." >&2 exit 1 fi if [ ! -s "$TEST_APK" ]; then - echo "Missing staging test APK: $TEST_APK. Run scripts/android-staging-build.sh first." >&2 + echo "Missing $variant test APK: $TEST_APK. Run $build_script first." >&2 exit 1 fi @@ -42,6 +65,7 @@ set +a : "${WNH_BASE_URL:?Set WNH_BASE_URL in .env}" : "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}" : "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}" +: "${WNH_ANDROID_VERSION_NAME:?Set WNH_ANDROID_VERSION_NAME in .env}" case "$WNH_BASE_URL" in https://*/* | https://*) ;; @@ -63,7 +87,7 @@ esac expected_host=${origin_without_scheme%%:*} mkdir -p "$output" -chmod 700 "$ROOT/output" "$ROOT/output/android-staging-smoke" "$output" +chmod 700 "$ROOT/output" "$output_root" "$output" cleanup() { status=$? @@ -133,7 +157,7 @@ docker run -d \ if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != "true" ]; then docker logs "$container" >"$output/emulator.log" 2>&1 || true - echo "Android staging emulator exited before ADB became available; inspect $output/emulator.log." >&2 + echo "Android $variant emulator exited before ADB became available; inspect $output/emulator.log." >&2 exit 1 fi @@ -156,7 +180,7 @@ while [ "$attempt" -lt 90 ]; do done if [ "$booted" != "1" ]; then - echo "Android staging emulator did not finish booting." >&2 + echo "Android $variant emulator did not finish booting." >&2 exit 1 fi @@ -164,11 +188,11 @@ docker exec "$container" adb shell input keyevent 82 docker exec "$container" adb shell settings put global window_animation_scale 0 docker exec "$container" adb shell settings put global transition_animation_scale 0 docker exec "$container" adb shell settings put global animator_duration_scale 0 -docker cp "$APK" "$container:/tmp/who-need-help-staging.apk" -docker cp "$TEST_APK" "$container:/tmp/who-need-help-staging-androidTest.apk" -docker exec "$container" adb install -r /tmp/who-need-help-staging.apk \ +docker cp "$APK" "$container:/tmp/who-need-help-public.apk" +docker cp "$TEST_APK" "$container:/tmp/who-need-help-public-androidTest.apk" +docker exec "$container" adb install -r /tmp/who-need-help-public.apk \ >"$output/install.txt" -docker exec "$container" adb install -r /tmp/who-need-help-staging-androidTest.apk \ +docker exec "$container" adb install -r /tmp/who-need-help-public-androidTest.apk \ >"$output/test-install.txt" docker exec "$container" adb shell pm list instrumentation \ >"$output/instrumentation.txt" @@ -189,12 +213,12 @@ while [ "$attempt" -lt 45 ]; do done if [ "$network_ready" != true ]; then - echo "The Android emulator could not resolve and reach the staging host." >&2 + echo "The Android emulator could not resolve and reach the $variant host." >&2 exit 1 fi -if ! grep -Fq "versionName=0.1.0-staging" "$output/package.txt"; then - echo "The installed package is not the expected staging variant." >&2 +if ! grep -Fq "versionName=$WNH_ANDROID_VERSION_NAME-$variant" "$output/package.txt"; then + echo "The installed package is not the expected $variant variant." >&2 exit 1 fi @@ -202,12 +226,12 @@ same_origin="$WNH_BASE_URL/safety" external_origin=https://example.com/ if ! grep -Fq "Authority: \"$expected_host\"" "$output/package.txt"; then - echo "The staging APK does not declare its exact HTTPS host." >&2 + echo "The $variant APK does not declare its exact HTTPS host." >&2 exit 1 fi if grep -Fq 'Authority: "example.com"' "$output/package.txt"; then - echo "The staging APK incorrectly declares an external HTTPS host." >&2 + echo "The $variant APK incorrectly declares an external HTTPS host." >&2 exit 1 fi @@ -222,7 +246,7 @@ docker exec "$container" adb shell cmd package resolve-activity --brief \ -d "$external_origin" >"$output/external-origin-resolver.txt" if grep -Fq "$package/" "$output/external-origin-resolver.txt"; then - echo "The staging APK incorrectly claimed an external HTTPS origin." >&2 + echo "The $variant APK incorrectly claimed an external HTTPS origin." >&2 exit 1 fi @@ -249,7 +273,7 @@ while [ "$attempt" -lt 45 ]; do done if [ "$home_loaded" != true ]; then - echo "The staging WebView did not finish loading the public home page." >&2 + echo "The $variant WebView did not finish loading the public home page." >&2 exit 1 fi @@ -294,7 +318,7 @@ docker exec "$container" adb exec-out screencap -p >"$output/safety.png" set +e docker exec "$container" adb shell am instrument -w -r \ -e class org.whoneedhelp.mobile.PublicStagingInstrumentedTest \ - org.whoneedhelp.mobile.staging.test/androidx.test.runner.AndroidJUnitRunner \ + "${package}.test/androidx.test.runner.AndroidJUnitRunner" \ >"$output/dom-results.txt" 2>&1 dom_status=$? set -e @@ -304,7 +328,7 @@ if [ "$dom_status" -ne 0 ] \ || grep -Eq 'FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed' \ "$output/dom-results.txt"; then cat "$output/dom-results.txt" >&2 - echo "The staging WebView DOM assertions failed." >&2 + echo "The $variant WebView DOM assertions failed." >&2 exit 1 fi @@ -316,12 +340,12 @@ docker exec "$container" adb shell dumpsys activity activities \ if grep -Eqi \ 'Main-frame load failed|net::ERR_|ERR_CERT|SSL handshake failed|chromium.*crash' \ "$output/webview-after-dom.txt"; then - echo "Android staging logcat contains a public-page load or TLS failure." >&2 + echo "Android $variant logcat contains a public-page load or TLS failure." >&2 exit 1 fi if ! grep -Fq 'INSTRUMENTATION_CODE: -1' "$output/dom-results.txt"; then - echo "The staging DOM runner did not finish normally." >&2 + echo "The $variant DOM runner did not finish normally." >&2 exit 1 fi @@ -339,5 +363,5 @@ fi printf 'load_or_tls_errors=0\n' } >"$output/summary.txt" -echo "Android public staging smoke passed." +echo "Android public $variant smoke passed." echo "Evidence: $output" diff --git a/scripts/check-environment-readiness.sh b/scripts/check-environment-readiness.sh index a0da9d1..20beee3 100755 --- a/scripts/check-environment-readiness.sh +++ b/scripts/check-environment-readiness.sh @@ -210,11 +210,30 @@ else partial "Android App Links" "package and signing fingerprints must be configured together" fi -if all_set WNH_ANDROID_VERSION_CODE WNH_ANDROID_VERSION_NAME \ - WNH_ANDROID_SIGNING_KEY_ALIAS WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS; then - ready "Android release inputs" "version and separate production/staging signing aliases are present" +android_signing_alias= +android_signing_label= +case "$deployment_env" in + development) + android_signing_alias=WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS + android_signing_label=development + ;; + test) + android_signing_alias=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS + android_signing_label=staging + ;; + production) + android_signing_alias=WNH_ANDROID_SIGNING_KEY_ALIAS + android_signing_label=production + ;; +esac + +if [[ -n "$android_signing_alias" ]] && + all_set WNH_ANDROID_VERSION_CODE WNH_ANDROID_VERSION_NAME "$android_signing_alias"; then + ready "Android release inputs" \ + "version and $android_signing_label signing alias are present" else - missing "Android release inputs" "version code/name and both signing aliases" + missing "Android release inputs" \ + "version code/name and the signing alias for DEPLOYMENT_ENV=$deployment_env" fi printf '\nEnvironment readiness: %d blocking item(s), %d local-only warning(s).\n' \ diff --git a/scripts/configure-android-development-env.sh b/scripts/configure-android-development-env.sh new file mode 100755 index 0000000..bb82816 --- /dev/null +++ b/scripts/configure-android-development-env.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +umask 077 + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-"$ROOT/.env"} +config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} +signing_dir=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"} +key_alias=${WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS:-who-need-help-development} +keystore="$signing_dir/who-need-help-development.p12" +password_file="$signing_dir/who-need-help-development.password" +values_file=$(mktemp "${TMPDIR:-/tmp}/wnh-android-development-env.XXXXXX") + +cleanup() { + if [ -e "$values_file" ]; then + unlink "$values_file" + fi +} +trap cleanup EXIT HUP INT TERM + +case "$env_file" in + /*) ;; + *) env_file="$ROOT/$env_file" ;; +esac + +if [ ! -f "$env_file" ]; then + echo "Development environment file does not exist: $env_file" >&2 + exit 1 +fi + +fingerprint=$( + "$ROOT/scripts/android-signing-fingerprint.sh" \ + "$keystore" \ + "$password_file" \ + "$key_alias" +) + +printf '%s\n' \ + 'DEPLOYMENT_ENV=development' \ + 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development' \ + "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$fingerprint" \ + "WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS=$key_alias" \ + >"$values_file" +chmod 600 "$values_file" + +"$ROOT/scripts/set-env-values.sh" "$env_file" "$values_file" >/dev/null +"$ROOT/scripts/validate-android-environment.sh" "$env_file" development + +echo "Configured the ignored development environment with the public Android identity." diff --git a/scripts/init-android-development-signing.sh b/scripts/init-android-development-signing.sh new file mode 100755 index 0000000..d24b270 --- /dev/null +++ b/scripts/init-android-development-signing.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} + +WNH_ANDROID_SIGNING_DIR=${WNH_ANDROID_DEVELOPMENT_SIGNING_DIR:-"$config_home/who_need_help/android-development"} \ +WNH_ANDROID_SIGNING_BASENAME=who-need-help-development \ +WNH_ANDROID_SIGNING_KEY_ALIAS=${WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS:-who-need-help-development} \ +WNH_ANDROID_SIGNING_SUBJECT="CN=Who Need Help development key" \ + exec "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/init-android-release-signing.sh" diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index 8a16591..17efdc3 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -322,6 +322,9 @@ TEST_UPSTREAM_VALUE=$test_upstream \ replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"] replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] + replacement["WNH_ANDROID_SIGNING_KEY_ALIAS"] = "who-need-help-upload" + replacement["WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS"] = "" + replacement["WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS"] = "" replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] } { diff --git a/scripts/init-test-env.sh b/scripts/init-test-env.sh index 84d2a87..a01a3c4 100755 --- a/scripts/init-test-env.sh +++ b/scripts/init-test-env.sh @@ -263,6 +263,9 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["FCM_SERVICE_ACCOUNT_JSON_BASE64"] = ENVIRON["FCM_SERVICE_ACCOUNT_JSON_BASE64_VALUE"] replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] + replacement["WNH_ANDROID_SIGNING_KEY_ALIAS"] = "" + replacement["WNH_ANDROID_DEVELOPMENT_SIGNING_KEY_ALIAS"] = "" + replacement["WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS"] = "who-need-help-staging" replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] } { diff --git a/scripts/quality.sh b/scripts/quality.sh index 0ad50fb..654d2ff 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -127,6 +127,51 @@ if ./scripts/set-env-values.sh \ exit 1 fi +echo "Checking single-file environment template synchronization" +sync_template="$scan_dir/sync-template.env.example" +sync_env="$scan_dir/sync.env" +printf '%s\n' \ + '# Template comment' \ + 'FIRST_VALUE=template-default' \ + 'SECOND_VALUE=' \ + >"$sync_template" +printf '%s\n' \ + 'SECOND_VALUE=preserve-this-value' \ + 'LOCAL_ONLY_VALUE=preserve-local-key' \ + 'FIRST_VALUE=preserve-first-value' \ + >"$sync_env" +chmod 600 "$sync_env" +sync_output=$( + ./scripts/sync-env-template.sh "$sync_env" "$sync_template" +) +if printf '%s' "$sync_output" | grep -F 'preserve-this-value' >/dev/null; then + echo "Environment synchronizer printed an environment value." >&2 + exit 1 +fi +test "$(stat -c '%a' "$sync_env")" = 600 +grep -Fx '# Template comment' "$sync_env" >/dev/null +grep -Fx 'FIRST_VALUE=preserve-first-value' "$sync_env" >/dev/null +grep -Fx 'SECOND_VALUE=preserve-this-value' "$sync_env" >/dev/null +grep -Fx 'LOCAL_ONLY_VALUE=preserve-local-key' "$sync_env" >/dev/null +test "$(grep -Fc 'FIRST_VALUE=' "$sync_env")" = 1 +test "$(grep -Fc 'SECOND_VALUE=' "$sync_env")" = 1 +test "$(grep -Fc 'LOCAL_ONLY_VALUE=' "$sync_env")" = 1 +sync_hash=$(sha256sum "$sync_env" | awk '{print $1}') +./scripts/sync-env-template.sh "$sync_env" "$sync_template" >/dev/null +test "$(sha256sum "$sync_env" | awk '{print $1}')" = "$sync_hash" + +sync_duplicate="$scan_dir/sync-duplicate.env" +printf '%s\n' \ + 'FIRST_VALUE=one' \ + 'FIRST_VALUE=two' \ + >"$sync_duplicate" +chmod 600 "$sync_duplicate" +if ./scripts/sync-env-template.sh \ + "$sync_duplicate" "$sync_template" >/dev/null 2>&1; then + echo "Environment synchronizer accepted duplicate source keys." >&2 + exit 1 +fi + google_client="$scan_dir/google-oauth-client.json" printf '%s\n' \ '{"web":{"client_id":"quality-google-client","project_id":"quality-development","client_secret":"quality-google-secret","redirect_uris":["https://dev.help.test/auth/google/callback"]}}' \ @@ -157,6 +202,42 @@ grep -Fx 'WNH_FIREBASE_API_KEY=quality-firebase-api-key' "$credential_env" >/dev grep -Fx 'WNH_FIREBASE_PROJECT_ID=quality-development' "$credential_env" >/dev/null grep -Fx 'WNH_FIREBASE_GCM_SENDER_ID=123456789' "$credential_env" >/dev/null +echo "Checking Android environment isolation" +android_fingerprint=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF +android_env="$scan_dir/android-development.env" +printf '%s\n' \ + 'DEPLOYMENT_ENV=development' \ + 'PHX_HOST=dev.help.test' \ + 'PHX_SCHEME=https' \ + 'PHX_URL_PORT=443' \ + 'WNH_BASE_URL=https://dev.help.test' \ + 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.development' \ + "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$android_fingerprint" \ + >"$android_env" +chmod 600 "$android_env" +./scripts/validate-android-environment.sh \ + "$android_env" development >/dev/null + +sed -i \ + 's|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \ + "$android_env" +if ./scripts/validate-android-environment.sh \ + "$android_env" development >/dev/null 2>&1; then + echo "Development Android validation accepted the test package." >&2 + exit 1 +fi + +sed -i \ + 's|^DEPLOYMENT_ENV=.*|DEPLOYMENT_ENV=test|; s|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \ + "$android_env" +./scripts/validate-android-environment.sh "$android_env" test >/dev/null + +sed -i \ + 's|^DEPLOYMENT_ENV=.*|DEPLOYMENT_ENV=production|; s|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile|' \ + "$android_env" +./scripts/validate-android-environment.sh \ + "$android_env" production >/dev/null + fcm_service_account="$scan_dir/fcm-service-account.json" printf '%s\n' \ '{"type":"service_account","project_id":"quality-development","client_email":"quality-fcm@quality-development.iam.gserviceaccount.com","private_key":"quality-private-key"}' \ diff --git a/scripts/sync-env-template.sh b/scripts/sync-env-template.sh new file mode 100755 index 0000000..dee9143 --- /dev/null +++ b/scripts/sync-env-template.sh @@ -0,0 +1,120 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-"$ROOT/.env"} +template_file=${2:-"$ROOT/.env.example"} + +case "$env_file" in + /*) ;; + *) env_file="$ROOT/$env_file" ;; +esac + +case "$template_file" in + /*) ;; + *) template_file="$ROOT/$template_file" ;; +esac + +if [ ! -f "$env_file" ]; then + echo "Environment file does not exist: $env_file" >&2 + exit 1 +fi + +if [ ! -f "$template_file" ]; then + echo "Environment template does not exist: $template_file" >&2 + exit 1 +fi + +if [ "$(stat -c '%a' "$env_file")" != 600 ]; then + echo "Environment file must have mode 0600: $env_file" >&2 + exit 1 +fi + +env_dir=$(CDPATH='' cd -- "$(dirname -- "$env_file")" && pwd) +env_name=$(basename -- "$env_file") +temporary_env=$(mktemp "$env_dir/$env_name.tmp.XXXXXX") +trap 'rm -f "$temporary_env"' EXIT HUP INT TERM +chmod 600 "$temporary_env" + +if ! awk ' + BEGIN { + current_file = 1 + } + + FNR == 1 && NR != 1 { + current_file = 0 + } + + current_file { + separator = index($0, "=") + + if (separator > 1) { + key = substr($0, 1, separator - 1) + + if (key ~ /^[A-Z][A-Z0-9_]*$/) { + if (key in current) { + exit 40 + } + + current[key] = substr($0, separator + 1) + current_order[++current_count] = key + } + } + + next + } + + { + separator = index($0, "=") + + if (separator > 1) { + key = substr($0, 1, separator - 1) + + if (key ~ /^[A-Z][A-Z0-9_]*$/) { + if (key in template_seen) { + exit 41 + } + + template_seen[key] = 1 + + if (key in current) { + print key "=" current[key] + emitted[key] = 1 + next + } + } + } + + print + } + + END { + unknown_count = 0 + + for (position = 1; position <= current_count; position++) { + key = current_order[position] + + if (!(key in emitted) && !(key in template_seen)) { + unknown[++unknown_count] = key + } + } + + if (unknown_count > 0) { + print "" + print "# Local keys not present in the current template." + + for (position = 1; position <= unknown_count; position++) { + key = unknown[position] + print key "=" current[key] + } + } + } +' "$env_file" "$template_file" >"$temporary_env"; then + echo "Refusing to synchronize the environment: invalid or duplicate keys were found." >&2 + exit 1 +fi + +mv "$temporary_env" "$env_file" +trap - EXIT HUP INT TERM + +echo "Environment synchronized with the template without printing values: $env_file" diff --git a/scripts/validate-android-environment.sh b/scripts/validate-android-environment.sh new file mode 100755 index 0000000..d8cd6ad --- /dev/null +++ b/scripts/validate-android-environment.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-"$ROOT/.env"} +expected_environment=${2:-} + +if [[ "$env_file" != /* ]]; then + env_file="$ROOT/$env_file" +fi + +case "$expected_environment" in + development) expected_package=org.whoneedhelp.mobile.development ;; + test) expected_package=org.whoneedhelp.mobile.staging ;; + production) expected_package=org.whoneedhelp.mobile ;; + *) + echo "Usage: $0 ENV_FILE development|test|production" >&2 + exit 2 + ;; +esac + +[[ -f "$env_file" ]] || { + echo "Android build environment does not exist: $env_file" >&2 + exit 1 +} +[[ "$(stat -c '%a' "$env_file")" == 600 ]] || { + echo "Android build environment must have mode 0600: $env_file" >&2 + exit 1 +} + +read_unique() { + local key=$1 + local output + + output=$( + awk -v key="$key" ' + index($0, key "=") == 1 { + count += 1 + value = substr($0, length(key) + 2) + } + END { + if (count != 1) exit 1 + if (value ~ /^".*"$/ || value ~ /^'\''.*'\''$/) { + value = substr(value, 2, length(value) - 2) + } + print value + } + ' "$env_file" + ) || { + echo "$key must occur exactly once in $env_file." >&2 + exit 1 + } + + [[ -n "$output" ]] || { + echo "$key must not be empty in $env_file." >&2 + exit 1 + } + printf '%s' "$output" +} + +deployment_environment=$(read_unique DEPLOYMENT_ENV) +phx_host=$(read_unique PHX_HOST) +phx_scheme=$(read_unique PHX_SCHEME) +phx_port=$(read_unique PHX_URL_PORT) +base_url=$(read_unique WNH_BASE_URL) +app_links_package=$(read_unique ANDROID_APP_LINKS_PACKAGE_NAME) +app_links_fingerprints=$(read_unique ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS) + +[[ "$deployment_environment" == "$expected_environment" ]] || { + echo "Android build requires DEPLOYMENT_ENV=$expected_environment." >&2 + exit 1 +} +[[ "$app_links_package" == "$expected_package" ]] || { + echo "Android build for $expected_environment requires package $expected_package." >&2 + exit 1 +} +[[ "$phx_scheme" == https ]] || { + echo "Public Android builds require PHX_SCHEME=https." >&2 + exit 1 +} +[[ "$phx_host" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || { + echo "PHX_HOST must be a lowercase public DNS hostname." >&2 + exit 1 +} +if ! [[ "$phx_port" =~ ^[1-9][0-9]{0,4}$ ]] || + ((phx_port > 65535)); then + echo "PHX_URL_PORT must be a valid TCP port." >&2 + exit 1 +fi + +expected_origin="https://$phx_host" +if [[ "$phx_port" != 443 ]]; then + expected_origin="$expected_origin:$phx_port" +fi +[[ "$base_url" == "$expected_origin" ]] || { + echo "WNH_BASE_URL must equal the canonical PHX origin: $expected_origin" >&2 + exit 1 +} + +IFS=',' read -r -a fingerprints <<<"$app_links_fingerprints" +for fingerprint in "${fingerprints[@]}"; do + compact=${fingerprint//:/} + compact=${compact//[[:space:]]/} + [[ "$compact" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS contains an invalid value." >&2 + exit 1 + } +done + +echo "Verified $expected_environment Android origin, application ID, and App Links identity."