Tests / tests (push) Successful in 2m19s
end-4's ./setup sudo keepalive orphans a `sleep 60` child as ii-builder; userdel then exits 8 and aborts the ISO build (CI exit 8). Sweep uid-matching processes inside the build chroot before userdel, mirroring shadow's user_busy_processes() checks (real/effective/saved uid + root device/inode). Because arch-chroot mounts proc before `unshare --fork --pid`, /proc entry numbers are outer-namespace pids: signal the last NSpid field instead, require matching NSpid depth, and surface kill errors instead of swallowing them. userdel keeps its stderr and, on final failure, dumps the leftover process list before aborting.
331 lines
14 KiB
Bash
331 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Populate the keyring in the built root from the keyring bundles
|
|
# (/usr/share/pacman/keyrings, incl. the copied cachyos keyring).
|
|
# This mirrors the guide; it runs inside the image you are making.
|
|
# pacman-key --populate needs no network.
|
|
pacman-key --init
|
|
pacman-key --populate archlinux cachyos chaotic
|
|
|
|
# Restore the strict live /etc/pacman.conf after pacstrap installs the
|
|
# 'pacman' package, which ships a stock /etc/pacman.conf and would otherwise
|
|
# overwrite this directory's configured version.
|
|
install -m 0644 /root/.live-pacman.conf /etc/pacman.conf
|
|
rm -f /root/.live-pacman.conf
|
|
|
|
# ============================================================================
|
|
# Pre-install dotfile dependencies into /etc/skel so new users get them
|
|
# instantly on first login (no network required).
|
|
# ============================================================================
|
|
|
|
SKEL="/etc/skel"
|
|
|
|
# ── Oh-My-Zsh ────────────────────────────────────────────────────────────────
|
|
if [[ ! -d "${SKEL}/.oh-my-zsh" ]]; then
|
|
echo "[customize] Cloning Oh-My-Zsh into skel..."
|
|
git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git "${SKEL}/.oh-my-zsh"
|
|
fi
|
|
|
|
# Oh-My-Zsh custom plugins
|
|
ZSH_CUSTOM="${SKEL}/.oh-my-zsh/custom"
|
|
mkdir -p "${ZSH_CUSTOM}/plugins"
|
|
|
|
declare -A OMZ_PLUGINS=(
|
|
[zsh-history-substring-search]="https://github.com/zsh-users/zsh-history-substring-search.git"
|
|
[zsh-autosuggestions]="https://github.com/zsh-users/zsh-autosuggestions.git"
|
|
[zsh-eza]="https://github.com/z-shell/zsh-eza.git"
|
|
[fzf-zsh-plugin]="https://github.com/unixorn/fzf-zsh-plugin.git"
|
|
[zsh-syntax-highlighting]="https://github.com/zsh-users/zsh-syntax-highlighting.git"
|
|
[zsh-autocomplete]="https://github.com/marlonrichert/zsh-autocomplete.git"
|
|
)
|
|
|
|
for plugin in "${!OMZ_PLUGINS[@]}"; do
|
|
target="${ZSH_CUSTOM}/plugins/${plugin}"
|
|
if [[ ! -d "${target}" ]]; then
|
|
echo "[customize] Cloning OMZ plugin: ${plugin}"
|
|
git clone --depth=1 "${OMZ_PLUGINS[${plugin}]}" "${target}"
|
|
fi
|
|
done
|
|
|
|
# ── LazyVim (lazy.nvim bootstraps on first launch, but pre-clone the plugin) ─
|
|
NVIM_DATA="${SKEL}/.local/share/nvim"
|
|
mkdir -p "${NVIM_DATA}"
|
|
if [[ ! -d "${NVIM_DATA}/lazy/lazy.nvim" ]]; then
|
|
echo "[customize] Pre-cloning lazy.nvim for LazyVim..."
|
|
git clone --filter=blob:none --branch=stable \
|
|
https://github.com/folke/lazy.nvim.git "${NVIM_DATA}/lazy/lazy.nvim"
|
|
fi
|
|
|
|
# ── end-4 dots-hyprland, staged for alvin-install (avoids a clone at install) ─
|
|
DOTS_REPO="/opt/dots-hyprland"
|
|
if [[ ! -d "${DOTS_REPO}/.git" ]]; then
|
|
echo "[customize] Cloning end-4 dots-hyprland..."
|
|
git clone --depth=1 https://github.com/end-4/dots-hyprland.git "${DOTS_REPO}"
|
|
git -C "${DOTS_REPO}" submodule update --init --depth=1
|
|
fi
|
|
|
|
# ── end4-pC: the default quickshell config, shipped in skel ──────────────────
|
|
# pctrade/end4-pC is a fork of the ii shell config. It lives next to ii in
|
|
# ~/.config/quickshell and does not modify it; which one runs is decided by
|
|
# qsConfig (patched just below). The repo has no submodules.
|
|
if [[ ! -d "${SKEL}/.config/quickshell/end4-pC" ]]; then
|
|
echo "[customize] Cloning end4-pC quickshell config into skel..."
|
|
git clone --depth=1 https://github.com/pctrade/end4-pC.git \
|
|
"${SKEL}/.config/quickshell/end4-pC"
|
|
fi
|
|
|
|
# ── Default quickshell config: ii -> end4-pC ─────────────────────────────────
|
|
# alvin-install copies this staged repo onto the target and runs `./setup
|
|
# install`, whose rsync writes hypr/hyprland/variables.lua into the user's
|
|
# home - so patching the staged copy is what sets qsConfig on the installed
|
|
# system. Re-running is a no-op; anything other than ii/end4-pC aborts.
|
|
VARIABLES_LUA="${DOTS_REPO}/dots/.config/hypr/hyprland/variables.lua"
|
|
if grep -Fq 'hl.env("qsConfig", "ii")' "${VARIABLES_LUA}"; then
|
|
echo "[customize] Setting qsConfig to end4-pC..."
|
|
sed -i 's|hl\.env("qsConfig", "ii")|hl.env("qsConfig", "end4-pC")|' "${VARIABLES_LUA}"
|
|
fi
|
|
if ! grep -Fq 'hl.env("qsConfig", "end4-pC")' "${VARIABLES_LUA}"; then
|
|
echo "[customize] qsConfig is neither ii nor end4-pC in ${VARIABLES_LUA}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Pre-install end-4 dependencies into the image (network heavy, once per ISO)
|
|
# ============================================================================
|
|
# ./setup install-deps resolves and *builds* every AUR/source dependency the
|
|
# dotfiles need (quickshell, MicroTeX, theming, fonts, ...). Doing it here lets
|
|
# alvin-install run its end-4 step with --skip-alldeps, so an install only
|
|
# copies files - no AUR clones, no makepkg, no dependency resolution.
|
|
#
|
|
# The script refuses to run as root, hence the throwaway builder account.
|
|
# ============================================================================
|
|
BUILD_USER="ii-builder"
|
|
|
|
if ! id -u "${BUILD_USER}" &>/dev/null; then
|
|
useradd -m -u 1500 "${BUILD_USER}"
|
|
fi
|
|
echo "${BUILD_USER} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/99-ii-builder
|
|
chmod 440 /etc/sudoers.d/99-ii-builder
|
|
|
|
# makepkg writes src/, pkg/ and *.pkg.tar.zst into the PKGBUILD directories,
|
|
# so the tree must be owned by the account that runs it.
|
|
chown -R "${BUILD_USER}:${BUILD_USER}" "${DOTS_REPO}"
|
|
|
|
# -f: never prompt (ask=false); -s: skip "pacman -Syu";
|
|
# --skip-plasmaintg: optional Firefox widget that pulls 410 pkgs / 526 MiB of
|
|
# KDE packages onto a non-KDE system;
|
|
# stdin: answer end-4's x() failure prompt with "e", so a failed command
|
|
# aborts the build instead of blocking forever without a TTY.
|
|
# shellcheck disable=SC2024 # the redirect is meant for the child, not sudo
|
|
if ! sudo -u "${BUILD_USER}" env HOME="/home/${BUILD_USER}" \
|
|
GIT_TERMINAL_PROMPT=0 \
|
|
bash -c "cd ${DOTS_REPO} && ./setup install-deps -f -s --skip-plasmaintg" \
|
|
< <(yes e); then
|
|
echo "[customize] end-4 ./setup install-deps failed" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ── Harvest the packages install-deps just built into the in-image local repo ─
|
|
# The live /etc/pacman.conf points [local-aur] at /opt/localrepo; without the
|
|
# harvested copies pacstrap cannot resolve these packages at install time and
|
|
# would silently drop them from the target system.
|
|
mkdir -p /opt/localrepo
|
|
shopt -s nullglob
|
|
|
|
# <name> <version> from a package's embedded .PKGINFO (update.sh does the same)
|
|
pkg_meta() {
|
|
tar -xOf "$1" .PKGINFO 2>/dev/null |
|
|
awk -F' = ' '/^pkgname = /{n=$2} /^pkgver = /{v=$2} END{print n, v}'
|
|
}
|
|
|
|
mapfile -d '' -t built_pkgs < <(
|
|
find "${DOTS_REPO}" "/home/${BUILD_USER}/.cache/yay" \
|
|
-name '*.pkg.tar.zst' -print0 2>/dev/null
|
|
)
|
|
|
|
declare -A built_file=() built_ver=()
|
|
for p in "${built_pkgs[@]}"; do
|
|
read -r n v < <(pkg_meta "$p")
|
|
[[ -n "${n:-}" && -n "${v:-}" ]] || continue
|
|
built_file["$n"]="$p"
|
|
built_ver["$n"]="$v"
|
|
done
|
|
|
|
# One entry per package name, and the entry must be the version the image
|
|
# actually installed - a stale copy with the same name but another version
|
|
# would let pacstrap install something else than what was tested.
|
|
for existing in /opt/localrepo/*.pkg.tar.zst; do
|
|
[[ -e "$existing" ]] || continue
|
|
read -r n v < <(pkg_meta "$existing")
|
|
[[ -n "${n:-}" ]] || continue
|
|
if [[ -n "${built_ver[$n]:-}" ]]; then
|
|
echo "[customize] Replacing localrepo ${n} ${v} with built ${built_ver[$n]}"
|
|
rm -f "$existing"
|
|
fi
|
|
done
|
|
|
|
if ((${#built_file[@]} > 0)); then
|
|
echo "[customize] Harvesting ${#built_file[@]} package(s) into /opt/localrepo"
|
|
cp -f -- "${built_file[@]}" /opt/localrepo/
|
|
# makepkg -Afsi leaves its archives next to the PKGBUILD. They are already
|
|
# harvested (and installed in this image); keeping them in the dots tree
|
|
# would ship a second copy inside /opt/dots-hyprland, which alvin-install
|
|
# copies to ~/.dots-hyprland on every installed system.
|
|
find "${DOTS_REPO}" -type f -name '*.pkg.tar.zst' -delete
|
|
fi
|
|
|
|
# Rebuild the repo database (CI/update.sh ship a pre-built one alongside the
|
|
# packages) and refresh the live system's copy of it. No network needed: the
|
|
# remote repos were synced during pacstrap.
|
|
local_pkgs=(/opt/localrepo/*.pkg.tar.zst)
|
|
if ((${#local_pkgs[@]} > 0)); then
|
|
echo "[customize] Rebuilding local-aur db (${#local_pkgs[@]} packages)..."
|
|
repo-add -R /opt/localrepo/local-aur.db.tar.gz "${local_pkgs[@]}"
|
|
mkdir -p /var/lib/pacman/sync
|
|
cp -fL /opt/localrepo/local-aur.db /var/lib/pacman/sync/local-aur.db
|
|
if [[ -f /opt/localrepo/local-aur.files ]]; then
|
|
cp -fL /opt/localrepo/local-aur.files /var/lib/pacman/sync/local-aur.files
|
|
fi
|
|
fi
|
|
|
|
# ── makepkg leftovers + throwaway account do not belong in the ISO ──────────
|
|
if [[ -d "${DOTS_REPO}/sdata/dist-arch" ]]; then
|
|
find "${DOTS_REPO}/sdata/dist-arch" -mindepth 2 -maxdepth 2 -type d \
|
|
\( -name src -o -name pkg \) -prune -exec rm -rf {} +
|
|
fi
|
|
rm -f /etc/sudoers.d/99-ii-builder
|
|
|
|
# Processes still running as BUILD_USER make userdel exit 8 and abort the
|
|
# build. end-4's ./setup is the usual source: sudo_init_keepalive forks
|
|
# `(sleep 60; sudo true) &` and its EXIT trap kills only the subshell,
|
|
# orphaning the sleep child for up to a minute. Mirrors shadow's
|
|
# user_busy_processes(): match real/effective/saved uid AND require the
|
|
# process to live inside this build chroot - the runner shares our PID
|
|
# namespace, and a host process could carry the same uid.
|
|
#
|
|
# mkarchiso runs this script via arch-chroot, which mounts proc *before*
|
|
# `unshare --fork --pid`: /proc entry numbers belong to the outer pid
|
|
# namespace, while kill(2) resolves pids in our (nested) namespace. Use the
|
|
# last NSpid field - the pid in our own namespace - for signalling; keep the
|
|
# /proc entry number for logging, since that is what userdel reports.
|
|
nspid_fields() {
|
|
awk -F'\t' '/^NSpid:/{ $1=""; sub(/^ +/, ""); print; exit }' "$1"
|
|
}
|
|
|
|
builder_procs() {
|
|
local uid broot st p key real eff saved kpid cmd
|
|
local -a nsf self_nsf=()
|
|
uid=$(id -u "${BUILD_USER}") || return 0
|
|
broot=$(stat -Lc '%d:%i' /)
|
|
read -r -a self_nsf < <(nspid_fields /proc/self/status)
|
|
for st in /proc/[0-9]*/status; do
|
|
[[ -r "$st" ]] || continue
|
|
real=""; eff=""; saved=""
|
|
while read -r key real eff saved _; do
|
|
[[ $key == "Uid:" ]] && break
|
|
done < "$st" || continue
|
|
[[ $real == "$uid" || $eff == "$uid" || $saved == "$uid" ]] || continue
|
|
p=${st#/proc/}; p=${p%/status}
|
|
# Stat of /proc/<pid>/root needs CAP_SYS_PTRACE (or same-uid): exactly
|
|
# what shadow's userdel uses, so a process we cannot see here is one
|
|
# userdel cannot flag either. Compare by device+inode; skip on failure.
|
|
[[ $(stat -Lc '%d:%i' "/proc/$p/root" 2>/dev/null) == "$broot" ]] || continue
|
|
# NSpid chain length must equal ours, i.e. the process must live in our
|
|
# pid namespace - only then is its last field killable from here.
|
|
nsf=()
|
|
read -r -a nsf < <(nspid_fields "/proc/$p/status") || continue
|
|
if ((${#nsf[@]} > 0 && ${#nsf[@]} == ${#self_nsf[@]})); then
|
|
kpid=${nsf[${#nsf[@]} - 1]}
|
|
else
|
|
kpid="-"
|
|
fi
|
|
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null)
|
|
printf '%s\t%s\t%s\n' "$p" "$kpid" "$cmd"
|
|
done
|
|
return 0
|
|
}
|
|
|
|
sweep_builder_procs() {
|
|
local sig=$1 p kpid cmd kerr
|
|
local -a pids=()
|
|
while IFS=$'\t' read -r p kpid cmd; do
|
|
[[ -n $p ]] || continue
|
|
if [[ -z $kpid || $kpid == "-" ]]; then
|
|
echo "[customize] ${BUILD_USER} process outside our pid namespace (cannot signal): pid $p: $cmd"
|
|
continue
|
|
fi
|
|
echo "[customize] ${BUILD_USER} process ($sig): pid $p: $cmd"
|
|
pids+=("$kpid")
|
|
done < <(builder_procs)
|
|
if ((${#pids[@]} > 0)); then
|
|
# Surface signal failures (they used to be swallowed, hiding a
|
|
# pid-namespace mismatch as a silent no-op sweep).
|
|
if ! kerr=$(kill -"$sig" "${pids[@]}" 2>&1); then
|
|
[[ -n $kerr ]] && printf '[customize] kill -%s: %s\n' "$sig" "$kerr"
|
|
fi
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
sweep_builder_procs TERM
|
|
sleep 2
|
|
sweep_builder_procs KILL
|
|
sleep 1
|
|
|
|
if ! userdel -r "${BUILD_USER}"; then
|
|
rm -rf "/home/${BUILD_USER:?}"
|
|
if ! userdel "${BUILD_USER}"; then
|
|
echo "[customize] FATAL: cannot remove user ${BUILD_USER}; leftover processes:" >&2
|
|
builder_procs >&2 || true
|
|
exit 1
|
|
fi
|
|
fi
|
|
chown -R root:root "${DOTS_REPO}"
|
|
|
|
# ── Google Sans Flex into skel: install_google_sans_flex() early-returns once
|
|
# fc-list sees the font, so the install step needs no network ──────────────
|
|
FONT_DIR="${SKEL}/.local/share/fonts/illogical-impulse-google-sans-flex"
|
|
if [[ ! -f "${FONT_DIR}/.git/HEAD" ]]; then
|
|
echo "[customize] Pre-installing Google Sans Flex into skel..."
|
|
rm -rf "${FONT_DIR}"
|
|
git clone --depth=1 --recurse-submodules --shallow-submodules \
|
|
https://github.com/end-4/google-sans-flex "${FONT_DIR}"
|
|
fi
|
|
|
|
# ── uv into skel: CPython 3.12 + the end-4 requirements wheel cache ──────────
|
|
# install-python-packages() runs `uv venv -p 3.12` and `uv pip install -r ...`
|
|
# during setup. Arch has no python312 package (uv would download one) and the
|
|
# pinned requirements are ~250 MiB of wheels, so warm both into skel: the venv
|
|
# is then created and filled straight from the image.
|
|
if command -v uv &>/dev/null; then
|
|
echo "[customize] Pre-warming uv (CPython 3.12 + requirements cache)..."
|
|
warm_venv="/tmp/uv-warm-venv"
|
|
rm -rf "${warm_venv}"
|
|
UV_CACHE_DIR="${SKEL}/.cache/uv" \
|
|
UV_PYTHON_INSTALL_DIR="${SKEL}/.local/share/uv/python" \
|
|
uv venv -p 3.12 "${warm_venv}"
|
|
UV_CACHE_DIR="${SKEL}/.cache/uv" \
|
|
UV_PYTHON_INSTALL_DIR="${SKEL}/.local/share/uv/python" \
|
|
uv pip install --python "${warm_venv}/bin/python" \
|
|
-r "${DOTS_REPO}/sdata/uv/requirements.txt"
|
|
rm -rf "${warm_venv}"
|
|
else
|
|
echo "[customize] uv not found - skipping python/wheel pre-warm."
|
|
fi
|
|
|
|
# ── Fish: ensure default shell is set for the live user ──────────────────────
|
|
# The live user (usually 'archlinux' or 'root') gets fish as default shell
|
|
if command -v fish &>/dev/null; then
|
|
echo "[customize] Setting fish as default shell for skel..."
|
|
chsh -s /usr/bin/fish root 2>/dev/null || true
|
|
fi
|
|
|
|
# ── Ensure skel directories have correct permissions ─────────────────────────
|
|
chmod 700 "${SKEL}/.config" 2>/dev/null || true
|
|
find "${SKEL}/.config" -type f -exec chmod 644 {} \; 2>/dev/null || true
|
|
find "${SKEL}/.config" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
|
find "${SKEL}/.config" -name '*.sh' -exec chmod 755 {} \; 2>/dev/null || true
|
|
find "${SKEL}/.local" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
|
|
|
echo "[customize] Dotfile pre-installation complete." |