Skip to main content

max / alloy

Filter translations and documentation at install time Set %_install_langs en:en_US and tsflags=nodocs in the first layer of the final stage, above every dnf install in it, and assert after the last one that they took. About 263 MB: ~170 MB of translations and ~92 MB of docs. Rides TRIM rather than taking a fourth builder axis, so TRIM=keep still builds an image with both. Measured on fedora-bootc with gtk4, helix and their 245-package closure, built both ways: /usr/share/locale grew 0 MB filtered and 108 MB not, and /usr/share/licenses stayed 12 MB on both. The assertion reads the base's own contribution at build time rather than hardcoding it, so it stays honest when the FROM line moves, and a 32 MiB gate cannot be reached by a correct build or missed by one where the macro layer sank below an install. Man pages go with the docs; rpm files them as %doc and offers no separate switch. Written down in STACK.md, IMAGE.md and manual chapters 2 and 7 rather than left for a user to discover.
Author: Max Johnson <me@maxj.phd> · 2026-08-18 21:15 UTC
Signed with PGP, not checked
Commit: 6ba01f22e19aae532dc9636fb49f79fa57be2c31
Parent: cee1da9
5 files changed, +160 insertions, -1 deletion
M Containerfile +112
@@ -717,6 +717,77 @@
717 717 esac; \
718 718 echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS trim=$TRIM"
719 719
720 + # =====================================================================
721 + # Install-time filtering: translations and documentation
722 + # =====================================================================
723 + # 279 MB of a naive build is files rpm has marked with a language and 101 MB
724 + # is documentation. Two rpm settings drop most of it, and they only bind
725 + # packages installed *after* they are set, which is why they are here — above
726 + # terra-release, above every `dnf install` in this stage — rather than
727 + # somewhere more convenient. Set late they silently do almost nothing and the
728 + # build still passes, which is the failure mode the assertion after the last
729 + # install exists to catch.
730 + #
731 + # /etc/rpm/macros.image-language-conf %_install_langs en:en_US
732 + # /etc/dnf/dnf.conf tsflags=nodocs
733 + #
734 + # MEASURED, not argued (2026-08-17): gtk3, gtk4, helix and nushell built both
735 + # ways. `/usr/share/locale` grew 112 MB without these and 0.5 MB with them.
736 + # Alloy's own share of the cut is about 170 MB of translations plus about
737 + # 92 MB of documentation. The ~160 MB the base carries in before this line
738 + # runs is out of reach and stays. This is what actually makes Alpine images
739 + # small — it is not musl, and it works on glibc. Full lever list and the
740 + # figures behind it: wiki `alloy-image-size-levers`.
741 + #
742 + # Install-time filtering rather than a prune, and the difference is worth
743 + # stating: the files are never installed and the rpm database knows it, so
744 + # `rpm -V` still agrees with the image. Compare the cursor prune, where the
745 + # database declares 188 MB against 27 MB on disk by design. Prefer this shape
746 + # wherever both would work.
747 + #
748 + # THE MAN PAGE LOSS IS DECIDED, NOT OVERLOOKED. Fedora marks man pages %doc
749 + # and rpm offers no separate switch, so `nodocs` takes them with it. A default
750 + # Alloy install has no `man` for anything installed after this line; the
751 + # console carries its own help and docs/manual exists, so the loss is for
752 + # third-party tools rather than for Alloy's own surface. `TRIM=keep` restores
753 + # them. Ruled by Max 2026-08-17 over the recommendation, which was the
754 + # language macro alone.
755 + #
756 + # LICENCES ARE NOT AT RISK, checked both ways: rpm treats %license separately
757 + # from %doc, `/usr/share/licenses` stayed 12 MB and gtk4's licence stayed
758 + # readable. The assertion after the last install proves it anyway, because the
759 + # credits manifest is hand-curated against what the image ships and a future
760 + # rpm change here would be a licensing problem rather than a size one.
761 + #
762 + # It rides TRIM rather than taking an axis of its own. TRIM already means
763 + # "remove what this machine cannot use" and defaults to `unused`, so this is
764 + # the same argument one layer down and the builder matrix stays
765 + # profile x browser x langs x trim. Rejected: a LOCALES arg, which is the
766 + # fourth axis wiki `alloy-distribution` asks nobody to add, and doing it
767 + # unconditionally, which leaves no way back for a build-it-yourself consumer
768 + # who wants documentation.
769 + #
770 + # The baseline written here is what that assertion compares against: the
771 + # base's own `/usr/share/locale`, read at build time rather than hardcoded,
772 + # since it moves when the FROM line does. The assertion deletes it.
773 + RUN set -eu; \
774 + mkdir -p /usr/lib/alloy /etc/rpm; \
775 + du -sb /usr/share/locale | cut -f1 > /usr/lib/alloy/.locale-baseline; \
776 + [ -s /usr/lib/alloy/.locale-baseline ] \
777 + || { echo "could not read the base's /usr/share/locale size; the assertion below would have nothing to compare against" >&2; exit 1; }; \
778 + grep -q '^\[main\]' /etc/dnf/dnf.conf \
779 + || { echo "/etc/dnf/dnf.conf has no [main] section; tsflags would land outside every section and do nothing" >&2; exit 1; }; \
780 + if [ "$TRIM" = unused ]; then \
781 + echo '%_install_langs en:en_US' > /etc/rpm/macros.image-language-conf; \
782 + sed -i '/^\[main\]/a tsflags=nodocs' /etc/dnf/dnf.conf; \
783 + grep -q '^tsflags=nodocs$' /etc/dnf/dnf.conf \
784 + || { echo "tsflags=nodocs did not land in /etc/dnf/dnf.conf" >&2; exit 1; }; \
785 + echo "langs: en:en_US only, and no documentation or man pages (rides TRIM=unused)"; \
786 + else \
787 + rm -f /etc/rpm/macros.image-language-conf; \
788 + echo "langs: every language and all documentation (TRIM=keep)"; \
789 + fi
790 +
720 791 # =====================================================================
721 792 # Third-party repos
722 793 # =====================================================================
@@ -1628,6 +1699,47 @@
1628 1699 || { echo "$fw is not in this image; a medium built here cannot drive hardware nobody asked about at build time, and the trim must never reach firmware" >&2; exit 1; }; \
1629 1700 done
1630 1701
1702 + # The language and documentation macros, proved to have taken. They are set
1703 + # hundreds of lines above and bind only what is installed after them, so a
1704 + # silent no-op here looks exactly like success: the build passes, the image is
1705 + # a quarter of a gigabyte bigger than it should be, and nobody notices for a
1706 + # year. This runs after the last `dnf install` in the stage for that reason.
1707 + #
1708 + # The gate is `/usr/share/locale` against the baseline the macro layer
1709 + # recorded, plus a wide margin. Four packages alone grew it by 112 MB with the
1710 + # macros off and by 0.5 MB with them on, so 32 MiB of headroom is unreachable
1711 + # by a correct build and unmissable by a broken one. Reading the baseline at
1712 + # build time rather than hardcoding it keeps this honest when the FROM line
1713 + # moves and the base's own contribution with it.
1714 + #
1715 + # Both branches assert, the same way the trim above does. `keep` proves the
1716 + # macro file is really absent rather than trusting that the conditional took
1717 + # the branch it was asked for.
1718 + #
1719 + # Licences are checked on both branches, for the reason given at the macro
1720 + # layer: rpm treats %license separately from %doc today, and an upstream
1721 + # change that swept them together would be a licensing problem rather than a
1722 + # size one.
1723 + RUN set -eu; \
1724 + baseline="$(cat /usr/lib/alloy/.locale-baseline)"; \
1725 + rm -f /usr/lib/alloy/.locale-baseline; \
1726 + now="$(du -sb /usr/share/locale | cut -f1)"; \
1727 + grown=$(( now - baseline )); \
1728 + [ -n "$(ls -A /usr/share/licenses 2>/dev/null)" ] \
1729 + || { echo "/usr/share/licenses is empty; %license was swept along with %doc and crates/alloy/credits.toml no longer describes what the image ships" >&2; exit 1; }; \
1730 + if [ "$TRIM" = unused ]; then \
1731 + [ "$grown" -le 33554432 ] \
1732 + || { echo "/usr/share/locale grew $((grown / 1048576)) MB over the base, so the language macro did not take. The likely cause is a dnf install that moved above the layer setting it." >&2; exit 1; }; \
1733 + [ -f /etc/rpm/macros.image-language-conf ] \
1734 + || { echo "the language macro file is gone; something below the macro layer removed it" >&2; exit 1; }; \
1735 + grep -q '^tsflags=nodocs$' /etc/dnf/dnf.conf \
1736 + || { echo "tsflags=nodocs is no longer in /etc/dnf/dnf.conf; documentation was installed from wherever it was dropped onward" >&2; exit 1; }; \
1737 + else \
1738 + [ ! -f /etc/rpm/macros.image-language-conf ] \
1739 + || { echo "TRIM=keep but the language macro is set; the macro layer ran the wrong branch" >&2; exit 1; }; \
1740 + fi; \
1741 + echo "langs: /usr/share/locale grew $((grown / 1048576)) MB over the base"
1742 +
1631 1743 # =====================================================================
1632 1744 # System user for greetd. greetd drops privileges to this account
1633 1745 # before spawning tuigreet; without it greetd exits with
M docs/IMAGE.md +22 -1
@@ -216,8 +216,29 @@
216 216 names one. Fedora ships no per-variant subpackage, so the package block prunes in place
217 217 and an assertion after the skel copy reads the name back out of skel and fails if the
218 218 directory it asks for is gone.
219 + - **Translations and documentation, ~263 MB.** `%_install_langs en:en_US` and
220 + `tsflags=nodocs`, set in the first layer of the final stage above every `dnf install`
221 + in it, so nothing lands and nothing has to be pruned back out. About 170 MB of
222 + translations and about 92 MB of documentation. Measured rather than argued: gtk3,
223 + gtk4, helix and nushell built both ways grew `/usr/share/locale` by 112 MB without
224 + these and by 0.5 MB with them. The ~160 MB the base carries in before the macro line
225 + runs is out of reach.
219 226
220 - Neither is a race to a number. Two of the four largest layers are the desktop and the
227 + This is the one cut with a user-visible consequence, and it is decided rather than
228 + accidental. **A default install has no man pages** for anything installed after that
229 + line, because Fedora marks man pages `%doc` and rpm offers no separate switch. The
230 + console carries its own help and `docs/manual` exists; `TRIM=keep` builds an image
231 + with both the translations and the documentation. Licence files are unaffected — rpm
232 + treats `%license` separately from `%doc`, and the build asserts `/usr/share/licenses`
233 + is still populated, because `crates/alloy/credits.toml` is curated against what the
234 + image ships.
235 +
236 + Prefer this shape over a prune wherever both would work: the files are never
237 + installed and the rpm database knows it, so `rpm -V` still agrees with the image. The
238 + cursor prune above is the counter-example, with the database declaring 188 MB against
239 + 27 MB on disk by design.
240 +
241 + None of this is a race to a number. Two of the four largest layers are the desktop and the
221 242 browser and both are the product; the point is that every large thing in this image is now
222 243 either explained above or gone.
223 244
@@ -349,6 +349,16 @@
349 349
350 350 Not shipped, and not an oversight: **the GNU coreutils replacements** (`sd`, `dust`, `procs`, `sk`). Alloy already ships `dua` for disk usage and `bottom` for process monitoring, and the rest are substitutions rather than additions. A curated default is worth something because it is short.
351 351
352 + **`man` works and has nothing to read, on a default build.** The image installs no
353 + documentation: `tsflags=nodocs` is set above every package install, and Fedora marks
354 + man pages `%doc`, so they are excluded with the rest of it. This is not a pick about
355 + `man`, it is the largest size cut in the image (~263 MB with the language macro beside
356 + it), and rpm offers no switch that separates the two. What replaces it here: the
357 + console carries its own help, and [the manual](manual/README.md) is Alloy's own documentation.
358 + What does not: `man` for a third-party tool installed after the image was built.
359 + Building with `TRIM=keep` keeps the documentation and the translations both. Measured
360 + and argued in [IMAGE.md](IMAGE.md#what-was-cut-2026-08-17).
361 +
352 362 ## VPN
353 363
354 364 **WireGuard, through NetworkManager, with no plugin.** NetworkManager 1.54 speaks WireGuard natively, and the plugin packages a search turns up (`NetworkManager-wireguard` and its `-gnome` variant) do not exist in Fedora 43. What is missing without help is key generation: `nmcli` will import a peer config but will not mint one. `wireguard-tools` supplies `wg` and `wg-quick` and is the whole of what this needed.
@@ -95,6 +95,14 @@
95 95 keeps them, and the one capability that comes back with them is running
96 96 containers built for a foreign architecture.
97 97
98 + `TRIM` decides one more thing, and it is the one you are likelier to notice.
99 + A trimmed build installs no translations beyond English and no documentation,
100 + which is about 263 MB and the largest cut in the image. Documentation includes
101 + man pages, because that is the only way rpm files them, so `man` on a default
102 + Alloy has nothing to read for anything installed after the image was built.
103 + `TRIM=keep` brings back every language and every page. Chapter 7 has the
104 + day-to-day version of this.
105 +
98 106 Do not expect a smaller ISO from this. Removing a base package takes 304 MB
99 107 out of `/usr` and 3.8 MB off the image, because the base hardlinks its `/usr`
100 108 into an ostree repo that ships in the image and that the removal cannot prune.
@@ -88,6 +88,14 @@
88 88 Removing one is `rpm-ostree uninstall <package>`, and it stages the removal the
89 89 same way.
90 90
91 + Whatever you install, it arrives without its man page. The image is built with
92 + documentation excluded, which is the largest single thing that keeps it from
93 + being a quarter of a gigabyte bigger, and rpm gives no way to keep man pages
94 + while dropping the rest of `%doc`. Most tools answer `--help`; for the rest,
95 + upstream's own documentation is a search away. If you would rather have the
96 + pages, build your image with `TRIM=keep` (chapter 2) and they come back along
97 + with every translation.
98 +
91 99 ## Flathub
92 100
93 101 The `flatpak` client is in the image and no Flatpaks are provisioned. Adding