#!/usr/bin/env bash set -Eeuo pipefail readonly KEYSTORE_SECRET="${MEMBY_KEYSTORE_SECRET:-/run/secrets/memby_android_keystore}" readonly STORE_PASSWORD_SECRET="${MEMBY_KEYSTORE_PASSWORD_SECRET:-/run/secrets/memby_android_keystore_password}" readonly KEY_ALIAS_SECRET="${MEMBY_KEY_ALIAS_SECRET:-/run/secrets/memby_android_key_alias}" readonly KEY_PASSWORD_SECRET="${MEMBY_KEY_PASSWORD_SECRET:-/run/secrets/memby_android_key_password}" readonly PUBLISH_TOKEN_SECRET="${MEMBY_RELEASE_PUBLISH_TOKEN_SECRET:-/run/secrets/memby_release_publish_token}" readonly SOURCE_REPOSITORY="${MEMBY_SOURCE_REPOSITORY:-https://github.com/ponzischeme89/memby.git}" readonly PUBLISH_URL="${MEMBY_RELEASE_PUBLISH_URL:-http://server:32768/admin/api/release}" readonly EXPECTED_APPLICATION_ID="com.ponzischeme89.memby" log() { printf '[memby-builder] %s\n' "$*" } fail() { printf '[memby-builder] ERROR: %s\n' "$*" >&2 exit 1 } require_secret() { local path="$1" local label="$2" [[ -r "$path" ]] || fail "$label secret is missing or unreadable at $path" [[ -s "$path" ]] || fail "$label secret is empty at $path" } semantic_latest_tag() { git ls-remote --tags --refs "$SOURCE_REPOSITORY" 'refs/tags/v[0-9]*' | sed -n 's#^[^[:space:]]\+[[:space:]]\+refs/tags/\(v[0-9]\+\.[0-9]\+\.[0-9]\+\)$#\1#p' | sort -V | tail -n 1 } release_notes() { local source_dir="$1" local version="$2" local notes_file="$3" if [[ -n "${MEMBY_RELEASE_NOTES_FILE:-}" ]]; then [[ -r "$MEMBY_RELEASE_NOTES_FILE" ]] || fail "release notes file is unreadable" cp "$MEMBY_RELEASE_NOTES_FILE" "$notes_file" elif [[ -n "${MEMBY_RELEASE_NOTES:-}" ]]; then printf '%s\n' "$MEMBY_RELEASE_NOTES" > "$notes_file" elif [[ -f "$source_dir/CHANGELOG.md" ]]; then awk -v version="$version" ' $0 ~ "^## " version "([[:space:]]|$)" { found=1; next } found && /^## / { exit } found && /^- / { sub(/^- /, ""); print } ' "$source_dir/CHANGELOG.md" > "$notes_file" fi if [[ ! -s "$notes_file" ]]; then printf 'Memby %s release.\n' "$version" > "$notes_file" fi } publish_release() { local apk="$1" local version="$2" local sha256="$3" local notes_file="$4" local mandatory="$5" local token token="$(<"$PUBLISH_TOKEN_SECRET")" # Feed the authorisation header through curl's stdin configuration. The token is # never present in the container configuration, process arguments or command log. printf 'header = "Authorization: Bearer %s"\n' "$token" | curl --config - --fail-with-body --show-error --silent \ --output /dev/null \ --request POST \ --form "version=$version" \ --form "sha256=$sha256" \ --form "mandatory=$mandatory" \ --form "notes=<$notes_file" \ --form "apk=@$apk;type=application/vnd.android.package-archive" \ "$PUBLISH_URL" } release() { require_secret "$KEYSTORE_SECRET" 'release keystore' require_secret "$STORE_PASSWORD_SECRET" 'keystore password' require_secret "$KEY_ALIAS_SECRET" 'key alias' require_secret "$KEY_PASSWORD_SECRET" 'key password' require_secret "$PUBLISH_TOKEN_SECRET" 'release publish token' local tag="${MEMBY_RELEASE_TAG:-}" if [[ -z "$tag" ]]; then log "Reading the latest GitHub tag from $SOURCE_REPOSITORY" tag="$(semantic_latest_tag)" fi [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]] || fail "release tag must look like v0.2.64 (found '${tag:-none}')" local version="${BASH_REMATCH[1]}" local work_dir work_dir="$(mktemp -d /work/memby-release.XXXXXX)" trap 'rm -rf -- "$work_dir"' RETURN local source_dir="$work_dir/source" local notes_file="$work_dir/notes.txt" local certificate_file="$work_dir/release-certificate.der" log "Fetching $tag" git init -q "$source_dir" git -C "$source_dir" remote add origin "$SOURCE_REPOSITORY" git -C "$source_dir" fetch --quiet --depth=1 origin "refs/tags/$tag:refs/tags/$tag" git -C "$source_dir" checkout --quiet --detach "refs/tags/$tag" export MEMBY_KEYSTORE="$KEYSTORE_SECRET" export MEMBY_KEYSTORE_PASSWORD_FILE="$STORE_PASSWORD_SECRET" export MEMBY_KEY_ALIAS_FILE="$KEY_ALIAS_SECRET" export MEMBY_KEY_PASSWORD_FILE="$KEY_PASSWORD_SECRET" # A fetched tag may predate *_FILE support in build.gradle.kts. Populate the # established secure environment form as a compatibility bridge; these values are # created inside the one-shot build process and never enter Compose or docker inspect. export MEMBY_KEYSTORE_PASSWORD MEMBY_KEYSTORE_PASSWORD="$(<"$STORE_PASSWORD_SECRET")" export MEMBY_KEY_ALIAS MEMBY_KEY_ALIAS="$(<"$KEY_ALIAS_SECRET")" export MEMBY_KEY_PASSWORD MEMBY_KEY_PASSWORD="$(<"$KEY_PASSWORD_SECRET")" local source_url="${MEMBY_SOURCE_URL:-${SOURCE_REPOSITORY%.git}/tree/$tag}" local -a gradle_tasks=() if [[ "${MEMBY_SKIP_APP_TESTS:-false}" != 'true' ]]; then gradle_tasks+=(testDebugUnitTest) fi gradle_tasks+=(assembleRelease) log "Building Memby $version with JDK 17 and Android API 35" chmod +x "$source_dir/gradlew" "$source_dir/gradlew" --project-dir "$source_dir" --console=plain --no-daemon \ "${gradle_tasks[@]}" \ "-Pmemby.versionName=$version" \ "-Pmemby.sourceUrl=$source_url" local apk="$source_dir/app/build/outputs/apk/release/app-release.apk" [[ -f "$apk" ]] || { [[ ! -f "$source_dir/app/build/outputs/apk/release/app-release-unsigned.apk" ]] || fail 'Gradle produced an unsigned APK; check the mounted signing secrets' fail "signed release APK was not produced at $apk" } local package_line application_id built_version package_line="$(aapt dump badging "$apk" | sed -n '1p')" application_id="$(sed -n "s/.*package: name='\([^']*\)'.*/\1/p" <<<"$package_line")" built_version="$(sed -n "s/.*versionName='\([^']*\)'.*/\1/p" <<<"$package_line")" [[ "$application_id" == "$EXPECTED_APPLICATION_ID" ]] || fail "APK applicationId is $application_id, expected $EXPECTED_APPLICATION_ID" [[ "$built_version" == "$version" ]] || fail "APK version is $built_version, expected $version" log 'Verifying the APK signature' local verification signer_digest keystore_digest if ! verification="$(apksigner verify --verbose --print-certs "$apk" 2>&1)"; then printf '%s\n' "$verification" >&2 fail 'apksigner verification failed' fi printf '%s\n' "$verification" local key_alias key_alias="$(<"$KEY_ALIAS_SECRET")" keytool -exportcert \ -keystore "$KEYSTORE_SECRET" \ -alias "$key_alias" \ -storepass:file "$STORE_PASSWORD_SECRET" \ -file "$certificate_file" >/dev/null keystore_digest="$(sha256sum "$certificate_file" | awk '{print $1}')" signer_digest="$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' <<<"$verification" | head -n 1 | tr -d ':')" [[ -n "$signer_digest" && "${signer_digest,,}" == "$keystore_digest" ]] || fail 'APK signer does not match the mounted Memby release certificate' local sha256 sha256="$(sha256sum "$apk" | awk '{print $1}')" release_notes "$source_dir" "$version" "$notes_file" local mandatory="${MEMBY_RELEASE_MANDATORY:-false}" [[ "$mandatory" == 'true' || "$mandatory" == 'false' ]] || fail 'MEMBY_RELEASE_MANDATORY must be true or false' log "Publishing Memby $version to the gateway" publish_release "$apk" "$version" "$sha256" "$notes_file" "$mandatory" local published_apk="/data/releases/memby-$version.apk" [[ -f "$published_apk" ]] || fail "gateway did not publish $published_apk" [[ "$(sha256sum "$published_apk" | awk '{print $1}')" == "$sha256" ]] || fail 'published APK checksum does not match the verified build' local checksum_file="/data/releases/memby-$version.apk.sha256" local checksum_temp checksum_temp="$(mktemp "/data/releases/.memby-$version.sha256.XXXXXX")" printf '%s %s\n' "$sha256" "memby-$version.apk" > "$checksum_temp" mv -f "$checksum_temp" "$checksum_file" log "Release complete: $published_apk" log "SHA-256: $sha256" log "Signing certificate SHA-256: $keystore_digest" } case "${1:-release}" in release) release ;; serve) exec /usr/local/bin/memby-builder-controller ;; *) fail "unknown command '$1' (expected: release or serve)" ;; esac