Tweak for plezy
Some checks failed
nightly-build / build (bacula) (push) Successful in 1m23s
nightly-build / build (fchat-horizon-appimage) (push) Successful in 40s
nightly-build / build (openrgb-git) (push) Successful in 1h21m2s
nightly-build / build (paru) (push) Successful in 51s
nightly-build / build (plezy) (push) Failing after 10m39s
nightly-build / build (qownnotes) (push) Successful in 44s
nightly-build / build (ventoy-bin) (push) Successful in 42s
nightly-build / build (visual-studio-code-bin) (push) Successful in 42s
Some checks failed
nightly-build / build (bacula) (push) Successful in 1m23s
nightly-build / build (fchat-horizon-appimage) (push) Successful in 40s
nightly-build / build (openrgb-git) (push) Successful in 1h21m2s
nightly-build / build (paru) (push) Successful in 51s
nightly-build / build (plezy) (push) Failing after 10m39s
nightly-build / build (qownnotes) (push) Successful in 44s
nightly-build / build (ventoy-bin) (push) Successful in 42s
nightly-build / build (visual-studio-code-bin) (push) Successful in 42s
This commit is contained in:
@@ -34,7 +34,7 @@ jobs:
|
||||
- fchat-horizon-appimage
|
||||
- openrgb-git
|
||||
- paru
|
||||
- plezy-bin
|
||||
- plezy
|
||||
- qownnotes
|
||||
- ventoy-bin
|
||||
- visual-studio-code-bin
|
||||
|
||||
547
autopackage.py
547
autopackage.py
@@ -12,19 +12,21 @@ thats super fine too!
|
||||
Examples:
|
||||
./autopackage.py --dry-run
|
||||
./autopackage.py --only openrgb-git paru
|
||||
./autopackage.py --no-publish # build everything, skip upload
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -33,19 +35,9 @@ from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
AUR_BASE = "https://aur.archlinux.org"
|
||||
|
||||
# Top-level directories that are never packages
|
||||
DEFAULT_IGNORE = {
|
||||
".git",
|
||||
".gitea",
|
||||
".github",
|
||||
".build",
|
||||
"scripts",
|
||||
"dist",
|
||||
"__pycache__",
|
||||
}
|
||||
|
||||
DEFAULT_IGNORE = {".git", ".gitea", ".github", ".build", "scripts", "dist", "__pycache__"}
|
||||
PKG_GLOBS = ("*.pkg.tar.zst", "*.pkg.tar.xz", "*.pkg.tar.gz", "*.pkg.tar.bz2")
|
||||
PATCH_SUFFIXES = (".patch", ".diff")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -58,7 +50,7 @@ class Config:
|
||||
dry_run: bool
|
||||
publish: bool
|
||||
replace: bool
|
||||
# Gitea Arch registry settings
|
||||
force: bool
|
||||
registry_url: str
|
||||
owner: str
|
||||
arch_repo: str
|
||||
@@ -67,9 +59,6 @@ class Config:
|
||||
ignore: set = field(default_factory=set)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Shell helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def run(cmd: List[str], cwd: Optional[Path] = None, dry_run: bool = False,
|
||||
check: bool = True) -> subprocess.CompletedProcess:
|
||||
pretty = " ".join(shlex.quote(c) for c in cmd)
|
||||
@@ -81,14 +70,15 @@ def run(cmd: List[str], cwd: Optional[Path] = None, dry_run: bool = False,
|
||||
return subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=check)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Discovery / classification
|
||||
# --------------------------------------------------------------------------- #
|
||||
def env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def load_ignore(repo_root: Path) -> set:
|
||||
ignore = set(DEFAULT_IGNORE)
|
||||
ignore_file = repo_root / ".autopackageignore"
|
||||
if ignore_file.is_file():
|
||||
for line in ignore_file.read_text(encoding="utf-8").splitlines():
|
||||
path = repo_root / ".autopackageignore"
|
||||
if path.is_file():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
ignore.add(line)
|
||||
@@ -96,26 +86,27 @@ def load_ignore(repo_root: Path) -> set:
|
||||
|
||||
|
||||
def discover_packages(cfg: Config, only: List[str]) -> List[Path]:
|
||||
pkgs: List[Path] = []
|
||||
pkgs = []
|
||||
for entry in sorted(cfg.repo_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
if not entry.is_dir() or entry.name.startswith(".") or entry.name in cfg.ignore:
|
||||
continue
|
||||
name = entry.name
|
||||
if name.startswith(".") or name in cfg.ignore:
|
||||
continue
|
||||
if only and name not in only:
|
||||
if only and entry.name not in only:
|
||||
continue
|
||||
pkgs.append(entry)
|
||||
return pkgs
|
||||
|
||||
|
||||
def package_files(pkg_src: Path) -> List[Path]:
|
||||
return sorted(
|
||||
p for p in pkg_src.iterdir()
|
||||
if p.is_file() and p.name != ".gitkeep"
|
||||
)
|
||||
|
||||
|
||||
def is_custom(pkg_src: Path) -> bool:
|
||||
return (pkg_src / "PKGBUILD").is_file()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Build preparation
|
||||
# --------------------------------------------------------------------------- #
|
||||
def reset_build_dir(build_dir: Path, dry_run: bool) -> None:
|
||||
if dry_run:
|
||||
print(f"+ rm -rf {build_dir}")
|
||||
@@ -125,115 +116,220 @@ def reset_build_dir(build_dir: Path, dry_run: bool) -> None:
|
||||
build_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def prepare_custom(pkg_src: Path, build_dir: Path, dry_run: bool) -> None:
|
||||
print(f" custom package -> copying sources into {build_dir}")
|
||||
if dry_run:
|
||||
print(f"+ cp -r {pkg_src} {build_dir}")
|
||||
return
|
||||
shutil.copytree(pkg_src, build_dir, ignore=shutil.ignore_patterns(".git"))
|
||||
|
||||
|
||||
def prepare_aur(pkg_src: Path, build_dir: Path, cfg: Config) -> None:
|
||||
def prepare_build_dir(pkg_src: Path, build_dir: Path, cfg: Config) -> None:
|
||||
name = pkg_src.name
|
||||
aur_url = f"{AUR_BASE}/{name}.git"
|
||||
print(f" AUR package -> cloning {aur_url}")
|
||||
run(["git", "clone", "--depth", "1", aur_url, str(build_dir)], dry_run=cfg.dry_run)
|
||||
apply_overrides(pkg_src, build_dir, cfg)
|
||||
|
||||
|
||||
def apply_overrides(pkg_src: Path, build_dir: Path, cfg: Config) -> None:
|
||||
patches = sorted(
|
||||
p for p in pkg_src.iterdir()
|
||||
if p.is_file() and p.suffix in (".patch", ".diff")
|
||||
)
|
||||
overrides = sorted(
|
||||
p for p in pkg_src.iterdir()
|
||||
if p.is_file()
|
||||
and p.suffix not in (".patch", ".diff")
|
||||
and p.name != ".gitkeep"
|
||||
)
|
||||
|
||||
for ov in overrides:
|
||||
dest = build_dir / ov.name
|
||||
print(f" override: {ov.name}")
|
||||
if is_custom(pkg_src):
|
||||
print(f" custom -> copy {pkg_src}")
|
||||
if cfg.dry_run:
|
||||
print(f"+ cp {ov} {dest}")
|
||||
print(f"+ cp -r {pkg_src} {build_dir}")
|
||||
return
|
||||
shutil.copytree(pkg_src, build_dir, ignore=shutil.ignore_patterns(".git"))
|
||||
return
|
||||
|
||||
print(f" AUR -> clone {AUR_BASE}/{name}.git")
|
||||
run(["git", "clone", "--depth", "1", f"{AUR_BASE}/{name}.git", str(build_dir)],
|
||||
dry_run=cfg.dry_run)
|
||||
|
||||
patches: List[str] = []
|
||||
for src in package_files(pkg_src):
|
||||
if src.suffix in PATCH_SUFFIXES:
|
||||
patches.append(src.name)
|
||||
print(f" patch: {src.name}")
|
||||
else:
|
||||
print(f" override: {src.name}")
|
||||
if cfg.dry_run:
|
||||
print(f"+ cp {src} {build_dir / src.name}")
|
||||
continue
|
||||
shutil.copy2(ov, dest)
|
||||
shutil.copy2(src, build_dir / src.name)
|
||||
|
||||
if patches and not cfg.dry_run:
|
||||
wire_source_patches(build_dir / "PKGBUILD", patches)
|
||||
elif patches and cfg.dry_run:
|
||||
print(f"+ wire patches into {build_dir / 'PKGBUILD'}: {', '.join(patches)}")
|
||||
|
||||
|
||||
def wire_source_patches(pkgbuild: Path, patches: List[str]) -> None:
|
||||
"""Append patches to source= and apply them in prepare() after the first cd."""
|
||||
text = pkgbuild.read_text(encoding="utf-8")
|
||||
for patch in patches:
|
||||
print(f" patch: {patch.name}")
|
||||
apply_patch(patch, build_dir, cfg.dry_run)
|
||||
if patch in text:
|
||||
continue
|
||||
text = text.rstrip() + f'\nsource+=("{patch}")\nsha256sums+=("SKIP")\n'
|
||||
m = re.search(r"(prepare\(\)\s*\{[^\n]*\n\s*cd [^\n]+\n)", text)
|
||||
if not m:
|
||||
raise RuntimeError(
|
||||
f"{pkgbuild}: need a prepare() with a cd line to apply {patch}"
|
||||
)
|
||||
insert = f' patch -Np1 -i "$srcdir/{patch}"\n'
|
||||
text = text[:m.end()] + insert + text[m.end():]
|
||||
pkgbuild.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def apply_patch(patch: Path, build_dir: Path, dry_run: bool) -> None:
|
||||
# Use `git apply` first if theres a patch that i have in there
|
||||
git_apply = run(
|
||||
["git", "apply", "--whitespace=nowarn", str(patch)],
|
||||
cwd=build_dir, dry_run=dry_run, check=False,
|
||||
)
|
||||
if git_apply.returncode == 0:
|
||||
return
|
||||
if dry_run:
|
||||
return
|
||||
print(f" (git apply failed, trying patch -p1) {patch.name}")
|
||||
with patch.open("rb") as fh:
|
||||
result = subprocess.run(
|
||||
["patch", "-Np1", "--forward"],
|
||||
cwd=str(build_dir), stdin=fh,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"failed to apply patch {patch.name}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Build
|
||||
# --------------------------------------------------------------------------- #
|
||||
def find_artifacts(build_dir: Path, want_sig: bool) -> List[Path]:
|
||||
found: List[Path] = []
|
||||
for pat in PKG_GLOBS:
|
||||
found.extend(build_dir.glob(pat))
|
||||
pkgs = [p for p in found if p.is_file() and not p.name.endswith(".sig")]
|
||||
pkgs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
artifacts: List[Path] = []
|
||||
pkgs = sorted(
|
||||
(p for p in found if p.is_file() and not p.name.endswith(".sig")),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True,
|
||||
)
|
||||
out: List[Path] = []
|
||||
for p in pkgs:
|
||||
artifacts.append(p)
|
||||
out.append(p)
|
||||
if want_sig:
|
||||
sig = p.with_name(p.name + ".sig")
|
||||
if sig.is_file():
|
||||
artifacts.append(sig)
|
||||
return artifacts
|
||||
|
||||
|
||||
def build_one(pkg_src: Path, cfg: Config) -> List[Path]:
|
||||
name = pkg_src.name
|
||||
build_dir = cfg.workdir / name
|
||||
print(f"\n=== {name} ===")
|
||||
|
||||
reset_build_dir(build_dir, cfg.dry_run)
|
||||
if is_custom(pkg_src):
|
||||
prepare_custom(pkg_src, build_dir, cfg.dry_run)
|
||||
else:
|
||||
prepare_aur(pkg_src, build_dir, cfg)
|
||||
|
||||
run(cfg.makepkg_cmd, cwd=build_dir, dry_run=cfg.dry_run)
|
||||
|
||||
if cfg.dry_run:
|
||||
return []
|
||||
|
||||
artifacts = find_artifacts(build_dir, want_sig=cfg.sign)
|
||||
pkg_files = [a for a in artifacts if not a.name.endswith(".sig")]
|
||||
if not pkg_files:
|
||||
raise RuntimeError(f"{name}: build finished but no *.pkg.tar.* produced")
|
||||
print(f" built: {', '.join(a.name for a in pkg_files)}")
|
||||
return artifacts
|
||||
out.append(sig)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Publish to Gitea Arch registry
|
||||
# Skip rebuild when this version is already on the registry
|
||||
# --------------------------------------------------------------------------- #
|
||||
_REGISTRY_FILES: Optional[set] = None
|
||||
|
||||
|
||||
def registry_base(cfg: Config) -> str:
|
||||
return f"{cfg.registry_url.rstrip('/')}/api/packages/{cfg.owner}/arch/{cfg.arch_repo}"
|
||||
|
||||
|
||||
def auth_header(cfg: Config) -> str:
|
||||
raw = f"{cfg.auth_user}:{cfg.token}".encode()
|
||||
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def http(url: str, method: str, cfg: Config, data: Optional[bytes] = None,
|
||||
retries: int = 4) -> tuple:
|
||||
last_err: Optional[Exception] = None
|
||||
for attempt in range(1, retries + 1):
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
if cfg.token:
|
||||
req.add_header("Authorization", auth_header(cfg))
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read()
|
||||
if e.code == 404:
|
||||
return 404, body
|
||||
if e.code in (500, 502, 503, 504) and attempt < retries:
|
||||
last_err = e
|
||||
else:
|
||||
return e.code, body
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
|
||||
last_err = e
|
||||
if attempt >= retries:
|
||||
raise
|
||||
time.sleep(min(2 ** attempt, 30))
|
||||
raise RuntimeError(f"{method} {url} failed: {last_err}")
|
||||
|
||||
|
||||
def registry_filenames(cfg: Config) -> Optional[set]:
|
||||
global _REGISTRY_FILES
|
||||
if _REGISTRY_FILES is not None:
|
||||
return _REGISTRY_FILES
|
||||
if not cfg.registry_url or not cfg.owner:
|
||||
return None
|
||||
|
||||
names: set = set()
|
||||
ok = False
|
||||
for arch in ("x86_64", "any"):
|
||||
url = f"{registry_base(cfg)}/{arch}/{cfg.arch_repo}.db"
|
||||
try:
|
||||
status, data = http(url, "GET", cfg)
|
||||
except (RuntimeError, OSError):
|
||||
continue
|
||||
if status == 404:
|
||||
ok = True
|
||||
continue
|
||||
if status != 200 or not data:
|
||||
continue
|
||||
ok = True
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
|
||||
for m in tf.getmembers():
|
||||
if not m.isfile() or not m.name.endswith("/desc"):
|
||||
continue
|
||||
fh = tf.extractfile(m)
|
||||
if not fh:
|
||||
continue
|
||||
lines = fh.read().decode("utf-8", "replace").splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "%FILENAME%" and i + 1 < len(lines):
|
||||
names.add(lines[i + 1].strip())
|
||||
break
|
||||
except (tarfile.TarError, OSError):
|
||||
continue
|
||||
|
||||
if not ok:
|
||||
return None
|
||||
_REGISTRY_FILES = names
|
||||
return names
|
||||
|
||||
|
||||
def srcinfo(build_dir: Path) -> Optional[dict]:
|
||||
pkgbuild = build_dir / "PKGBUILD"
|
||||
if not pkgbuild.is_file():
|
||||
return None
|
||||
if re.search(r"^\s*pkgver\s*\(\)", pkgbuild.read_text(errors="replace"), re.M):
|
||||
return {"vcs": True}
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["makepkg", "--printsrcinfo"], cwd=build_dir,
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return None
|
||||
|
||||
info = {"vcs": False, "names": [], "pkgver": None, "pkgrel": None,
|
||||
"epoch": None, "arch": "x86_64"}
|
||||
for line in out.splitlines():
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, val = (p.strip() for p in line.split("=", 1))
|
||||
if key == "pkgname":
|
||||
info["names"].append(val)
|
||||
elif key == "pkgver":
|
||||
info["pkgver"] = val
|
||||
elif key == "pkgrel":
|
||||
info["pkgrel"] = val
|
||||
elif key == "epoch":
|
||||
info["epoch"] = val
|
||||
elif key == "arch":
|
||||
if val == "x86_64":
|
||||
info["arch"] = "x86_64"
|
||||
elif val == "any" and info["arch"] != "x86_64":
|
||||
info["arch"] = "any"
|
||||
if not info["names"] or not info["pkgver"] or not info["pkgrel"]:
|
||||
return None
|
||||
ver = info["pkgver"]
|
||||
if info["epoch"]:
|
||||
ver = f"{info['epoch']}:{ver}"
|
||||
info["fullver"] = f"{ver}-{info['pkgrel']}"
|
||||
return info
|
||||
|
||||
|
||||
def already_built(build_dir: Path, cfg: Config) -> bool:
|
||||
info = srcinfo(build_dir)
|
||||
if not info or info.get("vcs"):
|
||||
return False
|
||||
published = registry_filenames(cfg)
|
||||
if published is None:
|
||||
return False
|
||||
arch = info["arch"]
|
||||
for name in info["names"]:
|
||||
stem = f"{name}-{info['fullver']}-{arch}.pkg.tar"
|
||||
if not any(f.startswith(stem) for f in published):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Publish
|
||||
# --------------------------------------------------------------------------- #
|
||||
def parse_pkg_filename(filename: str) -> Optional[tuple]:
|
||||
"""Return (name, version, arch) from <name>-<ver>-<rel>-<arch>.pkg.tar.zst."""
|
||||
base = filename
|
||||
for suffix in (".pkg.tar.zst", ".pkg.tar.xz", ".pkg.tar.gz", ".pkg.tar.bz2"):
|
||||
if base.endswith(suffix):
|
||||
@@ -248,107 +344,64 @@ def parse_pkg_filename(filename: str) -> Optional[tuple]:
|
||||
return name, f"{ver}-{rel}", arch
|
||||
|
||||
|
||||
def registry_base(cfg: Config) -> str:
|
||||
return f"{cfg.registry_url.rstrip('/')}/api/packages/{cfg.owner}/arch/{cfg.arch_repo}"
|
||||
|
||||
|
||||
def auth_header(cfg: Config) -> str:
|
||||
raw = f"{cfg.auth_user}:{cfg.token}".encode("utf-8")
|
||||
return "Basic " + base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def http_request(url: str, method: str, cfg: Config,
|
||||
data: Optional[bytes] = None, retries: int = 4) -> tuple:
|
||||
last_err: Optional[Exception] = None
|
||||
for attempt in range(1, retries + 1):
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", auth_header(cfg))
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", "replace")
|
||||
# Retry transient server errors; return everything else as-is.
|
||||
if e.code in (500, 502, 503, 504) and attempt < retries:
|
||||
last_err = e
|
||||
else:
|
||||
return e.code, body
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
|
||||
# Network-level failures (e.g. connection reset by peer) -> retry.
|
||||
last_err = e
|
||||
if attempt >= retries:
|
||||
raise
|
||||
backoff = min(2 ** attempt, 30)
|
||||
print(f" {method} {url} failed ({last_err}); "
|
||||
f"retry {attempt}/{retries - 1} in {backoff}s")
|
||||
time.sleep(backoff)
|
||||
raise RuntimeError(f"{method} {url} failed after {retries} attempts: {last_err}")
|
||||
|
||||
|
||||
def delete_version(filename: str, cfg: Config) -> None:
|
||||
meta = parse_pkg_filename(filename)
|
||||
if not meta:
|
||||
return
|
||||
name, version, arch = meta
|
||||
url = f"{registry_base(cfg)}/{name}/{version}/{arch}"
|
||||
status, body = http_request(url, "DELETE", cfg)
|
||||
print(f" delete {name} {version} {arch} -> {status}")
|
||||
|
||||
|
||||
def publish_file(path: Path, cfg: Config) -> None:
|
||||
url = registry_base(cfg)
|
||||
if cfg.dry_run:
|
||||
print(f"+ PUT {url} ({path.name})")
|
||||
print(f"+ PUT {url} ({path.name})")
|
||||
return
|
||||
data = path.read_bytes()
|
||||
status, body = http_request(url, "PUT", cfg, data=data)
|
||||
status, body = http(url, "PUT", cfg, data=data)
|
||||
body_s = body.decode("utf-8", "replace").strip()[:500]
|
||||
if status in (200, 201):
|
||||
print(f" uploaded {path.name} -> {status}")
|
||||
return
|
||||
if status == 409 and cfg.replace:
|
||||
print(f" {path.name} exists; replacing")
|
||||
delete_version(path.name, cfg)
|
||||
status, body = http_request(url, "PUT", cfg, data=data)
|
||||
meta = parse_pkg_filename(path.name)
|
||||
if meta:
|
||||
n, ver, arch = meta
|
||||
http(f"{registry_base(cfg)}/{n}/{ver}/{arch}", "DELETE", cfg)
|
||||
status, body = http(url, "PUT", cfg, data=data)
|
||||
body_s = body.decode("utf-8", "replace").strip()[:500]
|
||||
if status in (200, 201):
|
||||
print(f" uploaded {path.name} -> {status}")
|
||||
return
|
||||
if status == 409:
|
||||
print(f" {path.name} already published (409); skipping "
|
||||
f"(use --replace to overwrite)")
|
||||
print(f" {path.name} already published (409); skipping")
|
||||
return
|
||||
if status in (401, 403):
|
||||
raise RuntimeError(
|
||||
f"upload of {path.name} failed: HTTP {status}: "
|
||||
f"{body.strip()[:200]}\n"
|
||||
f" -> The registry rejected the credentials for owner "
|
||||
f"'{cfg.owner}'.\n"
|
||||
f" Gitea's automatic Actions token (github.token / "
|
||||
f"GITEA_TOKEN) cannot write packages.\n"
|
||||
f" Use a Personal Access Token with the 'write:package' scope "
|
||||
f"(set PACKAGE_TOKEN / --token)."
|
||||
f"upload of {path.name} failed: HTTP {status}: {body_s}\n"
|
||||
f" -> need a PAT with write:package (PACKAGE_TOKEN), not github.token"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"upload of {path.name} failed: HTTP {status}: {body.strip()[:500]}"
|
||||
)
|
||||
raise RuntimeError(f"upload of {path.name} failed: HTTP {status}: {body_s}")
|
||||
|
||||
|
||||
def publish(artifacts: List[Path], cfg: Config) -> None:
|
||||
for art in artifacts:
|
||||
publish_file(art, cfg)
|
||||
def build_one(pkg_src: Path, cfg: Config) -> List[Path]:
|
||||
name = pkg_src.name
|
||||
build_dir = cfg.workdir / name
|
||||
print(f"\n=== {name} ===")
|
||||
|
||||
reset_build_dir(build_dir, cfg.dry_run)
|
||||
prepare_build_dir(pkg_src, build_dir, cfg)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
def env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default)
|
||||
if not cfg.dry_run and not cfg.force and already_built(build_dir, cfg):
|
||||
info = srcinfo(build_dir)
|
||||
print(f" {name} {info['fullver']} already on registry; skipping build")
|
||||
return []
|
||||
|
||||
run(cfg.makepkg_cmd, cwd=build_dir, dry_run=cfg.dry_run)
|
||||
if cfg.dry_run:
|
||||
return []
|
||||
|
||||
artifacts = find_artifacts(build_dir, want_sig=cfg.sign)
|
||||
pkgs = [a for a in artifacts if not a.name.endswith(".sig")]
|
||||
if not pkgs:
|
||||
raise RuntimeError(f"{name}: no *.pkg.tar.* produced")
|
||||
print(f" built: {', '.join(a.name for a in pkgs)}")
|
||||
return artifacts
|
||||
|
||||
|
||||
def build_config(args: argparse.Namespace) -> Config:
|
||||
repo_root = args.repo_root.resolve()
|
||||
|
||||
makepkg_cmd = ["makepkg", "-s", "--noconfirm", "--needed"]
|
||||
if args.cleanbuild:
|
||||
makepkg_cmd.append("--cleanbuild")
|
||||
@@ -356,9 +409,8 @@ def build_config(args: argparse.Namespace) -> Config:
|
||||
makepkg_cmd.append("--skippgpcheck")
|
||||
if args.sign:
|
||||
makepkg_cmd.append("--sign")
|
||||
|
||||
cfg = Config(
|
||||
repo_root=repo_root,
|
||||
return Config(
|
||||
repo_root=args.repo_root.resolve(),
|
||||
workdir=args.workdir.resolve(),
|
||||
makepkg_cmd=makepkg_cmd,
|
||||
sign=args.sign,
|
||||
@@ -366,99 +418,67 @@ def build_config(args: argparse.Namespace) -> Config:
|
||||
dry_run=args.dry_run,
|
||||
publish=not args.no_publish,
|
||||
replace=args.replace,
|
||||
force=args.force,
|
||||
registry_url=args.registry_url,
|
||||
owner=args.owner,
|
||||
arch_repo=args.arch_repo,
|
||||
auth_user=args.auth_user or args.owner,
|
||||
token=args.token,
|
||||
ignore=load_ignore(repo_root),
|
||||
ignore=load_ignore(args.repo_root.resolve()),
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def main(argv: List[str]) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Nightly directory-driven Arch package builder for Gitea CI",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
ap.add_argument("--repo-root", type=Path, default=Path.cwd(),
|
||||
help="Repo root to scan for package directories")
|
||||
ap.add_argument("--workdir", type=Path, default=Path.cwd() / ".build",
|
||||
help="Scratch directory for clones/builds")
|
||||
ap.add_argument("--only", nargs="*", default=[],
|
||||
help="Only build these package directory names")
|
||||
ap.add_argument("--list-json", action="store_true",
|
||||
help="Print discovered package names as a JSON array and exit "
|
||||
"(used to build the CI matrix)")
|
||||
ap.add_argument("--cleanbuild", action="store_true",
|
||||
help="Pass --cleanbuild to makepkg")
|
||||
ap = argparse.ArgumentParser(description="Build Arch packages and publish to Gitea")
|
||||
ap.add_argument("--repo-root", type=Path, default=Path.cwd())
|
||||
ap.add_argument("--workdir", type=Path, default=Path.cwd() / ".build")
|
||||
ap.add_argument("--only", nargs="*", default=[])
|
||||
ap.add_argument("--list-json", action="store_true")
|
||||
ap.add_argument("--cleanbuild", action="store_true")
|
||||
ap.add_argument("--skip-pgp", action="store_true",
|
||||
default=env("AUTOPKG_SKIP_PGP", "") not in ("", "0", "false"),
|
||||
help="Skip source PGP signature checks (sha256sums still "
|
||||
"verified). Useful when upstream signing keys aren't in "
|
||||
"the build keyring.")
|
||||
ap.add_argument("--sign", action="store_true",
|
||||
help="Sign packages (makepkg --sign) and upload .sig files")
|
||||
ap.add_argument("--keep-going", action="store_true",
|
||||
help="Continue with remaining packages if one fails")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="Print actions without executing them")
|
||||
ap.add_argument("--no-publish", action="store_true",
|
||||
help="Build only; do not upload to the registry")
|
||||
default=env("AUTOPKG_SKIP_PGP", "") not in ("", "0", "false"))
|
||||
ap.add_argument("--sign", action="store_true")
|
||||
ap.add_argument("--keep-going", action="store_true")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--no-publish", action="store_true")
|
||||
ap.add_argument("--replace", action="store_true",
|
||||
default=env("AUTOPKG_REPLACE", "") not in ("", "0", "false"),
|
||||
help="Delete + re-upload when a file already exists (409)")
|
||||
|
||||
# Gitea Arch registry (CLI overrides env; env defaults suit Gitea Actions)
|
||||
ap.add_argument("--registry-url", default=env("AUTOPKG_REGISTRY_URL",
|
||||
env("GITHUB_SERVER_URL")),
|
||||
help="Gitea base URL, e.g. https://git.example.com")
|
||||
ap.add_argument("--owner", default=env("AUTOPKG_OWNER",
|
||||
env("GITHUB_REPOSITORY_OWNER")),
|
||||
help="Package registry owner (user or org)")
|
||||
ap.add_argument("--arch-repo", default=env("AUTOPKG_REPO", "arch"),
|
||||
help="Registry repository/group name (the [repo] in pacman.conf)")
|
||||
ap.add_argument("--auth-user", default=env("AUTOPKG_USER",
|
||||
env("GITHUB_ACTOR")),
|
||||
help="Username for HTTP basic auth (defaults to owner)")
|
||||
ap.add_argument("--token", default=env("AUTOPKG_TOKEN",
|
||||
env("PACKAGE_TOKEN", env("GITHUB_TOKEN"))),
|
||||
help="Gitea token with write:package scope")
|
||||
|
||||
default=env("AUTOPKG_REPLACE", "") not in ("", "0", "false"))
|
||||
ap.add_argument("--force", action="store_true",
|
||||
default=env("AUTOPKG_FORCE", "") not in ("", "0", "false"))
|
||||
ap.add_argument("--registry-url", default=env("AUTOPKG_REGISTRY_URL", env("GITHUB_SERVER_URL")))
|
||||
ap.add_argument("--owner", default=env("AUTOPKG_OWNER", env("GITHUB_REPOSITORY_OWNER")))
|
||||
ap.add_argument("--arch-repo", default=env("AUTOPKG_REPO", "arch"))
|
||||
ap.add_argument("--auth-user", default=env("AUTOPKG_USER", env("GITHUB_ACTOR")))
|
||||
ap.add_argument("--token", default=env("AUTOPKG_TOKEN", env("PACKAGE_TOKEN", env("GITHUB_TOKEN"))))
|
||||
args = ap.parse_args(argv)
|
||||
cfg = build_config(args)
|
||||
|
||||
if args.list_json:
|
||||
pkgs = discover_packages(cfg, args.only)
|
||||
print(json.dumps([p.name for p in pkgs]))
|
||||
print(json.dumps([p.name for p in discover_packages(cfg, args.only)]))
|
||||
return 0
|
||||
|
||||
pkgs = discover_packages(cfg, args.only)
|
||||
if not pkgs:
|
||||
print("No package directories found.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"Discovered {len(pkgs)} package(s): "
|
||||
f"{', '.join(p.name for p in pkgs)}")
|
||||
print(f"Discovered {len(pkgs)} package(s): {', '.join(p.name for p in pkgs)}")
|
||||
|
||||
if cfg.publish and not cfg.dry_run:
|
||||
missing = [n for n, v in (("registry-url", cfg.registry_url),
|
||||
("owner", cfg.owner),
|
||||
("token", cfg.token)) if not v]
|
||||
("owner", cfg.owner), ("token", cfg.token)) if not v]
|
||||
if missing:
|
||||
print(f"Cannot publish: missing {', '.join(missing)}. "
|
||||
f"Set the matching env/secret or pass --no-publish.",
|
||||
file=sys.stderr)
|
||||
print(f"Cannot publish: missing {', '.join(missing)}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
failures: List[str] = []
|
||||
failures = []
|
||||
for pkg_src in pkgs:
|
||||
try:
|
||||
artifacts = build_one(pkg_src, cfg)
|
||||
if cfg.publish and not cfg.dry_run:
|
||||
publish(artifacts, cfg)
|
||||
if cfg.publish and artifacts and not cfg.dry_run:
|
||||
for art in artifacts:
|
||||
publish_file(art, cfg)
|
||||
elif cfg.publish and cfg.dry_run:
|
||||
print(f" (dry-run) would publish artifacts for {pkg_src.name}")
|
||||
print(f" (dry-run) would publish {pkg_src.name}")
|
||||
except (subprocess.CalledProcessError, RuntimeError, OSError) as e:
|
||||
print(f"✗ {pkg_src.name}: {e}", file=sys.stderr)
|
||||
failures.append(pkg_src.name)
|
||||
@@ -468,7 +488,6 @@ def main(argv: List[str]) -> int:
|
||||
if failures:
|
||||
print(f"\nFailed: {', '.join(failures)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("\nAll done.")
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user