211 lines
8.4 KiB
Bash
211 lines
8.4 KiB
Bash
#!/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
|