Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,10 +403,9 @@ bootloader:

```yaml
plymouth:
theme: spinner # passed to plymouth-set-default-theme
theme: spinner # a built-in archinstall theme (see below)

grub:
cmdline_extra: "quiet splash" # appended to GRUB_CMDLINE_LINUX_DEFAULT
theme:
source: vinceliuice # vinceliuice | url | none
name: tela # vinceliuice theme: tela|stylish|vimix|whitesur|slaze
Expand All @@ -415,6 +414,13 @@ grub:
# url: https://example.com/theme.tar.gz # used when source: url
```

Plymouth is installed and configured by **archinstall in Phase A**: it adds the initramfs hook,
appends `quiet splash` to the kernel cmdline, sets the theme and regenerates the initramfs. `theme`
must be one of archinstall's built-in themes — `bgrt`, `fade-in`, `glow`, `script`, `solar`,
`spinner`, `spinfinity`, `tribar`, `text`, `details` — because archinstall aborts the install on an
unknown value; **custom Plymouth themes are not supported** via this path. Leave `plymouth` unset for
no boot splash.

For `source: vinceliuice`, `screen` maps to the installer's `-s` flag and `background` supplies a
custom image (the installer uses a `background.jpg` in its checkout). `background` may be a local
path or an `http(s)` URL. **Heads-up on ordering:** the `grub-theme` stage (60) runs *before*
Expand Down Expand Up @@ -650,14 +656,13 @@ name **or** number.
| # | Stage | Phase | What it does |
|---|-------|-------|--------------|
| 0 | `preflight` | A | UEFI + config + archinstall version checks (warns, doesn't block) |
| 10 | `archinstall` | A | reflector → probe geometry → render JSON → `archinstall --silent` → chroot: repos + kernels → stage the binary for Phase B |
| 10 | `archinstall` | A | reflector → probe geometry → render JSON (incl. `plymouth` boot splash) → `archinstall --silent` → chroot: repos + kernels → stage the binary for Phase B |
| 10 | `yay` | B | install the AUR helper (`aur_helper`) |
| 20 | `packages` | B | `pacman -S --needed` the official/custom-repo packages |
| 25 | `snapper` | B | provision Snapper (only when btrfs + `snapshots: snapper`) |
| 30 | `flatpak` | B | register `flatpak_remotes`, install `flatpaks` |
| 40 | `aur` | B | build/install the `aur` list via the helper |
| 50 | `plymouth` | B | set the boot splash theme |
| 60 | `grub-theme` | B | apply the GRUB theme + cmdline extras |
| 60 | `grub-theme` | B | apply the GRUB theme |
| 70 | `kde` | B | KDE global theme via `LookAndFeelPackage` in kdeglobals (no-op for other DEs) |
| 80 | `dotfiles` | B | apply dotfiles via the configured manager |
| 85 | `setup` | B | run the ordered `setup.steps` (clones/commands) |
Expand Down
7 changes: 5 additions & 2 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,14 @@ bootloader:
kind: grub # grub | systemd-boot

# --- boot splash / theming --------------------------------------------------
# Plymouth is installed and configured by archinstall in Phase A: it adds the
# initramfs hook, appends "quiet splash" to the kernel cmdline and sets the theme.
# theme must be one of archinstall's built-in themes (custom themes unsupported):
# bgrt | fade-in | glow | script | solar | spinner | spinfinity | tribar | text | details
plymouth:
theme: spinner # name passed to plymouth-set-default-theme
theme: spinner

grub:
cmdline_extra: "quiet splash" # appended to GRUB_CMDLINE_LINUX_DEFAULT
theme:
source: vinceliuice # vinceliuice | url | none
name: tela # vinceliuice theme name (tela|stylish|vimix|whitesur|slaze)
Expand Down
7 changes: 6 additions & 1 deletion docs/bugs/plymouth-bootctl-update-fails-systemd-boot.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Bug: Phase B `regenerateBootConfig` runs `bootctl update` on systemd-boot and fails, aborting bootstrap (via the always-on plymouth stage)

**Status:** open — found by the automated VM e2e harness (`test/e2e/vm/`, descriptor `sdboot-lvm`), 2026-06-23
**Status:** resolved 2026-06-30 — Plymouth moved to archinstall (Phase A) on the 4.4 bump.
The Phase B `plymouth` stage, `regenerateBootConfig`/`ensureKernelParam` helpers, and the
`bootctl update --graceful` workaround described below were all removed; archinstall now installs
and configures Plymouth (hook, `quiet splash`, theme) during the install, so no Phase B step ever
runs `bootctl update`. The failure path no longer exists. (Originally found by the automated VM e2e
harness `test/e2e/vm/`, descriptor `sdboot-lvm`, 2026-06-23.)
**Area:** `internal/stages/helpers.go` (`regenerateBootConfig`); surfaced via `internal/stages/plymouth.go`
**Severity:** high — Phase B `bootstrap` **fails on any systemd-boot system** at the plymouth
stage (and any other stage that regenerates boot config), even with a default config.
Expand Down
3 changes: 2 additions & 1 deletion docs/vm-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ Each shape was reverse-engineered; the harness has now exercised them all.
| Snapper | timer-unit + `set-config` key names | ✅ confirmed — `btrfs-snapper` installs snapper + green |
| Btrfs | subvolume JSON `{name, mountpoint}` | ❌ **bug** — shape parses, but archinstall installs to the **top-level** subvolume; the configured `@` is created but unused as root → [`btrfs-subvolume-not-used-as-root.md`](bugs/btrfs-subvolume-not-used-as-root.md) |
| LVM | multi-volume "rest of VG" sizing (fixed root + remainder `/home`) | ❌ **bug** — the PV partition renders with an empty `fs_type`; archinstall aborts Phase A before sizing is reached → [`lvm-multivolume-pv-fstype-empty.md`](bugs/lvm-multivolume-pv-fstype-empty.md) |
| systemd-boot | loader-entry default + `bootctl update` cmdline-refresh path | ⚠️ install + boot **work**; the Phase-B `bootctl update` refresh path **fails** (nonzero when already current), reached via the always-on plymouth stage → [`plymouth-bootctl-update-fails-systemd-boot.md`](bugs/plymouth-bootctl-update-fails-systemd-boot.md) |
| systemd-boot | loader-entry default | ⚠️ install + boot **work**. The Phase-B `bootctl update` refresh path that previously failed is **gone**: the always-on plymouth stage that triggered it was removed when Plymouth moved to archinstall (Phase A) — see [`plymouth-bootctl-update-fails-systemd-boot.md`](bugs/plymouth-bootctl-update-fails-systemd-boot.md) (resolved). |
| Plymouth | `bootloader_config.plymouth` (archinstall 4.4 built-in theme) | ⚠️ **not yet VM-validated** — archinstall installs/configures the splash in Phase A; confirm the `spinner` theme is set and quiet/splash reach the kernel cmdline on a real run. |

(A fourth bug unrelated to a disk shape — the flatpak stage hangs on a polkit prompt — is in
[`flatpak-system-remote-add-polkit-hang.md`](bugs/flatpak-system-remote-add-polkit-hang.md).)
Expand Down
13 changes: 11 additions & 2 deletions internal/archinstall/archinstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import (
)

// Version is the archinstall release this schema was modelled against.
const Version = "4.3"
const Version = "4.4"

// newObjID generates the unique obj_id values archinstall uses to cross-reference
// partitions from lvm_pvs. It is a package var solely so golden render tests can
Expand Down Expand Up @@ -167,10 +167,19 @@ type DiskConfig struct {
// (args.py BootloaderConfiguration). It replaces the deprecated bare
// `bootloader: "Grub"` string. Bootloader is one of "Grub", "Systemd-boot",
// "Efistub", "Limine", "Refind", "No bootloader"; uki/removable default false.
//
// Plymouth, added in archinstall 4.4, is one of archinstall's built-in
// PlymouthTheme values (e.g. "spinner", "bgrt"). When set, archinstall installs
// plymouth, inserts the initramfs hook, appends "quiet splash" to the kernel
// cmdline, runs plymouth-set-default-theme and regenerates the initramfs — all in
// Phase A. omitempty keeps the unconfigured render byte-identical to before, and a
// non-built-in value is rejected (archinstall sys.exit(1)s on unknown themes, so
// config validation restricts Plymouth.Theme to the same built-in set).
type BootloaderConfig struct {
Bootloader string `json:"bootloader"`
UKI bool `json:"uki"`
Removable bool `json:"removable"`
Plymouth string `json:"plymouth,omitempty"`
}

// archBootloader maps our config bootloader kind ("grub"/"systemd-boot") to
Expand Down Expand Up @@ -293,7 +302,7 @@ func Build(cfg *config.Config, geom Geometry, password string) (*Config, *Creds,
pkgs := append([]string(nil), cfg.Pacstrap...)
c := &Config{
Lang: "English",
BootloaderConfig: BootloaderConfig{Bootloader: archBootloader(cfg.Bootloader.EffectiveKind()), UKI: false, Removable: false},
BootloaderConfig: BootloaderConfig{Bootloader: archBootloader(cfg.Bootloader.EffectiveKind()), UKI: false, Removable: false, Plymouth: cfg.Plymouth.Theme},
Kernels: kernelBase(cfg),
Hostname: cfg.System.Hostname,
Packages: pkgs,
Expand Down
19 changes: 19 additions & 0 deletions internal/archinstall/bootloader_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,22 @@ func TestBuild_BootloaderSystemdBoot(t *testing.T) {
t.Errorf("systemd-boot bootloader = %q, want %q", got, "Systemd-boot")
}
}

// A configured plymouth theme is rendered into bootloader_config.plymouth so
// archinstall installs and configures the splash in Phase A (replacing the old
// Phase B plymouth stage). The theme string passes through verbatim.
func TestBuild_PlymouthThemeRendered(t *testing.T) {
out := buildForBootloader(t, "plymouth:\n theme: spinner\n")
if got := out.BootloaderConfig.Plymouth; got != "spinner" {
t.Errorf("plymouth theme = %q, want %q", got, "spinner")
}
}

// With no plymouth theme the field is omitted (omitempty), keeping the rendered
// bootloader_config byte-identical to the pre-4.4 output the golden files assert.
func TestBuild_PlymouthOmittedWhenUnset(t *testing.T) {
out := buildForBootloader(t, "")
if got := out.BootloaderConfig.Plymouth; got != "" {
t.Errorf("plymouth theme = %q, want empty", got)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,6 @@
"lvm_config": null,
"disk_encryption": null
},
"version": "4.3",
"config_version": "4.3"
"version": "4.4",
"config_version": "4.4"
}
4 changes: 2 additions & 2 deletions internal/archinstall/testdata/golden/ext4-sata.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,6 @@
},
"disk_encryption": null
},
"version": "4.3",
"config_version": "4.3"
"version": "4.4",
"config_version": "4.4"
}
4 changes: 2 additions & 2 deletions internal/archinstall/testdata/golden/lvm-volumes.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,6 @@
},
"disk_encryption": null
},
"version": "4.3",
"config_version": "4.3"
"version": "4.4",
"config_version": "4.4"
}
4 changes: 2 additions & 2 deletions internal/archinstall/testdata/golden/lvm-zram.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,6 @@
},
"disk_encryption": null
},
"version": "4.3",
"config_version": "4.3"
"version": "4.4",
"config_version": "4.4"
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,6 @@
},
"disk_encryption": null
},
"version": "4.3",
"config_version": "4.3"
"version": "4.4",
"config_version": "4.4"
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,6 @@
},
"disk_encryption": null
},
"version": "4.3",
"config_version": "4.3"
"version": "4.4",
"config_version": "4.4"
}
10 changes: 7 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,17 @@ type Config struct {
Multilib bool `yaml:"multilib"`
} `yaml:"pacman"`

