Переглянути джерело

ci(download-kernel): refactor action to use bash script for modular phases

Split the monolithic Python script into a modular bash script with separate prepare, parse, sync, and post phases. This improves maintainability and allows for better caching and error handling. Also fix URL quoting for refs containing slashes and add validation for build-tools BUILD.bazel.
TheWildJames 4 місяців тому
батько
коміт
5a63d93f8b

BIN
.github/actions/download-kernel/__pycache__/download_kernel_archives.cpython-312.pyc


+ 29 - 2
.github/actions/download-kernel/action.yml

@@ -48,7 +48,7 @@ runs:
       run: |
         mkdir -p "${{ github.workspace }}/kernel"
 
-    - name: Download Kernel Repository (Manifest Archives)
+    - name: Prepare Manifest
       id: sync
       shell: bash
       working-directory: ${{ github.workspace }}/kernel
@@ -56,4 +56,31 @@ runs:
         PYTHONUNBUFFERED: "1"
         GITHUB_TOKEN: ${{ github.token }}
       run: |
-        python3 "${{ github.action_path }}/download_kernel_archives.py" --android-version "${{ inputs.android_version }}" --kernel-version "${{ inputs.kernel_version }}" --os-patch-level "${{ inputs.os_patch_level }}"
+        bash "${{ github.action_path }}/download_kernel_archives.sh" prepare "${{ inputs.android_version }}" "${{ inputs.kernel_version }}" "${{ inputs.os_patch_level }}"
+
+    - name: Parse Manifest
+      shell: bash
+      working-directory: ${{ github.workspace }}/kernel
+      env:
+        PYTHONUNBUFFERED: "1"
+        GITHUB_TOKEN: ${{ github.token }}
+      run: |
+        bash "${{ github.action_path }}/download_kernel_archives.sh" parse "${{ inputs.android_version }}" "${{ inputs.kernel_version }}" "${{ inputs.os_patch_level }}"
+
+    - name: Sync Projects
+      shell: bash
+      working-directory: ${{ github.workspace }}/kernel
+      env:
+        PYTHONUNBUFFERED: "1"
+        GITHUB_TOKEN: ${{ github.token }}
+      run: |
+        bash "${{ github.action_path }}/download_kernel_archives.sh" sync "${{ inputs.android_version }}" "${{ inputs.kernel_version }}" "${{ inputs.os_patch_level }}"
+
+    - name: Post Process
+      shell: bash
+      working-directory: ${{ github.workspace }}/kernel
+      env:
+        PYTHONUNBUFFERED: "1"
+        GITHUB_TOKEN: ${{ github.token }}
+      run: |
+        bash "${{ github.action_path }}/download_kernel_archives.sh" post "${{ inputs.android_version }}" "${{ inputs.kernel_version }}" "${{ inputs.os_patch_level }}"

+ 36 - 1
.github/actions/download-kernel/download_kernel_archives.py

@@ -539,7 +539,7 @@ def main() -> int:
                     os.remove(p)
 
     def url_quote_rev(rev: str) -> str:
-        return urllib.parse.quote(rev, safe="")
+        return urllib.parse.quote(rev, safe="/")
 
     def googlesource_archive_urls(repo_url: str, rev: str) -> list[str]:
         qrev = url_quote_rev(rev)
@@ -614,6 +614,41 @@ def main() -> int:
                             last_err = err
                             continue
                         extract_tar_gz(tmp_path, dest_dir, strip_components)
+                        if path == "prebuilts/build-tools":
+                            try:
+                                build_bazel = os.path.join(dest_dir, "BUILD.bazel")
+                                if not os.path.exists(build_bazel):
+                                    return {
+                                        "ok": False,
+                                        "name": name,
+                                        "path": path,
+                                        "rev": rev,
+                                        "base_url": base_url,
+                                        "error": "BUILD.bazel missing after extract",
+                                        "urls": urls,
+                                    }
+                                with open(build_bazel, "r", encoding="utf-8", errors="replace") as f:
+                                    content = f.read()
+                                if "py_toolchain" not in content:
+                                    return {
+                                        "ok": False,
+                                        "name": name,
+                                        "path": path,
+                                        "rev": rev,
+                                        "base_url": base_url,
+                                        "error": "BUILD.bazel missing py_toolchain",
+                                        "urls": urls,
+                                    }
+                            except Exception:
+                                return {
+                                    "ok": False,
+                                    "name": name,
+                                    "path": path,
+                                    "rev": rev,
+                                    "base_url": base_url,
+                                    "error": "failed to validate build-tools BUILD.bazel",
+                                    "urls": urls,
+                                }
                         label = get_toolchain_label(name)
                         if label:
                             ensure_toolchain_cached(label, rev, dest_dir)

