Every installer is the same program twice: a menu, a set of questions, somewhere to keep the answers, a list of steps and a way to say which one broke. Oak is that program, written once. You supply the part that is actually yours — the questions, in YAML, and the work, in shell.
Oak knows nothing about any operating system. Not a disk, not a package, not a bootloader. That half stays in shell, where you can read it.
Arch OS is a full Arch Linux installer built this way — a good place to see a real one. The example in this repository is a small one you can run in a minute: every screenshot below comes out of it.
- One config file per installer. Questions, stages, the last warning before anything changes — all in one YAML file next to your scripts
- A task pipeline. A task is a folder holding a
task.yamland the shell it runs. The order comes out of the stage each task names and what it declares it needs, so there is no list of steps to keep in step - Error handling you did not write. A script that fails is caught, and the frame names the module, the task, the file, the line, the command and the exit code
- Tests that come with the steps. A task may say how to tell that it took. Those run on the machine as the work goes, read and change nothing, and the run ends by saying how many of them passed
- Answers that survive. Every answer is written down the moment it is given, as plain shell. An interrupted run picks up where it left off; copy the file to the next machine and every question it answers is skipped
- Modular. A module is one whole program. Ship an installer and a recovery from the same binary, or add a third by adding a folder
- One file to ship. A static binary, your YAML and your scripts beside it. Bash is the only thing it expects of the machine
Oak looks next to itself, and nowhere else:
oak the binary
oak.yaml the product: name, colour, version, wordmark
modules/setup/ one module — everything below belongs to it
module.yaml what it asks and what order it works in
module.sh optional: shell everything this module runs gets
tasks/@prepare/format/ one task, in the folder of the phase it runs in
hooks/@preflight/uefi/ optional: a hook — can this machine be worked on at all
modules/recovery/ another module, another program
A module is one whole program. A product is the modules a binary ships with, under one name and one colour. One folder under modules/ is opened on the way in; a second is what turns that into a page.
flowchart LR
subgraph Y["What you write"]
direction TB
C["oak.yaml<br/>module.yaml"]
S["module.sh<br/>task.sh"]
end
subgraph O["What Oak does"]
direction TB
A["Ask"] --> K["Keep"] --> R["Run"] --> F["Report"]
end
C --> O
S --> O
O --> U["Terminal interface"]
style Y fill:#eceff4,stroke:#8fbcbb,color:#2e3440
style O fill:#8fbcbb,stroke:#8fbcbb,color:#2e3440
Every page appears only when it has something to show. A module with no presets never shows a page offering none.
flowchart TD
L["Welcome<br/>the link · the language"] --> W["Which module"] --> Q1["Questions marked first"]
Q1 --> N["Network"] --> P["Preflight check"] --> PR["Presets"]
PR --> Q["The questions<br/>one per page"]
Q --> H["Menu"]
H --> SE["Settings"] --> H
H --> CF["Last warning"] --> R["The run<br/>tasks, top to bottom"]
R --> OK["Done"]
R --> ER["Failure<br/>script · line · command"]
style ER fill:#bf616a,stroke:#bf616a,color:#eceff4
style R fill:#8fbcbb,stroke:#8fbcbb,color:#2e3440
curl -LO https://github.com/murkl/oak/releases/latest/download/oak-linux-amd64
gh attestation verify oak-linux-amd64 --repo murkl/oak
install -m755 oak-linux-amd64 oak
The middle line reads the provenance CI signed the release with: which repository the file came out of, and which run built it. Without gh, GitHub prints the download's SHA-256 beside it on the release page, which says only that the file arrived whole.
latest is whatever is newest. A product that releases versions of its own pins the Oak it was built against instead — releases/download/vX.Y.Z/oak-linux-amd64 — so the same tag builds the same thing twice. Which one drove it is under the wordmark on the way in: powered by oak X.Y.Z.
Oak is versioned by what a product may declare: a new key is a minor version, and anything that stops a product loading that used to load is a major one. Below 1.0.0 that major is a decision rather than a count, so a break moves the minor until the first one is chosen. An upgrade inside a major is safe to take, so pinning is for building the same thing twice, not for surviving the next release.
title: Tux Linux
version: 1.0.0
accent: "#8fbcbb"
url: https://github.com/tux/tux-linuxtitle: Tux Setup
description: Set a machine up for Tux.
stages: [install]
variables:
- name: TUX_HOST
title: Hostname
description: What the machine calls itself on the network.
required: trueThe folder over it is the phase it runs in, one of the stages: above, marked with @. task.yaml says what the step is:
title: Write the hostnametask.sh beside it does the work. No shebang, no set -e, no error handling — Oak wraps it:
mkdir -p ./tux/etc
echo "$TUX_HOST" >./tux/etc/hostnameA step short enough to read at a glance skips the file and says it in the yaml instead:
title: Write the hostname
script: |
mkdir -p ./tux/etc
echo "$TUX_HOST" >./tux/etc/hostname
# Optional: how to tell that it took. Reads the machine, changes nothing.
test: grep -q "^$TUX_HOST$" ./tux/etc/hostname./oak
Oak opens on its welcome page, asks the one question that is required and still unanswered, then runs the task. The answers land in setup.conf, everything the script printed in setup.log.
The example is the same shape, filled out: two modules, three stages, a task that only runs under a condition, tests beside the work, and a page the run stops on when it is done.
Nothing lists the tasks anywhere. The folder is the list, and the order follows two rules:
- A task runs after every task of an earlier stage, which is the folder it lies in
- Inside its stage, it runs after whatever it named in
needs:
flowchart LR
subgraph S1["tasks/@prepare"]
direction TB
P1["partition"] --> P2["format"]
end
subgraph S2["tasks/@install"]
direction TB
B["base"] --> D["desktop"]
B --> G["graphics"]
end
subgraph S3["tasks/@finish"]
direction TB
U["users"]
end
S1 --> S2 --> S3
A task with conditions: that do not hold is left out of the run entirely. Everything is checked when the module loads, so a renamed variable or a cycle is an error at startup — never a step that silently never fires.
The run stops there and says so, on the page a finished run stops on with the mark the other way round. Behind it: which module, which task, which file and line, which command, and what the tool said. The rest is in the log.
A test that disagrees is not that. The work said it worked, so the run carries on and says how many of how many passed — on every page it stops to report something, and again when it is over. Where any of them disagreed, the next page lists them once and opens each on the same report. One switch in the settings turns the whole of it off.
Five options, and nothing else. Three are about a run:
oak --module=setup # open that module outright, instead of asking which
oak --debug # hand every script DEBUG=true and touch nothing
oak --version # print the Oak release this binary is — `0.1.0` — and exit
Two are about the folder, for whoever is writing one. They print and draw nothing:
oak --inspect # load the product the way a run does, and report what it holds
oak --strings # write a module's translation template
Nothing on the command line is an answer. Questions are answered in the interface.
Arch OS — a reproducible Arch Linux installation: an installer and a recovery, both modules, on one bootable image.
➜ Reference — the whole of what a product may declare: questions, presets, tasks, tests, conditions, hooks, the script contract and translations.
➜ Changelog — what each release changed.
➜ Contributing — how to work on Oak itself.
GPL-3.0. See LICENSE.
- Bubble Tea by charm
- gettext






