kepr-build-helpers.sh

8.5 KB · raw

1#!/bin/sh2# Sourceable, secret-free helpers for the Kepr release gate.3#4# This file intentionally contains no signing or Gradle commands. It only5# selects a stable SDK tool directory and validates public release metadata so6# the publication script and its hermetic negative-path tests share one7# implementation.89# Return success only for a stable build-tools directory basename such as10# "35.0.0". Preview/RC/beta/canary labels, "latest", leading-zero segments,11# and arbitrary names are rejected before any version sorting occurs.12is_stable_build_tools_basename() {13    build_tools_name=$114    old_ifs=$IFS15    IFS=.16    # shellcheck disable=SC208617    set -- $build_tools_name18    IFS=$old_ifs19    [ "$#" -eq 3 ] || return 120    for segment in "$@"; do21        case "$segment" in22            0|[1-9]|[1-9][0-9]*) ;;23            *) return 1 ;;24        esac25    done26}2728# Print the newest stable build-tools directory containing aapt2. Only29# basenames that passed is_stable_build_tools_basename reach sort -V.30find_stable_build_tools_dir() {31    sdk_root=$132    build_tools_root=$sdk_root/build-tools33    [ -d "$build_tools_root" ] || return 13435    best_name=36    for candidate in "$build_tools_root"/*; do37        [ -d "$candidate" ] || continue38        candidate_name=${candidate##*/}39        is_stable_build_tools_basename "$candidate_name" || continue40        [ -x "$candidate/aapt2" ] || continue4142        if [ -z "$best_name" ] ||43            [ "$(printf '%s\n' "$best_name" "$candidate_name" | LC_ALL=C sort -V | tail -n 1)" = "$candidate_name" ]; then44            best_name=$candidate_name45        fi46    done4748    [ -n "$best_name" ] || return 149    printf '%s\n' "$build_tools_root/$best_name"50}5152# Read and validate the one-line semantic version authority. Keep this in the53# shared helper so publication tests exercise the exact same root-file gate.54read_root_semver() {55    version_file=$156    [ -r "$version_file" ] || return 157    root_version=$(sed -n '1p' "$version_file") || return 158    [ -n "$root_version" ] || return 159    is_strict_semver "$root_version" || return 160    # A second non-empty line would make VERSION ambiguous even if the first61    # line happened to be valid.62    extra_lines=$(sed -n '2,$p' "$version_file" | sed '/^[[:space:]]*$/d')63    [ -z "$extra_lines" ] || return 164    printf '%s\n' "$root_version"65}6667is_strict_semver() {68    semantic_version=$169    case "$semantic_version" in70        ''|*[!0-9.]* ) return 1 ;;71    esac72    old_ifs=$IFS73    IFS=.74    # shellcheck disable=SC208675    set -- $semantic_version76    IFS=$old_ifs77    [ "$#" -eq 3 ] || return 178    for segment in "$@"; do79        case "$segment" in80            0|[1-9]|[1-9][0-9]*) ;;81            *) return 1 ;;82        esac83    done84}8586# Validate Gradle output-metadata.json, the APK's aapt2 badging, and VERSION as87# one release identity. The Python standard library is used only for parsing88# generated JSON; duplicate keys and ambiguous element lists are rejected.89# Arguments: metadata-json badging-file root-version expected-app-id expected-output expected-abi90validate_release_metadata() {91    metadata_file=$192    badging_file=$293    root_version=$394    expected_app_id=$495    expected_output=$596    expected_abi=$69798    python3 - "$metadata_file" "$badging_file" "$root_version" "$expected_app_id" "$expected_output" "$expected_abi" <<'PY'99import json100import re101import sys102103metadata_path, badging_path, root_version, expected_app_id, expected_output, expected_abi = sys.argv[1:]104105def reject(message):106    raise SystemExit("release metadata rejected: " + message)107108def strict_semver(value):109    if not isinstance(value, str) or not re.fullmatch(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", value):110        reject("version is not strict SemVer")111    for segment in value.split("."):112        if int(segment) > 2147483647:113            reject("version segment is out of range")114115class NoDuplicateKeys(dict):116    def __init__(self, pairs):117        seen = set()118        for key, value in pairs:119            if key in seen:120                reject("duplicate JSON key: " + key)121            seen.add(key)122            self[key] = value123124try:125    with open(metadata_path, encoding="utf-8") as stream:126        metadata = json.load(stream, object_pairs_hook=NoDuplicateKeys)127except (OSError, json.JSONDecodeError) as error:128    reject("malformed Gradle metadata: " + str(error))129130if not isinstance(metadata, dict):131    reject("Gradle metadata root is not an object")132if metadata.get("applicationId") != expected_app_id:133    reject("Gradle applicationId does not match production package")134elements = metadata.get("elements")135if not isinstance(elements, list) or not all(isinstance(item, dict) for item in elements):136    reject("Gradle metadata elements are malformed")137matches = [item for item in elements if item.get("outputFile") == expected_output]138if len(matches) != 1:139    reject("Gradle metadata must contain exactly one matching APK element")140element = matches[0]141if element.get("versionName") != root_version:142    reject("Gradle versionName does not match VERSION")143expected_code = element.get("versionCode")144if isinstance(expected_code, bool) or not isinstance(expected_code, int) or expected_code < 1:145    reject("Gradle versionCode is missing or invalid")146strict_semver(root_version)147148try:149    with open(badging_path, encoding="utf-8") as stream:150        badging = stream.read()151except OSError as error:152    reject("APK badging could not be read: " + str(error))153154package_lines = [line for line in badging.splitlines() if line.startswith("package:")]155if len(package_lines) != 1:156    reject("APK badging must contain exactly one package line")157match = re.fullmatch(158    r"package: name='([^']+)' versionCode='([^']+)' versionName='([^']+)'(?: .*)?",159    package_lines[0],160)161if not match:162    reject("APK badging package line is malformed")163apk_app_id, apk_code, apk_version = match.groups()164if apk_app_id != expected_app_id:165    reject("APK applicationId does not match Gradle metadata")166if apk_code != str(expected_code):167    reject("APK versionCode does not match Gradle metadata")168if apk_version != root_version:169    reject("APK versionName does not match VERSION")170strict_semver(apk_version)171native_lines = [line for line in badging.splitlines() if line.startswith("native-code:")]172if native_lines != [f"native-code: '{expected_abi}'"]:173    reject("APK native ABI does not match requested platform")174PY175}176177# PB-26-132: the publish path claims it validates the APK's signing identity, but178# aapt2 badging reports no signature. Compare the APK's signer certificate digest179# against the certificate in the configured release keystore — a build180# environment that produced an artifact signed with a different key is exactly181# the drift this refuses. Both helpers print a lowercase hex digest, or nothing182# when the source could not be read, so a missing digest fails closed below.183#184# Arguments: keystore storepass alias185release_certificate_sha256() {186    keystore=$1; storepass=$2; alias=$3187    "$JAVA_HOME/bin/keytool" -list -v -keystore "$keystore" -storepass "$storepass" -alias "$alias" 2>/dev/null |188        sed -n 's/.*SHA256: //p' |189        sed -n '1p' |190        tr -d ':' |191        tr 'A-F' 'a-f' |192        tr -d '[:space:]'193}194195# The release keystore is a trust anchor, not merely a local build input.196# Keep the established certificate pinned so a temporary or replacement key197# cannot pass a self-consistent local comparison and reach existing installs.198EXPECTED_RELEASE_CERTIFICATE="e10b975178f0bee7fb5a4802519da70ec2988b6ef7d8c9401fbbcff5a1137668"199assert_established_release_certificate() {200    actual=$1201    if [ "$actual" != "$EXPECTED_RELEASE_CERTIFICATE" ]; then202        echo "refusing to publish: configured keystore is not the established Peach release certificate" >&2203        return 1204    fi205}206207# Arguments: apksigner apk208apk_signer_sha256() {209    apksigner=$1; apk=$2210    "$apksigner" verify --print-certs "$apk" 2>/dev/null |211        sed -n 's/.*certificate SHA-256 digest: //p' |212        sed -n '1p' |213        tr -d ':' |214        tr 'A-F' 'a-f' |215        tr -d '[:space:]'216}217218# Arguments: expected actual label219assert_release_signer_matches() {220    expected=$1; actual=$2; label=$3221    if [ -z "$expected" ] || [ -z "$actual" ]; then222        echo "refusing to publish: $label signing identity could not be established" >&2223        return 1224    fi225    if [ "$expected" != "$actual" ]; then226        echo "refusing to publish: $label is not signed by the release certificate" >&2227        echo "  release keystore certificate: $expected" >&2228        echo "  APK signer certificate:       $actual" >&2229        return 1230    fi231    echo "release signing identity verified: $actual"232}