diff --git a/README.de.md b/README.de.md index fdb9b03..7ce168d 100644 --- a/README.de.md +++ b/README.de.md @@ -12,15 +12,17 @@ Diese Seite auch auf [Englisch](README.md). - [Anforderungen](#anforderungen) - [Verwendung](#verwendung) - [Funktionsweise](#funktionsweise) +- [Fedora-Atomic--Kinoite-Installationsmethoden](#fedora-atomic--kinoite-installationsmethoden) - [Konfiguration](#konfiguration) - [Quellen](#quellen) - [Lizenz](#lizenz) ## Features -- **Funktioniert auf verschiedenen Distributionen.** Verwendet die dokumentierte Installationsmethode für topgrade jeder Distribution, sofern vorhanden (Fedora/RHEL über COPR, Alpine über `apk`, Void über `xbps-install`, Homebrew/Linuxbrew), und lädt ansonsten die vorkompilierte Binärdatei von GitHub Releases in `~/.local/bin` herunter. -- **Behandelt atomare/unveränderliche Systeme korrekt.** Auf rpm-ostree-Hosts (Bazzite, Fedora Silverblue/Kinoite/Atomic) wird die `lilay/topgrade`-COPR-Repo-Datei direkt heruntergeladen und das Paket mit `rpm-ostree install` überlagert — kein `dnf`-Binary erforderlich — und das System wird nicht ohne Rückfrage neu gestartet. Auf atomaren Hosts ohne `rpm-ostree` (nur bootc, oder wenn die COPR-/rpm-ostree-Überlagerung fehlschlägt) wird stattdessen die Binärdatei von GitHub Releases heruntergeladen. -- **Idempotent.** Kann sicher erneut ausgeführt werden. Bereits installiert? Das Skript springt direkt zur Konfiguration und Überprüfung. Bereits konfiguriert? Bestehende Konfigurationen werden nicht überschrieben. +- **Funktioniert auf verschiedenen Distributionen.** Verwendet die dokumentierte Installationsmethode für topgrade jeder Distribution, sofern vorhanden (Fedora/RHEL über COPR auf klassischen Hosts, Alpine über `apk`, Void über `xbps-install`, Homebrew/Linuxbrew), und lädt ansonsten die vorkompilierte Binärdatei von GitHub Releases in `~/.local/bin` herunter. +- **Gibt Fedora-Atomic-Nutzern eine klare Wahl.** Auf rpm-ostree-Hosts (Bazzite, Fedora Silverblue/Kinoite/Atomic) kann entweder die COPR/rpm-ostree-Paketintegration oder die offizielle Upstream-Binärdatei gewählt werden. Auf reinen bootc-Atomic-Hosts wird die Binärmethode verwendet. +- **Härtet beide Atomic-Pfade.** Der COPR-Pfad beschränkt das hinzugefügte `lilay/topgrade`-Repo mit `includepkgs=topgrade`; der GitHub-Release-Pfad wählt das erwartete Linux-Asset aus den Release-Metadaten, verifiziert den von GitHub bereitgestellten SHA-256-Digest, sofern vorhanden, testet `topgrade --version` und aktiviert die neue Binärdatei erst nach erfolgreicher Prüfung atomar. +- **Idempotent.** Kann sicher erneut ausgeführt werden. Bereits installiert? Das Skript springt direkt zur Konfiguration und Überprüfung. Bereits konfiguriert? Die Konfiguration wird nur mit `--force-config` neu erzeugt; kleine Installer-Policy-Reparaturen können mit zeitgestempeltem Backup angewendet werden. - **Wird mit einer sinnvollen Standardkonfiguration ausgeliefert**, wobei einige Teile (siehe [Konfiguration](#konfiguration)) nur hinzugefügt werden, wenn sie für das System tatsächlich relevant sind. - **Optionale [chezmoi](https://www.chezmoi.io/)-Integration.** Ist chezmoi installiert und initialisiert, wird die generierte Konfiguration automatisch unter chezmoi-Verwaltung gestellt. - **Keine unerwarteten Überraschungen.** Dieses Skript soll interaktiv von einer Person ausgeführt werden. Das System wird nicht neu gestartet und bestehende Konfigurationen werden nicht überschrieben, ohne dass eine Bestätigung erfolgt. @@ -30,6 +32,7 @@ Diese Seite auch auf [Englisch](README.md). - Bash - `curl` oder `wget` - `sudo`, falls nicht bereits als root läuft und ein privilegierter Installationsschritt erforderlich ist +- `python3`, zum sicheren Auswerten der GitHub-Release-Metadaten beim Installieren der Upstream-Binärdatei ## Verwendung @@ -37,18 +40,42 @@ Diese Seite auch auf [Englisch](README.md). ./install-topgrade.sh ``` -Das Skript kann jederzeit erneut ausgeführt werden — es erkennt, was bereits abgeschlossen wurde, und setzt an der entsprechenden Stelle fort. Dies ist auf rpm-ostree-Atomic-Hosts zu erwarten: Bei einer neuen Installation wird das Paket über `rpm-ostree` bereitgestellt, was einen Reboot benötigt, um aktiv zu werden, und das Skript wird nicht ohne Rückfrage neu gestartet. Wird diese Rückfrage abgelehnt (oder läuft das Skript in einer nicht-interaktiven Shell, in der es gar nicht fragt), kann der Reboot manuell durchgeführt und das Skript anschließend erneut ausgeführt werden, um die Installation abzuschließen. Auf reinen bootc-Atomic-Hosts ohne `rpm-ostree` wird topgrade als Binärdatei von GitHub Releases installiert, daher ist kein Reboot erforderlich. +Das Skript kann jederzeit erneut ausgeführt werden — es erkennt, was bereits abgeschlossen wurde, und setzt an der entsprechenden Stelle fort. + +Auf Fedora Atomic / Kinoite kann die Installationsmethode explizit gewählt werden: + +```bash +./install-topgrade.sh --install-method=binary +./install-topgrade.sh --install-method=copr +``` + +Zusätzlich gibt es Aliase: + +```bash +./install-topgrade.sh --binary +./install-topgrade.sh --copr +``` + +Interaktive Atomic-Sitzungen fragen nach, wenn keine Methode angegeben wurde. Nicht-interaktive Atomic-Sitzungen verwenden standardmäßig die Binärmethode, weil sie keine Root-Rechte, kein Host-Paket-Layering und keinen Reboot benötigt. + +Um eine bestimmte Upstream-Binärversion zu installieren: + +```bash +./install-topgrade.sh --binary --version v17.9.0 +``` + +oder `TOPGRADE_VERSION=v17.9.0` setzen. ## Funktionsweise -topgrade wird in den offiziellen Repositories von Fedora, RHEL oder AlmaLinux nicht paketiert. Auf diesen Distributionen (und auf rpm-ostree/atomaren Hosts, die davon abgeleitet sind) installiert dieses Skript topgrade aus [lilays `lilay/topgrade` COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) — die dokumentierte Methode, um es über `dnf` oder `rpm-ostree` zu erhalten. +topgrade wird in den offiziellen Repositories von Fedora, RHEL oder AlmaLinux nicht paketiert. Auf klassischen Fedora/RHEL-Systemen kann dieses Skript topgrade aus [lilays `lilay/topgrade` COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) via `dnf` installieren. Auf Fedora-Atomic-Hosts unterstützt das Skript sowohl das COPR/rpm-ostree-Paketmodell als auch das offizielle Upstream-Binärmodell. Das Skript wählt seine Installationsstrategie basierend auf dem erkannten System: | System | Strategie | |---|---| -| Bazzite, Fedora Silverblue/Kinoite/Atomic (rpm-ostree vorhanden) | Lädt die `lilay/topgrade`-COPR-Repo-Datei direkt herunter (kein `dnf` erforderlich) und überlagert das Paket mit `rpm-ostree install`, fragt vor dem Reboot nach | -| Atomarer Host ohne `rpm-ostree` (nur bootc), oder wenn die COPR-/rpm-ostree-Überlagerung fehlschlägt | Lädt die neueste Release-Binärdatei von GitHub in `~/.local/bin/topgrade` herunter | +| Bazzite, Fedora Silverblue/Kinoite/Atomic (`rpm-ostree` vorhanden) | Nachfrage oder explizites `--install-method`: COPR/rpm-ostree-Paketintegration oder Upstream-Binärdatei in `~/.local/bin/topgrade` | +| Atomarer Host ohne `rpm-ostree` (nur bootc) | Lädt die offizielle Upstream-Binärdatei von GitHub Releases nach `~/.local/bin/topgrade` | | Fedora / RHEL / AlmaLinux (nicht-atomar) | Aktiviert das `lilay/topgrade`-COPR, installiert mit `dnf install` | | Alpine | `apk add topgrade` | | Void Linux | `xbps-install -Sy topgrade` | @@ -57,9 +84,29 @@ Das Skript wählt seine Installationsstrategie basierend auf dem erkannten Syste Privilegierte Schritte werden unter `sudo` ausgeführt. Läuft das Skript bereits als root, wird `sudo` übersprungen — dann ist das `sudo`-Binary nicht erforderlich. +## Fedora Atomic / Kinoite Installationsmethoden + +Fedora-Atomic-Nutzer können zwischen zwei unterstützten Besitzmodellen wählen. Keine Methode ist grundsätzlich falsch; sie haben unterschiedliche Abwägungen. + +| Thema | COPR / rpm-ostree | Upstream-Binärdatei | +|---|---|---| +| Installationsort | Host-Deployment (`/usr/bin/topgrade`) | `~/.local/bin/topgrade` | +| Root erforderlich | Ja | Nein | +| Reboot erforderlich | Ja | Nein | +| Fügt Drittanbieter-RPM-Repo hinzu | Ja, das spezifische `lilay/topgrade` COPR | Nein | +| Repo-Scope | Mit `includepkgs=topgrade` eingeschränkt, wo unterstützt | N/A | +| Topgrade-Updates | rpm-ostree | Eingebautes Self-Update von `topgrade-rs/topgrade` | +| Host-Paket-Layering | Ja | Nein | +| Paketmanager-Integration | Sehr gut | Keine | +| Einfache Deinstallation | `rpm-ostree uninstall topgrade` + Reboot | `~/.local/bin/topgrade` löschen | + +Die COPR-Methode fügt das spezifische `lilay/topgrade`-Repository hinzu und installiert Topgrade als gelayertes RPM über rpm-ostree. Das aktiviert keine beliebigen COPR-Repositories. Der Installer beschränkt dieses Repository mit `includepkgs=topgrade` auf das Paket `topgrade`, aber man vertraut weiterhin dem Maintainer dieses COPR-Projekts für das Topgrade-RPM und dessen Updates. + +Die Binärmethode installiert das offizielle Topgrade-Release direkt nach `~/.local/bin`, ohne ein RPM-Repository hinzuzufügen oder ein Paket ins Betriebssystem zu layern. Topgrade aktualisiert sich danach selbst aus den offiziellen `topgrade-rs/topgrade`-Upstream-Releases. + ## Konfiguration -topgrade liest seine Konfiguration aus `${XDG_CONFIG_HOME:-~/.config}/topgrade.toml`. Existiert diese Datei noch nicht, erstellt das Skript eine. **Bestehende Konfigurationen werden niemals angetastet oder überschrieben.** +topgrade liest seine Konfiguration aus `${XDG_CONFIG_HOME:-~/.config}/topgrade.toml`. Existiert diese Datei noch nicht, erstellt das Skript eine. Bestehende Konfigurationen werden nur mit `--force-config` neu erzeugt, aber das Skript kann kleine Policy-Reparaturen mit zeitgestempeltem Backup vornehmen: zum Beispiel `rpm_ostree = true` auf Atomic-Hosts sicherstellen oder `no_self_update = true` auf `false` ändern, wenn Topgrade als user-lokale Upstream-Binärdatei installiert ist. Die generierte Konfiguration enthält einige Teile bedingt, basierend darauf, was tatsächlich auf dem System vorhanden ist: @@ -68,7 +115,7 @@ Die generierte Konfiguration enthält einige Teile bedingt, basierend darauf, wa | `[include] paths = ["/etc/ublue-os/topgrade.toml"]` | `/etc/ublue-os/topgrade.toml` existiert (ublue-os/Bazzite theme-update-Befehle) — da `~/.config/topgrade.toml` in `toolbx`/`distrobox`-Container eingebunden wird, kann dies dort einen harmlosen `Unable to read /etc/ublue-os/topgrade.toml`-Fehler protokollieren; das Skript gibt einen Hinweis aus, wenn es die Zeile hinzufügt | | `[linux] rpm_ostree = true` | Wird auf einem atomaren Host ausgeführt, auf dem `rpm-ostree` verfügbar ist (hat Vorrang vor `bootc`) | | `[linux] bootc = true` | Wird auf einem atomaren Host ausgeführt, auf dem `rpm-ostree` nicht verfügbar ist, aber `bootc` (reiner bootc-Host ohne anderen unterstützten Paketmanager) | -| `[misc] no_self_update = true` | topgrade wurde tatsächlich über einen Paketmanager installiert (COPR+`rpm-ostree install`, `dnf`, `apk`, `xbps-install` oder Homebrew) — nicht allein deshalb, weil es sich um einen atomaren Host handelt. Reine bootc-Hosts ohne `rpm-ostree` erhalten die Binärdatei von GitHub Releases und erhalten daher **kein** `no_self_update = true` | +| `[misc] no_self_update = true` | topgrade wurde tatsächlich über einen Paketmanager installiert (`dnf`, COPR/rpm-ostree, `apk`, `xbps-install` oder Homebrew). Atomic-Binärinstallationen lassen Self-Update aktiviert | | `"chezmoi"` in `disable`, `[misc] last = ["custom_commands"]`, plus ein `"Chezmoi Push"`-Befehl | chezmoi ist installiert und initialisiert | | `"ScummVM Nightly"`-Befehl | `scummvm-nightly-update` ist in `$PATH` | | `[containers] runtime = "podman"` | `podman` ist in `$PATH` und `docker` ist nicht | @@ -92,7 +139,7 @@ Das Skript stellt nur die topgrade-Konfiguration in das chezmoi-Quellverzeichnis ## Quellen - [topgrade](https://github.com/topgrade-rs/topgrade) — das Werkzeug, das dieses Skript installiert und konfiguriert. -- [lilays Fedora/RHEL COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) — die Paketquelle, die auf Fedora, RHEL, AlmaLinux und rpm-ostree/atomaren Hosts verwendet wird. +- [lilays Fedora/RHEL COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) — die Paketquelle für klassische Fedora/RHEL-Systeme und optionale COPR/rpm-ostree-Atomic-Installationen. - [Universal Blue / ublue-os](https://universal-blue.org/) — die theme-update-Befehle, die auf Bazzite und anderen ublue-os-Images über `[include]` eingebunden werden. - [chezmoi](https://www.chezmoi.io/) — der Dotfiles-Manager, an den dieses Skript die generierte Konfiguration optional übergeben kann. diff --git a/README.md b/README.md index 13872ad..3f34aaf 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,17 @@ Read this in [Deutsch](README.de.md). - [Requirements](#requirements) - [Usage](#usage) - [How it works](#how-it-works) +- [Fedora Atomic / Kinoite installation methods](#fedora-atomic--kinoite-installation-methods) - [Configuration](#configuration) - [Credits](#credits) - [License](#license) ## Features -- **Works across distros.** Uses each distro's actual documented topgrade install method where one exists (Fedora/RHEL via COPR, Alpine via `apk`, Void via `xbps-install`, Homebrew/Linuxbrew), and falls back to downloading the prebuilt binary from GitHub Releases into `~/.local/bin` everywhere else. -- **Handles atomic/immutable systems correctly.** On rpm-ostree hosts (Bazzite, Fedora Silverblue/Kinoite/Atomic), it downloads the `lilay/topgrade` COPR repo file directly and layers the package with `rpm-ostree install` — no `dnf` binary required — and never reboots without asking first. On atomic hosts without `rpm-ostree` (bootc-only, or if the COPR/rpm-ostree layer fails), it falls back to the GitHub release binary instead. -- **Idempotent.** Safe to re-run. Already installed? It skips straight to configuration and verification. Already configured? Your config is never overwritten. +- **Works across distros.** Uses each distro's actual documented topgrade install method where one exists (Fedora/RHEL via COPR on traditional hosts, Alpine via `apk`, Void via `xbps-install`, Homebrew/Linuxbrew), and falls back to downloading the prebuilt binary from GitHub Releases into `~/.local/bin` everywhere else. +- **Gives Fedora Atomic users an explicit choice.** On rpm-ostree hosts (Bazzite, Fedora Silverblue/Kinoite/Atomic), choose either COPR/rpm-ostree package integration or the official upstream binary. On bootc-only Atomic hosts, use the binary method. +- **Hardens both Atomic paths.** The COPR path restricts the added `lilay/topgrade` repo to `includepkgs=topgrade`; the GitHub release path selects the expected Linux asset from release metadata, verifies the GitHub-provided SHA-256 digest when present, smoke-tests `topgrade --version`, and atomically activates the new binary only after validation. +- **Idempotent.** Safe to re-run. Already installed? It skips straight to configuration and verification. Already configured? Your config is not regenerated unless you pass `--force-config`; small installer-policy repairs may be applied with a timestamped backup. - **Ships a sensible default config**, with a few pieces (see [Configuration](#configuration)) only added when they're actually relevant to the machine it's running on. - **Optional [chezmoi](https://www.chezmoi.io/) integration.** If chezmoi is installed and initialized, the generated config is automatically brought under chezmoi management. - **No unattended surprises.** Meant to be run interactively by a human. It never reboots or overwrites an existing config without you being there to say so. @@ -30,6 +32,7 @@ Read this in [Deutsch](README.de.md). - Bash - `curl` or `wget` - `sudo`, if not already running as root and a privileged install step is needed +- `python3`, for safe GitHub release metadata parsing when installing the upstream release binary ## Usage @@ -37,18 +40,42 @@ Read this in [Deutsch](README.de.md). ./install-topgrade.sh ``` -Run it again any time — it detects what's already done and picks up where it left off. This is expected on rpm-ostree atomic hosts: a fresh install there stages the package via `rpm-ostree`, which needs a reboot to become active, and the script won't reboot for you without asking. If you decline (or you're in a non-interactive shell, where it won't even ask), just reboot manually and re-run the script to finish. On bootc-only atomic hosts without `rpm-ostree`, topgrade is installed as the GitHub release binary instead, so no reboot is needed. +Run it again any time — it detects what's already done and picks up where it left off. + +On Fedora Atomic / Kinoite, choose an installation method explicitly: + +```bash +./install-topgrade.sh --install-method=binary +./install-topgrade.sh --install-method=copr +``` + +Aliases are also available: + +```bash +./install-topgrade.sh --binary +./install-topgrade.sh --copr +``` + +Interactive Atomic sessions prompt when no method is provided. Non-interactive Atomic sessions default to the binary method because it does not require root privileges, host package layering, or a reboot. + +To install a specific upstream binary release, use: + +```bash +./install-topgrade.sh --binary --version v17.9.0 +``` + +or set `TOPGRADE_VERSION=v17.9.0`. ## How it works -topgrade is not packaged in Fedora, RHEL, or AlmaLinux's official repositories. On those distros (and on rpm-ostree/atomic hosts derived from them), this script installs topgrade from [lilay's `lilay/topgrade` COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) — the documented way to get it via `dnf` or `rpm-ostree`. +topgrade is not packaged in Fedora, RHEL, or AlmaLinux's official repositories. On traditional Fedora/RHEL-family hosts, this script can install topgrade from [lilay's `lilay/topgrade` COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) via `dnf`. On Fedora Atomic hosts, the script supports both the COPR/rpm-ostree package-managed model and the official upstream binary model. The script picks its install strategy based on what it detects: | System | Strategy | |---|---| -| Bazzite, Fedora Silverblue/Kinoite/Atomic (rpm-ostree present) | Downloads the `lilay/topgrade` COPR repo file directly (no `dnf` needed) and layers the package with `rpm-ostree install`, prompts before rebooting | -| Atomic host without `rpm-ostree` (bootc-only), or if the COPR/rpm-ostree layer fails | Downloads the latest release binary from GitHub into `~/.local/bin/topgrade` | +| Bazzite, Fedora Silverblue/Kinoite/Atomic (rpm-ostree present) | Prompt or explicit `--install-method`: COPR/rpm-ostree package integration or upstream binary in `~/.local/bin/topgrade` | +| Atomic host without `rpm-ostree` (bootc-only) | Downloads the official upstream GitHub release binary into `~/.local/bin/topgrade` | | Fedora / RHEL / AlmaLinux (non-atomic) | Enables the `lilay/topgrade` COPR, installs with `dnf install` | | Alpine | `apk add topgrade` | | Void Linux | `xbps-install -Sy topgrade` | @@ -57,9 +84,29 @@ The script picks its install strategy based on what it detects: Privileged steps run under `sudo` unless the script is already running as root, in which case `sudo` is skipped entirely — no `sudo` binary is required on systems where you're already root. +## Fedora Atomic / Kinoite installation methods + +Fedora Atomic users can choose between two supported ownership models. Neither is inherently wrong; they make different tradeoffs. + +| Topic | COPR / rpm-ostree | Upstream binary | +|---|---|---| +| Install location | Host deployment (`/usr/bin/topgrade`) | `~/.local/bin/topgrade` | +| Root required | Yes | No | +| Reboot required | Yes | No | +| Adds third-party RPM repo | Yes, the specific `lilay/topgrade` COPR | No | +| Repo scope | Restricted with `includepkgs=topgrade` where supported | N/A | +| Topgrade updates | rpm-ostree | Built-in self-update from `topgrade-rs/topgrade` | +| Host package layering | Yes | No | +| Package-manager integration | Excellent | None | +| Easy uninstall | `rpm-ostree uninstall topgrade` + reboot | remove `~/.local/bin/topgrade` | + +The COPR method adds the specific `lilay/topgrade` repository to your system and installs Topgrade as a layered RPM through rpm-ostree. This does not enable arbitrary COPR repositories. The installer restricts that repository to the `topgrade` package with `includepkgs=topgrade`, but you still trust the maintainer of that COPR project to provide the Topgrade RPM and updates. + +The binary method installs the official Topgrade release directly into `~/.local/bin` without adding an RPM repository or layering a package into the operating system. Topgrade then updates itself from the official `topgrade-rs/topgrade` upstream releases. + ## Configuration -topgrade reads its config from `${XDG_CONFIG_HOME:-~/.config}/topgrade.toml`. If that file doesn't already exist, the script creates one. **It never touches or overwrites an existing config.** +topgrade reads its config from `${XDG_CONFIG_HOME:-~/.config}/topgrade.toml`. If that file doesn't already exist, the script creates one. Existing configs are not regenerated unless you pass `--force-config`, but the script may make small policy repairs with a timestamped backup: for example, ensuring `rpm_ostree = true` on Atomic hosts or changing `no_self_update = true` to `false` when Topgrade is installed as the user-local upstream binary. The generated config includes a few pieces conditionally, based on what's actually present on the machine: @@ -68,7 +115,7 @@ The generated config includes a few pieces conditionally, based on what's actual | `[include] paths = ["/etc/ublue-os/topgrade.toml"]` | `/etc/ublue-os/topgrade.toml` exists (ublue-os/Bazzite theme-update commands) — since `~/.config/topgrade.toml` is shared into `toolbx`/`distrobox` containers, this may log a harmless `Unable to read /etc/ublue-os/topgrade.toml` error there; the script prints a heads-up about this when it adds the line | | `[linux] rpm_ostree = true` | Running on an atomic host where `rpm-ostree` is available (takes precedence over `bootc`) | | `[linux] bootc = true` | Running on an atomic host where `rpm-ostree` is not available, but `bootc` is (a bootc-only host without another supported package manager) | -| `[misc] no_self_update = true` | topgrade was actually installed via a package manager (COPR+`rpm-ostree install`, `dnf`, `apk`, `xbps-install`, or Homebrew) — not merely because the host is atomic. bootc-only hosts without `rpm-ostree` get the GitHub release binary instead and therefore do **not** get `no_self_update = true` | +| `[misc] no_self_update = true` | topgrade was actually installed via a package manager (`dnf`, COPR/rpm-ostree, `apk`, `xbps-install`, or Homebrew). Atomic binary installs leave self-update enabled | | `"chezmoi"` in `disable`, `[misc] last = ["custom_commands"]`, plus a `"Chezmoi Push"` custom command | chezmoi is installed and initialized | | `"ScummVM Nightly"` custom command | `scummvm-nightly-update` is on `$PATH` | | `[containers] runtime = "podman"` | `podman` is on `$PATH` and `docker` is not | @@ -92,7 +139,7 @@ The script itself only stages the config file into chezmoi's source directory; i ## Credits - [topgrade](https://github.com/topgrade-rs/topgrade) — the tool this script installs and configures. -- [lilay's Fedora/RHEL COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) — the package source used on Fedora, RHEL, AlmaLinux, and rpm-ostree/atomic hosts. +- [lilay's Fedora/RHEL COPR](https://copr.fedorainfracloud.org/coprs/lilay/topgrade/) — the package source used on traditional Fedora/RHEL-family hosts and optional COPR/rpm-ostree Atomic installs. - [Universal Blue / ublue-os](https://universal-blue.org/) — the theme-update custom commands pulled in via `[include]` on Bazzite and other ublue-os images. - [chezmoi](https://www.chezmoi.io/) — the dotfiles manager this script can optionally hand the generated config off to. diff --git a/install-topgrade.sh b/install-topgrade.sh index d41245b..6349fa1 100755 --- a/install-topgrade.sh +++ b/install-topgrade.sh @@ -5,6 +5,9 @@ readonly COPR_REPO="lilay/topgrade" readonly GITHUB_REPO="topgrade-rs/topgrade" FORCE_CONFIG=0 PACKAGE_MANAGED=0 +TOPGRADE_VERSION="${TOPGRADE_VERSION:-}" +INSTALL_METHOD="${TOPGRADE_INSTALL_METHOD:-}" +ATOMIC_INSTALL_METHOD="" log() { printf '[install-topgrade] %s\n' "$*" @@ -55,6 +58,58 @@ bootc_available() { command -v bootc >/dev/null 2>&1 } +validate_install_method() { + local method="$1" + case "$method" in + ""|binary|copr) return 0 ;; + *) + log_error "Invalid install method '${method}'. Use --install-method=binary or --install-method=copr." + return 1 + ;; + esac +} + +choose_atomic_install_method() { + ATOMIC_INSTALL_METHOD="$INSTALL_METHOD" + if [[ -n "$ATOMIC_INSTALL_METHOD" ]]; then + validate_install_method "$ATOMIC_INSTALL_METHOD" || return 1 + return 0 + fi + + if [[ ! -t 0 ]]; then + log "Atomic install method: no method specified in a non-interactive session; defaulting to official upstream binary" + ATOMIC_INSTALL_METHOD="binary" + return 0 + fi + + cat >&2 <<'EOF' +[install-topgrade] Topgrade can be installed in two ways on Fedora Atomic: +[install-topgrade] +[install-topgrade] 1. COPR / rpm-ostree +[install-topgrade] - Integrates Topgrade into the host package manager +[install-topgrade] - Adds the specific lilay/topgrade COPR repository +[install-topgrade] - Restricts that repository to the topgrade package where supported +[install-topgrade] - Requires sudo and a reboot +[install-topgrade] - Topgrade updates through rpm-ostree +[install-topgrade] +[install-topgrade] 2. Official upstream binary +[install-topgrade] - Installs to ~/.local/bin/topgrade +[install-topgrade] - Does not add a COPR repository or layer a package into the OS +[install-topgrade] - Does not require sudo or a reboot +[install-topgrade] - Topgrade updates itself from topgrade-rs/topgrade +EOF + + local answer + while true; do + read -r -p "[install-topgrade] Choose installation method [1=copr, 2=binary, default 2]: " answer + case "$answer" in + 1|copr|COPR) ATOMIC_INSTALL_METHOD="copr"; return 0 ;; + ""|2|binary|BINARY) ATOMIC_INSTALL_METHOD="binary"; return 0 ;; + *) log_error "Please choose 1 for COPR/rpm-ostree or 2 for the upstream binary." ;; + esac + done +} + chezmoi_available() { local source_path command -v chezmoi >/dev/null 2>&1 || return 1 @@ -74,35 +129,219 @@ run_privileged() { fi } -fetch_url() { - local url="$1" +download_file() { + local url="$1" dest="$2" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" + curl -fL --retry 3 --retry-delay 2 \ + -H "User-Agent: setup-topgrade" \ + -o "$dest" "$url" elif command -v wget >/dev/null 2>&1; then - wget -qO- "$url" + wget -qO "$dest" --header="User-Agent: setup-topgrade" "$url" else - log_error "Neither curl nor wget is available to reach ${url}" + log_error "Neither curl nor wget is available to download ${url}" return 1 fi } -download_file() { - local url="$1" dest="$2" +fetch_release_metadata() { + local tag="$1" dest="$2" + local api_url + if [[ -n "$tag" ]]; then + api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${tag}" + else + api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest" + fi + if command -v curl >/dev/null 2>&1; then - curl -fsSL -o "$dest" "$url" + local http_status + http_status=$(curl -sS -L \ + -H "User-Agent: setup-topgrade" \ + -H "Accept: application/vnd.github+json" \ + -w '%{http_code}' \ + -o "$dest" \ + "$api_url") || { + log_error "GitHub release lookup failed: network/TLS error. No changes were made." + return 1 + } + if [[ "$http_status" != "200" ]]; then + case "$http_status" in + 403) log_error "GitHub release lookup failed: HTTP 403 (possibly API rate-limited). Retry later or use --version vX.Y.Z." ;; + 404) log_error "GitHub release lookup failed: HTTP 404 for ${tag:-latest release}. Check --version value." ;; + 429) log_error "GitHub release lookup failed: HTTP 429 (rate limited). Retry later or use --version vX.Y.Z." ;; + 5*) log_error "GitHub release lookup failed: HTTP ${http_status} from GitHub. Retry later or use --version vX.Y.Z." ;; + *) log_error "GitHub release lookup failed: HTTP ${http_status}. No changes were made." ;; + esac + return 1 + fi elif command -v wget >/dev/null 2>&1; then - wget -qO "$dest" "$url" + wget -qO "$dest" \ + --header="User-Agent: setup-topgrade" \ + --header="Accept: application/vnd.github+json" \ + "$api_url" || { + log_error "GitHub release lookup failed. Retry later or use --version vX.Y.Z." + return 1 + } else - log_error "Neither curl nor wget is available to download ${url}" + log_error "Neither curl nor wget is available to query GitHub releases" + return 1 + fi +} + +parse_release_asset() { + local metadata_file="$1" target_triple="$2" + if ! command -v python3 >/dev/null 2>&1; then + log_error "python3 is required to parse GitHub release metadata safely" + return 1 + fi + python3 - "$metadata_file" "$target_triple" <<'PY' +import json, re, sys +path, target = sys.argv[1], sys.argv[2] +try: + data = json.load(open(path, encoding="utf-8")) +except Exception as exc: + raise SystemExit(f"JSON parse failure: {exc}") +tag = data.get("tag_name") +if not isinstance(tag, str) or not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?", tag): + raise SystemExit("Release metadata does not contain a valid tag_name") +expected = f"topgrade-{tag}-{target}.tar.gz" +matches = [a for a in data.get("assets", []) if a.get("name") == expected] +if len(matches) != 1: + names = ", ".join(a.get("name", "") for a in data.get("assets", [])) + raise SystemExit(f"Expected exactly one asset named {expected}, found {len(matches)}. Available assets: {names}") +asset = matches[0] +url = asset.get("browser_download_url") +digest = asset.get("digest") or "" +if not isinstance(url, str) or not url.startswith("https://github.com/"): + raise SystemExit("Selected release asset is missing a valid GitHub download URL") +if digest and not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest): + raise SystemExit("Selected release asset has an invalid digest field") +print(tag) +print(expected) +print(url) +print(digest) +PY +} + +verify_archive_digest() { + local archive="$1" expected_digest="$2" + [[ -n "$expected_digest" ]] || { + log "Release asset digest: not present in GitHub metadata; continuing without digest verification" + return 0 + } + command -v sha256sum >/dev/null 2>&1 || { + log_error "sha256sum is required because this release asset includes a SHA-256 digest" + return 1 + } + local expected actual + expected="${expected_digest#sha256:}" + actual=$(sha256sum "$archive" | awk '{print $1}') + if [[ "$actual" != "$expected" ]]; then + log_error "Downloaded Topgrade release but SHA-256 verification failed. Existing Topgrade binary was left untouched." return 1 fi + log "Release asset digest: verified" } -fetch_latest_tag() { - local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest" - local response - response=$(fetch_url "$api_url") || return 1 - printf '%s\n' "$response" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' +assert_safe_tar_archive() { + local archive="$1" listing="$2" + tar -tzf "$archive" > "$listing" + if grep -Eq '(^/|(^|/)\.\.(/|$))' "$listing"; then + log_error "Release archive contains an unsafe absolute or parent-directory path" + return 1 + fi +} + +backup_config_once() { + local config_file="$1" + local backup_file="${config_file}.bak.$(date +%Y%m%d%H%M%S)" + cp -p "$config_file" "$backup_file" + printf '%s\n' "$backup_file" +} + +ensure_section_key() { + local config_file="$1" section="$2" key="$3" value="$4" + if grep -Eq "^[[:space:]]*${key}[[:space:]]*=" "$config_file"; then + if grep -Eq "^[[:space:]]*${key}[[:space:]]*=[[:space:]]*${value}([[:space:]]*(#.*)?)?$" "$config_file"; then + return 0 + fi + python3 - "$config_file" "$key" "$value" <<'PY' +import re, sys +path, key, value = sys.argv[1:4] +lines = open(path, encoding='utf-8').read().splitlines() +pat = re.compile(rf'^(\s*{re.escape(key)}\s*=).*$') +for i, line in enumerate(lines): + if pat.match(line): + lines[i] = f'{key} = {value}' + break +open(path, 'w', encoding='utf-8').write('\n'.join(lines) + '\n') +PY + return 0 + fi + + python3 - "$config_file" "$section" "$key" "$value" <<'PY' +import re, sys +path, section, key, value = sys.argv[1:5] +lines = open(path, encoding='utf-8').read().splitlines() +header = f'[{section}]' +for i, line in enumerate(lines): + if line.strip() == header: + j = i + 1 + while j < len(lines) and not re.match(r'^\s*\[.*\]\s*$', lines[j]): + j += 1 + lines.insert(j, f'{key} = {value}') + break +else: + if lines and lines[-1].strip(): + lines.append('') + lines.extend([header, f'{key} = {value}']) +open(path, 'w', encoding='utf-8').write('\n'.join(lines) + '\n') +PY +} + +repair_existing_config() { + local config_file="$1" + local changed=0 backup_file="" + + if ! command -v python3 >/dev/null 2>&1; then + log "Existing config found at ${config_file}; python3 unavailable, leaving it untouched" + return 0 + fi + + if is_atomic_host; then + if command -v rpm-ostree >/dev/null 2>&1 && ! grep -Eq '^[[:space:]]*rpm_ostree[[:space:]]*=[[:space:]]*true([[:space:]]*(#.*)?)?$' "$config_file"; then + [[ -n "$backup_file" ]] || backup_file=$(backup_config_once "$config_file") + ensure_section_key "$config_file" "linux" "rpm_ostree" "true" + log "Updated existing config: ensured [linux] rpm_ostree = true" + changed=1 + elif ! command -v rpm-ostree >/dev/null 2>&1 && bootc_available && ! grep -Eq '^[[:space:]]*bootc[[:space:]]*=[[:space:]]*true([[:space:]]*(#.*)?)?$' "$config_file"; then + [[ -n "$backup_file" ]] || backup_file=$(backup_config_once "$config_file") + ensure_section_key "$config_file" "linux" "bootc" "true" + log "Updated existing config: ensured [linux] bootc = true" + changed=1 + fi + fi + + if [[ "$PACKAGE_MANAGED" -eq 1 ]]; then + if ! grep -Eq '^[[:space:]]*no_self_update[[:space:]]*=[[:space:]]*true([[:space:]]*(#.*)?)?$' "$config_file"; then + [[ -n "$backup_file" ]] || backup_file=$(backup_config_once "$config_file") + ensure_section_key "$config_file" "misc" "no_self_update" "true" + log "Updated existing config: package-managed install uses [misc] no_self_update = true" + changed=1 + fi + else + if grep -Eq '^[[:space:]]*no_self_update[[:space:]]*=[[:space:]]*true([[:space:]]*(#.*)?)?$' "$config_file"; then + [[ -n "$backup_file" ]] || backup_file=$(backup_config_once "$config_file") + ensure_section_key "$config_file" "misc" "no_self_update" "false" + log "Updated existing config: user-local GitHub release binary uses [misc] no_self_update = false" + changed=1 + fi + fi + + if [[ "$changed" -eq 1 ]]; then + log "Existing config was minimally repaired; backup saved to ${backup_file}" + else + log "Existing config found at ${config_file}; no installer policy changes needed" + fi } configure_topgrade() { @@ -113,7 +352,7 @@ configure_topgrade() { mkdir -p "$config_dir" if [[ -f "$config_file" && "$FORCE_CONFIG" -ne 1 ]]; then - log "Existing config found at ${config_file}; leaving it untouched" + repair_existing_config "$config_file" else if [[ -f "$config_file" ]]; then local backup_file="${config_file}.bak.$(date +%Y%m%d%H%M%S)" @@ -342,13 +581,25 @@ fedora_release_version() { printf '%s\n' "$version" } +restrict_copr_repo_to_topgrade() { + local repo_file="$1" + if grep -Eq '^[[:space:]]*includepkgs[[:space:]]*=' "$repo_file"; then + sed -i -E 's/^[[:space:]]*includepkgs[[:space:]]*=.*/includepkgs=topgrade/' "$repo_file" + else + printf ' +includepkgs=topgrade +' >> "$repo_file" + fi + log "COPR repository scope: restricted to includepkgs=topgrade" +} + install_via_copr_rpmostree() { # Atomic hosts must never depend on dnf: some rpm-ostree images (e.g. # Bazzite/uBlue variants) don't ship a dnf binary at all, and dnf's own # package install/removal semantics don't apply on ostree systems anyway. # "dnf copr enable" only ever wrote a .repo file under /etc/yum.repos.d, # so write that file directly and let rpm-ostree/libdnf pick it up. - log "Enabling COPR repo ${COPR_REPO} and layering topgrade via rpm-ostree" + log "Enabling specific COPR repo ${COPR_REPO} and layering topgrade via rpm-ostree" local fedora_version fedora_version=$(fedora_release_version) || { @@ -361,15 +612,19 @@ install_via_copr_rpmostree() { local tmpfile tmpfile=$(mktemp) - trap 'rm -f "$tmpfile"' RETURN log "Downloading COPR repo file from ${repo_url}" download_file "$repo_url" "$tmpfile" || { + rm -f "$tmpfile" log_error "Failed to download COPR repo file for ${COPR_REPO} (fedora-${fedora_version})" return 1 } + restrict_copr_repo_to_topgrade "$tmpfile" run_privileged install -m 0644 "$tmpfile" "/etc/yum.repos.d/${repo_filename}" && run_privileged rpm-ostree install -y topgrade + local install_status=$? + rm -f "$tmpfile" + return "$install_status" } install_via_apk() { @@ -388,50 +643,88 @@ install_via_brew() { } install_via_github_release() { - log "==> Installing topgrade from GitHub releases" + log "==> Installing topgrade from official GitHub releases (${GITHUB_REPO})" + log "Installation scope: user" + log "COPR: not used" + log "rpm-ostree package layering: not used" - local kernel_arch arch + local kernel_arch target_triple kernel_arch=$(uname -m) case "$kernel_arch" in - x86_64) arch="x86_64" ;; - aarch64) arch="aarch64" ;; - arm64) arch="aarch64" ;; + x86_64) target_triple="x86_64-unknown-linux-gnu" ;; + aarch64) target_triple="aarch64-unknown-linux-gnu" ;; + arm64) target_triple="aarch64-unknown-linux-gnu" ;; *) log_error "Unsupported architecture: ${kernel_arch}" return 1 ;; esac - local tag - tag=$(fetch_latest_tag) || { log_error "Failed to determine latest topgrade release tag"; return 1; } - if [[ -z "$tag" ]]; then - log_error "Could not parse latest release tag from GitHub API response" + local requested_tag="${TOPGRADE_VERSION}" + if [[ -n "$requested_tag" && ! "$requested_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then + log_error "Invalid --version value '${requested_tag}'. Expected a tag like v17.9.0." return 1 fi - local asset_url="https://github.com/${GITHUB_REPO}/releases/download/${tag}/topgrade-${tag}-${arch}-unknown-linux-gnu.tar.gz" - local tmpdir tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' RETURN - local archive="${tmpdir}/topgrade.tar.gz" + local metadata="${tmpdir}/release.json" + fetch_release_metadata "$requested_tag" "$metadata" || { rm -rf "$tmpdir"; return 1; } + + local parsed tag asset_name asset_url asset_digest + parsed=$(parse_release_asset "$metadata" "$target_triple") || { + rm -rf "$tmpdir" + log_error "Could not select a matching Linux release asset for ${target_triple}" + return 1 + } + tag=$(printf '%s\n' "$parsed" | sed -n '1p') + asset_name=$(printf '%s\n' "$parsed" | sed -n '2p') + asset_url=$(printf '%s\n' "$parsed" | sed -n '3p') + asset_digest=$(printf '%s\n' "$parsed" | sed -n '4p') + + local archive="${tmpdir}/${asset_name}" + log "Selected release: ${tag}" + log "Selected asset: ${asset_name}" log "Downloading ${asset_url}" - download_file "$asset_url" "$archive" || { log_error "Failed to download topgrade release asset"; return 1; } + download_file "$asset_url" "$archive" || { rm -rf "$tmpdir"; log_error "Failed to download topgrade release asset. No changes were made."; return 1; } + verify_archive_digest "$archive" "$asset_digest" || { rm -rf "$tmpdir"; return 1; } + local listing="${tmpdir}/archive.list" + assert_safe_tar_archive "$archive" "$listing" || { rm -rf "$tmpdir"; return 1; } tar -xzf "$archive" -C "$tmpdir" - local bin_path - bin_path=$(find "$tmpdir" -type f -name topgrade -print -quit) - if [[ -z "$bin_path" ]]; then - log_error "topgrade binary not found inside downloaded archive" + local candidates=() + while IFS= read -r candidate; do + candidates+=("$candidate") + done < <(find "$tmpdir" -type f -name topgrade -print) + if [[ ${#candidates[@]} -ne 1 ]]; then + rm -rf "$tmpdir" + log_error "Expected exactly one topgrade binary inside downloaded archive, found ${#candidates[@]}" + return 1 + fi + local bin_path="${candidates[0]}" + + chmod 0755 "$bin_path" + if ! "$bin_path" --version | grep -F "${tag#v}" >/dev/null 2>&1; then + rm -rf "$tmpdir" + log_error "Downloaded topgrade binary did not report expected version ${tag}. Existing binary was left untouched." return 1 fi mkdir -p "${HOME}/.local/bin" - install -m 0755 "$bin_path" "${HOME}/.local/bin/topgrade" - log "Installed topgrade binary to ${HOME}/.local/bin/topgrade" + local install_path="${HOME}/.local/bin/topgrade" + local new_path="${HOME}/.local/bin/.topgrade.new.$$" + local previous_path="${HOME}/.local/bin/topgrade.previous" + install -m 0755 "$bin_path" "$new_path" + if [[ -e "$install_path" ]]; then + cp -p "$install_path" "$previous_path" + fi + mv -f "$new_path" "$install_path" + log "Installed topgrade binary to ${install_path}" + log "Self-update mode: Topgrade built-in" log "Note: ensure \$HOME/.local/bin is on your PATH (e.g. in ~/.bashrc or ~/.profile) if it is not already." + rm -rf "$tmpdir" } install_traditional() { @@ -479,6 +772,9 @@ verify_installation() { if command -v topgrade >/dev/null 2>&1; then topgrade_bin="topgrade" log "Using topgrade found on PATH" + if [[ -x "${HOME}/.local/bin/topgrade" && "$(command -v topgrade)" != "${HOME}/.local/bin/topgrade" ]]; then + log "Warning: ${HOME}/.local/bin/topgrade exists but PATH resolves topgrade to $(command -v topgrade). Check PATH ordering or remove older package-managed installs." + fi elif [[ -x "${HOME}/.local/bin/topgrade" ]]; then topgrade_bin="${HOME}/.local/bin/topgrade" log "Using topgrade at ${HOME}/.local/bin/topgrade" @@ -493,10 +789,16 @@ verify_installation() { usage() { cat <<'EOF' -Usage: install-topgrade.sh [--force-config] - - --force-config Regenerate ~/.config/topgrade.toml even if one already exists - (the existing file is backed up first) +Usage: install-topgrade.sh [--force-config] [--install-method binary|copr] [--version vX.Y.Z] + + --force-config Regenerate ~/.config/topgrade.toml even if one already exists + (the existing file is backed up first) + --install-method MODE On Fedora Atomic hosts, choose 'binary' or 'copr' + (also available as TOPGRADE_INSTALL_METHOD=binary|copr) + --binary Alias for --install-method=binary + --copr Alias for --install-method=copr + --version TAG Install a specific topgrade release tag for binary installs, + for example v17.9.0 (also available as TOPGRADE_VERSION=v17.9.0) EOF } @@ -507,6 +809,38 @@ main() { FORCE_CONFIG=1 shift ;; + --install-method) + if [[ $# -lt 2 ]]; then + log_error "--install-method requires 'binary' or 'copr'" + usage >&2 + exit 1 + fi + INSTALL_METHOD="$2" + validate_install_method "$INSTALL_METHOD" || exit 1 + shift 2 + ;; + --install-method=*) + INSTALL_METHOD="${1#*=}" + validate_install_method "$INSTALL_METHOD" || exit 1 + shift + ;; + --binary) + INSTALL_METHOD="binary" + shift + ;; + --copr) + INSTALL_METHOD="copr" + shift + ;; + --version) + if [[ $# -lt 2 ]]; then + log_error "--version requires a tag such as v17.9.0" + usage >&2 + exit 1 + fi + TOPGRADE_VERSION="$2" + shift 2 + ;; -h|--help) usage exit 0 @@ -533,24 +867,33 @@ main() { if is_atomic_host; then log "Detected OSTree-based atomic system" + choose_atomic_install_method || exit 1 + if command -v rpm >/dev/null 2>&1 && rpm -q topgrade >/dev/null 2>&1; then + log "Warning: an RPM-managed topgrade package is present. This script will not remove it automatically; consider migrating explicitly if PATH precedence is ambiguous." + fi - log "==> Installing topgrade" - if command -v rpm-ostree >/dev/null 2>&1; then - if install_via_copr_rpmostree; then + case "$ATOMIC_INSTALL_METHOD" in + copr) + if ! command -v rpm-ostree >/dev/null 2>&1; then + log_error "--install-method=copr requires rpm-ostree on Atomic hosts. Use --install-method=binary on bootc-only systems." + exit 1 + fi + log "Atomic install method: COPR / rpm-ostree" + install_via_copr_rpmostree PACKAGE_MANAGED=1 configure_topgrade log "topgrade has been staged via rpm-ostree and requires a reboot to become active." offer_reboot log "After rebooting, re-run this script to finish verification." exit 0 - fi - log "COPR/rpm-ostree install path failed; falling back to GitHub release binary" - else - log "rpm-ostree not available on this atomic host (bootc-only); skipping COPR/rpm-ostree layering, using GitHub release binary" - fi - - install_via_github_release - configure_topgrade + ;; + binary) + log "Atomic install method: official upstream binary" + install_via_github_release + PACKAGE_MANAGED=0 + configure_topgrade + ;; + esac else log "Detected traditional (non-atomic) Linux system" log "==> Installing topgrade"