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
5 changes: 3 additions & 2 deletions .github/workflows/e2e-disks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
# A full install-to-completion is deliberately NOT run here: a bare container has
# no booted systemd, so archinstall's install phase fails on udev/D-Bus
# (timedatectl, systemctl) assumptions it makes about a live ISO. Real end-to-end
# installs run in the QEMU VM harness instead (test/vm.sh / `task vm`), which
# boots a real systemd. `test/e2e/disks.sh --mode full` still works for manual
# installs run in the automated QEMU VM harness instead (`task vm-e2e`,
# test/e2e/vm/), which boots a real systemd and drives install + bootstrap +
# validation end-to-end. `test/e2e/disks.sh --mode full` still works for manual
# runs on a real host/VM (`task e2e-disks-full`); it's just not container CI.
name: e2e-disks

Expand Down
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
/archwright
/.vm/

# Cached Arch live ISO for the QEMU smoke test (downloaded by `task iso`).
/.iso/

# Per-run work dir for the automated VM e2e harness (disks, logs, scaffold).
/.e2e/

# Local upstream archinstall checkout, kept only as a schema reference.
/archinstall/

Expand All @@ -13,5 +19,9 @@
*~
.DS_Store

# Python bytecode cache (the e2e matrix loader imports modules under test/e2e/vm)
__pycache__/
*.pyc

# agent worktrees
.claude/worktrees/
181 changes: 181 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Contributing to archwright

archwright is a single static Go binary that rebuilds an Arch box from bare disks to a themed
KDE desktop from one declarative `config.yaml` (see the [README](README.md) for the user
workflow and [CLAUDE.md](CLAUDE.md) for the deep architecture + the parallel-development "wave"
playbook). This guide is the practical how-to: **add → test → commit → PR.**

## Setup

```sh
mise install # provisions the pinned Go + Task (see mise.toml)
go build -o archwright . # or: task build
```

