action.yml 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. name: 'Download Kernel Repository'
  2. description: 'Initialize and sync Android kernel source repository'
  3. inputs:
  4. android_version:
  5. description: 'Android version (e.g., android14)'
  6. required: true
  7. kernel_version:
  8. description: 'Kernel version (e.g., 5.15, 6.1)'
  9. required: true
  10. os_patch_level:
  11. description: 'OS patch level (e.g., 2024-01)'
  12. required: true
  13. outputs:
  14. deprecated_branch:
  15. description: 'Whether the branch is deprecated'
  16. value: ${{ steps.sync.outputs.deprecated }}
  17. runs:
  18. using: composite
  19. steps:
  20. # - name: Initialize and Sync Kernel Repository
  21. # id: sync
  22. # shell: bash
  23. # working-directory: ${{ github.workspace }}/kernel
  24. # run: |
  25. # FORMATTED_BRANCH="${{ inputs.android_version }}-${{ inputs.kernel_version }}-${{ inputs.os_patch_level }}"
  26. #
  27. # repo init -u https://android.googlesource.com/kernel/manifest -b common-${FORMATTED_BRANCH} --depth=1 --partial-clone --clone-filter=blob:limit=10M
  28. #
  29. # REMOTE_BRANCH=$(git ls-remote https://android.googlesource.com/kernel/common ${FORMATTED_BRANCH})
  30. # DEFAULT_MANIFEST_PATH=.repo/manifests/default.xml
  31. # DEPRECATED=false
  32. #
  33. # if grep -q deprecated <<< $REMOTE_BRANCH; then
  34. # sed -i "s/\"${FORMATTED_BRANCH}\"/\"deprecated\/${FORMATTED_BRANCH}\"/g" $DEFAULT_MANIFEST_PATH
  35. # echo "⚠ Note: Branch ${FORMATTED_BRANCH} is considered deprecated."
  36. # DEPRECATED=true
  37. # fi
  38. #
  39. # echo "deprecated=$DEPRECATED" >> $GITHUB_OUTPUT
  40. #
  41. # repo sync -c -j$(nproc --all) --fail-fast --no-tags --force-sync --no-clone-bundle
  42. - name: Ensure Kernel Directory
  43. shell: bash
  44. run: |
  45. mkdir -p "${{ github.workspace }}/kernel"
  46. - name: Download Kernel Repository (Manifest Archives)
  47. id: sync
  48. shell: python
  49. working-directory: ${{ github.workspace }}/kernel
  50. env:
  51. PYTHONUNBUFFERED: "1"
  52. GITHUB_TOKEN: ${{ github.token }}
  53. run: |
  54. import base64
  55. import os
  56. import shutil
  57. import subprocess
  58. import tempfile
  59. import time
  60. import threading
  61. import urllib.parse
  62. import urllib.request
  63. import xml.etree.ElementTree as ET
  64. from concurrent.futures import ThreadPoolExecutor
  65. android_version = "${{ inputs.android_version }}"
  66. kernel_version = "${{ inputs.kernel_version }}"
  67. os_patch_level = "${{ inputs.os_patch_level }}"
  68. formatted_branch = f"{android_version}-{kernel_version}-{os_patch_level}"
  69. manifest_branch = f"common-{formatted_branch}"
  70. manifest_ref = f"refs/heads/{manifest_branch}"
  71. manifest_base = f"https://android.googlesource.com/kernel/manifest/+/{manifest_ref}/"
  72. def fetch_gitiles_file_text(file_name: str) -> str:
  73. url = f"{manifest_base}{file_name}?format=TEXT"
  74. req = urllib.request.Request(url, headers={"User-Agent": "actions-download-kernel"})
  75. with urllib.request.urlopen(req, timeout=60) as resp:
  76. data = resp.read()
  77. decoded = base64.b64decode(data)
  78. return decoded.decode("utf-8", errors="replace")
  79. def is_deprecated_branch(branch: str) -> bool:
  80. try:
  81. out = subprocess.check_output(
  82. ["git", "ls-remote", "https://android.googlesource.com/kernel/common", branch],
  83. text=True,
  84. stderr=subprocess.STDOUT,
  85. timeout=60,
  86. )
  87. return "deprecated" in out
  88. except Exception:
  89. return False
  90. deprecated = is_deprecated_branch(formatted_branch)
  91. if os.environ.get("GITHUB_OUTPUT"):
  92. with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
  93. f.write(f"deprecated={str(deprecated).lower()}\n")
  94. print(f"Manifest branch: {manifest_branch}")
  95. print(f"Deprecated: {deprecated}")
  96. def load_manifest_with_includes(entry_file: str) -> tuple[ET.Element, list[tuple[str, ET.Element]]]:
  97. seen_files: set[str] = set()
  98. to_process = [entry_file]
  99. combined = ET.Element("manifest")
  100. combined_default = None
  101. combined_remotes: dict[str, ET.Element] = {}
  102. projects: list[ET.Element] = []
  103. link_copy: list[tuple[str, ET.Element]] = []
  104. while to_process:
  105. file_name = to_process.pop()
  106. if file_name in seen_files:
  107. continue
  108. seen_files.add(file_name)
  109. xml_text = fetch_gitiles_file_text(file_name)
  110. if deprecated:
  111. xml_text = xml_text.replace(f"\"{formatted_branch}\"", f"\"deprecated/{formatted_branch}\"")
  112. root = ET.fromstring(xml_text)
  113. for include in root.findall("include"):
  114. inc_name = include.get("name")
  115. if inc_name:
  116. to_process.append(inc_name)
  117. for remote in root.findall("remote"):
  118. remote_name = remote.get("name")
  119. if remote_name and remote_name not in combined_remotes:
  120. combined_remotes[remote_name] = remote
  121. if combined_default is None:
  122. d = root.find("default")
  123. if d is not None:
  124. combined_default = d
  125. for project in root.findall("project"):
  126. projects.append(project)
  127. path = project.get("path", project.get("name") or "")
  128. for child in list(project):
  129. if child.tag in ("linkfile", "copyfile"):
  130. link_copy.append((path, child))
  131. for r in combined_remotes.values():
  132. combined.append(r)
  133. if combined_default is not None:
  134. combined.append(combined_default)
  135. for p in projects:
  136. combined.append(p)
  137. return combined, link_copy
  138. manifest_root, link_copy = load_manifest_with_includes("default.xml")
  139. remotes = {r.get("name"): (r.get("fetch") or "").rstrip("/") for r in manifest_root.findall("remote")}
  140. default = manifest_root.find("default")
  141. def_remote = default.get("remote") if default is not None else None
  142. def_rev = default.get("revision") if default is not None else None
  143. target_repo = os.environ.get("GITHUB_REPOSITORY", "")
  144. github_token = os.environ.get("GITHUB_TOKEN", "")
  145. toolchain_map = {
  146. "clang/host/linux-x86": "clang",
  147. "prebuilts/rust": "rust",
  148. "prebuilts/clang-tools": "clang-tools",
  149. "prebuilts/build-tools": "build-tools",
  150. }
  151. _release_assets_lock = threading.Lock()
  152. _release_assets_cache: list[dict] | None = None
  153. _release_info_lock = threading.Lock()
  154. _release_info_cache: dict | None = None
  155. _upload_locks: dict[tuple[str, str], threading.Lock] = {}
  156. _upload_locks_guard = threading.Lock()
  157. def github_api_get_json(url: str) -> object:
  158. headers = {
  159. "Accept": "application/vnd.github.v3+json",
  160. "User-Agent": "actions-download-kernel",
  161. }
  162. if github_token:
  163. headers["Authorization"] = f"token {github_token}"
  164. req = urllib.request.Request(url, headers=headers)
  165. with urllib.request.urlopen(req, timeout=60) as resp:
  166. data = resp.read()
  167. import json
  168. return json.loads(data.decode("utf-8", errors="replace"))
  169. def github_api_post_json(url: str, payload: dict) -> object:
  170. headers = {
  171. "Accept": "application/vnd.github.v3+json",
  172. "User-Agent": "actions-download-kernel",
  173. "Content-Type": "application/json",
  174. }
  175. if github_token:
  176. headers["Authorization"] = f"token {github_token}"
  177. import json
  178. data = json.dumps(payload).encode("utf-8")
  179. req = urllib.request.Request(url, headers=headers, data=data, method="POST")
  180. with urllib.request.urlopen(req, timeout=60) as resp:
  181. body = resp.read()
  182. return json.loads(body.decode("utf-8", errors="replace"))
  183. def get_toolchain_label(project_name: str) -> str | None:
  184. for key, value in toolchain_map.items():
  185. if key in project_name:
  186. return value
  187. return None
  188. def get_toolchain_release_assets() -> list[dict]:
  189. nonlocal _release_assets_cache
  190. with _release_assets_lock:
  191. if _release_assets_cache is not None:
  192. return _release_assets_cache
  193. if not target_repo:
  194. _release_assets_cache = []
  195. return _release_assets_cache
  196. url = f"https://api.github.com/repos/{target_repo}/releases?per_page=100"
  197. try:
  198. releases = github_api_get_json(url)
  199. if not isinstance(releases, list):
  200. _release_assets_cache = []
  201. return _release_assets_cache
  202. release = next(
  203. (
  204. r
  205. for r in releases
  206. if isinstance(r, dict)
  207. and (r.get("name") == "Toolchains Mirror Cache" or r.get("tag_name") == "toolchain-cache")
  208. ),
  209. None,
  210. )
  211. if not release:
  212. _release_assets_cache = []
  213. return _release_assets_cache
  214. assets = release.get("assets", [])
  215. _release_assets_cache = assets if isinstance(assets, list) else []
  216. return _release_assets_cache
  217. except Exception:
  218. _release_assets_cache = []
  219. return _release_assets_cache
  220. def get_or_create_toolchain_release() -> dict | None:
  221. nonlocal _release_info_cache, _release_assets_cache
  222. with _release_info_lock:
  223. if _release_info_cache is not None:
  224. return _release_info_cache
  225. if not target_repo or not github_token:
  226. _release_info_cache = None
  227. return _release_info_cache
  228. try:
  229. url = f"https://api.github.com/repos/{target_repo}/releases/tags/toolchain-cache"
  230. release = github_api_get_json(url)
  231. if isinstance(release, dict):
  232. _release_info_cache = release
  233. assets = release.get("assets", [])
  234. with _release_assets_lock:
  235. _release_assets_cache = assets if isinstance(assets, list) else []
  236. return _release_info_cache
  237. except Exception:
  238. pass
  239. try:
  240. create_url = f"https://api.github.com/repos/{target_repo}/releases"
  241. release = github_api_post_json(
  242. create_url,
  243. {
  244. "tag_name": "toolchain-cache",
  245. "name": "Toolchains Mirror Cache",
  246. "draft": False,
  247. "prerelease": False,
  248. },
  249. )
  250. if isinstance(release, dict):
  251. _release_info_cache = release
  252. assets = release.get("assets", [])
  253. with _release_assets_lock:
  254. _release_assets_cache = assets if isinstance(assets, list) else []
  255. return _release_info_cache
  256. except Exception:
  257. try:
  258. url = f"https://api.github.com/repos/{target_repo}/releases/tags/toolchain-cache"
  259. release = github_api_get_json(url)
  260. if isinstance(release, dict):
  261. _release_info_cache = release
  262. assets = release.get("assets", [])
  263. with _release_assets_lock:
  264. _release_assets_cache = assets if isinstance(assets, list) else []
  265. return _release_info_cache
  266. except Exception:
  267. _release_info_cache = None
  268. return _release_info_cache
  269. _release_info_cache = None
  270. return _release_info_cache
  271. def download_github_release_asset(api_url: str, out_path: str) -> None:
  272. headers = {
  273. "Accept": "application/octet-stream",
  274. "User-Agent": "actions-download-kernel",
  275. }
  276. if github_token:
  277. headers["Authorization"] = f"token {github_token}"
  278. req = urllib.request.Request(api_url, headers=headers)
  279. with urllib.request.urlopen(req, timeout=120) as resp, open(out_path, "wb") as f:
  280. shutil.copyfileobj(resp, f, length=1024 * 1024)
  281. def try_extract_toolchain_cache(project_name: str, rev: str, dest_dir: str, strip_components: int) -> bool:
  282. label = get_toolchain_label(project_name)
  283. if not label:
  284. return False
  285. assets = get_toolchain_release_assets()
  286. if not assets:
  287. return False
  288. base_filename = f"{label}-{rev}.tar.gz"
  289. prefix = f"{base_filename}"
  290. matching = []
  291. for a in assets:
  292. if not isinstance(a, dict):
  293. continue
  294. asset_name = a.get("name")
  295. asset_url = a.get("url")
  296. if isinstance(asset_name, str) and isinstance(asset_url, str) and asset_name.startswith(prefix):
  297. matching.append((asset_name, asset_url))
  298. if not matching:
  299. return False
  300. matching.sort(key=lambda x: x[0])
  301. part_paths: list[str] = []
  302. try:
  303. for asset_name, asset_url in matching:
  304. part_path = os.path.abspath(asset_name)
  305. download_github_release_asset(asset_url, part_path)
  306. part_paths.append(part_path)
  307. if len(part_paths) == 1 and part_paths[0].endswith(".tar.gz"):
  308. extract_tar_gz(part_paths[0], dest_dir, strip_components)
  309. else:
  310. tmp_fd, tmp_path = tempfile.mkstemp(prefix="toolchain-", suffix=".tar.gz")
  311. os.close(tmp_fd)
  312. try:
  313. with open(tmp_path, "wb") as out_f:
  314. for p in part_paths:
  315. with open(p, "rb") as in_f:
  316. shutil.copyfileobj(in_f, out_f, length=1024 * 1024)
  317. extract_tar_gz(tmp_path, dest_dir, strip_components)
  318. finally:
  319. if os.path.exists(tmp_path):
  320. os.remove(tmp_path)
  321. return True
  322. except Exception:
  323. return False
  324. finally:
  325. for p in part_paths:
  326. if os.path.exists(p):
  327. os.remove(p)
  328. def upload_release_asset(upload_url_template: str, file_path: str, asset_name: str) -> None:
  329. upload_url = upload_url_template.split("{", 1)[0]
  330. url = f"{upload_url}?name={urllib.parse.quote(asset_name, safe='')}"
  331. tmp_fd, tmp_resp = tempfile.mkstemp(prefix="gh-release-upload-", suffix=".json")
  332. os.close(tmp_fd)
  333. cmd = [
  334. "curl",
  335. "-LsS",
  336. "-X",
  337. "POST",
  338. "-H",
  339. "Accept: application/vnd.github.v3+json",
  340. "-H",
  341. "Content-Type: application/octet-stream",
  342. "-H",
  343. "User-Agent: actions-download-kernel",
  344. "-o",
  345. tmp_resp,
  346. "-w",
  347. "%{http_code}",
  348. ]
  349. if github_token:
  350. cmd.extend(["-H", f"Authorization: token {github_token}"])
  351. cmd.extend(["--data-binary", f"@{file_path}", url])
  352. try:
  353. res = subprocess.run(cmd, check=True, text=True, capture_output=True)
  354. code = (res.stdout or "").strip()
  355. if code in {"200", "201"}:
  356. return
  357. if code == "422":
  358. return
  359. body = ""
  360. try:
  361. with open(tmp_resp, "r", encoding="utf-8", errors="replace") as f:
  362. body = f.read()
  363. except Exception:
  364. body = ""
  365. raise RuntimeError(f"Release asset upload failed (HTTP {code}): {body}")
  366. finally:
  367. if os.path.exists(tmp_resp):
  368. os.remove(tmp_resp)
  369. def ensure_toolchain_cached(label: str, rev: str, src_dir: str, strip_components: int) -> None:
  370. if not github_token or not target_repo:
  371. return
  372. cache_key = (label, rev)
  373. with _upload_locks_guard:
  374. lock = _upload_locks.get(cache_key)
  375. if lock is None:
  376. lock = threading.Lock()
  377. _upload_locks[cache_key] = lock
  378. with lock:
  379. try:
  380. base_filename = f"{label}-{rev}.tar.gz"
  381. assets = get_toolchain_release_assets()
  382. for a in assets:
  383. if isinstance(a, dict):
  384. asset_name = a.get("name")
  385. if isinstance(asset_name, str) and asset_name.startswith(base_filename):
  386. return
  387. release = get_or_create_toolchain_release()
  388. if not release:
  389. return
  390. upload_url_template = release.get("upload_url")
  391. if not isinstance(upload_url_template, str) or not upload_url_template:
  392. return
  393. tmp_dir = tempfile.mkdtemp(prefix="toolchain-cache-")
  394. try:
  395. archive_path = os.path.join(tmp_dir, base_filename)
  396. subprocess.run(
  397. ["tar", "-I", "gzip -1", "-cf", archive_path, "-C", src_dir, "."],
  398. check=True,
  399. text=True,
  400. )
  401. size = os.path.getsize(archive_path)
  402. max_part = 1900 * 1024 * 1024
  403. if size <= max_part:
  404. upload_release_asset(upload_url_template, archive_path, base_filename)
  405. else:
  406. subprocess.run(
  407. ["split", "-b", str(max_part), "-d", "-a", "2", archive_path, f"{archive_path}.part"],
  408. check=True,
  409. text=True,
  410. )
  411. for name in sorted(os.listdir(tmp_dir)):
  412. if name.startswith(f"{base_filename}.part"):
  413. part_path = os.path.join(tmp_dir, name)
  414. upload_release_asset(upload_url_template, part_path, name)
  415. with _release_assets_lock:
  416. _release_assets_cache = None
  417. finally:
  418. shutil.rmtree(tmp_dir, ignore_errors=True)
  419. except Exception:
  420. return
  421. def url_quote_rev(rev: str) -> str:
  422. return urllib.parse.quote(rev, safe="")
  423. def run_curl_download(url: str, out_path: str) -> None:
  424. subprocess.run(
  425. ["curl", "-LfsS", "--retry", "5", "--connect-timeout", "30", "-o", out_path, url],
  426. check=True,
  427. text=True,
  428. )
  429. def extract_tar_gz(archive_path: str, dest_dir: str, strip_components: int) -> None:
  430. cmd = ["tar", "-xzf", archive_path, "-C", dest_dir]
  431. if strip_components > 0:
  432. cmd.extend(["--strip-components", str(strip_components)])
  433. subprocess.run(cmd, check=True, text=True)
  434. def github_archive_urls(repo_url: str, rev: str) -> list[str]:
  435. if rev.startswith("refs/"):
  436. return [f"{repo_url}/archive/{rev}.tar.gz"]
  437. return [
  438. f"{repo_url}/archive/refs/heads/{rev}.tar.gz",
  439. f"{repo_url}/archive/refs/tags/{rev}.tar.gz",
  440. f"{repo_url}/archive/{rev}.tar.gz",
  441. ]
  442. def gitlab_archive_urls(repo_url: str, rev: str, repo_basename: str) -> list[str]:
  443. return [f"{repo_url}/-/archive/{urllib.parse.quote(rev, safe='')}/{repo_basename}-{urllib.parse.quote(rev, safe='')}.tar.gz"]
  444. def build_project_urls(base_url: str, name: str, rev: str) -> tuple[list[str], int]:
  445. repo_url = f"{base_url}/{name}"
  446. if "github.com" in base_url:
  447. return github_archive_urls(repo_url, rev), 1
  448. if "googlesource.com" in base_url or "android.googlesource.com" in base_url:
  449. return [f"{repo_url}/+archive/{url_quote_rev(rev)}.tar.gz"], 0
  450. if "git.codelinaro.org" in base_url or "gitlab" in base_url:
  451. repo_basename = name.rstrip("/").split("/")[-1]
  452. return gitlab_archive_urls(repo_url, rev, repo_basename), 1
  453. return [], 0
  454. def sync_project(task: tuple[str, str, str, str]) -> bool:
  455. name, path, rev, base_url = task
  456. start_time = time.time()
  457. dest_dir = os.path.abspath(path if path not in ("./", ".") else ".")
  458. os.makedirs(dest_dir, exist_ok=True)
  459. try:
  460. urls, strip_components = build_project_urls(base_url, name, rev)
  461. if not urls:
  462. return False
  463. if try_extract_toolchain_cache(name, rev, dest_dir, strip_components):
  464. duration = time.time() - start_time
  465. print(f"Synced {name} -> {path} ({duration:.2f}s)")
  466. return True
  467. tmp_fd, tmp_path = tempfile.mkstemp(prefix="kernel-src-", suffix=".tar.gz")
  468. os.close(tmp_fd)
  469. try:
  470. last_err = None
  471. for url in urls:
  472. try:
  473. run_curl_download(url, tmp_path)
  474. extract_tar_gz(tmp_path, dest_dir, strip_components)
  475. label = get_toolchain_label(name)
  476. if label:
  477. ensure_toolchain_cached(label, rev, dest_dir, strip_components)
  478. duration = time.time() - start_time
  479. print(f"Synced {name} -> {path} ({duration:.2f}s)")
  480. return True
  481. except subprocess.CalledProcessError as e:
  482. last_err = e
  483. continue
  484. if last_err:
  485. raise last_err
  486. return False
  487. finally:
  488. if os.path.exists(tmp_path):
  489. os.remove(tmp_path)
  490. except Exception:
  491. return False
  492. sync_tasks: list[tuple[str, str, str, str]] = []
  493. for project in manifest_root.findall("project"):
  494. name = project.get("name")
  495. if not name:
  496. continue
  497. path = project.get("path", name)
  498. remote_name = project.get("remote", def_remote)
  499. if not remote_name:
  500. continue
  501. base_url = remotes.get(remote_name)
  502. if not base_url:
  503. continue
  504. rev = project.get("revision", def_rev)
  505. if not rev:
  506. continue
  507. sync_tasks.append((name, path, rev, base_url))
  508. max_workers = min(32, (os.cpu_count() or 2) * 2)
  509. results = []
  510. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  511. results = list(executor.map(sync_project, sync_tasks))
  512. if not all(results):
  513. raise SystemExit("One or more projects failed to sync")
  514. for project_path, child in link_copy:
  515. src_rel = child.get("src")
  516. dest_rel = child.get("dest")
  517. if not src_rel or not dest_rel:
  518. continue
  519. src_path = os.path.join(os.getcwd(), project_path, src_rel)
  520. dest_path = os.path.join(os.getcwd(), dest_rel)
  521. os.makedirs(os.path.dirname(dest_path), exist_ok=True)
  522. if child.tag == "linkfile":
  523. if os.path.lexists(dest_path):
  524. os.remove(dest_path)
  525. rel_target = os.path.relpath(src_path, os.path.dirname(dest_path))
  526. os.symlink(rel_target, dest_path)
  527. elif child.tag == "copyfile":
  528. shutil.copy2(src_path, dest_path)