#!/usr/bin/env bash # Host wrapper: build CI image + run pipeline with log capture. # # Usage: # ./scripts/docker-build-runner.sh --build-id 42 --workdir /path/to/repo # # Env: see ci-build-and-publish-ota.sh # When invoked from PHP (BUILD_LOG_STDOUT_ONLY=1), log via stdout only — parent redirects to build.log. # # Parallel builds: set BUILD_ISOLATE_SOURCE=1 (default from builder) to shallow-clone into # $OUT_DIR/src instead of mutating the shared deploy checkout. # Per-build step logs: $OUT_DIR/docker-build.log, $OUT_DIR/docker-run.log (also echoed to build.log). set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" IMAGE="${ANDROIDCAST_CI_IMAGE:-androidcast-ci:latest}" CI_VERSION="${ANDROIDCAST_CI_VERSION:-00.01.00.1000}" BUILD_ID="${BUILD_ID:-local}" LOG_FILE="${BUILD_LOG_FILE:-${ROOT}/out/builds/${BUILD_ID}/build.log}" WORK_DIR="${BUILD_WORK_DIR:-${ROOT}}" OUT_DIR="${BUILD_OUT_DIR:-${WORK_DIR}/out/builds/${BUILD_ID}}" LOG_STDOUT_ONLY="${BUILD_LOG_STDOUT_ONLY:-0}" ISOLATE_SOURCE="${BUILD_ISOLATE_SOURCE:-1}" CLONE_DEPTH="${BUILD_GIT_DEPTH:-1}" MOBILE_SUBDIR="${BUILD_MOBILE_SUBDIR:-ac-mobile-android}" HOST_SCRIPTS_DIR="${BUILD_SCRIPTS_DIR:-${SCRIPTS_DIR}}" DOCKERFILE_PATH="${BUILD_DOCKERFILE:-}" SOURCE_DIR="${OUT_DIR}/src" DOCKER_WORKSPACE="${WORK_DIR}" CONTAINER_NAME="androidcast-bld-${BUILD_ID}" RESUME_FROM="${BUILD_RESUME_FROM:-}" REUSE_SRC_DIR="${BUILD_REUSE_SRC_DIR:-}" SSH_DEBUG="${BUILD_SSH_DEBUG:-0}" SSH_DEBUG_SECONDS="${BUILD_SSH_DEBUG_SECONDS:-7200}" mkdir -p "$(dirname "$LOG_FILE")" "$OUT_DIR" log() { if [[ "$LOG_STDOUT_ONLY" == "1" ]]; then echo "$@" else echo "$@" | tee -a "$LOG_FILE" fi } run_logged() { if [[ "$LOG_STDOUT_ONLY" == "1" ]]; then "$@" else "$@" 2>&1 | tee -a "$LOG_FILE" fi } # Mirror command output to build.log (stdout) and a per-build step file for parallel debugging. run_step() { local step_name="$1" shift local step_log="${OUT_DIR}/${step_name}.log" log "[runner] step ${step_name} -> ${step_log}" "$@" 2>&1 | tee -a "${step_log}" } on_runner_err() { local ec=$? log "[runner] ERROR: command failed (exit ${ec}) at ${BASH_SOURCE[0]}:${LINENO}: ${BASH_COMMAND}" if command -v docker >/dev/null 2>&1; then if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "${CONTAINER_NAME}"; then log "[runner] docker logs ${CONTAINER_NAME} (last 100 lines):" docker logs --tail 100 "${CONTAINER_NAME}" 2>&1 | while IFS= read -r line; do log "$line"; done fi fi echo "${ec}" > "${OUT_DIR}/runner.exit" exit "${ec}" } on_runner_exit() { local ec=$? echo "${ec}" > "${OUT_DIR}/runner.exit" } trap on_runner_err ERR trap on_runner_exit EXIT # Git 2.35+ refuses repos owned by another uid (PHP-FPM nginx vs deploy user). git_safe() { git -c safe.directory="${WORK_DIR}" "$@" } resolve_git_remote() { if [[ -n "${GIT_REMOTE:-}" ]]; then echo "$GIT_REMOTE" return fi if [[ -d "${WORK_DIR}/.git" ]]; then git_safe -C "${WORK_DIR}" remote get-url origin 2>/dev/null || true fi } host_mobile_tree() { if [[ -d "${WORK_DIR}/${MOBILE_SUBDIR}/app" ]]; then echo "${WORK_DIR}/${MOBILE_SUBDIR}" elif [[ -d "${WORK_DIR}/app" ]]; then echo "${WORK_DIR}" else echo "" fi } isolated_mobile_tree() { local base="$1" if [[ -d "${base}/${MOBILE_SUBDIR}/app" ]]; then echo "${base}/${MOBILE_SUBDIR}" elif [[ -d "${base}/app" ]]; then echo "${base}" else echo "" fi } third_party_incomplete() { local tree="$1" local p="$2" local dir="${tree}/third-party/${p}" if [[ ! -d "$dir" ]]; then return 0 fi case "$p" in wireguard-android) [[ ! -d "${dir}/tunnel/src/main/java" ]] ;; libvpx) [[ ! -d "${dir}/vp8" && ! -f "${dir}/configure" ]] ;; opus|speex) [[ ! -f "${dir}/configure" && ! -f "${dir}/CMakeLists.txt" && ! -d "${dir}/include" ]] ;; *) [[ -z "$(ls -A "${dir}" 2>/dev/null)" ]] ;; esac } seed_third_party_from_host() { local host_tree="$1" local iso_tree="$2" if [[ -z "$host_tree" || -z "$iso_tree" || ! -d "$host_tree/third-party" ]]; then return 0 fi mkdir -p "${iso_tree}/third-party" local p for p in libvpx opus speex wireguard-android; do if [[ -d "${host_tree}/third-party/${p}" ]] && third_party_incomplete "$iso_tree" "$p"; then log "[runner] seed third-party/${p} from host checkout" rm -rf "${iso_tree}/third-party/${p}" cp -r "${host_tree}/third-party/${p}" "${iso_tree}/third-party/" fi done } stage_ci_scripts() { local iso_tree="$1" if [[ -z "$iso_tree" || ! -d "$HOST_SCRIPTS_DIR" ]]; then return 0 fi mkdir -p "${iso_tree}/scripts" local f for f in ci-build-and-publish-ota.sh init-third-party-submodules.sh build-native-codecs.sh android-ndk.sh generate-ota-v0.sh native-build-cache.sh; do if [[ -f "${HOST_SCRIPTS_DIR}/${f}" ]]; then cp -f "${HOST_SCRIPTS_DIR}/${f}" "${iso_tree}/scripts/${f}" chmod +x "${iso_tree}/scripts/${f}" 2>/dev/null || true fi done } init_mobile_submodule_only() { local base="$1" if [[ ! -d "${base}/.git" ]]; then return 0 fi if git -C "$base" config -f .gitmodules --get "submodule.${MOBILE_SUBDIR}.path" >/dev/null 2>&1; then log "[runner] isolate: init workspace submodule ${MOBILE_SUBDIR} (no third-party recurse)" run_step git-submodule-mobile git -c safe.directory="${base}" -C "${base}" submodule update --init "${MOBILE_SUBDIR}" fi } prepare_isolated_source() { local ref="${GIT_REF:-}" local remote sha remote="$(resolve_git_remote)" if [[ -z "$ref" || -z "$remote" ]]; then log "[runner] isolate: skip (need GIT_REF and origin remote)" return 0 fi rm -rf "${SOURCE_DIR}" mkdir -p "${SOURCE_DIR}" log "[runner] isolate: shallow clone depth=${CLONE_DEPTH} ref=${ref} remote=*** -> ${SOURCE_DIR}" if git clone --depth "${CLONE_DEPTH}" \ --single-branch --branch "${ref}" "${remote}" "${SOURCE_DIR}"; then : else log "[runner] isolate: branch clone failed; clone default branch then checkout ${ref}" run_step git-clone git clone --depth "${CLONE_DEPTH}" "${remote}" "${SOURCE_DIR}" run_step git-fetch git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" fetch --depth "${CLONE_DEPTH}" origin "${ref}" run_step git-checkout git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" checkout "${ref}" fi if [[ -n "${GIT_SHA:-}" ]]; then log "[runner] isolate: checkout pin GIT_SHA=${GIT_SHA}" run_step git-pin git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" checkout "${GIT_SHA}" fi init_mobile_submodule_only "${SOURCE_DIR}" local host_tree iso_tree host_tree="$(host_mobile_tree)" iso_tree="$(isolated_mobile_tree "${SOURCE_DIR}")" if [[ -n "$iso_tree" ]]; then seed_third_party_from_host "$host_tree" "$iso_tree" stage_ci_scripts "$iso_tree" DOCKER_WORKSPACE="${iso_tree}" else DOCKER_WORKSPACE="${SOURCE_DIR}" fi run_step git-rev-parse git -c safe.directory="${SOURCE_DIR}" -C "${SOURCE_DIR}" rev-parse HEAD } sync_shared_checkout() { if [[ -z "${GIT_REF:-}" ]] || [[ ! -d "${WORK_DIR}/.git" ]]; then return 0 fi log "[runner] syncing shared checkout ref=${GIT_REF}" run_logged git_safe -C "${WORK_DIR}" fetch origin --prune || true run_logged git_safe -C "${WORK_DIR}" checkout "${GIT_REF}" run_logged git_safe -C "${WORK_DIR}" pull --rebase origin "${GIT_REF}" || true if [[ -n "${GIT_SHA:-}" ]]; then run_logged git_safe -C "${WORK_DIR}" checkout "${GIT_SHA}" fi run_logged git_safe -C "${WORK_DIR}" rev-parse HEAD } should_skip_git() { [[ "$RESUME_FROM" == "docker-build" || "$RESUME_FROM" == "docker-run" ]] } should_skip_docker_build() { [[ "$RESUME_FROM" == "docker-run" ]] && docker image inspect "$IMAGE" >/dev/null 2>&1 } reuse_source_tree() { if [[ -n "$REUSE_SRC_DIR" && -d "${REUSE_SRC_DIR}/.git" ]]; then log "[runner] reuse: source tree from ${REUSE_SRC_DIR}" rm -rf "${SOURCE_DIR}" mkdir -p "${SOURCE_DIR}" cp -a "${REUSE_SRC_DIR}/." "${SOURCE_DIR}/" DOCKER_WORKSPACE="${SOURCE_DIR}" return 0 fi return 1 } run_git_step() { if [[ "$ISOLATE_SOURCE" == "1" ]]; then prepare_isolated_source else sync_shared_checkout DOCKER_WORKSPACE="${WORK_DIR}" fi } start_ssh_debug_container() { log "[runner] SSH debug: starting container ${CONTAINER_NAME} (${SSH_DEBUG_SECONDS}s)" docker rm -f "${CONTAINER_NAME}" 2>/dev/null || true run_step docker-run-ssh docker run -d --name "${CONTAINER_NAME}" \ -v "${DOCKER_WORKSPACE}:/workspace" \ -v "${ROOT}/out:/workspace/out" \ -w /workspace \ -e BUILD_ID="${BUILD_ID}" \ -e OUT_DIR="/workspace/out/builds/${BUILD_ID}" \ "$IMAGE" \ sleep "${SSH_DEBUG_SECONDS}" printf '%s\n' 'ssh_debug' > "${OUT_DIR}/runner.mode" local host host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo builder)" log "[runner] SSH debug: session open on ${host}" log "[runner] SSH debug: ssh @${host}" log "[runner] SSH debug: docker exec -it ${CONTAINER_NAME} bash" log "[runner] SSH debug: workspace is /workspace inside the container" log "[runner] finished build_id=${BUILD_ID} (ssh debug — stop build or wait ${SSH_DEBUG_SECONDS}s)" } log "[runner] image=${IMAGE} ci_version=${CI_VERSION} build_id=${BUILD_ID} container=${CONTAINER_NAME}" log "[runner] workdir=${WORK_DIR} out=${OUT_DIR} isolate=${ISOLATE_SOURCE} resume=${RESUME_FROM:-full}" if should_skip_git; then if reuse_source_tree; then log "[runner] resume: skipped git (reused source)" elif [[ "$ISOLATE_SOURCE" != "1" && -d "${WORK_DIR}/.git" ]]; then DOCKER_WORKSPACE="${WORK_DIR}" log "[runner] resume: skipped git (shared workdir ${WORK_DIR})" else log "[runner] resume: no reusable source; running git step" run_git_step fi else run_git_step fi log "[runner] docker workspace=${DOCKER_WORKSPACE}" if should_skip_docker_build; then log "[runner] resume: skipped docker-build (image ${IMAGE} exists)" else dockerfile="${DOCKERFILE_PATH}" if [[ -z "$dockerfile" || ! -f "$dockerfile" ]]; then for candidate in \ "${HOST_SCRIPTS_DIR}/Dockerfile.androidcast-ci" \ "${DOCKER_WORKSPACE}/Dockerfile" \ "${ROOT}/Dockerfile"; do if [[ -f "$candidate" ]]; then dockerfile="$candidate" break fi done fi if [[ -z "$dockerfile" || ! -f "$dockerfile" ]]; then log "[runner] ERROR: CI Dockerfile not found (set BUILD_DOCKERFILE or add Dockerfile.androidcast-ci under ac-scripts)" exit 1 fi docker_ctx="${DOCKER_WORKSPACE}" if [[ "$(basename "$dockerfile")" == "Dockerfile.androidcast-ci" ]]; then docker_ctx="$(dirname "$dockerfile")" fi run_step docker-build docker build \ --build-arg ANDROIDCAST_CI_VERSION="${CI_VERSION}" \ -t "$IMAGE" \ -f "${dockerfile}" \ "${docker_ctx}" fi if [[ "$SSH_DEBUG" == "1" ]]; then start_ssh_debug_container exit 0 fi run_step docker-run docker run --rm --name "${CONTAINER_NAME}" \ --network host \ -v "${DOCKER_WORKSPACE}:/workspace" \ -v "${ROOT}/out:/workspace/out" \ -w /workspace \ -e BUILD_ID="${BUILD_ID}" \ -e OUT_DIR="/workspace/out/builds/${BUILD_ID}" \ -e OTA_BASE_URL="${OTA_BASE_URL:-}" \ -e OTA_CHANNEL="${OTA_CHANNEL:-staging}" \ -e GRADLE_TASK="${GRADLE_TASK:-assembleDebug}" \ -e RUN_UNIT_TESTS="${RUN_UNIT_TESTS:-1}" \ -e RUN_NATIVE="${RUN_NATIVE:-1}" \ -e AUTO_OTA="${AUTO_OTA:-0}" \ -e GIT_REF="${GIT_REF:-}" \ -e GIT_SHA="${GIT_SHA:-}" \ -e RELEASE_NOTES="${RELEASE_NOTES:-}" \ "$IMAGE" \ ./scripts/ci-build-and-publish-ota.sh log "[runner] finished build_id=${BUILD_ID}"