Inital ver (port)

This commit is contained in:
KenwoodFox
2026-06-28 11:34:54 -04:00
commit b8be2ef95a
13 changed files with 642 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
name: nightly-build
on:
schedule:
# 03:30 UTC every night
- cron: "30 3 * * *"
workflow_dispatch:
inputs:
only:
description: "Space-separated package dirs to build (blank = all)"
required: false
default: ""
replace:
description: "Replace existing registry files on version clash"
required: false
default: "false"
jobs:
build:
runs-on: ubuntu-latest
container:
image: archlinux:latest
# makepkg refuses to run as root; we create a build user below.
options: --privileged
steps:
- name: Install toolchain
run: |
pacman -Syu --noconfirm --needed base-devel git python sudo
- name: Create build user
run: |
useradd -m -s /bin/bash builder
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
chmod 0440 /etc/sudoers.d/builder
- name: Checkout
uses: actions/checkout@v4
- name: Fix permissions
run: chown -R builder:builder "$GITHUB_WORKSPACE"
- name: Build and publish
env:
AUTOPKG_REGISTRY_URL: ${{ github.server_url }}
AUTOPKG_OWNER: ${{ github.repository_owner }}
AUTOPKG_REPO: arch
AUTOPKG_USER: ${{ github.actor }}
AUTOPKG_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
AUTOPKG_REPLACE: ${{ github.event.inputs.replace }}
ONLY: ${{ github.event.inputs.only }}
run: |
sudo -E -u builder \
python3 autopackage.py --keep-going ${ONLY:+--only $ONLY}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.build/
__pycache__/
*.pkg.tar.*
*.log

134
README.md Normal file
View File

