diff --git a/.github/actions/apt-install/action.yml b/.github/actions/apt-install/action.yml index 3f20230e6..e73ca221c 100644 --- a/.github/actions/apt-install/action.yml +++ b/.github/actions/apt-install/action.yml @@ -1,53 +1,62 @@ name: Install apt packages description: > - Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node. + Caches apt packages and their dependencies for an exact hosted runner image. Cache hits install offline; cache misses download from Ubuntu mirrors with bounded retries. inputs: packages: - description: Space separated list of packages to install + description: Space separated list of package names to install required: true attempts: - description: Maximum number of attempts + description: Maximum number of download attempts default: "3" attempt-timeout: - description: Seconds before a single attempt is killed + description: Seconds before a single download attempt is killed default: "180" runs: using: composite steps: + - name: Identify apt cache + id: cache-key + shell: bash + env: + PACKAGES: ${{ inputs.packages }} + run: | + # The bundle contains only dependencies missing from this runner. Never + # restore it onto a different image or initial package installation. + # Without an image version (e.g. self-hosted), download normally. + if [ -n "${ImageVersion:-}" ]; then + fingerprint=$({ + printf '%s\n' "$PACKAGES" + dpkg --print-architecture + dpkg-query -W -f='${binary:Package}=${Version} ${db:Status-Abbrev}\n' + sha256sum "$GITHUB_ACTION_PATH/action.yml" "$GITHUB_ACTION_PATH/install.sh" | cut -d ' ' -f 1 + } | sha256sum | cut -d ' ' -f 1) + echo "key=apt-v1-${ImageOS}-${ImageVersion}-${fingerprint}" >> "$GITHUB_OUTPUT" + fi + - name: Restore apt packages + id: cache + if: steps.cache-key.outputs.key != '' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ runner.temp }}/apt-packages/*.deb + ${{ runner.temp }}/apt-packages/complete + key: ${{ steps.cache-key.outputs.key }} - name: Install ${{ inputs.packages }} shell: bash env: PACKAGES: ${{ inputs.packages }} ATTEMPTS: ${{ inputs.attempts }} ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }} - run: | - SUDO="" - if [ "$(id -u)" -ne 0 ]; then - SUDO="sudo" - fi - - attempt_install() { - $SUDO apt-get update -q - # Intentionally unquoted, this is a list of package names - $SUDO apt-get install -q --yes --no-install-recommends $PACKAGES - } - export -f attempt_install - export SUDO PACKAGES - - for attempt in $(seq 1 "${ATTEMPTS}"); do - echo "::group::apt install attempt ${attempt}/${ATTEMPTS}" - start=$(date +%s) - if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then - echo "::endgroup::" - echo "Installed in $(( $(date +%s) - start ))s" - exit 0 - fi - echo "::endgroup::" - echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s" - # A killed attempt can leave dpkg half configured - $SUDO dpkg --configure -a || true - done - - echo "::error::apt install failed after ${ATTEMPTS} attempts" - exit 1 + CACHE_DIR: ${{ runner.temp }}/apt-packages + CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} + run: bash "$GITHUB_ACTION_PATH/install.sh" + # Save immediately: a later test failure must not discard a good apt bundle. + - name: Save apt packages + if: steps.cache-key.outputs.key != '' && steps.cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ runner.temp }}/apt-packages/*.deb + ${{ runner.temp }}/apt-packages/complete + key: ${{ steps.cache-key.outputs.key }} diff --git a/.github/actions/apt-install/install.sh b/.github/actions/apt-install/install.sh new file mode 100644 index 000000000..cb9161183 --- /dev/null +++ b/.github/actions/apt-install/install.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +SUDO=() +if [ "$(id -u)" -ne 0 ]; then + SUDO=(sudo) +fi + +# Inputs are package names, separated by whitespace (not shell expressions). +read -r -a requested_packages <<< "$PACKAGES" +mkdir -p "$CACHE_DIR" + +install_local() { + local archives=("$CACHE_DIR"/*.deb) + if [ -e "${archives[0]}" ]; then + # Local .deb arguments pin the downloaded versions. No package lists or + # network access are needed, and apt still runs all package maintainer scripts. + "${SUDO[@]}" chmod a+r "${archives[@]}" + "${SUDO[@]}" apt-get install -q --yes --no-install-recommends \ + --no-download -o "Dir::Cache::archives=$CACHE_DIR" "${archives[@]}" + fi + # An empty bundle is valid when the runner already includes every package. + local package + for package in "${requested_packages[@]}"; do + [ "$(dpkg-query -W -f='${Status}' "$package")" = "install ok installed" ] + done +} + +if [ "$CACHE_HIT" = true ]; then + echo "Installing cached apt packages without contacting package repositories" + install_local + exit 0 +fi + +# The hosted runner gives Azure priority 1. A slow transfer never triggers its +# fallback mirrors, so retrying alone keeps selecting the same unhealthy mirror. +if [ -f /etc/apt/apt-mirrors.txt ]; then + "${SUDO[@]}" sed -i '\|azure\.archive\.ubuntu\.com|d' /etc/apt/apt-mirrors.txt +fi + +# Download first so the bundle contains every missing transitive dependency. +# Keep downloads outside apt's default cache, which Docker cleanup hooks may wipe. +download_packages() { + "${SUDO[@]}" apt-get update -q --error-on=any + "${SUDO[@]}" apt-get install -q --yes --no-install-recommends \ + --download-only -o "Dir::Cache::archives=$CACHE_DIR" "${requested_packages[@]}" +} +# Recreate the sudo and package arrays in the timed child process. +if [ "${1:-}" = download ]; then + download_packages + exit 0 +fi + +for attempt in $(seq 1 "$ATTEMPTS"); do + echo "::group::apt download attempt ${attempt}/${ATTEMPTS}" + if timeout --kill-after=10 "$ATTEMPT_TIMEOUT" bash "$0" download; then + echo "::endgroup::" + install_local + touch "$CACHE_DIR/complete" + exit 0 + fi + echo "::endgroup::" + echo "::warning::apt download attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s" +done + +echo "::error::apt download failed after ${ATTEMPTS} attempts" +exit 1 diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 6bac2c24f..4d1ccff76 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -76,6 +76,7 @@ jobs: - 'uv.lock' - 'docker/compose/docker-compose.ci-test.yml' - '.github/workflows/ci-backend.yml' + - '.github/actions/apt-install/**' test: needs: changes if: needs.changes.outputs.backend_changed == 'true' diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index 4935a767b..c7d55910f 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -68,6 +68,7 @@ jobs: frontend: - 'src-ui/**' - '.github/workflows/ci-frontend.yml' + - '.github/actions/apt-install/**' install-dependencies: needs: changes if: needs.changes.outputs.frontend_changed == 'true'