Skip to content

bootstrap v2026.09

Choose a tag to compare

@github-actions github-actions released this 06 Sep 15:27
· 112 commits to main since this release

Windows 1.8.1

Fixed

  • -ShowVersion could not run anywhere but Windows, which is a problem
    because the release workflow runs it on a Linux runner to prove the script
    can start at all. The check found this on its first real release, which is
    the entire reason it exists.

    The version block sat after the path resolution, and three lines of that
    resolution read $env:LOCALAPPDATA, $env:ProgramFiles and $env:WINDIR.
    All three are null off Windows, so Join-Path threw "Cannot bind argument
    to parameter 'Path' because it is null" long before the version was printed.

    The early return is now the first thing the script does after
    Set-StrictMode, so nothing about the host has been assumed by the time it
    answers. Same shape as the $PSScriptRoot bug the file already documents:
    it parses, every line is right on its own, and only one invocation shows it.

Linux 1.2.0

Added

  • A Nerd Font, which powerlevel10k has needed since day one. The Windows
    manifest has installed Meslo since its first release; this side installed the
    theme that requires it and no font at all, so the prompt rendered as boxes.

    Desktop machines only, and that is not a size argument: the glyphs are drawn
    by the terminal you are typing at. SSH into a headless box from a terminal
    that already has Meslo and the prompt is correct with no font on the server;
    install one there and nothing anywhere looks different.

  • RELEASES, a third category of software alongside apt packages and git
    clones: a single static binary from a GitHub release, into ~/.local/bin.
    For things that are in neither the Debian archive nor a repository you can
    clone and run.

    Unlike the TOOLS clones these are version-checked properly — the binary is
    asked what it is, the newest release tag is fetched, and a run where they
    already agree downloads nothing and says current.

  • tflint and terraform-docs, as the first two RELEASES entries. Neither
    is packaged by Debian in any release. They are the Terraform counterpart to
    the ansible-lint and yamllint pair that was already here.

  • Claude Code, installed once by Anthropic's script into ~/.local/bin and
    then left alone, because it updates itself. Reinstalling it every run would
    be the second installer in a fight it cannot win.

  • ~/.local/bin on PATH in the managed zsh fragment. The stock ~/.profile
    on Debian adds it, but zsh never reads .profile — so without this the three
    things above install correctly and none of their commands exist.

Changed

  • btop replaces htop and tmux replaces screen in the cli group.

  • {UNAME_ARCH} is substituted into release URLs, alongside the {ARCH}
    that was already there. The same machine has two names — dpkg says amd64
    and arm64, uname -m says x86_64 and aarch64 — and upstream projects
    are split about evenly over which they name their assets for. Both words are
    now available to a RELEASES entry, off one definition that the AWS CLI
    phase shares, so the two phases cannot drift on what the word means.

    Not named GOARCH, which was the obvious suggestion and is wrong: Go's own
    GOARCH values are amd64 and arm64 — the dpkg spelling — so the name
    would have pointed at the wrong one of the two while sounding decisive.

Fixed

  • The cli group installed the wrong yq. Debian's yq package is
    kislyuk/yq — the distribution's own description calls it a "jq wrapper for
    YAML documents", a Python script that transcodes YAML and shells out to jq.
    The Windows manifest installs MikeFarah.yq and Homebrew's yq formula is
    mikefarah's too: the Go program, a different project that answers to the same
    command name.

    That is the whole point of the cli group inverted. It exists so muscle
    memory transfers between the three shells, and a simple read is spelled
    identically on both — yq '.a.b' file works either way — which is exactly
    what let this survive unnoticed. Everything past a simple read diverges:
    yq eval, -i in place, -o=json. A snippet written on the other two
    platforms failed here in a way that reads like a typo rather than a different
    program.

    yq is now a RELEASES entry taking mikefarah's static binary, which needed
    no new code — its asset has no extension, and unpack_asset already treats
    that shape as a plain binary rather than guessing at an archive.

    Nothing uninstalls the old one. A machine that ran an earlier manifest
    keeps Debian's yq at /usr/bin/yq; it is shadowed rather than removed,
    because the managed zsh fragment prepends ~/.local/bin to PATH. Run
    sudo apt remove yq by hand if you would rather not have both on disk.

  • set -e inside a subshell used as an if condition does nothing, and
    the first draft of the release-binary downloader relied on it. Bash
    suppresses the abort for the whole condition context, so a failed download
    went on to unpack nothing, find nothing, and report whatever the last command
    thought of being handed an empty path. Both new download paths chain their
    steps with && instead, so the exit status means what it looks like it means.

  • A release whose tag moved without its asset changing reported
    upgraded 0.60.0 -> 0.60.0 — an arrow saying nothing happened in the colour
    that says something did. It reports current now.