@@ -0,0 +1,134 @@
# autopackage
Nightly, directory-driven Arch package builder for Gitea CI.
Every top-level folder in this repo is a package. The nightly job
([`.gitea/workflows/nightly.yml`](.gitea/workflows/nightly.yml)) builds them all
in an `archlinux:latest` container and publishes the resulting
`*.pkg.tar.zst` files to a [Gitea Arch package
registry](https://docs.gitea.com/usage/packages/arch). Gitea keeps the pacman
database up to date automatically — there is no `repo-add` step.
## How a folder is classified
For a folder `foo/`:
| Contents of `foo/` | Treated as | What happens |
| ----------------------------------------- | ---------- | ------------ |
| Contains a `PKGBUILD` | **custom** | Built as-is from the folder. |
| No `PKGBUILD` | **AUR** | `https://aur.archlinux.org/foo.git` is cloned, then overrides/patches from `foo/` are applied. |
### AUR packages
The folder name **is** the AUR package name. The simplest AUR package is a
folder that just contains a `.gitkeep` (git won't track an empty directory):
```
openrgb-git/
.gitkeep
```
To customize an AUR package without forking the whole `PKGBUILD`, drop files in
the folder:
- **Patches** — any `*.patch` / `*.diff` files are applied (in sorted order) on
top of the freshly cloned AUR repo. `git apply` is tried first, then
`patch -Np1`.
- **Overrides** — any other file (e.g. an extra source file, a `.install`
script) is copied into the clone, replacing a file of the same name.
> Note: if you add a full `PKGBUILD` to the folder it becomes a *custom*
> package and the AUR repo is no longer cloned. Use patches/overrides to tweak
> an AUR package.
Example AUR package with a patch:
```
openrgb-git/
.gitkeep
0001-fix-udev-rules.patch
```
### Custom packages
A folder with its own `PKGBUILD` (plus any local sources) is built directly:
```
my-thing/
PKGBUILD
my-thing.install
some-source.tar.gz
```
## Publishing / consuming
Packages are uploaded to `{GITEA_URL}/api/packages/{owner}/arch/{repo}` where
`{repo}` defaults to `arch` (configurable via `AUTOPKG_REPO`).
Add this to `/etc/pacman.conf` on client machines (replace the host/owner):
```ini
[arch]
SigLevel = Optional TrustAll
Server = https://git.example.com/api/packages/<owner>/arch/arch/$arch
```
> `SigLevel = Optional TrustAll` is used because packages are unsigned by
> default. To sign them, set up a GPG key on the runner and pass `--sign`
> (the `.sig` files are uploaded automatically); then you can switch to a
> stricter `SigLevel`.
## CI configuration
The workflow needs one secret:
- **`PACKAGE_TOKEN`** — a Gitea access token with the `write:package` scope
(and `read:package`). Create it under *Settings → Applications → Generate New
Token*, then add it under the repo's *Settings → Actions → Secrets*.
Everything else is derived from built-in Gitea Actions variables
(`GITHUB_SERVER_URL`, `GITHUB_REPOSITORY_OWNER`, `github.actor`).
The job runs nightly at 03:30 UTC and can also be triggered manually
(*workflow_dispatch*) with optional `only` (subset of package dirs) and
`replace` inputs.
## Running locally
```bash
# Dry run: show what would be built/published
./autopackage.py --dry-run
# Build a subset without publishing
./autopackage.py --only openrgb-git paru --no-publish
# Build everything and publish to a registry
AUTOPKG_REGISTRY_URL=https://git.example.com \
AUTOPKG_OWNER=joe \
AUTOPKG_TOKEN=xxxxxxxx \
./autopackage.py --keep-going
```
makepkg must run as a non-root user with `sudo` available for dependency
installation. Builds happen in `./.build/` (gitignored).
### Useful flags
| Flag | Purpose |
| ---- | ------- |
| `--only NAME ...` | Build just these package dirs. |
| `--no-publish` | Build only; skip uploads. |
| `--dry-run` | Print actions without running them. |
| `--cleanbuild` | Pass `--cleanbuild` to makepkg. |
| `--sign` | Sign packages and upload `.sig` files. |
| `--replace` | Delete + re-upload on a version clash (HTTP 409). |
| `--keep-going` | Continue if a package fails. |
Config can also come from env vars: `AUTOPKG_REGISTRY_URL`, `AUTOPKG_OWNER`,
`AUTOPKG_REPO`, `AUTOPKG_USER`, `AUTOPKG_TOKEN`, `AUTOPKG_REPLACE`.
## Ignoring folders
Top-level dirs `.git`, `.gitea`, `.github`, `.build`, `scripts`, `dist` and
`__pycache__` are never treated as packages. Add more (one name per line) in a
`.autopackageignore` file at the repo root.

432
autopackage.py Normal file
View File

@@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""
autopackage.py
This is a rewrite/port of my original autopackage python script, that i used
to just keep to myself but... now im just trusting enough of my personal supply chain
that i think im comfy sharing package builds... at least for friends!
I mostly intend for CI to run this script but, if you want to make tweaks or experiment locally
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 os
import shlex
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
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__",
}
PKG_GLOBS = ("*.pkg.tar.zst", "*.pkg.tar.xz", "*.pkg.tar.gz", "*.pkg.tar.bz2")
@dataclass
class Config:
repo_root: Path
workdir: Path
makepkg_cmd: List[str]
sign: bool
keep_going: bool
dry_run: bool
publish: bool
replace: bool
# Gitea Arch registry settings
registry_url: str
owner: str
arch_repo: str
auth_user: str
token: str
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)
if cwd:
pretty = f"(cd {cwd}) {pretty}"
print(f"+ {pretty}", flush=True)
if dry_run:
return subprocess.CompletedProcess(cmd, 0)
return subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=check)
# --------------------------------------------------------------------------- #
# Discovery / classification
# --------------------------------------------------------------------------- #
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():
line = line.strip()
if line and not line.startswith("#"):
ignore.add(line)
return ignore
def discover_packages(cfg: Config, only: List[str]) -> List[Path]:
pkgs: List[Path] = []
for entry in sorted(cfg.repo_root.iterdir()):
if not entry.is_dir():
continue
name = entry.name
if name.startswith(".") or name in cfg.ignore:
continue
if only and name not in only:
continue
pkgs.append(entry)
return pkgs
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}")
return
if build_dir.exists():
shutil.rmtree(build_dir)
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:
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 cfg.dry_run:
print(f"+ cp {ov} {dest}")
continue
shutil.copy2(ov, dest)
for patch in patches:
print(f" patch: {patch.name}")
apply_patch(patch, build_dir, cfg.dry_run)
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] = []
for p in pkgs:
artifacts.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
# --------------------------------------------------------------------------- #
# Publish to Gitea Arch registry
# --------------------------------------------------------------------------- #
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):
base = base[: -len(suffix)]
break
else:
return None
parts = base.rsplit("-", 3)
if len(parts) != 4:
return None
name, ver, rel, arch = parts
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) -> tuple:
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) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
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})")
return
data = path.read_bytes()
status, body = http_request(url, "PUT", cfg, data=data)
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)
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)")
return
raise RuntimeError(
f"upload of {path.name} failed: HTTP {status}: {body.strip()[:500]}"
)
def publish(artifacts: List[Path], cfg: Config) -> None:
for art in artifacts:
publish_file(art, cfg)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def env(name: str, default: str = "") -> str:
return os.environ.get(name, default)
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")
if args.sign:
makepkg_cmd.append("--sign")
cfg = Config(
repo_root=repo_root,
workdir=args.workdir.resolve(),
makepkg_cmd=makepkg_cmd,
sign=args.sign,
keep_going=args.keep_going,
dry_run=args.dry_run,
publish=not args.no_publish,
replace=args.replace,
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),
)
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("--cleanbuild", action="store_true",
help="Pass --cleanbuild to makepkg")
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")
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")
args = ap.parse_args(argv)
cfg = build_config(args)
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)}")
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]
if missing:
print(f"Cannot publish: missing {', '.join(missing)}. "
f"Set the matching env/secret or pass --no-publish.",
file=sys.stderr)
return 2
failures: List[str] = []
for pkg_src in pkgs:
try:
artifacts = build_one(pkg_src, cfg)
if cfg.publish and not cfg.dry_run:
publish(artifacts, cfg)
elif cfg.publish and cfg.dry_run:
print(f" (dry-run) would publish artifacts for {pkg_src.name}")
except (subprocess.CalledProcessError, RuntimeError, OSError) as e:
print(f"{pkg_src.name}: {e}", file=sys.stderr)
failures.append(pkg_src.name)
if not cfg.keep_going:
break
if failures:
print(f"\nFailed: {', '.join(failures)}", file=sys.stderr)
return 1
print("\nAll done.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

0
bacula/.gitkeep Normal file
View File

View File

View File

0
openrgb-git/.gitkeep Normal file
View File

0
paru/.gitkeep Normal file
View File

View File

@@ -0,0 +1,19 @@
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
index 29b0af464..721fa7864 100644
--- a/linux/CMakeLists.txt
+++ b/linux/CMakeLists.txt
@@ -81,6 +81,14 @@ set_target_properties(${BINARY_NAME}
)
+# Build sentry-native with the in-process backend instead of crashpad. The
+# crashpad backend forks a handler and calls setsid(), which fails (EPERM) when
+# plezy is launched as a systemd app scope / session leader, tripping a fatal
+# CHECK in crashpad that SIGTRAPs on startup. inproc installs in-process signal
+# handlers and avoids the fork/setsid path. Overrides the plugin's forced
+# SENTRY_BACKEND=crashpad via the env-var escape hatch in sentry-native.cmake.
+set(ENV{SENTRY_NATIVE_BACKEND} "inproc")
+
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)

0
qownnotes/.gitkeep Normal file
View File

0
ventoy-bin/.gitkeep Normal file
View File

View File