// Plymouth selects the boot splash theme. Theme must be one of archinstall's
// built-in PlymouthTheme values: archinstall installs and configures plymouth
// in Phase A (it appends "quiet splash" to the kernel cmdline itself), and it
// sys.exit(1)s on a theme outside this set, so the enum is enforced here too.
// Custom (non-built-in) themes are unsupported by this path. Empty = no splash.
Plymouth struct {
Theme string `yaml:"theme"`
Theme string `yaml:"theme" validate:"omitempty,oneof=bgrt fade-in glow script solar spinner spinfinity tribar text details"`
} `yaml:"plymouth"`

GRUB struct {
CmdlineExtra string `yaml:"cmdline_extra"`
Theme struct {
Theme struct {
Source string `yaml:"source" validate:"omitempty,oneof=vinceliuice url none"`
Name string `yaml:"name"`
URL string `yaml:"url" validate:"omitempty,url"`
Expand Down
6 changes: 5 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ aur: [1password]
plymouth:
theme: bgrt
grub:
cmdline_extra: "quiet splash"
theme:
source: vinceliuice
name: tela
Expand Down Expand Up @@ -203,6 +202,11 @@ func TestValidate_Errors(t *testing.T) {
yaml: strings.Replace(validYAML, "pvs: [/dev/nvme0n1p3, /dev/sda]", "pvs: []", 1),
want: []string{"disks.lvm.pvs must have at least 1 item(s)"},
},
{
name: "non-built-in plymouth theme",
yaml: strings.Replace(validYAML, "theme: bgrt", "theme: my-custom-theme", 1),
want: []string{"plymouth.theme must be one of: bgrt fade-in glow script solar spinner spinfinity tribar text details"},
},
{
name: "bad grub theme source",
yaml: strings.Replace(validYAML, "source: vinceliuice", "source: bananas", 1),
Expand Down
26 changes: 0 additions & 26 deletions internal/stages/bootloader_test.go

This file was deleted.

20 changes: 10 additions & 10 deletions internal/stages/fromto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ func TestWithin(t *testing.T) {
{
name: "from only by name is inclusive lower bound",
from: "flatpak", to: "",
want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"},
want: []string{"flatpak", "aur", "grub-theme", "kde", "dotfiles", "setup", "services"},
},
{
name: "from only by number resolves same as name",
from: "30", to: "",
want: []string{"flatpak", "aur", "plymouth", "grub-theme", "kde", "dotfiles", "setup", "services"},
want: []string{"flatpak", "aur", "grub-theme", "kde", "dotfiles", "setup", "services"},
},
{
name: "to only by name is inclusive upper bound",
Expand All @@ -46,13 +46,13 @@ func TestWithin(t *testing.T) {
},
{
name: "both bounds inclusive on each end",
from: "packages", to: "plymouth",
want: []string{"packages", "snapper", "flatpak", "aur", "plymouth"},
from: "packages", to: "aur",
want: []string{"packages", "snapper", "flatpak", "aur"},
},
{
name: "both bounds mixing name and number",
from: "20", to: "kde",
want: []string{"packages", "snapper", "flatpak", "aur", "plymouth", "grub-theme", "kde"},
want: []string{"packages", "snapper", "flatpak", "aur", "grub-theme", "kde"},
},
{
name: "single-stage window when from equals to",
Expand All @@ -61,7 +61,7 @@ func TestWithin(t *testing.T) {
},
{
name: "inverted bounds is an error",
from: "plymouth",
from: "aur",
to: "packages",
wantErr: "from",
},
Expand Down Expand Up @@ -105,20 +105,20 @@ func TestWithin(t *testing.T) {
// TestWithin_ComposesWithSkip confirms the filter operates on whatever Select
// already produced: a skipped stage stays absent even when inside the window.
func TestWithin_ComposesWithSkip(t *testing.T) {
in := Select(Bootstrap, "", []string{"aur"}, nil)
got, err := Within(in, "packages", "plymouth")
in := Select(Bootstrap, "", []string{"snapper"}, nil)
got, err := Within(in, "packages", "aur")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"packages", "snapper", "flatpak", "plymouth"} // aur removed by --skip
want := []string{"packages", "flatpak", "aur"} // snapper removed by --skip
if g := names(got); !equalStrings(g, want) {
t.Fatalf("Within over skipped input = %v, want %v", g, want)
}
}

// TestWithin_EmptyInput is a no-op pass-through, never a panic.
func TestWithin_EmptyInput(t *testing.T) {
got, err := Within(nil, "packages", "plymouth")
got, err := Within(nil, "packages", "aur")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
48 changes: 0 additions & 48 deletions internal/stages/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,54 +20,6 @@ func ensureTool(ctx *Context, bin, pkg string) error {
return ctx.R.Root("pacman", "-S", "--needed", "--noconfirm", pkg)
}

// ensureKernelParam adds a single kernel command-line token, idempotently. It is
// the bootloader-aware seam for kernel-cmdline edits, keyed off the configured
// bootloader:
// - grub: appends tok to GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub
// (grep-guarded sed) — UNCHANGED from before, byte-identical command.
// - systemd-boot: appends tok to /etc/kernel/cmdline (the single-line cmdline
// consumed by kernel-install when generating loader entries), grep-guarded so
// re-runs don't duplicate the token. The file is created if missing.
func ensureKernelParam(ctx *Context, tok string) error {
if ctx.Cfg.Bootloader.EffectiveKind() == "systemd-boot" {
// /etc/kernel/cmdline is a single space-separated line. Add tok only if its
// word boundary isn't already present; create the file if it doesn't exist.
// The whole pipeline runs as root via RootShell, so no inner per-command sudo.
return ctx.R.RootShell(fmt.Sprintf(
`grep -qE '\b%[1]s\b' /etc/kernel/cmdline 2>/dev/null || `+
`{ touch /etc/kernel/cmdline && `+
`sed -i -E '$ s/$/ %[1]s/' /etc/kernel/cmdline; }`,
tok))
}
return ctx.R.RootShell(fmt.Sprintf(
`grep -qE 'GRUB_CMDLINE_LINUX_DEFAULT="[^"]*\b%[1]s\b' /etc/default/grub || `+
`sed -i -E 's/(GRUB_CMDLINE_LINUX_DEFAULT=")/\1%[1]s /' /etc/default/grub`,
tok))
}

// regenerateBootConfig regenerates the bootloader's generated configuration after a
// kernel-cmdline or theme change. It is the bootloader-aware seam for the
// "apply the config" step, keyed off the configured bootloader:
// - grub: runs grub-mkconfig -o /boot/grub/grub.cfg through Root — UNCHANGED from
// before, byte-identical command.
// - systemd-boot: there is no grub.cfg to regenerate. We refresh the installed
// boot loader binary on the ESP with `bootctl update --graceful`; loader entries
// are regenerated separately by kernel-install when the cmdline changes. The
// `--graceful` flag makes bootctl exit 0 when there is nothing to do (the loader
// archinstall just installed is already current) instead of failing, and the
// whole step is best-effort via TryRoot so a nonzero exit can never abort the
// stage — the loader was already installed by archinstall and our cmdline edits
// live in /etc/kernel/cmdline, which systemd-boot reads directly. (See
// docs/bugs/plymouth-bootctl-update-fails-systemd-boot.md.)
func regenerateBootConfig(ctx *Context) error {
if ctx.Cfg.Bootloader.EffectiveKind() == "systemd-boot" {
// Best-effort + graceful: never fail the stage on "already current".
ctx.R.TryRoot("bootctl", "update", "--graceful")
return nil
}
return ctx.R.Root("grub-mkconfig", "-o", "/boot/grub/grub.cfg")
}

// cloneBuild git-clones a repo into a temp dir, runs inCheckout from within it,
// and removes the temp dir — the shared "clone, build, clean" idiom. cloneArgs is
// the git-clone argument string (any flags plus the URL); inCheckout is the shell
Expand Down
Loading
Loading