macOS 1.0.0

First release. A Homebrew counterpart to the Windows and Linux bootstraps,
built on the same idea: one command that both builds a fresh machine and
updates an existing one, a manifest that says what rather than how, and one
printed line per decision so a run that changes nothing says so.

Added

  • An architecture check that survives Rosetta, which is the reason the
    Homebrew prefix is ever right.
    Homebrew lives at /opt/homebrew on Apple
    silicon and /usr/local on Intel, and installing into the wrong one produces
    no error — just a second, parallel Homebrew the shell never picks up.

    uname -m is the obvious signal and it lies: under Rosetta it reports
    x86_64 on an Apple silicon Mac, because that is what a translated process
    is entitled to believe, and a Terminal with Open using Rosetta ticked is
    enough to trigger it. sysctl -n sysctl.proc_translated is asked instead —
    the kernel sets it to 1 when this process is translated, and it is absent
    on a real Intel Mac, so a missing value and a 0 both mean native. A run
    from a Rosetta shell says so and targets the native prefix anyway.

  • Formulae and casks looked up separately, never with a bare brew list.
    The same name can be both. docker is a formula — the CLI client on its own,
    with no engine behind it — and docker-desktop is the cask with the engine
    and the app. A check that does not say which kind it means reports one as
    installed when the other is, and the symptom is a working docker command
    that cannot reach a daemon.

  • --no-gui, as a flag rather than a probe. Groups marked
    GROUP_<name>_GUI=yes are skipped for a headless build agent. This is
    deliberately not auto-detected the way the Linux script detects a desktop:
    every Mac has a window server, and the signals that do differ — a console
    user, $SSH_CONNECTION — describe the session rather than the machine, which
    is the exact mistake the Linux check exists to avoid. Groups are kept
    homogeneous so the gate cannot take the linters down with Docker Desktop.

  • A refusal to run as root. The inverse of the Linux script, which calls
    sudo for the steps that need it. Homebrew refuses to operate as root, and a
    run that got far enough would leave root-owned files in the prefix and in
    $HOME that later normal runs cannot write.

  • Upgrades that report what actually moved. brew upgrade exits 0 whether
    it moved forty packages or none, so brew outdated is asked first and the
    count is both the decision and the detail printed. Nothing outdated means
    current, and the upgrade is not run at all.

  • No --greedy on cask upgrades, on purpose. Casks that declare
    auto_updates — VS Code, Docker Desktop, Chrome — keep themselves current,
    and --greedy makes Homebrew download and reinstall them on top of an app
    that has already updated itself. That is two installers fighting over one
    .app, and the visible symptom is a large download on every run for
    something that was never out of date. They are named in the output instead.
    An app already in /Applications that Homebrew did not put there is likewise
    reported as present and left alone.

  • The managed zsh fragment, compared before it is replaced. oh-my-zsh,
    powerlevel10k and the plugin list, written to ~/.zshrc.bootstrap and
    sourced from a .zshrc this script never rewrites. The fragment is rendered
    to a temp file beside the target and compared, so a run that changes nothing
    reports current rather than claiming an install.

    brew shellenv goes first and unconditionally: nothing below it can find a
    Homebrew-installed binary until the prefix is on PATH, and on a first run the
    calling shell has not read a profile since Homebrew appeared.

  • Xcode Command Line Tools checked, never installed by force.
    xcode-select --install opens a modal dialog and waits for a human, which
    would hang an unattended run with no output explaining why. Homebrew's own
    installer brings them in, so the check only has to be fatal when Homebrew is
    already present.

  • The Meslo Nerd Font, which powerlevel10k needs and which the Windows
    manifest has installed since its first release. Without it the prompt is not
    merely plain — it is boxes, because p10k draws from the private-use area.

  • Ghostty as the terminal. Windows gets Windows Terminal and a merged
    settings file; macOS was being left with Terminal.app. Ghostty over iTerm2
    because its config is a plain text file this script could manage the same way
    it manages the zsh fragment, where iTerm2 keeps its settings in a plist.

    Worth knowing before you SSH anywhere: ghostty sets TERM=xterm-ghostty, and
    a Debian box that has never heard of that terminfo entry will complain until
    you use ghostty's SSH integration or force TERM=xterm-256color.

  • Claude Code, as the claude-code cask in the dev group — not the
    vendor script the Linux side uses. Homebrew carries it, so it is installed,
    version-checked and reported by the phase that already does that for every
    other package, rather than by a phase of its own that could only ever say
    present.

    The "two installers fighting" objection does not apply here the way it first
    appears. Claude Code still updates itself, and brew upgrade is not greedy —
    this script never passes --greedy, which is the flag that would let brew
    reach past a self-updating package and stamp on it. So brew installs it once
    and then defers, which is exactly what the hand-rolled phase was doing.

  • tflint and terraform-docs, the Terraform counterpart to the ansible-lint
    and yamllint pair.