+ 571 - 0
.github/actions/download-kernel/download_kernel_archives.sh

@@ -0,0 +1,571 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PHASE="${1:-all}"
+shift || true
+RUN_ALL=false
+
+ANDROID_VERSION="${1:-${ANDROID_VERSION:-}}"
+KERNEL_VERSION="${2:-${KERNEL_VERSION:-}}"
+OS_PATCH_LEVEL="${3:-${OS_PATCH_LEVEL:-}}"
+
+if [[ "$PHASE" == "prepare" || "$PHASE" == "all" ]]; then
+  if [[ -z "$ANDROID_VERSION" || -z "$KERNEL_VERSION" || -z "$OS_PATCH_LEVEL" ]]; then
+    echo "usage: $0 <prepare|parse|sync|post|all> <android_version> <kernel_version> <os_patch_level>" >&2
+    exit 2
+  fi
+fi
+
+require_env() {
+  local key="$1"
+  if [[ -z "${!key:-}" ]]; then
+    echo "missing env: ${key}" >&2
+    exit 2
+  fi
+}
+
+fetch_gitiles_text() {
+  local manifest_base="$1"
+  local file_name="$2"
+  curl -LfsS -H "User-Agent: actions-download-kernel" "${manifest_base}${file_name}?format=TEXT" | base64 -d
+}
+
+prepare_manifest() {
+  local formatted_branch="${ANDROID_VERSION}-${KERNEL_VERSION}-${OS_PATCH_LEVEL}"
+  local manifest_branch="common-${formatted_branch}"
+  local manifest_ref="refs/heads/${manifest_branch}"
+  local manifest_base="https://android.googlesource.com/kernel/manifest/+/${manifest_ref}/"
+
+  local deprecated=false
+  if git ls-remote https://android.googlesource.com/kernel/common "${formatted_branch}" 2>/dev/null | rg -q deprecated; then
+    deprecated=true
+  fi
+
+  if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
+    printf 'deprecated=%s\n' "$(echo "$deprecated" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
+  fi
+
+  echo "Manifest branch: ${manifest_branch}"
+  echo "Deprecated: ${deprecated}"
+
+  TMP_DIR="$(mktemp -d)"
+  export TMP_DIR
+
+  if [[ -n "${GITHUB_ENV:-}" ]]; then
+    {
+      echo "TMP_DIR=${TMP_DIR}"
+      echo "ANDROID_VERSION=${ANDROID_VERSION}"
+      echo "KERNEL_VERSION=${KERNEL_VERSION}"
+      echo "OS_PATCH_LEVEL=${OS_PATCH_LEVEL}"
+      echo "FORMATTED_BRANCH=${formatted_branch}"
+      echo "MANIFEST_BRANCH=${manifest_branch}"
+      echo "MANIFEST_REF=${manifest_ref}"
+      echo "MANIFEST_BASE=${manifest_base}"
+      echo "DEPRECATED=${deprecated}"
+    } >> "$GITHUB_ENV"
+  fi
+
+  local manifest_xml
+  manifest_xml="$(fetch_gitiles_text "${manifest_base}" default.xml)"
+  if [[ "$deprecated" == "true" ]]; then
+    manifest_xml="$(printf '%s' "$manifest_xml" | sed "s/\"${formatted_branch//\//\\/}\"/\"deprecated\\/${formatted_branch//\//\\/}\"/g")"
+  fi
+  printf '%s' "$manifest_xml" > "${TMP_DIR}/manifest.xml"
+}
+
+parse_manifest() {
+  require_env TMP_DIR
+  python3 - <<'PY' > "${TMP_DIR}/parsed.jsonl"
+import json
+import os
+import urllib.parse
+import xml.etree.ElementTree as ET
+
+manifest_origin = "https://android.googlesource.com"
+
+with open(os.path.join(os.environ["TMP_DIR"], "manifest.xml"), "r", encoding="utf-8", errors="replace") as f:
+    root = ET.fromstring(f.read())
+
+remotes = {}
+for r in root.findall("remote"):
+    name = r.get("name")
+    fetch = (r.get("fetch") or "").strip()
+    if not name or not fetch:
+        continue
+    if fetch.startswith("http://") or fetch.startswith("https://"):
+        remotes[name] = fetch.rstrip("/")
+    else:
+        remotes[name] = urllib.parse.urljoin(manifest_origin + "/", fetch.rstrip("/") + "/").rstrip("/")
+
+default = root.find("default")
+def_remote = default.get("remote") if default is not None else None
+def_rev = default.get("revision") if default is not None else None
+
+projects = []
+link_copy = []
+
+for p in root.findall("project"):
+    name = p.get("name")
+    if not name:
+        continue
+    path = p.get("path", name)
+    remote_name = p.get("remote", def_remote)
+    rev = p.get("revision", def_rev)
+    base_url = remotes.get(remote_name) if remote_name else None
+    if not base_url or not rev:
+        continue
+    projects.append({"name": name, "path": path, "rev": rev, "base_url": base_url})
+    for child in list(p):
+        if child.tag in ("linkfile", "copyfile"):
+            link_copy.append({"project_path": path, "tag": child.tag, "src": child.get("src"), "dest": child.get("dest")})
+
+for prj in projects:
+    print(json.dumps({"type": "project", **prj}, separators=(",", ":")))
+
+for op in link_copy:
+    print(json.dumps({"type": "op", **op}, separators=(",", ":")))
+PY
+
+  jq -r 'select(.type=="project") | [.name,.path,.rev,.base_url] | @tsv' "${TMP_DIR}/parsed.jsonl" > "${TMP_DIR}/projects.txt"
+  jq -r 'select(.type=="op") | [.tag,.project_path,(.src//""),(.dest//"")] | @tsv' "${TMP_DIR}/parsed.jsonl" > "${TMP_DIR}/ops.txt"
+}
+
+if [[ "$PHASE" == "prepare" ]]; then
+  prepare_manifest
+  exit 0
+fi
+
+if [[ "$PHASE" == "parse" ]]; then
+  parse_manifest
+  exit 0
+fi
+
+if [[ "$PHASE" == "all" ]]; then
+  prepare_manifest
+  parse_manifest
+  RUN_ALL=true
+  PHASE="sync"
+fi
+
+require_env TMP_DIR
+
+TARGET_REPO="${GITHUB_REPOSITORY:-}"
+GITHUB_TOKEN_VALUE="${GITHUB_TOKEN:-}"
+
+release_json="${TMP_DIR}/release.json"
+assets_json="${TMP_DIR}/assets.json"
+
+get_or_create_release() {
+  if [[ -z "$TARGET_REPO" || -z "$GITHUB_TOKEN_VALUE" ]]; then
+    return 1
+  fi
+
+  if curl -LfsS -H "Authorization: token ${GITHUB_TOKEN_VALUE}" -H "Accept: application/vnd.github.v3+json" \
+    "https://api.github.com/repos/${TARGET_REPO}/releases/tags/toolchain-cache" > "$release_json"; then
+    return 0
+  fi
+
+  curl -LfsS -X POST -H "Authorization: token ${GITHUB_TOKEN_VALUE}" -H "Accept: application/vnd.github.v3+json" \
+    -H "Content-Type: application/json" \
+    -d '{"tag_name":"toolchain-cache","name":"Toolchains Mirror Cache","draft":false,"prerelease":false}' \
+    "https://api.github.com/repos/${TARGET_REPO}/releases" > "$release_json" || true
+
+  curl -LfsS -H "Authorization: token ${GITHUB_TOKEN_VALUE}" -H "Accept: application/vnd.github.v3+json" \
+    "https://api.github.com/repos/${TARGET_REPO}/releases/tags/toolchain-cache" > "$release_json"
+}
+
+refresh_assets() {
+  if [[ -z "$TARGET_REPO" || -z "$GITHUB_TOKEN_VALUE" ]]; then
+    printf '[]' > "$assets_json"
+    return 0
+  fi
+  if ! curl -LfsS -H "Authorization: token ${GITHUB_TOKEN_VALUE}" -H "Accept: application/vnd.github.v3+json" \
+    "https://api.github.com/repos/${TARGET_REPO}/releases/tags/toolchain-cache" > "$release_json"; then
+    get_or_create_release || true
+  fi
+  jq -c '.assets // []' "$release_json" > "$assets_json" || printf '[]' > "$assets_json"
+}
+
+upload_asset() {
+  local upload_url_template="$1"
+  local file_path="$2"
+  local asset_name="$3"
+  local upload_url
+  upload_url="${upload_url_template%%\{*}"
+  local url="${upload_url}?name=$(python3 - <<PY
+import urllib.parse
+print(urllib.parse.quote("${asset_name}", safe=""))
+PY
+)"
+  local http_code
+  http_code="$(curl -LsS -X POST \
+    -H "Authorization: token ${GITHUB_TOKEN_VALUE}" \
+    -H "Accept: application/vnd.github.v3+json" \
+    -H "Content-Type: application/octet-stream" \
+    -H "User-Agent: actions-download-kernel" \
+    --data-binary "@${file_path}" \
+    -o /dev/null -w "%{http_code}" \
+    "$url" || true)"
+  if [[ "$http_code" == "200" || "$http_code" == "201" || "$http_code" == "422" ]]; then
+    return 0
+  fi
+  return 1
+}
+
+download_release_asset() {
+  local api_url="$1"
+  local out_path="$2"
+  curl -LfsS \
+    -H "Authorization: token ${GITHUB_TOKEN_VALUE}" \
+    -H "Accept: application/octet-stream" \
+    -H "User-Agent: actions-download-kernel" \
+    "$api_url" -o "$out_path"
+}
+
+sha256_file() {
+  sha256sum "$1" | awk '{print $1}'
+}
+
+toolchain_label_for() {
+  local name="$1"
+  if [[ "$name" == *"clang/host/linux-x86"* ]]; then echo "clang"; return 0; fi
+  if [[ "$name" == *"prebuilts/rust"* ]]; then echo "rust"; return 0; fi
+  if [[ "$name" == *"prebuilts/clang-tools"* ]]; then echo "clang-tools"; return 0; fi
+  if [[ "$name" == *"platform/prebuilts/build-tools"* ]]; then echo "build-tools"; return 0; fi
+  if [[ "$name" == *"kernel/prebuilts/build-tools"* ]]; then echo "kernel-build-tools"; return 0; fi
+  if [[ "$name" == *"platform/prebuilts/bazel/linux-x86_64"* ]]; then echo "bazel-linux-x86_64"; return 0; fi
+  if [[ "$name" == *"platform/prebuilts/jdk/jdk11"* ]]; then echo "jdk11"; return 0; fi
+  if [[ "$name" == *"toolchain/prebuilts/ndk/r23"* ]]; then echo "ndk-r23"; return 0; fi
+  if [[ "$name" == *"platform/prebuilts/gcc/linux-x86/host/x86_64-linux-glibc2.17-4.8"* ]]; then echo "gcc-glibc2.17-4.8"; return 0; fi
+  return 1
+}
+
+googlesource_urls() {
+  local repo_url="$1"
+  local rev="$2"
+  if [[ "$rev" == refs/* ]]; then
+    printf '%s/+archive/%s.tar.gz\n' "$repo_url" "$(python3 - <<PY
+import urllib.parse
+print(urllib.parse.quote("${rev}", safe="/"))
+PY
+)"
+    return 0
+  fi
+  printf '%s/+archive/%s.tar.gz\n' "$repo_url" "$(python3 - <<PY
+import urllib.parse
+print(urllib.parse.quote("refs/heads/${rev}", safe="/"))
+PY
+)"
+  printf '%s/+archive/%s.tar.gz\n' "$repo_url" "$(python3 - <<PY
+import urllib.parse
+print(urllib.parse.quote("refs/tags/${rev}", safe="/"))
+PY
+)"
+  printf '%s/+archive/%s.tar.gz\n' "$repo_url" "$(python3 - <<PY
+import urllib.parse
+print(urllib.parse.quote("${rev}", safe="/"))
+PY
+)"
+}
+
+github_urls() {
+  local repo_url="$1"
+  local rev="$2"
+  if [[ "$rev" == refs/* ]]; then
+    printf '%s/archive/%s.tar.gz\n' "$repo_url" "$rev"
+    return 0
+  fi
+  printf '%s/archive/refs/heads/%s.tar.gz\n' "$repo_url" "$rev"
+  printf '%s/archive/refs/tags/%s.tar.gz\n' "$repo_url" "$rev"
+  printf '%s/archive/%s.tar.gz\n' "$repo_url" "$rev"
+}
+
+gitlab_urls() {
+  local repo_url="$1"
+  local rev="$2"
+  local base
+  base="$(basename "$repo_url")"
+  local qrev
+  qrev="$(python3 - <<PY
+import urllib.parse
+print(urllib.parse.quote("${rev}", safe=""))
+PY
+)"
+  printf '%s/-/archive/%s/%s-%s.tar.gz\n' "$repo_url" "$qrev" "$base" "$qrev"
+}
+
+extract_archive() {
+  local archive_path="$1"
+  local dest_dir="$2"
+  local strip="$3"
+  mkdir -p "$dest_dir"
+  if [[ "$strip" == "1" ]]; then
+    tar -xzf "$archive_path" -C "$dest_dir" --strip-components 1
+  else
+    tar -xzf "$archive_path" -C "$dest_dir"
+  fi
+}
+
+try_extract_from_cache() {
+  local label="$1"
+  local rev="$2"
+  local dest_dir="$3"
+  local strip="$4"
+
+  if [[ -z "$TARGET_REPO" || -z "$GITHUB_TOKEN_VALUE" ]]; then
+    return 1
+  fi
+
+  refresh_assets
+  local meta_name="${label}-${rev}.cache.json"
+  local meta_url
+  meta_url="$(jq -r --arg n "$meta_name" '.[] | select(.name==$n) | .url' "$assets_json" | head -n 1)"
+  local parts=()
+  if [[ -n "$meta_url" && "$meta_url" != "null" ]]; then
+    local meta_path="${TMP_DIR}/${meta_name}"
+    download_release_asset "$meta_url" "$meta_path" || return 1
+    mapfile -t parts < <(jq -r '.parts[]?.name' "$meta_path" 2>/dev/null || true)
+    if [[ "${#parts[@]}" -eq 0 ]]; then
+      return 1
+    fi
+  else
+    mapfile -t parts < <(jq -r --arg p "${label}-${rev}.tar.gz" '.[] | select(.name|startswith($p)) | .name' "$assets_json" | sort)
+    if [[ "${#parts[@]}" -eq 0 ]]; then
+      return 1
+    fi
+  fi
+
+  local downloaded_parts=()
+  for part in "${parts[@]}"; do
+    local url
+    url="$(jq -r --arg n "$part" '.[] | select(.name==$n) | .url' "$assets_json" | head -n 1)"
+    if [[ -z "$url" || "$url" == "null" ]]; then
+      return 1
+    fi
+    local out="${TMP_DIR}/${part}"
+    download_release_asset "$url" "$out" || return 1
+    downloaded_parts+=("$out")
+  done
+
+  if [[ "${#downloaded_parts[@]}" -eq 1 && "${downloaded_parts[0]}" == *.tar.gz ]]; then
+    extract_archive "${downloaded_parts[0]}" "$dest_dir" "$strip"
+    return 0
+  fi
+
+  local merged="${TMP_DIR}/${label}-${rev}.tar.gz"
+  cat "${downloaded_parts[@]}" > "$merged"
+  extract_archive "$merged" "$dest_dir" "$strip"
+  return 0
+}
+
+ensure_cached() {
+  local label="$1"
+  local rev="$2"
+  local src_dir="$3"
+
+  if [[ -z "$TARGET_REPO" || -z "$GITHUB_TOKEN_VALUE" ]]; then
+    return 0
+  fi
+
+  get_or_create_release || return 0
+  local upload_url_template
+  upload_url_template="$(jq -r '.upload_url // empty' "$release_json")"
+  if [[ -z "$upload_url_template" ]]; then
+    return 0
+  fi
+
+  refresh_assets
+  local meta_name="${label}-${rev}.cache.json"
+  if jq -e --arg n "$meta_name" '.[] | select(.name==$n)' "$assets_json" >/dev/null 2>&1; then
+    return 0
+  fi
+
+  local work_dir="${TMP_DIR}/cache-${label}-${rev}"
+  mkdir -p "$work_dir"
+  local base_filename="${label}-${rev}.tar.gz"
+  local archive_path="${work_dir}/${base_filename}"
+  tar -I "gzip -1" -cf "$archive_path" -C "$src_dir" .
+
+  local max_part=$((1900*1024*1024))
+  local meta_path="${work_dir}/${meta_name}"
+  if [[ "$(stat -c '%s' "$archive_path")" -le "$max_part" ]]; then
+    upload_asset "$upload_url_template" "$archive_path" "$base_filename" || true
+    python3 - <<PY > "$meta_path"
+import json, os, hashlib
+p="${archive_path}"
+h=hashlib.sha256()
+with open(p,"rb") as f:
+  for c in iter(lambda:f.read(1024*1024), b""):
+    h.update(c)
+meta={"format":1,"label":"${label}","rev":"${rev}","archive":"${base_filename}","parts":[{"name":"${base_filename}","size":os.path.getsize(p),"sha256":h.hexdigest()}]}
+print(json.dumps(meta,separators=(',',':')))
+PY
+    upload_asset "$upload_url_template" "$meta_path" "$meta_name" || true
+    return 0
+  fi
+
+  split -b "$max_part" -d -a 2 "$archive_path" "${archive_path}.part"
+  python3 - <<PY > "$meta_path"
+import json, os, glob, hashlib
+parts=[]
+for p in sorted(glob.glob("${archive_path}.part"*)):
+  name=os.path.basename(p)
+  h=hashlib.sha256()
+  with open(p,"rb") as f:
+    for c in iter(lambda:f.read(1024*1024), b""):
+      h.update(c)
+  parts.append({"name":name,"size":os.path.getsize(p),"sha256":h.hexdigest()})
+meta={"format":1,"label":"${label}","rev":"${rev}","archive":"${base_filename}","parts":parts}
+print(json.dumps(meta,separators=(',',':')))
+PY
+  for part_path in "${archive_path}.part"*; do
+    upload_asset "$upload_url_template" "$part_path" "$(basename "$part_path")" || true
+  done
+  upload_asset "$upload_url_template" "$meta_path" "$meta_name" || true
+}
+
+sync_one_project() {
+  local name="$1"
+  local path="$2"
+  local rev="$3"
+  local base_url="$4"
+  local dest_dir
+  if [[ "$path" == "." || "$path" == "./" ]]; then
+    dest_dir="$(pwd)"
+  else
+    dest_dir="$(pwd)/$path"
+  fi
+  mkdir -p "$dest_dir"
+
+  local label=""
+  if label="$(toolchain_label_for "$name" 2>/dev/null)"; then
+    if try_extract_from_cache "$label" "$rev" "$dest_dir" "0"; then
+      echo "Synced ${name} -> ${path}"
+      return 0
+    fi
+  fi
+
+  local repo_url="${base_url}/${name}"
+  local urls=()
+  local strip="0"
+  if [[ "$base_url" == *github.com* ]]; then
+    mapfile -t urls < <(github_urls "$repo_url" "$rev")
+    strip="1"
+  elif [[ "$base_url" == *googlesource.com* || "$base_url" == *android.googlesource.com* ]]; then
+    mapfile -t urls < <(googlesource_urls "$repo_url" "$rev")
+    strip="0"
+  elif [[ "$base_url" == *git.codelinaro.org* || "$base_url" == *gitlab* ]]; then
+    mapfile -t urls < <(gitlab_urls "$repo_url" "$rev")
+    strip="1"
+  else
+    echo "[FAIL] ${name} -> ${path} rev=${rev} remote=${base_url} err=no supported remote" >&2
+    return 1
+  fi
+
+  local tmp_archive="${TMP_DIR}/dl-$(echo -n "${name}-${rev}" | sha256sum | awk '{print $1}').tar.gz"
+  local ok=false
+  local last_err=""
+  for u in "${urls[@]}"; do
+    if curl -LfsS --retry 5 --connect-timeout 30 -o "$tmp_archive" "$u" >/dev/null 2>&1; then
+      extract_archive "$tmp_archive" "$dest_dir" "$strip"
+      ok=true
+      break
+    else
+      last_err="curl failed: $u"
+      continue
+    fi
+  done
+  if [[ "$ok" != "true" ]]; then
+    echo "[FAIL] ${name} -> ${path} rev=${rev} remote=${base_url} err=${last_err}" >&2
+    for u in "${urls[@]}"; do
+      echo "       url=${u}" >&2
+    done
+    return 1
+  fi
+
+  if [[ "$path" == "prebuilts/build-tools" ]]; then
+    if [[ ! -f "${dest_dir}/BUILD.bazel" ]]; then
+      echo "[FAIL] ${name} -> ${path} rev=${rev} remote=${base_url} err=BUILD.bazel missing after extract" >&2
+      return 1
+    fi
+    if ! rg -q "py_toolchain" "${dest_dir}/BUILD.bazel"; then
+      echo "[FAIL] ${name} -> ${path} rev=${rev} remote=${base_url} err=BUILD.bazel missing py_toolchain" >&2
+      return 1
+    fi
+  fi
+
+  if [[ -n "$label" ]]; then
+    ensure_cached "$label" "$rev" "$dest_dir" || true
+  fi
+
+  echo "Synced ${name} -> ${path}"
+}
+
+projects_jsonl="${TMP_DIR}/parsed.jsonl"
+project_lines="${TMP_DIR}/projects.txt"
+op_lines="${TMP_DIR}/ops.txt"
+
+if [[ ! -f "$project_lines" || ! -f "$op_lines" ]]; then
+  jq -r 'select(.type=="project") | [.name,.path,.rev,.base_url] | @tsv' "$projects_jsonl" > "$project_lines"
+  jq -r 'select(.type=="op") | [.tag,.project_path,(.src//""),(.dest//"")] | @tsv' "$projects_jsonl" > "$op_lines"
+fi
+
+if [[ "$PHASE" != "post" ]]; then
+  fail_count=0
+  while IFS=$'\t' read -r name path rev base_url; do
+    if ! sync_one_project "$name" "$path" "$rev" "$base_url"; then
+      fail_count=$((fail_count+1))
+    fi
+  done < "$project_lines"
+
+  if [[ "$fail_count" -ne 0 ]]; then
+    exit 1
+  fi
+fi
+
+if [[ "$PHASE" == "sync" && "$RUN_ALL" != "true" ]]; then
+  exit 0
+fi
+
+if [[ "$PHASE" != "post" && "$RUN_ALL" != "true" ]]; then
+  exit 0
+fi
+
+build_tools_dir="$(pwd)/prebuilts/build-tools"
+expected="${build_tools_dir}/path/linux-x86/python3"
+if [[ ! -e "$expected" ]]; then
+  mkdir -p "$(dirname "$expected")"
+  candidate="$(find "$build_tools_dir" -type f -name python3 -print -quit 2>/dev/null || true)"
+  if [[ -z "$candidate" ]]; then
+    candidate="$(command -v python3 || true)"
+  fi
+  if [[ -n "$candidate" ]]; then
+    rm -f "$expected" 2>/dev/null || true
+    ln -s "$(python3 - <<PY
+import os
+print(os.path.relpath("${candidate}", os.path.dirname("${expected}")))
+PY
+)" "$expected" || true
+  fi
+fi
+
+while IFS=$'\t' read -r tag project_path src dest; do
+  if [[ -z "$src" || -z "$dest" ]]; then
+    continue
+  fi
+  src_path="$(pwd)/${project_path}/${src}"
+  dest_path="$(pwd)/${dest}"
+  mkdir -p "$(dirname "$dest_path")"
+  if [[ "$tag" == "linkfile" ]]; then
+    rm -f "$dest_path" 2>/dev/null || true
+    ln -s "$(python3 - <<PY
+import os
+print(os.path.relpath("${src_path}", os.path.dirname("${dest_path}")))
+PY
+)" "$dest_path"
+  else
+    cp -p "$src_path" "$dest_path"
+  fi
+done < "$op_lines"
+
+exit 0