Zweiter Beitrag aus demselben Pilotversuch wie Der Descriptor überlebte, const nicht. Der erste fragte, ob ein Entwurfsmuster die Portierung von React nach Rust übersteht. Dieser fragt, was passiert, wenn man das Ergebnis ausliefern will. Topcoats eigene Roadmap führt „Docs for how to deploy Topcoat“ weiterhin als offen, das hier ist also ein Bericht aus ungeklärtem Gelände und keine Anleitung entlang eines dokumentierten Wegs. Alles Folgende wurde an einem offenen Probe-Crate gemessen, das Sie klonen und nachvollziehen können.
Der Descriptor-Pilotversuch endet mit einer Tatsache, die wie ein Fazit klingt. Er kompiliert zu einer einzigen 10-MB-Binärdatei, diese läuft aus einem leeren Verzeichnis, und die ausgelieferten Seiten enthalten weder <link> noch <script>. Ich habe jede dieser Aussagen geprüft, bevor ich sie aufgeschrieben habe.
Sie sind zugleich nahezu bedeutungslos, und ich habe länger gebraucht als nötig, um zu sehen, warum. Die zwei Bildschirme des Pilotversuchs verwenden kein Styling-Framework, keine Icons, keine Web-Schriften und kein clientseitiges JavaScript — es gab also nie etwas, das ein Build hätte laden oder ein Deployment hätte mitliefern müssen. Das als „Topcoat deploys as a single binary“ zu berichten, wäre eine Aussage über einen Test gewesen, den niemand durchgeführt hat: technisch korrekt und in jeder Hinsicht falsch, die für eine Entscheidung zählt.
Also habe ich ein zweites Crate gebaut, in dem diese Teile eingeschaltet sind, und gemessen, was sich ändert. Die Antwort ist interessanter als die Schlagzeile, und der Teil, den ich unterwegs falsch hatte, ist der lesenswerte.
Die Deklaration, die nicht alles deklariert
Dieses Blog kreist immer wieder um eine Idee, und es lohnt sich, sie zu benennen, bevor die Details sie zudecken. Ein Skelett, viele Masken plädierte dafür, Masken als Daten zu deklarieren statt sie zu programmieren. Der Descriptor-Beitrag stellte fest, dass die Deklaration bei Werten hielt, bei Struktur brach und eine Enum-Variante zur Reparatur kostete.
Build-Systeme sind dasselbe Problem an einer weniger offensichtlichen Stelle. Eine Cargo.toml ist eine Deklaration dessen, wovon ein Projekt abhängt, und das ganze Versprechen einer Lockfile lautet, dass diese Deklaration vollständig ist — dass Sie alles haben, was Sie brauchen, wenn Sie holen, was dort steht. Data Engineering lebt täglich von derselben Annahme, denn eine Pipeline, die sich nicht aus ihren deklarierten Eingaben rekonstruieren lässt, ist eine Pipeline, für deren Ergebnis Sie nicht geradestehen können. Reproduzierbarkeit, Provenienz und die Fähigkeit, woher stammt dieses Artefakt zu beantworten, hängen alle daran, dass das Manifest ehrlich über seine Abhängigkeiten ist.
Deshalb ist das Erste, was man prüfen sollte, nicht die Build-Geschwindigkeit, sondern ob die Deklaration die Wahrheit sagt.
Kein Node, und vier Downloads zur Build-Zeit an seiner Stelle
Topcoats Tailwind-Integration beschreibt sich selbst als „a thin Rust wrapper around the standalone Tailwind CSS CLI“, die „does not run Node, PostCSS, or a Vite-style asset pipeline“. Das trifft zu, und das Repository bestätigt es: Das Crate hat keine package.json, nichts, was node_modules ähnelt, und keine zweite Lockfile neben Cargo.lock. Für alle, die je eine Pipeline betreut haben, in der Rust-Build und Frontend-Build zwei getrennte Caches mit zwei getrennten Fehlerbildern sind, ist das eine echte Vereinfachung, und ich will sie nicht kleinreden.
Aber npm verschwindet nicht, es wechselt die Form. An seine Stelle tritt HTTP zur Build-Zeit, über vier dokumentierte Wege:
| Mechanismus | Lädt | Cache in |
|---|---|---|
| Tailwind | die eigenständige CLI-Binärdatei, von GitHub | target/topcoat/cache/tailwind |
| Icons | Icon-Set-JSON pro Set, über build.rs |
target/topcoat/cache oder ein selbst gewähltes Verzeichnis |
asset!(url) |
JavaScript von einem CDN, zur Build-Zeit eingelagert | target/topcoat/cache/assets |
| Schriften | woff2-Dateien von Fontsource | wie oben |
Jeder dieser Wege ist eine bewusste, dokumentierte Entwurfsentscheidung und kein Versehen. Aber jeder verschiebt auch eine Abhängigkeit aus dem Manifest in ein Build-Skript, und diese Verschiebung hat drei Folgen: Die Lockfile beschreibt sie nicht mehr, nichts legt die Version fest, die ankommt, und cargo fetch holt sie nicht. Vier Abhängigkeiten, von denen Rust-Entwickelnde erwarten würden, dass Cargo.lock sie regelt, werden von etwas anderem geregelt. Das ist der Handel, und im nächsten Abschnitt hört er auf, theoretisch zu sein.
Was --offline nicht abdeckt
Der übliche Cargo-Vertrag lautet, dass cargo fetch alles holt, wofür das Netz gebraucht wird, und cargo build --offline danach durchläuft. Bei kaltem Cache gilt das hier nicht:
$ cargo fetch # läuft durch
$ cargo build --offline # mit blockiertem Netz
error: failed to run custom build command for `deploy-probe`
panicked at build.rs:
called `Result::unwrap()` on an `Err` value:
Http(Io(Custom { kind: ConnectionRefused, error: "Connection refused" }))--offline regelt die Crate-Registry. Es regelt keine Build-Skripte, denn das sind gewöhnliche Programme, die tun dürfen, was sie wollen, einschließlich Sockets öffnen. Jeder hermetische Build — einer, der bewusst vom Netz getrennt ist, damit sein Ergebnis nur von den deklarierten Eingaben abhängt — scheitert hier, solange die Tailwind-CLI nicht bereitgestellt statt heruntergeladen wird. Topcoat bietet genau diesen Schalter, über BuildConfig::executable("tailwindcss") für die Auflösung über PATH oder executable_env("TAILWIND_CLI") für einen Pfad aus der Umgebung. Die Dokumentation ist in der Wirkung eindeutig: „A user-provided executable is used as-is: no download happens and no network access is needed.“ Das ist die Einstellung, die eine ernsthafte Pipeline will, und sie ist nicht der Standard.
Getrennt erwähnenswert, weil es die schärfere Kante ist: version_checksum("4.3.2", "sha256:…") existiert und ist ebenfalls nicht standardmäßig aktiv. Ab Werk lädt ein Build eine Binärdatei von GitHub und führt sie aus, ohne zu prüfen, was angekommen ist.
Die Korrektur
Hier lag ich falsch, und die Form des Irrtums ist nützlicher als der Befund.
Ich schloss aus dem obigen Fehler, dass der Build die Assets lädt, schrieb das auf und machte weiter. Das ist eine plausible Lesart eines Build-Skripts, das an einem Verbindungsfehler abbricht, und es ist nicht, was passiert. Als ich später den Asset-Cache leerte und das Netz sauber blockierte, lief cargo build anstandslos durch. Gescheitert ist der Bundler:
$ topcoat asset bundle
failed to bundle assets: failed to download asset from
https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js: io: Connection refused
$ echo $?
1Es gibt also zwei Netzwerkstufen, nicht eine, und nur eine davon ist cargo. Die gelben Kästen mit der Beschriftung network unten sind die drei Momente, in denen ein Build nach außen greift: zwei hängen an build.rs, der dritte am Bundler, der danach als eigener Befehl läuft.
asset!(url) lädt nicht zur Kompilierzeit. Es hält die URL und einen Inhalts-Hash fest, und der Bundler löst das anschließend auf, indem er die kompilierte Binärdatei durchsucht. Für eine Pipeline ist dieser Unterschied wichtig, weil sich Build und Download so voneinander trennen lassen und weil der Fehler in einem anderen Job mit einer anderen Meldung landet.
Warum ich die falsche Version so lange geglaubt habe, ist für alle erwähnenswert, die Ähnliches prüfen. Der Asset-Cache liegt unter target/topcoat/cache/assets, außerhalb von Cargos Fingerprint-Verwaltung, weshalb cargo clean -p ihn nicht entfernt. Mehrere Builds, die für mich offline-sauber aussahen, lebten still von diesem Verzeichnis. Ein Cache, den man vergisst, bestätigt genau das, was man ohnehin glaubt.
Das Artefakt ist eine Binärdatei und ein Verzeichnis
topcoat asset bundle schreibt seine Ausgabe neben die ausführbare Datei:
target/release/assets/
htmx.min-71ea67185bfa8c98.js 51 KB
latin-400-normal-ead637fd0b6b887d.woff2 13 KB
latin-600-normal-edde7cc4e2719898.woff2 13 KB
tailwind-59a9db92df193729.css 18 KB
manifest.tomlJeder Dateiname trägt einen Inhalts-Hash — einen Fingerabdruck der Bytes, sodass eine geänderte Datei einen geänderten Namen bekommt und deshalb unbegrenzt im Browser zwischengespeichert werden kann, ohne eine veraltete Kopie zu riskieren. Die auslieferbare Einheit besteht aus einer 4,8-MB-Release-Binärdatei plus 108 KB Assets, und beide müssen aus demselben Build stammen; die Dokumentation ist eindeutig, dass eine Abweichung zur Renderzeit abbricht.
Ich habe die Hälften getrennt getestet, indem ich Dateien in ein leeres Verzeichnis kopiert habe:
| Ausgeliefert | Ergebnis |
|---|---|
| nur die Binärdatei | Absturz beim Start — no asset bundle at …/assets |
Binärdatei + assets/ |
HTTP 200, alles wird unter /_topcoat/assets/… ausgeliefert |
Es gibt eine Alternative, die man kennen sollte: Assets extern hosten und nur das Manifest einkompilieren, über AssetConfig::hosted_at(...) mit include_str!. Das ergibt tatsächlich eine einzige Binärdatei, um den Preis eines Orts zum Hochladen.
Drei Arten, die Assets zu verdrahten, und wann jede scheitert
Zwei Dinge müssen stimmen, damit Assets funktionieren: Der Router muss das Bundle über .assets(...) bekommen, und das Bundle-Verzeichnis muss tatsächlich neben der Binärdatei liegen. Drei der vier Kombinationen sind es wert, benannt zu werden, und der Grund, warum das in eine Deploy-Prüfung gehört und nicht in eine Build-Prüfung, ist, dass sie nicht gleich laut scheitern.
asset!verwendet,.assets(...)nie im Router verdrahtet. Es kompiliert, es startet, und dann bricht es bei der ersten Anfrage ab, die ein Asset rendert, mit einem 500er. Eine Deploy-Prüfung, die nur fragt, ob der Prozess hochgekommen ist, verbucht das als Erfolg..assets(...)verdrahtet, Bundle fehlt. Es scheitert beim Start und nennt das erwartete Verzeichnis. Das ist der gute Fehler: früh genug, um ein Release zu stoppen, und konkret genug, um ihn ohne Untersuchung zu beheben.- Beides vorhanden. Es funktioniert.
Gegen den ersten muss man entwerfen, und er hat eine unmittelbare Folge für den Smoke-Test. Zu prüfen, ob der Port offen ist, beweist nichts. Der Test muss eine Seite abrufen, die ein Asset rendert, und danach die Assets, auf die diese Seite verweist:
- name: Smoke test — fetch a page, then its assets
run: |
cd dist
PORT=8080 ./deploy-probe &
for i in $(seq 1 30); do
curl -sf -o page.html http://127.0.0.1:8080/ && break
sleep 1
done
test -s page.html || { echo "page never served"; exit 1; }
grep -q '_topcoat/assets' page.html \
|| { echo "page rendered no asset URLs — bundle not wired"; exit 1; }
for u in $(grep -oE '(src|href)="/[^"]*"' page.html \
| sed 's/.*="//;s/"//' | sort -u); do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8080$u")
test "$code" = "200" || exit 1
doneNoch ein Detail entscheidet, ob ein gescheitertes Bundle gefährlich ist. Als der Download oben scheiterte, blieb das erzeugte CSS in assets/ liegen, aber keine manifest.toml. Da der Loader das Manifest braucht, bekommt eine Pipeline, die den Exit-Code ignoriert, Zustand zwei — einen Startfehler — statt eines halb befüllten Deployments, das hochkommt und kaputte Seiten ausliefert. Dem Exit-Code ist trotzdem zu trauen, aber der Rückfall ist der sichere.
Zwei Integrationen, entgegengesetzte Standardwerte
Der am besten übertragbare Befund hat nichts mit Rust zu tun. Die beiden sind kein willkürlich gewähltes Paar: Topcoats README nennt sie in einem einzigen Satz als die mitgelieferten Integrationen — „utilities for web fonts and icons, as well as easy integrations for Fontsource (Google Fonts) and Iconify.“ Sie lösen dasselbe Problem, laden zur Build-Zeit aus einem Katalog und landen im selben Bundle. Jeder Standardwert in ihnen zeigt trotzdem in die andere Richtung.
Fontsource liefert alles aus, solange man es nicht stoppt. Das Theme der Komponentenbibliothek verlangt die Schrift Geist und bringt sie nicht mit, ein Projekt mit Theme braucht also eine Schrift-Deklaration. Im Standard belassen, ohne die einschränkenden Argumente, liefert diese Deklaration jede Strichstärke und jeden Schnitt der Familie aus:
| Deklaration | woff2-Dateien | assets/ |
|---|---|---|
fontsource_font!(GEIST, host: Asset) |
18 — neun Strichstärken, aufrecht und kursiv | 364 KB |
weight: [400, 600], style: Normal, subset: Latin |
2 | 108 KB |
Eine Zeile hat das Auslieferbare von 68 KB Assets auf 364 KB gebracht, für eine Seite, die zwei Strichstärken lateinischen Texts rendert. Die einschränkenden Argumente sind dokumentiert, und die Dokumentation sagt sogar: „every combination of weight, style, and subset is a separate font file, so only include what you use.“ Aber nichts erzwingt diesen Rat: Der Build warnt nicht, und der Unterschied wird nur sichtbar, wenn man das Bundle auflistet. host: Asset ist eine eigene und wirklich gute Entscheidung — ohne sie zeigt das Stylesheet auf ein öffentliches CDN, und jeder Browser eines Besuchers lädt zur Laufzeit von dort.
Iconify kehrt all das um. Icons kompilieren zu eingebettetem <svg> im HTML, bleiben also vollständig aus dem Bundle heraus — es gibt keine Datei, die ein Browser holen müsste, und keinen Inhalts-Hash, den man nachziehen müsste. Mehr noch: Einen ganzen Katalog zu benennen, kostet fast nichts. Material Design Icons sind rund 7 500 Icons in einer 3,0-MB-JSON-Datei; iconify::include!("mdi") expandiert das Set zu Konstanten, und der Compiler verwirft alle, die man nie erwähnt. Im Release gemessen, mit und ohne ein tatsächlich gerendertes Icon:
| Build | Bytes |
|---|---|
| Set eingebunden, kein Icon gerendert | 4 871 936 |
| dasselbe, ein Icon gerendert | 4 878 400 |
6 464 Bytes. Das Set verhält sich wie ein Katalog, in den man hineingreift, nicht wie eine Last, die man mitträgt.
Iconify hat außerdem die Einlagerungs-Geschichte, die dem Rest des Builds fehlt. Zeigt cache_dir auf ein Verzeichnis im Repository und wird das Set mit icon_set_version festgenagelt, ergibt das einen Icon-Build, der wirklich offline und reproduzierbar ist, genau wie dokumentiert: „Files you place there yourself are used as-is.“ Eine falsche Festlegung scheitert zur Build-Zeit mit einem 404, der die URL nennt, statt still abzudriften.
Innerhalb eines Frameworks liefert also die eine Integration standardmäßig das Zwölffache dessen aus, was man verlangt hat, und die andere standardmäßig nur das, was man benutzt hat. Keines von beidem ist ein Fehler. Beides sind vertretbare Auslegungen dessen, was Entwickelnde wollen. Die Lehre, die ich in jeden Stack mitnehmen würde: Standard und sinnvoll sind unabhängige Eigenschaften, und der einzige Weg, sie auseinanderzuhalten, ist aufzulisten, was tatsächlich ausgeliefert wurde.
Die Komponentenbibliothek gibt Ihnen den Code — und das Zusammenführen
Topcoat UI wird beschrieben als „premade components you can edit — a component library based on Tailwind inspired by shadcn/ui.“ Das trifft zu, und das entscheidende Wort ist edit. topcoat ui add button fügt keine Abhängigkeit hinzu; es kopiert Quelltext ins Repository und hält ihn fest:
[registries.topcoat.components.button]
hash = "sha256:ba4ab32d87e4c9a982faf657258edd8cf2a0da904ece5976e70185eeb5808f57"
file = "src/components/button.rs"Zwei Folgen für eine Pipeline. Da die Komponenten eingecheckter Quelltext sind, führt CI nie ui add aus — sie werden wie jedes andere Modul kompiliert, die CLI ist hier also ein Werkzeug für die Entwicklung und kein Build-Schritt, auch wenn asset bundle sie in der Pipeline hält. Und dieser festgehaltene Hash wird von nichts geprüft: Es gibt kein check, diff oder update, ui list meldet eine von Hand geänderte Komponente in jedem Fall als installiert, und ein erneutes Hinzufügen verweigert wegen der vorhandenen Datei, nicht wegen ihres Inhalts. Aktualisieren heißt überschreiben und die eigenen Änderungen verlieren — oder von Hand vergleichen.
Das ist der Handel, ehrlich benannt: Ihnen gehört der Code, also gehört Ihnen das Zusammenführen. Und der Hash reicht, um den Abweichungsbericht selbst zu schreiben. Unserer gibt aus, was auseinandergelaufen ist, ohne den Build scheitern zu lassen, denn Komponenten zu bearbeiten ist der ganze Sinn des Modells:
state = tomllib.loads(pathlib.Path("components.toml").read_text())
for registry in state.get("registries", {}).values():
for name, meta in registry.get("components", {}).items():
path = pathlib.Path(meta["file"])
actual = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
print(name, path, "=" if actual == meta["hash"] else "edited locally")Eine kleinere Sache, die trotzdem am ersten Tag zubeißt: Eine frisch hinzugefügte Komponente exportiert Hilfsfunktionen, die Ihre Anwendung vielleicht nie aufruft, weshalb cargo clippy -- -D warnings an Code scheitert, den Sie nicht geschrieben haben.
Die Prüfung, an die ich nicht gedacht habe
Alles bisher Genannte ist ein Deployment-Fehler, und jeder davon lässt sich maschinell fangen. Der Workflow, der aus dieser Arbeit entstanden ist, führt Formatierung aus, Linting mit Warnungen als Fehler, einen Release-Build, das Asset-Bundle, den Abweichungsbericht für Komponenten und den Smoke-Test, der eine Seite und danach jedes referenzierte Asset abruft. Jeder Schritt trägt einen Kommentar, welcher Befund ihn dorthin gebracht hat, sodass die Datei das Argument dieses Beitrags in ausführbarer Form ist.
All das war grün — auf einer Seite, die sichtbar kaputt war.

