Some checks are pending
test / test (windows-11-arm, 3.13) (push) Waiting to run
test / test (ubuntu-24.04, 3.12) (push) Waiting to run
test / test (ubuntu-24.04, 3.13) (push) Waiting to run
test / test (windows-11-arm, 3.12) (push) Waiting to run
test / test (windows-2022, 3.12) (push) Waiting to run
test / test (windows-2022, 3.13) (push) Waiting to run
test / zizmor (push) Waiting to run
422 lines
16 KiB
Python
422 lines
16 KiB
Python
import fnmatch
|
|
import json
|
|
import os
|
|
import time
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from pathlib import Path, PurePath, PurePosixPath
|
|
from subprocess import check_call
|
|
from typing import Any, TypeVar
|
|
from collections.abc import Generator, Sequence
|
|
|
|
from gitea import Attachment
|
|
|
|
from .config import ArchType, BuildType, Config
|
|
from .gh import (CachedAssets, download_asset, get_asset_filename,
|
|
get_release, get_repo_for_build_type, upload_asset)
|
|
from .queue import Package
|
|
from .utils import SCRIPT_DIR, PathLike
|
|
|
|
|
|
class BuildError(Exception):
|
|
pass
|
|
|
|
|
|
def get_python_path(msys2_root: PathLike, msys2_path: PathLike) -> Path:
|
|
return Path(os.path.normpath(str(msys2_root) + str(msys2_path)))
|
|
|
|
|
|
def to_pure_posix_path(path: PathLike) -> PurePath:
|
|
return PurePosixPath("/" + str(path).replace(":", "", 1).replace("\\", "/"))
|
|
|
|
|
|
def get_build_environ(build_type: BuildType) -> dict[str, str]:
|
|
environ = os.environ.copy()
|
|
|
|
# Set PACKAGER for makepkg
|
|
packager_ref = Config.RUNNER_CONFIG[build_type]["repo"]
|
|
if "GITHUB_SHA" in environ and "GITHUB_RUN_ID" in environ:
|
|
packager_ref += "/" + environ["GITHUB_SHA"][:8] + "/" + environ["GITHUB_RUN_ID"]
|
|
environ["PACKAGER"] = f"CI ({packager_ref})"
|
|
|
|
return environ
|
|
|
|
|
|
@contextmanager
|
|
def temp_pacman_script(pacman_config: PathLike) -> Generator[PathLike, None, None]:
|
|
"""Gives a temporary pacman script which uses the passed in pacman config
|
|
without having to pass --config to it. Required because makepkg doesn't allow
|
|
setting the pacman conf path, but it allows setting the pacman executable path
|
|
via the 'PACMAN' env var.
|
|
"""
|
|
|
|
fd, filename = tempfile.mkstemp("pacman")
|
|
os.close(fd)
|
|
|
|
try:
|
|
with open(filename, "w", encoding="utf-8") as h:
|
|
cli = shlex.join(['/usr/bin/pacman', '--config', str(to_pure_posix_path(pacman_config))])
|
|
h.write(f"""\
|
|
#!/bin/bash
|
|
set -e
|
|
exec {cli} "$@"
|
|
""")
|
|
yield filename
|
|
finally:
|
|
try:
|
|
os.unlink(filename)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@contextmanager
|
|
def temp_pacman_conf(msys2_root: PathLike) -> Generator[Path, None, None]:
|
|
"""Gives a unix path to a temporary copy of pacman.conf"""
|
|
|
|
fd, filename = tempfile.mkstemp("pacman.conf")
|
|
os.close(fd)
|
|
try:
|
|
conf = get_python_path(msys2_root, "/etc/pacman.conf")
|
|
with open(conf, "rb") as src:
|
|
with open(filename, "wb") as dest:
|
|
shutil.copyfileobj(src, dest)
|
|
|
|
yield Path(filename)
|
|
finally:
|
|
try:
|
|
os.unlink(filename)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@contextmanager
|
|
def temp_makepkg_confd(msys2_root: PathLike, config_name: str) -> Generator[Path, None, None]:
|
|
"""Gives a path to a temporary $config_name.d file"""
|
|
|
|
conf_dir = get_python_path(msys2_root, f"/etc/{config_name}.d")
|
|
os.makedirs(conf_dir, exist_ok=True)
|
|
conf_file = conf_dir / "msys2_autobuild.conf"
|
|
try:
|
|
open(conf_file, "wb").close()
|
|
yield conf_file
|
|
finally:
|
|
try:
|
|
os.unlink(conf_file)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.rmdir(conf_dir)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def clean_environ(environ: dict[str, str]) -> dict[str, str]:
|
|
"""Returns an environment without any CI related variables.
|
|
|
|
This is to avoid leaking secrets to package build scripts we call.
|
|
While in theory we trust them this can't hurt.
|
|
"""
|
|
|
|
new_env = environ.copy()
|
|
for key in list(new_env):
|
|
if key.startswith(("GITHUB_", "RUNNER_")):
|
|
del new_env[key]
|
|
return new_env
|
|
|
|
|
|
def run_cmd(msys2_root: PathLike, args: Sequence[PathLike], **kwargs: Any) -> None:
|
|
executable = os.path.join(msys2_root, 'usr', 'bin', 'bash.exe')
|
|
env = clean_environ(kwargs.pop("env", os.environ.copy()))
|
|
env["CHERE_INVOKING"] = "1"
|
|
env["MSYSTEM"] = "MSYS"
|
|
env["MSYS2_PATH_TYPE"] = "minimal"
|
|
|
|
check_call([executable, '-lc'] + [shlex.join([str(a) for a in args])], env=env, **kwargs)
|
|
|
|
|
|
def make_tree_writable(topdir: PathLike) -> None:
|
|
# Ensure all files and directories under topdir are writable
|
|
# (and readable) by owner.
|
|
# Taken from meson, and adjusted
|
|
|
|
def chmod(p: PathLike) -> None:
|
|
os.chmod(p, os.stat(p).st_mode | stat.S_IWRITE | stat.S_IREAD)
|
|
|
|
chmod(topdir)
|
|
for root, dirs, files in os.walk(topdir):
|
|
for d in dirs:
|
|
chmod(os.path.join(root, d))
|
|
# Work around Python bug following junctions
|
|
# https://github.com/python/cpython/issues/67596#issuecomment-1918112817
|
|
dirs[:] = [d for d in dirs if not os.path.isjunction(os.path.join(root, d))]
|
|
for fname in files:
|
|
fpath = os.path.join(root, fname)
|
|
if os.path.isfile(fpath):
|
|
chmod(fpath)
|
|
|
|
|
|
def remove_junctions(topdir: PathLike) -> None:
|
|
# work around a git issue where it can't handle junctions
|
|
# https://github.com/git-for-windows/git/issues/5320
|
|
for root, dirs, _ in os.walk(topdir):
|
|
no_junctions = []
|
|
for d in dirs:
|
|
if not os.path.isjunction(os.path.join(root, d)):
|
|
no_junctions.append(d)
|
|
else:
|
|
os.remove(os.path.join(root, d))
|
|
dirs[:] = no_junctions
|
|
|
|
|
|
def reset_git_repo(path: PathLike):
|
|
|
|
def clean():
|
|
assert os.path.exists(path)
|
|
|
|
# Try to avoid git hanging in a junction loop, by removing them
|
|
# before running git clean/reset
|
|
# https://github.com/msys2/msys2-autobuild/issues/108#issuecomment-2776420879
|
|
try:
|
|
remove_junctions(path)
|
|
except OSError as e:
|
|
print("Removing junctions failed", e)
|
|
|
|
check_call(["git", "clean", "-xfdf"], cwd=path)
|
|
check_call(["git", "reset", "--hard", "HEAD"], cwd=path)
|
|
|
|
made_writable = False
|
|
for i in range(10):
|
|
try:
|
|
clean()
|
|
except subprocess.CalledProcessError:
|
|
try:
|
|
if not made_writable:
|
|
print("Trying to make files writable")
|
|
make_tree_writable(path)
|
|
remove_junctions(path)
|
|
made_writable = True
|
|
except OSError as e:
|
|
print("Making files writable failed", e)
|
|
print(f"git clean/reset failed, sleeping for {i} seconds")
|
|
time.sleep(i)
|
|
else:
|
|
break
|
|
else:
|
|
# run it one more time to raise
|
|
clean()
|
|
|
|
|
|
@contextmanager
|
|
def fresh_git_repo(url: str, path: PathLike) -> Generator:
|
|
if not os.path.exists(path):
|
|
check_call(["git", "clone", url, path])
|
|
check_call(["git", "config", "core.longpaths", "true"], cwd=path)
|
|
else:
|
|
reset_git_repo(path)
|
|
check_call(["git", "fetch", "origin"], cwd=path)
|
|
check_call(["git", "reset", "--hard", "origin/master"], cwd=path)
|
|
try:
|
|
yield
|
|
finally:
|
|
assert os.path.exists(path)
|
|
reset_git_repo(path)
|
|
|
|
|
|
@contextmanager
|
|
def staging_dependencies(
|
|
build_type: BuildType, pkg: Package, msys2_root: PathLike,
|
|
builddir: PathLike) -> Generator[PathLike, None, None]:
|
|
|
|
def add_to_repo(repo_root: PathLike, pacman_config: PathLike, repo_name: str,
|
|
assets: list[Attachment]) -> None:
|
|
repo_dir = Path(repo_root) / repo_name
|
|
os.makedirs(repo_dir, exist_ok=True)
|
|
|
|
todo = []
|
|
for asset in assets:
|
|
asset_path = os.path.join(repo_dir, get_asset_filename(asset))
|
|
todo.append((asset_path, asset))
|
|
|
|
def fetch_item(item: tuple[str, Attachment]) -> tuple[str, Attachment]:
|
|
asset_path, asset = item
|
|
download_asset(asset, asset_path)
|
|
return item
|
|
|
|
package_paths = []
|
|
with ThreadPoolExecutor(8) as executor:
|
|
for i, item in enumerate(executor.map(fetch_item, todo)):
|
|
asset_path, asset = item
|
|
print(f"[{i + 1}/{len(todo)}] {get_asset_filename(asset)}")
|
|
package_paths.append(asset_path)
|
|
|
|
repo_name = f"autobuild-{repo_name}"
|
|
repo_db_path = os.path.join(repo_dir, f"{repo_name}.db.tar.gz")
|
|
|
|
with open(pacman_config, encoding="utf-8") as h:
|
|
text = h.read()
|
|
uri = to_pure_posix_path(repo_dir).as_uri()
|
|
if uri not in text:
|
|
with open(pacman_config, "w", encoding="utf-8") as h2:
|
|
h2.write(f"""[{repo_name}]
|
|
Server={uri}
|
|
SigLevel=Never
|
|
""")
|
|
h2.write(text)
|
|
|
|
# repo-add 15 packages at a time so we don't hit the size limit for CLI arguments
|
|
ChunkItem = TypeVar("ChunkItem")
|
|
|
|
def chunks(lst: list[ChunkItem], n: int) -> Generator[list[ChunkItem], None, None]:
|
|
for i in range(0, len(lst), n):
|
|
yield lst[i:i + n]
|
|
|
|
base_args: list[PathLike] = ["repo-add", to_pure_posix_path(repo_db_path)]
|
|
posix_paths: list[PathLike] = [to_pure_posix_path(p) for p in package_paths]
|
|
for chunk in chunks(posix_paths, 15):
|
|
args = base_args + chunk
|
|
run_cmd(msys2_root, args, cwd=repo_dir)
|
|
|
|
cached_assets = CachedAssets()
|
|
repo_root = os.path.join(builddir, "_REPO")
|
|
try:
|
|
shutil.rmtree(repo_root, ignore_errors=True)
|
|
os.makedirs(repo_root, exist_ok=True)
|
|
with temp_pacman_conf(msys2_root) as pacman_config:
|
|
to_add: dict[ArchType, list[GitReleaseAsset]] = {}
|
|
for dep_type, deps in pkg.get_depends(build_type).items():
|
|
assets = cached_assets.get_assets(dep_type)
|
|
for dep in deps:
|
|
for pattern in dep.get_build_patterns(dep_type):
|
|
for asset in assets:
|
|
if fnmatch.fnmatch(get_asset_filename(asset), pattern):
|
|
to_add.setdefault(dep_type, []).append(asset)
|
|
break
|
|
else:
|
|
if pkg.is_optional_dep(dep, dep_type):
|
|
# If it's there, good, if not we ignore it since it's part of a cycle
|
|
pass
|
|
else:
|
|
raise SystemExit(f"asset for {pattern} in {dep_type} not found")
|
|
|
|
for dep_type, assets in to_add.items():
|
|
add_to_repo(repo_root, pacman_config, dep_type, assets)
|
|
|
|
with temp_pacman_script(pacman_config) as temp_pacman:
|
|
# in case they are already installed we need to upgrade
|
|
run_cmd(msys2_root, [to_pure_posix_path(temp_pacman), "--noconfirm", "-Suy"])
|
|
run_cmd(msys2_root, [to_pure_posix_path(temp_pacman), "--noconfirm", "-Su"])
|
|
yield temp_pacman
|
|
finally:
|
|
shutil.rmtree(repo_root, ignore_errors=True)
|
|
# downgrade again
|
|
run_cmd(msys2_root, ["pacman", "--noconfirm", "-Suuy"])
|
|
run_cmd(msys2_root, ["pacman", "--noconfirm", "-Suu"])
|
|
|
|
|
|
def build_package(build_type: BuildType, pkg: Package, msys2_root: PathLike, builddir: PathLike) -> None:
|
|
assert os.path.isabs(builddir)
|
|
assert os.path.isabs(msys2_root)
|
|
os.makedirs(builddir, exist_ok=True)
|
|
|
|
repo_name = {"MINGW-packages": "W", "MSYS2-packages": "S"}.get(pkg['repo'], pkg['repo'])
|
|
repo_dir = os.path.join(builddir, repo_name)
|
|
to_upload: list[str] = []
|
|
|
|
repo = get_repo_for_build_type(build_type)
|
|
|
|
with fresh_git_repo(pkg['repo_url'], repo_dir):
|
|
orig_pkg_dir = os.path.join(repo_dir, pkg['repo_path'])
|
|
# Rename it to get a shorter overall build path
|
|
# https://github.com/msys2/msys2-autobuild/issues/71
|
|
pkg_dir = os.path.join(repo_dir, 'B')
|
|
assert not os.path.exists(pkg_dir)
|
|
os.rename(orig_pkg_dir, pkg_dir)
|
|
|
|
# Fetch all keys mentioned in the PKGBUILD
|
|
validpgpkeys = to_pure_posix_path(os.path.join(SCRIPT_DIR, 'fetch-validpgpkeys.sh'))
|
|
run_cmd(msys2_root, ['bash', validpgpkeys], cwd=pkg_dir)
|
|
|
|
with staging_dependencies(build_type, pkg, msys2_root, builddir) as temp_pacman:
|
|
try:
|
|
env = get_build_environ(build_type)
|
|
# this makes makepkg use our custom pacman script
|
|
env['PACMAN'] = str(to_pure_posix_path(temp_pacman))
|
|
if build_type == Config.MINGW_SRC_BUILD_TYPE:
|
|
with temp_makepkg_confd(msys2_root, "makepkg_mingw.conf") as makepkg_conf:
|
|
with open(makepkg_conf, "w", encoding="utf-8") as h:
|
|
h.write("COMPRESSZST=(zstd -c -T0 --ultra -22 -)\n")
|
|
|
|
env['MINGW_ARCH'] = Config.MINGW_SRC_ARCH
|
|
run_cmd(msys2_root, [
|
|
'makepkg-mingw',
|
|
'--noconfirm',
|
|
'--noprogressbar',
|
|
'--allsource'
|
|
], env=env, cwd=pkg_dir)
|
|
elif build_type == Config.MSYS_SRC_BUILD_TYPE:
|
|
with temp_makepkg_confd(msys2_root, "makepkg.conf") as makepkg_conf:
|
|
with open(makepkg_conf, "w", encoding="utf-8") as h:
|
|
h.write("COMPRESSZST=(zstd -c -T0 --ultra -22 -)\n")
|
|
|
|
run_cmd(msys2_root, [
|
|
'makepkg',
|
|
'--noconfirm',
|
|
'--noprogressbar',
|
|
'--allsource'
|
|
], env=env, cwd=pkg_dir)
|
|
elif build_type in Config.MINGW_ARCH_LIST:
|
|
with temp_makepkg_confd(msys2_root, "makepkg_mingw.conf") as makepkg_conf:
|
|
with open(makepkg_conf, "w", encoding="utf-8") as h:
|
|
h.write("COMPRESSZST=(zstd -c -T0 --ultra -20 -)\n")
|
|
|
|
env['MINGW_ARCH'] = build_type
|
|
run_cmd(msys2_root, [
|
|
'makepkg-mingw',
|
|
'--noconfirm',
|
|
'--noprogressbar',
|
|
'--nocheck',
|
|
'--syncdeps',
|
|
'--rmdeps',
|
|
'--cleanbuild'
|
|
], env=env, cwd=pkg_dir)
|
|
elif build_type in Config.MSYS_ARCH_LIST:
|
|
with temp_makepkg_confd(msys2_root, "makepkg.conf") as makepkg_conf:
|
|
with open(makepkg_conf, "w", encoding="utf-8") as h:
|
|
h.write("COMPRESSZST=(zstd -c -T0 --ultra -20 -)\n")
|
|
|
|
run_cmd(msys2_root, [
|
|
'makepkg',
|
|
'--noconfirm',
|
|
'--noprogressbar',
|
|
'--nocheck',
|
|
'--syncdeps',
|
|
'--rmdeps',
|
|
'--cleanbuild'
|
|
], env=env, cwd=pkg_dir)
|
|
else:
|
|
assert 0
|
|
|
|
entries = os.listdir(pkg_dir)
|
|
for pattern in pkg.get_build_patterns(build_type):
|
|
found = fnmatch.filter(entries, pattern)
|
|
if not found:
|
|
raise BuildError(f"{pattern} not found, likely wrong version built")
|
|
to_upload.extend([os.path.join(pkg_dir, e) for e in found])
|
|
|
|
except (subprocess.CalledProcessError, BuildError) as e:
|
|
release = get_release(repo, "staging-failed")
|
|
failed_data = {}
|
|
content = json.dumps(failed_data).encode()
|
|
upload_asset(repo, release, pkg.get_failed_name(build_type), text=True, content=content)
|
|
|
|
raise BuildError(e)
|
|
else:
|
|
release = get_release(repo, "staging-" + build_type)
|
|
for path in to_upload:
|
|
upload_asset(repo, release, path)
|