Notes

  • Three names in this manifest were wrong, and all three were found by
    machine rather than by a failed install.
    verify-manifests.yml checks every
    formula and cask against the Homebrew index, and its first run caught:

    • google-cloud-sdk → gcloud-cli. The cask was renamed and the old
      token resolves to nothing.
    • mpv → stolendata-mpv. Also renamed. Homebrew records this in the
      cask's old_tokens, which is how the new name was recovered rather than
      merely the absence noticed.
    • tflint is not in homebrew-core at all and needs the
      terraform-linters/tap now declared in TAPS. Without it the infra group
      failed with "No available formula".

    Each was a brew install that could only ever fail. The check reads the
    whole index once instead of asking per name, which is what lets it tell a
    rename from a disappearance — and what stops it calling python3 and
    sqlite3 bugs, since both are aliases with no page of their own.

  • GNU make installs as gmake. /usr/bin/make is BSD make and Homebrew
    will not shadow a system binary, so the formula lands under a different name
    — the macOS counterpart to Debian's fdfind and batcat. The managed zsh
    fragment aliases make to it, guarded on command -v.

  • bat and fd keep their real names, unlike on Debian, so the fragment
    is simpler here. Every alias is still guarded, because the same fragment has
    to work on a machine where one of those installs failed.

  • Temurin rather than the openjdk formula. openjdk is keg-only on
    macOS: it installs where /usr/libexec/java_home cannot see it, and
    Homebrew's own caveat tells you to finish the job with a sudo symlink. The
    cask puts a real JDK bundle where every Java launcher already looks.

  • Ansible is not installed by Homebrew. It comes from a per-project
    virtualenv instead, so its version is a decision the playbook repository
    makes rather than one brew makes for every project on the machine at once.
    The Linux manifest does install it from the distribution, where it is the
    control node for a fleet — a different job. ansible-lint is still here and
    does pull ansible-core in behind it; that copy sits in the brew prefix and
    does not shadow a virtualenv, and the manifest says how to drop it too.

  • No creative group, and no Telegram, qBittorrent or Unity Hub. The Linux
    manifest carries a creative group for Blender, GIMP, Inkscape and OBS, plus
    those three elsewhere; this one carries none of them.

  • Homebrew's zsh is not in the manifest. macOS has shipped zsh as the
    default login shell since Catalina, and installing a second one that a login
    shell will not use without chsh is worse than useless. The system zsh is
    reported and used as-is.