Links: der Zustand, auf dem jede Prüfung grün meldete. Rechts: dieselbe Seite, nachdem eine Zeile die Tokens des Themes übernommen hat — was auch den Hintergrund ändert, weil beide jetzt aus dem Theme stammen.
Der Hauptbutton der Probe wurde dunkelgrau auf dunklem Marineblau gerendert: vorhanden, korrekt positioniert und für jede Person, die hinsieht, offensichtlich falsch. Ein nachträgliches Auslesen der Pixel setzte diesen Button auf 1,36:1 gegenüber seiner eigenen Seite. Kontrastverhältnisse reichen von 1:1, also zwei identischen Farben, bis 21:1 für Schwarz auf Weiß, und die Barrierefreiheits-Richtlinien, die inzwischen ein Großteil Europas in Gesetze schreibt — WCAG 2.1 — verlangen mindestens 3:1 zwischen einem Bedienelement und dem, was dahinterliegt, damit es überhaupt auffindbar ist. Die Beschriftung dagegen lag bei 12,56:1. Genau diese Lücke ist das Problem: Der Text blieb einwandfrei lesbar, das Ergebnis wirkte also gewollt und nicht kaputt. Die Ursache: Ich hatte die Seite mit Tailwinds Standardpalette gestrichen, während das Komponenten-Theme seine dunklen Werte hinter @custom-variant dark (&:is(.dark *)) deklariert, was nur greift, wenn ein Vorfahrenelement die Klasse dark trägt — und kein Element auf meiner Seite trug sie, <html> eingeschlossen. Der Button verlangte also bg-primary, bekam den Wert des hellen Themes und stylte sich für eine weiße Seite, die es nicht gab.
Die Reparatur ist eine Zeile, in der man die Tokens des Themes übernimmt, statt Farben von Hand zu wählen:
<html class="dark">
<body class="bg-background text-foreground">Nichts in meiner Pipeline hat gewarnt. Beide Klassensätze sind gültiges Tailwind, beide erreichen das erzeugte Stylesheet, und der Build hatte keine Möglichkeit zu wissen, welchen Hintergrund ich meinte — der Kontrast fiel schlecht aus, nicht aus. Es brauchte jemanden, der die Seite öffnet und hinsieht.
Das ist nicht dasselbe wie die Aussage, kein Werkzeug hätte es fangen können, und es lohnt sich, genau zu sagen, warum die naheliegenden es nicht getan hätten.
Barrierefreiheits-Scanner prüfen die falsche Hälfte. axe-core, worauf die meisten aufbauen, veröffentlicht genau zwei Kontrastregeln, und beide vergleichen Text mit seinem Hintergrund. In der Regelliste steht nichts zu WCAG 1.4.11, dem Nicht-Text-Kontrast, an dem dieser Button scheiterte. Ein Scan hätte die Beschriftung mit 12,56:1 gemessen, sie durchgewinkt und über die Füllung dahinter kein Wort verloren.
Visuelle Regressionstests brauchen etwas, wovon sie abweichen können. Playwrights Screenshot-Vergleich oder ein gehostetes Äquivalent meldet ein Rendering, das von einer freigegebenen Referenz abweicht. Das hier war das erste Rendering — der kaputte Zustand war der einzige, den es je gegeben hatte, es gab also kein früheres Bild und nichts zu melden.
Eine Prüfung, die funktioniert, ist kurz, und sie läuft jetzt. Sie fragt den Browser nach dem Hintergrund jedes Bedienelements und nach dem ersten nicht transparenten Hintergrund dahinter, berechnet das Verhältnis und lässt alles unter 3:1 scheitern. Das sind etwa vierzig Zeilen, der Pipeline hinzugefügt, nachdem dieser Abschnitt entworfen war — sie fängt den Button bei 1,36:1 und endet mit einem Fehlercode, was beim ersten Mal hätte passieren sollen.
Sie zu schreiben, hat noch einen Fall dessen erzeugt, worum es in diesem Abschnitt geht. Ihre erste lauffähige Fassung meldete 1,00:1 auf der Seite, die in Ordnung war. Das Theme deklariert seine Farben in oklch(), einem CSS-Farbraum, der neuer ist als das vertraute rgb(), und der Browser gibt sie genauso zurück — die Füllung des Buttons kam also als oklch(0.92 0.008 260) an. Meine erste Fassung las die drei Zahlen darin als Rot, Grün und Blau, was eine selbstbewusst falsche Antwort ergibt statt eines Absturzes. Jetzt malt sie jede Farbe in ein Canvas und liest das Pixel zurück, überlässt die Umrechnung also dem Browser, statt das Format zu raten. Deshalb habe ich sie gegen beide Zustände laufen lassen, bevor ich ihr getraut habe: 1,36:1 scheiternd, 15,08:1 bestehend, übereinstimmend mit einer zweiten Messung aus Screenshot-Pixeln.
Die Kosten sind der Teil, der gesagt gehört. Der Smoke-Test oben ist curl, das Ihnen sagt, dass eine Seite 200 zurückgab, aber nicht, in welcher Farbe irgendetwas herauskam — gerenderten Kontrast zu messen heißt also, einen Browser ohne Oberfläche einzusetzen. Zu prüfen, ob eine Anwendung ohne Node richtig rendert, hat Node wieder in die Pipeline gebracht — eine package.json, eine Lockfile und einen Chromium-Download bei jedem Lauf. Nichts davon kommt dem Crate oder dem Auslieferbaren nahe, und die Aussage des Frameworks bleibt unversehrt: Es ist die Anwendung, die kein Node braucht. Die Pipeline, die sie prüft, braucht es jetzt schon, und das ist ein Preis, den die Aussage nie abgedeckt hat.
Das Fazit lautet also nicht automatische Prüfungen reichen nicht, sieh dir deine App an. Die Prüfungen, die ich hatte, waren nicht schwach; sie waren präzise zugeschnitten, und jede deckte genau das ab, was sie behauptete:
cargo fmt --checkprüfte die Formatierung.clippy -D warningsprüfte auf Lints und fand einen echten in einer kopierten Komponente.- Der Bundle-Schritt prüfte, dass jedes deklarierte Asset geladen und geschrieben werden konnte.
- Der Smoke-Test prüfte, dass eine Seite rendert und ihre Assets auflösbar sind, was sie waren.
Keine davon behauptete zu prüfen, ob die Seite richtig aussieht, und der grüne Lauf führte mich in die Irre, weil ich eine breitere Zusicherung heraushörte, als er gab. Es gibt jetzt eine fünfte Prüfung, und sie hätte diesen Fall gefangen — aber sie existiert, weil jemand die Seite geöffnet und hingesehen hat.
Das ist derselbe Fehler, in den der Descriptor-Beitrag aus der anderen Richtung gelaufen ist. Dort wurde ein Refactoring geprüft, indem das HTML jeder Maske vorher und nachher aufgezeichnet und verglichen wurde, und der Lauf meldete null Unterschiede — während der Code darunter eine globale Variable für die Locale der Anfrage bekommen hatte, was bedeutete, dass zwei nur sprachlich verschiedene Masken nicht mehr gleichzeitig existieren konnten. Die gerenderte Ausgabe war tatsächlich identisch. Der Test verglich, was ein Mensch sieht, und nie, was es gekostet hat, das herzustellen; ein grüner Lauf belegte also, dass die globale Variable für den Vergleich unsichtbar war, nicht dass sie akzeptabel war. Eine grüne Prüfung beantwortet die Frage, für die sie geschrieben wurde. Das Risiko ist nie, dass sie lügt — es ist, dass wir eine größere Frage beantwortet hören.
Das Fazit
Fünf Dinge, die ich jedem mitgeben würde, der eine Topcoat-Anwendung in eine Pipeline bringt, und vier davon gelten weit darüber hinaus.
Nageln Sie die CLI auf die Bibliotheksversion fest. topcoat asset bundle stammt aus einem separat installierten topcoat-cli, und ein cargo install ohne Version landete bei einer Fassung, die einen neueren Compiler verlangt als das Projekt. Eine Pipeline, die die neueste CLI gegen eine festgelegte Toolchain installiert, zerbricht am Release-Kalender anderer Leute.
Cachen Sie target/topcoat ausdrücklich, mit der Plattform im Schlüssel. Die übliche Rust-Cache-Action richtet sich nach dem Cargo-Zustand und weiß von diesem Verzeichnis nichts, ohne sie wiederholt sich der 81-MB-Tailwind-Download also bei jedem Lauf. Die zwischengespeicherte Binärdatei trägt die Plattform im Namen, ein macOS-Cache einer Entwicklerin nützt einem Linux-Runner also nichts. Beides zusammen sind sechs Zeilen und eine festgelegte Installation:
- uses: Swatinem/rust-cache@v2 # Cargo state only — see below
with:
workspaces: deploy-probe
# The Tailwind CLI, the staged icon sets and the bundler's asset cache all
# live here, and rust-cache knows about none of them. The key carries the
# runner OS and arch because the cached CLI is platform-tagged:
# tailwindcss-4.3.2-linux-x64 here, macos-arm64 on a developer's machine.
- uses: actions/cache@v4
with:
path: |
deploy-probe/target/topcoat
deploy-probe/icons
key: topcoat-${{ runner.os }}-${{ runner.arch }}-tailwind-4.3.2-mdi-1.2.3
# Pinned deliberately. An unpinned `cargo install topcoat-cli` resolved to a
# version requiring a newer rustc than this project pins, so the build broke
# on someone else's release schedule rather than on a change of ours.
- run: cargo install topcoat-cli --version 0.6.2 --lockedNehmen Sie an, dass das Manifest unvollständig ist, und prüfen Sie diese Annahme. cargo fetch gefolgt von einem Offline-Build ist die billigstmögliche Prüfung, ob Ihre deklarierten Eingaben Ihre tatsächlichen Eingaben sind, und sie kostet einen CI-Job. Unsere scheiterte, was die wertvollsten zehn Sekunden der ganzen Übung waren.
Listen Sie mindestens einmal auf, was ausgeliefert wurde. Nicht, was der Build behauptet hat, und nicht, was das Framework dokumentiert — die tatsächlichen Dateien im tatsächlichen Artefakt. So kam heraus, dass eine Schrift-Deklaration ein Deployment mit einer Zeile auf das Fünffache aufgebläht hat, und nichts in der Pipeline hat danach gesucht.
Lassen Sie etwas auf die gerenderte Seite schauen. Alles Vorherige prüft, ob der Build hergestellt hat, was er deklariert hat; nichts davon prüft, ob das Ergebnis benutzbar ist. Diese Lücke zu schließen heißt, einen Browser ohne Oberfläche in CI zu betreiben, was für ein Projekt, dessen Reiz gerade darin liegt, keinen zu brauchen, echte Kosten sind — und der Grund, sie zu zahlen, steht im Titel. Eine grüne Pipeline und eine kaputte Seite sehen von außen gleich aus.
Topcoats Deployment-Dokumentation ist weiterhin ungeschrieben, und nachdem ich das Gelände durchgearbeitet habe, das sie abdecken müsste, halte ich das Fehlen für ehrlich statt nachlässig: Die Form ist tatsächlich komplizierter als cargo build und die Binärdatei kopieren, und sie schlecht aufzuschreiben wäre schlimmer, als sie offen zu lassen. Was die Probe stattdessen hervorgebracht hat, sind die Notizen, aus denen dieser Beitrag entstanden ist — jeder Befund von hier mit der Messung und den Befehlen dahinter, einschließlich der beiden, die ich zuerst falsch hatte. Diese den Maintainern als Ausgangspunkt anzubieten, ist mehr wert als der Beitrag, und genau das habe ich als Nächstes damit vor.
Entstanden mit einem KI-Paar, was aus dem Begleitbeitrag zu wiederholen ist, weil Topcoats erste Veröffentlichung im April 2026 lag und die Dokumentation den größten Teil des Korpus ausmacht: Die Messungen und die Fehler sind meine, das Gerüst war es nicht. Wenn Sie eine Topcoat-Anwendung ausgeliefert und die Asset-Geschichte einfacher vorgefunden haben — oder einen Chromium-Download für viel zu viel Maschinerie halten, um eine einzige Kontrastregel zu kaufen, was eine vertretbare Position ist, die ich selbst etwa eine Stunde lang vertreten habe — möchte ich das hören. Die Tür steht offen unter cordata.tech/contact. Weiterführend: Der Descriptor überlebte, const nicht ist die erste Hälfte desselben Pilotversuchs, und Eine Pipeline ist ein Descriptor, kein Programm führt das Argument über Deklarationen eine Domäne weiter, wo das Deklarierte Daten sind statt eines Builds.