For the VM/loopback test harnesses you also need QEMU, OVMF, etc. — see the dependency table in
[`test/e2e/vm/README.md`](test/e2e/vm/README.md#requirements). In short, on Arch:

```sh
sudo pacman -S --needed qemu-base edk2-ovmf libarchive git curl
sudo usermod -aG kvm "$USER" # re-login afterwards
```

## Project layout (where things live)

```
main.go cobra CLI: install / bootstrap / validate / render / list-stages
internal/config/ Config struct + Validate() (go-playground/validator struct tags)
internal/configsrc/ resolve + deep-merge remote/layered --config refs and imports:
internal/archinstall/ render config.yaml -> archinstall JSON + creds (Phase A core)
internal/run/ Runner: Cmd/Shell/Chroot/Root/Try, dry-run, recorded .Plan
internal/stages/ one file per stage; self-registering ordered registry
test/e2e/disks.sh loopback (losetup) integration harness — Phase A render vs real archinstall
test/vm.sh interactive QEMU (boot ISO / installed disk by hand)
test/e2e/vm/ automated, headless QEMU e2e harness (install -> bootstrap -> validate)
docs/bugs/ open findings from the e2e harness, ready to pick up
```

## Adding things

### A Phase B stage (the common case)

A stage is a tiny struct in its own file under `internal/stages/` implementing
`Order() int`, `Name() string`, `Phase() Phase`, `Run(ctx *Context) error`, and registering
itself in `init()` via `register(...)`. Copy `internal/stages/packages.go` for the minimal
pattern. Rules:

- `Order` is the numeric prefix (10, 20, …); keep existing ones stable so `--only <number>`
and `--from/--to` keep working. `task build && ./archwright list-stages` shows the order.
- **All** side effects go through `ctx.R` (the `Runner`), never `os/exec` directly — that's
what makes the stage dry-run-safe and testable. Use `Root` for privileged, `Cmd` for
unprivileged, `Shell` only when you need pipes/redirects, `Try` for best-effort, `Chroot`
for Phase A arch-chroot work.
- **Degrade to a no-op when unconfigured**, so existing configs/goldens are unaffected.
- Add a `*_test.go` that runs the stage in dry-run and asserts on the recorded `.Plan`
(self-contained config snippet — don't edit shared fixtures).
- ⚠️ Don't let a stage block on an interactive prompt during `bootstrap` (pass `-y` /
`--noninteractive` to anything that might ask) — see
[`docs/bugs/flatpak-system-remote-add-polkit-hang.md`](docs/bugs/flatpak-system-remote-add-polkit-hang.md).

### A config option / schema field

The `Config` struct in `internal/config/config.go` **is** the schema. Add the field with its
`yaml:` + `validate:` tags; put cross-field rules in `semanticErrors()` (not struct tags);
add a table case in `config_test.go`. If the option changes Phase A output, also update
`internal/archinstall/` and add a golden case (see below).

### An automated e2e descriptor

To grow VM coverage, add a `matrix/<family>.py` + `configs/<name>.yaml` under `test/e2e/vm/`
— **new files only**, following the descriptor contract in
[`test/e2e/vm/README.md`](test/e2e/vm/README.md). Keep configs trimmed/cheap.

## Testing

Work up the pyramid — fast checks first, VMs last.

### 1. Fast checks (always, no disks)

```sh
go build -o archwright . # task build
go test ./... # task test — validation table + per-stage dry-run command plans
go vet ./... # task vet
gofmt -l . # must print nothing
```

These run every stage in `--dry-run` and assert on the recorded command plan, so they verify
behavior without touching disks. `internal/archinstall` is unit-tested against fake geometry
(layout, PV↔VG `obj_id` wiring, size math).

**Schema-shape changes** (anything that alters the rendered archinstall JSON) use **two
commits**: first the behavior-preserving refactor (goldens unchanged), then the shape change
that regenerates them:

```sh
go test ./internal/archinstall/ -run TestRenderGolden -update # regenerate goldens
```
Keep the diff reviewable, and remember **archinstall's JSON is not a stable API** — after an
archinstall version bump, diff its schema and update `internal/archinstall` + the pinned
`Version` together, then re-validate in a VM (see CLAUDE.md "Key gotchas").

### 2. Loopback integration (root, no boot)

Proves the rendered archinstall JSON is accepted by a *real* archinstall against `losetup`
loop devices — the cheapest way to catch schema drift:

```sh
task e2e-disks-light # archinstall --dry-run validation (fast, no network)
task e2e-disks-light LAYOUT=single-disk-lvm FS=ext4
task e2e-disks-full # real partition/format/pacstrap, then assert layout
```

### 3. Manual interactive VM (`test/vm.sh`)

Use this to **poke by hand** — try a brand-new layout before codifying it, watch a desktop
actually render, or explore a confusing failure with a live shell. It boots a *graphical* QEMU
and shares the repo in over 9p, so your freshly built binary shows up in the VM.

```sh
cp config.example.yaml config.yaml # gitignored; set devices to /dev/vda, /dev/vdb, /dev/vdc
task build # rebuild on the host; the 9p share picks it up
task vm-fresh # boot the live ISO with clean disks
# inside the VM (repo auto-mounts at /mnt/host):
# cp /mnt/host/archwright /root/ && cp /mnt/host/config.yaml /root/
# /root/archwright install --dry-run # inspect the rendered archinstall JSON
# /root/archwright install --yes # DESTRUCTIVE: wipes vda/vdb/vdc, installs
task vm-disk # reboot into the installed system to poke around
```

`task vm` is the same without wiping disks; disk sizes are env-overridable
(`DISK1=40G … task vm`). This is interactive only — for unattended pass/fail use the harness
below.

### 4. Automated VM e2e (`task vm-e2e`)

The full last-mile proof: boots the ISO headless, runs `install --yes`, reboots, runs
`bootstrap`, and asserts the installed system — no human interaction. This is what you run to
confirm a change works end-to-end across layouts/features.

```sh
task vm-e2e -- lvm-multi # one descriptor
task vm-e2e -- features-min # the cheap stage-coverage bundle
task vm-e2e -- --jobs 5 # the whole matrix, 5 VMs at once
task vm-e2e-list # list descriptors
```

See [`test/e2e/vm/README.md`](test/e2e/vm/README.md) for how it works, the descriptor
contract, and per-run logs (`.e2e/runs/<name>/serial.log`). Known open findings it has
surfaced live in [`docs/bugs/`](docs/bugs/) — good first contributions.

### What to run for a given change

- Stage / config logic → **1** (and a VM run of an affected descriptor if behavior is
disk/boot-visible).
- Anything touching `internal/archinstall` / Phase A layout → **1 + 2**, then **4** for the
affected layout (CLAUDE.md's archinstall-drift rule: validate against a real run).
- New `disks.*` layout, swap, bootloader, encryption → **4** with a matching descriptor.

## Committing

- **Branch off `main`** — never commit straight to `main`.
- **[Conventional Commits](https://www.conventionalcommits.org/):** `feat:`, `fix(scope):`,
`refactor:`, `chore:`, `docs:`, `test:` (match the existing `git log` style, e.g.
`feat: add services stage to enable systemd units in Phase B`).
- Keep commits focused; use the two-commit split for schema-shape changes (above).
- Before pushing: `go build ./... && go vet ./... && go test ./... && gofmt -l .` (clean).
- `config.yaml` and `.vm/` / `.e2e/` / `.iso/` are gitignored — never commit them. Stage
explicit paths; don't `git add -A`.

## Opening a PR

```sh
git switch -c feat/my-change # or fix/…, docs/…
# … commits …
git push -u origin feat/my-change
gh pr create --base main --fill # then edit title/body
```

In the PR description: what changed and why, which test tiers you ran (paste the `task vm-e2e`
PASS line for any layout you exercised), and call out any archinstall schema change (with the
regenerated goldens in their own commit). CI runs the Go suite and the loopback
`e2e-disks-light` check on every PR; the VM harness is run locally (it needs `/dev/kvm`).
50 changes: 45 additions & 5 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ vars:
LAYOUT: '{{.LAYOUT | default "multi-disk-lvm"}}'
FS: '{{.FS | default "xfs"}}'

# Arch live ISO for the QEMU smoke test. Cached under the gitignored .iso/ dir
# and pinned to a dated archive build so the download is reproducible. Bump
# ISO_VERSION (CLI-overridable) in lockstep with the version in test/vm.sh.
ISO_VERSION: '{{.ISO_VERSION | default "2026.06.01"}}'
ISO_FILE: 'archlinux-{{.ISO_VERSION}}-x86_64.iso'
ISO_PATH: '.iso/{{.ISO_FILE}}'

tasks:
build:
desc: Build the archwright binary.
Expand Down Expand Up @@ -41,17 +48,50 @@ tasks:
cmds:
- sudo bash test/e2e/disks.sh --mode full --layout {{.LAYOUT}} --fs {{.FS}} --disk1-size 12G --extra-size 6G

iso:
desc: >-
Download the pinned Arch live ISO into the gitignored .iso/ cache. Skipped
when the ISO is already present (no re-download). Override the build with
`task iso ISO_VERSION=YYYY.MM.DD`.
cmds:
- mkdir -p .iso
- curl -fL --progress-bar -o {{.ISO_PATH}} https://archive.archlinux.org/iso/{{.ISO_VERSION}}/{{.ISO_FILE}}
generates:
- '{{.ISO_PATH}}'
status:
- test -f {{.ISO_PATH}}

# vm / vm-fresh / vm-disk: INTERACTIVE, graphical QEMU for poking by hand (boot
# the live ISO and run archwright yourself, or boot the installed disk to look
# around). For unattended pass/fail validation use `vm-e2e` (below) instead.
vm:
desc: Interactive QEMU smoke test — boot the Arch live ISO (run Phase A).
desc: "Interactive QEMU: boot the Arch live ISO and run Phase A by hand (automated: vm-e2e)."
deps: [iso]
cmds:
- bash test/vm.sh iso
- ARCH_ISO={{.ISO_PATH}} bash test/vm.sh iso

vm-fresh:
desc: Boot the Arch live ISO, wiping the virtual disks first.
desc: "Interactive QEMU: boot the live ISO, wiping the virtual disks first."
deps: [iso]
cmds:
- bash test/vm.sh iso --fresh
- ARCH_ISO={{.ISO_PATH}} bash test/vm.sh iso --fresh

vm-disk:
desc: Boot the installed system off disk 1.
desc: "Interactive QEMU: boot the installed system off disk 1 (post-install poking)."
cmds:
- bash test/vm.sh disk

vm-e2e:
desc: >-
Fully automated VM e2e: boot ISO headless, run Phase A install, inject the
Phase B autorun scaffold, reboot, run bootstrap + validation. Pass a
descriptor name to run one (e.g. `task vm-e2e -- lvm-multi`); no arg runs the
whole matrix. Add `-- --jobs 5` (or `-j 5`) to run several VMs at once.
deps: [iso]
cmds:
- ARCH_ISO={{.ISO_PATH}} python3 test/e2e/vm/e2e.py {{.CLI_ARGS}}

vm-e2e-list:
desc: List the VM e2e matrix descriptors.
cmds:
- python3 test/e2e/vm/e2e.py --list
Loading
Loading