Eigene CI für Radicle: vom Ref-Event zum überprüfbaren Release

Eine praktische Fortsetzung zum CI-Broker: eigene Radicle-Repositories bauen, Images und Helm-Charts veröffentlichen, mit Syft und Trivy prüfen und Releases mit überprüfbaren Digests beschreiben. Mit Konfigurationsbeispielen, den Gründen für cosign-legacy und den Grenzen des nativen Runners.
Table of contents

Der erste grüne Build ist ein gutes Gefühl. Für eine eigene CI ist er trotzdem erst die halbe Strecke. Danach kommen die Fragen, die bei einer gehosteten Forge oft hinter einem fertigen Produkt verschwinden: Wer darf Code ausführen? Welches Image gehört zu welchem Commit? Wie prüfe ich beide Architekturen? Und was genau bedeutet „Release“, wenn Git-Tag, Registry-Artefakt und Radicle-Veröffentlichung drei verschiedene Dinge sind?

Mein erster CI-Broker-Beitrag war ein Reisebericht durch die Probleme beim Aufbau. Diese Fortsetzung ist die geordnete Variante für Menschen, die Radicle bereits verwenden und ihre eigene CI betreiben möchten. Der Broker ist inzwischen bei v1.5.0 angekommen: mit gemischtem Image-/Chart-Publishing, signierten Artefakten, Werkzeugen für SBOMs und Schwachstellenscans und einer besseren Behandlung von Release-Ereignissen.

Das Ziel ist nicht, mein komplettes Lab zu kopieren. Es ist ein nachvollziehbarer Weg vom replizierten Commit bis zu einem Artefakt, dessen Herkunft und Prüfergebnisse ein anderer Mensch kontrollieren kann.

Die Kurzfassung: Was braucht man wirklich?#

Für die erste Pipeline braucht es erstaunlich wenig:

  1. einen Radicle-Node, der das Repository lokal repliziert;
  2. einen Broker, der dessen Ereignisse filtert;
  3. einen Adapter, der einen Checkout und die Buildbefehle ausführt;
  4. dauerhaften Platz für Identität, Brokerzustand und Berichte;
  5. eine .radicle/native.yaml im Repository.

Erst für die Veröffentlichung kommen eine OCI-Registry, Schreibzugang und Signiermaterial dazu. Kubernetes ist eine mögliche Laufzeitumgebung. Flux, Ceph, mein Tailnet und die übrigen Lab-Dienste sind keine Voraussetzung für Radicle-CI.

Ich verwende den Radicle CI Broker von Lars Wirzenius und dessen Native-Adapter. Mein eigenes Projekt paketiert und erweitert diese Bausteine; es ist kein von Grund auf neu implementierter Ersatz für den Upstream-Broker.

Im hier beschriebenen Stand enthält das Image cib 0.32.1, radicle-native-ci 0.14.0 sowie die Buildwerkzeuge. Hinzu kommen ein gepatchter Ereignispfad, Start- und Wartungslogik und vor allem ci-build als gemeinsame Veröffentlichungskonvention für mehrere Repositories.

Drei Ebenen, die man nicht vermischen sollte#

flowchart LR NODE["CI-Node<br/>Ref-Ereignis"] --> BROKER["cib<br/>Filter und Queue"] BROKER --> RUN["Native-Adapter<br/>Tests und ci-build"] RUN --> REG["OCI-Registry<br/>Images und Charts"] REG --> AUDIT["Repo-Audit<br/>SBOM, Trivy,<br/>Attestierungen"] AUDIT --> RELEASE["Delegate<br/>Release-Abnahme<br/>und COB"] REG --> VERIFY["Consumer<br/>Digest und<br/>Signatur prüfen"]

Radicle transportiert Quellen und Zusammenarbeit. Der CI-Node empfängt Refs und stellt das Repository lokal bereit.

Die CI entscheidet über Ausführung und Veröffentlichung. Der Broker filtert Ereignisse. Der Native-Adapter führt die Repository-Pipeline aus. ci-build baut und veröffentlicht die unterstützten Artefakte.

Der Consumer entscheidet über Vertrauen. Eine Signatur, ein SBOM und ein Release-Datensatz helfen ihm dabei. Sie ersetzen seine Policy nicht.

Diese Trennung wird später wichtig: Ein grüner Brokerlauf, ein vorhandenes Image und ein vom Delegate veröffentlichter Release sind verwandte, aber nicht identische Aussagen.

Zuerst die Vertrauensfrage beantworten#

Der Native-Adapter führt die Shell aus .radicle/native.yaml direkt im Runnercontainer aus. Es gibt keine automatisch pro Build erzeugte Sicherheits-VM. Im Publishingbetrieb sind dort außerdem Registry-Zugang und Signierschlüssel verfügbar.

Damit gehört der ausgeführte Repositorycode zur vertrauenswürdigen Betriebsbasis. Ein Buildskript kann nicht nur kompilieren, sondern grundsätzlich auf die Rechte und Ressourcen dieses Runners zugreifen.

Dieser Aufbau ist für vertrauenswürdige Repositories gedacht, nicht für beliebigen fremden Patch-Code. Ein Delegate-Filter begrenzt, welche Ereignisse die Ausführung auslösen. Er macht den anschließend ausgeführten Code nicht ungefährlich. Für untrusted Builds gehören isolierte, kurzlebige Runner ohne Publishing-Credentials vor eine getrennte Veröffentlichungsstufe.

Deshalb beginne ich nicht mit „alles auf dem Seed bauen“, sondern mit drei getrennten Entscheidungen:

  • Replikation: Welche Repositories darf und soll der CI-Node besitzen?
  • Ausführung: Welche Ereignisse welcher Repositories akzeptiert der Broker?
  • Publikation: In welche Registry-Repositories darf der Runner schreiben, und welchen Schlüssel darf er verwenden?

Eine eigene CI-Identität ist sinnvoll. Sie muss dafür aber kein Repository-Delegate werden. Der Node empfängt die Arbeit; die Autorität, eine Veröffentlichung als Maintainer freizugeben, bleibt davon getrennt.

Schritt 1: Node, Storage und Broker zusammenbringen#

Der CI-Node und der Broker brauchen denselben Radicle-Zustand: Repository-Storage und node/control.sock unter einem gemeinsamen RAD_HOME. Dass ein Repository auf irgendeinem öffentlichen Seed vorhanden ist, genügt nicht. Es muss im Store des Nodes liegen, dessen Ereignisse der Broker beobachtet.

Für eine bestehende Radicle-Installation sind die ersten Prüfungen daher unspektakulär:

1# Im Kontext des dedizierten CI-Nodes, mit dessen RAD_HOME:
2rad node status
3rad seed
4rad inspect <RID> --delegates

Die konkrete Replikationskonfiguration hängt vom Nodebetrieb ab. Mein Node-Image verwendet dafür unter anderem RAD_HUB_SEED und RAD_SEEDS. Das sind Schnittstellen dieses Images, keine allgemeinen Radicle-CLI-Schalter.

Beim Storage trenne ich zwei Lebenszyklen:

Dauerhafter ZustandWiederherstellbarer Arbeitsspeicher
Radicle-Identität und Repository-StoreBuildah-Layer und Arbeitscontainer
Brokerdatenbank und Runhistorietemporäre Checkouts und Auditdateien
aufbewahrte BerichteBuildcache

Ein voller Buildcache darf nicht nebenbei die SQLite-Datenbank oder den Radicle-Store blockieren. Im Lab liegen Zustand und Buildah deshalb auf getrennten PVCs. Für eine kleine Installation können es getrennte Dateisysteme oder Volumes sein; die Trennung ist wichtiger als der Storageanbieter.

Dateirechte gehören ebenfalls zum Vertrag. Node und Broker teilen Dateien, von denen einige absichtlich nur dem Eigentümer zugänglich sind. Das Referenzdeployment normalisiert diese Eigentümerschaft und lässt beide Prozesse mit derselben UID arbeiten. Das ist eine konkrete Betriebsentscheidung, keine Empfehlung, beliebige Hostverzeichnisse für alle Benutzer freizugeben.

Schritt 2: die drei Konfigurationsdateien auseinanderhalten#

Der häufigste Denkfehler ist, Brokerkonfiguration und Pipeline in dieselbe Datei stecken zu wollen. Es sind drei verschiedene Ebenen.

cib.yaml: Was darf wann laufen?#

Eine kompakte Betreiberkonfiguration für den hier verwendeten Broker sieht so aus:

 1# /etc/cib/cib.yaml
 2
 3db: /ci-broker/cib/cib.db
 4report_dir: /ci-broker/cib/reports
 5concurrent_adapters: 1
 6max_run_time: 120min
 7queue_len_interval: 1min
 8
 9adapters:
10  native:
11    command: /usr/local/bin/ci-native
12    env:
13      RADICLE_NATIVE_CI: /etc/cib/native-ci.yaml
14
15triggers:
16  - adapter: native
17    filters:
18      - !And
19        - !AnyDelegate
20        - !HasFile ".radicle/native.yaml"
21        - !Or
22          - !Branch main
23          - !TagCreated

Die YAML-Tags sind Teil des Brokerformats. Gemeinsam verlangen sie einen Delegate als Ereignisursprung, eine deklarierte Pipeline und entweder ein main- oder Tag-Erstellungsereignis.

Ein neuer Patch löst mit diesem Filter nicht automatisch einen Build aus. Ebenso ist !TagCreated noch keine Prüfung auf einen gültigen Release-Tag oder dessen Zugehörigkeit zu main. Das sind weitere Entscheidungen im Veröffentlichungsprozess.

Für einen gemeinsam genutzten Node lässt sich zusätzlich eine Repository-Allowlist über den Repository-Filter einziehen. Seeding allein sollte nicht die einzige Beschreibung der Ausführungsberechtigung bleiben.

Den Einstieg würde ich mit einem parallelen Adapter beginnen. Mehr Parallelität spart nur dann Zeit, wenn CPU, RAM und I/O dafür vorhanden sind. Der ci-native-Wrapper koordiniert sich über einen Shared Lock mit exklusiv lockender Buildah-Wartung. Dieser Lock serialisiert nicht automatisch alle Builds untereinander.

native-ci.yaml: Wo liegen die Ergebnisse?#

1# /etc/cib/native-ci.yaml
2state: /ci-broker/cib/reports/http
3log: /ci-broker/cib/native-ci.log
4base_url: https://ci.example.net/http

Das ist die Adapterkonfiguration, nicht die Builddefinition. Der Reportserver muss die Dateien unter dem zu base_url passenden Pfad ausliefern. Ein falscher Präfix produziert zuverlässig kaputte Links, auch wenn die Builds selbst funktionieren.

.radicle/native.yaml: Was macht dieses Repository?#

Für den ersten Test braucht es weder Registry noch Cosign:

1shell: |
2  git rev-parse --verify HEAD
3  test -f README.md

Der Native-Adapter führt das shell-Feld im Checkoutroot unter Bash mit set -xeuo pipefail aus. Ein erfundenes GitHub-Actions-artiges jobs:-Schema hilft hier nicht.

Das -x ist zugleich ein Grund, Secret-Verarbeitung nicht unbedacht in Pipelinezeilen zu schreiben: Expandierte Befehle können im Log landen. Geheimnisse gehören nicht in Git, und Berichte sollten nicht pauschal öffentlich sein, nur weil ihr Dateiformat HTML ist.

Schritt 3: einen ersten grünen Run erzeugen#

Das Broker-Repository enthält unter deploy/ eine Kubernetes-Referenz. Sie ist bewusst kein unangepasst ausführbarer Universalinstaller.

Vor der Anwendung sind mindestens diese Punkte festzulegen:

  • Node-Identität, Seed-Verbindung und Repositoryliste;
  • Versionen und verifizierte Image-Digests;
  • StorageClass und Volumegrößen;
  • Report-URL und Zugangsschutz;
  • native Zielarchitektur;
  • für den vollständigen Publisher die referenzierten Registry- und Cosign-Secrets.

Die folgenden Befehle setzen einen Checkout des Broker-Repositories auf v1.5.0 voraus. Die erwarteten Secret-Namen sind im Referenzdeployment festgelegt:

SecretInhalt
ci-node-identityNode-Schlüsselpaar als Dateien radicle und radicle.pub; auch für den Smoke-Test nötig
ci-broker-secretsRegistrykonfiguration, Zugangsdaten und Verweis auf den Cosign-Key
ci-broker-cosignprivate Schlüsseldatei cosign.key

Das Schema steht in deploy/secrets.example.yaml; seine Platzhalter sind bewusst nicht Teil der Kustomization. Eigene Werte außerhalb von Klartext-Git bereitstellen.

Das mitgelieferte Deployment enthält bereits Publisher-Mounts. Wer zunächst nur den Smoke-Test ausführen möchte, muss diese Abhängigkeiten in einer eigenen Variante entfernen; eine reine Testpipeline macht einen unveränderten Publisher-Pod nicht plötzlich secretfrei. Auch HTTPS für die Reports entsteht nicht automatisch: Die Referenz erzeugt einen ClusterIP-Service. Externen Zugang, TLS und gegebenenfalls Authentifizierung muss man davor ergänzen.

Nach der Anpassung lautet der Kubernetes-Einstieg:

1kubectl create namespace ci
2kubectl label namespace ci \
3  pod-security.kubernetes.io/enforce=privileged
4
5# Erst nach Bereitstellung der benötigten Secrets und Anpassung der Manifeste:
6kubectl apply -k deploy -n ci

Die privilegierte Namespace-Policy ist hier eine bewusste Lockerung für das Buildprofil. Der Brokercontainer läuft mit UID 0, Chroot-Isolation und seccompProfile: Unconfined, jedoch ohne privileged: true. Das ist kein stark isolierter Runner, nur weil dieses eine Flag nicht gesetzt ist.

Der aktuelle Weg verwendet rootful Buildah mit Overlay-Storage. Er benötigt keinen Dockerdaemon. Die Schwierigkeiten aus meinem früheren Aufbau sind dabei keine allgemeine Aussage, dass rootless Builds unter Kubernetes grundsätzlich unmöglich wären.

Im Brokercontainer lässt sich anschließend die Konfiguration prüfen:

1cib --config /etc/cib/cib.yaml config
2cibtool --db /ci-broker/cib/cib.db run list
3cibtool --db /ci-broker/cib/cib.db event list

Ein Delegate-Push auf main des geseedeten Testrepositories sollte nun einen Run erzeugen. Erst wenn Ereignis, Checkout, Shell und Report zusammen funktionieren, würde ich Publishing anschließen.

Schritt 4: aus einer Pipeline einen OCI-Publisher machen#

ci-build liest im einfachen Imagefall zwei Konventionen aus dem Dockerfile:

1FROM scratch
2ARG CAT=examples
3ARG APP=hello
4COPY README.md /README.md

Das Beispiel erzeugt absichtlich nur ein kleines OCI-Datenimage, keinen startbaren Dienst. Sein Ziel ist ${REGISTRY}/examples/hello. Für eine Anwendung kommen deren eigene Build- und Runtime-Stufen hinzu.

Die Pipeline kann zunächst schlicht lauten:

1shell: |
2  test -f README.md
3  ci-build

Registryadresse und Credentials liefert der Betreiber, nicht das Repository:

VariableBedeutung
REGISTRYbeispielsweise registry.example.net
OCI_REG_USERNAME, OCI_REG_PASSWORDbegrenzter Registry-Schreibzugang
COSIGN_KEYPfad zur gemounteten privaten Schlüsseldatei
COSIGN_PASSWORDPassphrase für diesen Schlüssel
ARCHESbeim einfachen Imagebuilder explizit gewählte Zielarchitekturen
BUILDAH_ISOLATIONhier chroot

Das normale Publishing setzt Signiermaterial voraus. Einen allgemeinen „einfach unsigned weiter“-Modus beschreibt dieser Aufbau nicht.

Für den Start würde ich ARCHES auf die Architektur des Runners begrenzen. Ein Dockerfile mit fremdarchitektonischen RUN-Schritten wird nicht durch einen zusätzlichen String im Environment cross-kompilierbar. Der Broker bringt keinen magischen QEMU-Pfad mit.

Der tailnet-operator verwendet dagegen einen eigenen Buildvertrag mit plattformbezogenen Artefakten und Cross-Kompilierung. Deshalb können dort zwei Plattformen veröffentlicht werden, obwohl die Tests auf der nativen Runnerarchitektur laufen. Runnerarchitektur, Testplattform und veröffentlichte Plattformen sind drei verschiedene Größen.

Was ci-build inzwischen zusätzlich übernimmt#

Der gemeinsame Publisher ist mehr als ein Wrapper um buildah push:

  • Images: Commit-Tag und latest, bei einem erkannten Release zusätzlich vX.Y.Z.
  • Strukturierte Bundles: Plattformartefakte nach dem Vertrag /bundle/<app>/<os>/<arch>/<artifact>, mit eigenem OCI-Index pro Anwendung.
  • Helm-Charts: auch verschachtelte Charts unter charts/<name>/, gemeinsam mit Images oder Bundles.
  • Gepinnte zusätzliche Quellkontexte: über .radicle/build-contexts.yaml, aus lokal vorhandenem Radicle-Storage exportiert.
  • Veröffentlichungsergebnis: ein maschinenlesbarer Datensatz mit den tatsächlich publizierten Image-Digests.

Gerade zusätzliche Buildkontexte sind für aufgeteilte Projekte nützlich. Sie referenzieren feste Commits und werden ohne spontanen Netzwerk-Fetch aus dem lokalen Store materialisiert. Fehlt ein Repository oder der gepinnte Commit, bricht der Schritt ab. Das verhindert zumindest, dass ein Build unbemerkt auf „irgendein aktuelles main“ einer zweiten Quelle ausweicht.

Alle diese Konventionen sind Erweiterungen dieses Publishers, keine automatisch vorhandenen Eigenschaften jedes Radicle-Runners.

Syft und Trivy: verfügbare Werkzeuge sind noch keine Policy#

Seit v1.3 enthält das Brokerimage Syft und Trivy; im beschriebenen v1.5.0 sind Syft 1.54.1 und Trivy 0.75.0 gepinnt.

Das allein aktiviert noch keinen Scan für jedes Repository. Der Broker stellt Werkzeuge bereit. Das Repository entscheidet, was gescannt wird, wann es geschieht und welcher Befund einen Release verhindert.

Beim tailnet-operator sieht der Ablauf so aus:

1Chart- und Manifestprüfungen
2  → Audit-Preflight
3  → frische Testausführung einschließlich govulncheck
4  → ci-build: Images und Charts veröffentlichen und signieren
5  → Image-Audit per unveränderlichem Digest
6  → SBOM- und Provenance-Attestierungen

Vor dem Publishing werden Chart-Linting, Rendering, Schema- und Driftprüfungen ausgeführt. Die eigentliche Testsuite läuft in einem frischen Arbeitscontainer mit ausgewählten getrackten Eingaben. Ein gecachtes Image ersetzt keinen neuen Testlauf.

govulncheck untersucht dabei Go-spezifisch erreichbare Schwachstellenpfade. Trivy hat später eine andere Perspektive: Was steckt im tatsächlich veröffentlichten Image? Beide Prüfungen ergänzen sich; sie sind nicht austauschbar.

Die Übergabe erfolgt per Digest, nicht per latest#

Der Publisher kann über CI_BUILD_RESULT_FILE eine Ergebnisdatei schreiben. Sie enthält vollständigen Source-Commit, Releaseklassifikation und die verifizierten unveränderlichen Imagereferenzen. Sie wird erst nach erfolgreichem Abschluss aller ausgewählten Veröffentlichungen geschrieben, atomar und außerhalb des Worktrees.

Die Schnittstelle lässt sich auf diese beiden Zeilen reduzieren:

1# Ausschnitt: result_path liegt in einem privaten temporären Verzeichnis.
2CI_BUILD_RESULT_FILE="$result_path" ci-build
3bash hack/audit-image.sh --result "$result_path"

hack/audit-image.sh ist hier repositoryspezifisch. Es erwartet unter anderem das Imageformat und die Binaries des Operators. Wer es übernimmt, muss diese Erwartungen an das eigene Projekt anpassen, statt nur den Dateinamen zu kopieren.

Der Image-Audit läuft nach der Veröffentlichung. Er ist ein Akzeptanz-Gate für den Release, kein vorgeschalteter Registry-Wächter. Schlägt er fehl, sind bereits veröffentlichte Images, Charts oder Signaturen nicht automatisch zurückgerollt. „Artefakt vorhanden“ und „Release akzeptiert“ müssen im Betrieb getrennt bleiben.

Multi-Arch bedeutet: beide Images prüfen#

Ein Multi-Arch-Tag zeigt normalerweise auf einen OCI-Index. Der Index verweist auf Plattform-Manifeste, und diese wiederum beschreiben die jeweiligen Images. Ein SBOM für nur einen dieser Zweige beschreibt nicht automatisch den anderen.

Der Operator-Audit erwartet genau die vorgesehenen Plattformen linux/amd64 und linux/arm64. Für jeden Child-Digest liest Syft das Image aus der Registry und erzeugt mit expliziter Plattformauswahl ein SPDX-JSON-SBOM. Trivy scannt denselben bereits plattformspezifischen Child-Digest; OS und Architektur der Imagekonfiguration werden vorher geprüft.

flowchart TB INDEX["Image-Index @sha256:…"] --> AMD["amd64 Child @sha256:…"] INDEX --> ARM["arm64 Child @sha256:…"] AMD --> SA[SPDX-SBOM und Attestierung] ARM --> SB[SPDX-SBOM und Attestierung] INDEX --> PROV[SLSA-v1-Provenance-Attestierung] CHART["Chart-Manifest @sha256:…"] --> CS[Eigene Chart-Signatur]

Die Prüfpolitik ist ausdrücklich festgelegt:

ErgebnisNicht-Release, im vorgesehenen Ablauf mainRelease
HIGH-/CRITICAL-BefundWarnung mit ZählwertenAkzeptanz verweigern
Scannerfehler oder TimeoutFehlerFehler
unvollständiger BerichtFehlerFehler
ungültige Signatur oder falscher DigestFehlerFehler

Ungefixte HIGH-/CRITICAL-Befunde werden nicht ausgeblendet. LOW und MEDIUM sind nicht Teil dieses Gates. Der Audit verwendet einen frischen Cache und aktivierte Datenbankupdates; er verhindert außerdem, dass lokale Ignore-Dateien oder geerbte Scanneroptionen die Prüfung unbemerkt abschwächen.

Eine zusätzliche Vollständigkeitsprüfung verlangt, dass beide erwarteten Go-Binaries samt relevanter Komponenten in den Ergebnissen vorkommen. Ein leerer Bericht mit Exitcode null soll nicht als Erfolg durchgehen.

Die vollständigen Trivy-JSON-Dateien sind dabei temporäre Arbeitsdaten, kein dauerhaftes öffentliches CVE-Archiv. Dauerhaft veröffentlicht werden nach erfolgreicher Prüfung die SBOM-Attestierungen und eine Provenance mit Audit-Zusammenfassung. Wer ein Befundarchiv oder spätere Neubewertung braucht, muss dafür einen eigenen Aufbewahrungs- und Rescan-Prozess ergänzen.

Signatur, SBOM und Provenance beantworten unterschiedliche Fragen#

Diese drei Begriffe werden schnell zu einem einzigen „Supply-Chain-Häkchen“ zusammengezogen. Das hilft beim Lesen der Nachweise nicht.

Die Signatur bindet ein Objekt kryptografisch an einen Schlüssel. Sie sagt nicht automatisch, ob es sicher, reproduzierbar oder aus dem behaupteten Sourcecode gebaut wurde.

Das SBOM inventarisiert erkannte Bestandteile. Seine Qualität hängt vom untersuchten Artefakt, den Werkzeugen und deren Erkennung ab. Ein signiertes SBOM macht seine Angaben zu einer signierten Aussage, nicht zu einer vollständigen Wahrheit.

Die Provenance beschreibt den Buildzusammenhang. Hier enthält sie unter anderem Source-RID, vollständigen Git-Commit, Release-Tag, Builder-Image-Digest und Toolversionen.

Die Zuordnung im Beispiel ist präzise:

  • Image-Signaturen betreffen rekursiv Index und Plattform-Images.
  • SPDX-Attestierungen hängen an den Child-Digests.
  • Die SLSA-v1-Provenance hängt am Index-Digest.
  • Die Chartsignatur betrifft den separaten Chart-Manifest-Digest.

Die verwendete Provenance ist ein selbst erzeugtes Prädikat im SLSA-v1-Format. Sie beansprucht kein SLSA-Level, keine reproduzierbaren Builds und keine unabhängig gemessene Builder-Identität. CI_BUILDER_IMAGE wird aus der Deploymentkonfiguration geliefert und soll auf den tatsächlich eingesetzten Broker-Digest zeigen.

Nach dem Attestieren prüft das Script zudem nicht nur Cosigns Exitcode. Es verlangt den richtigen Subject-Digest, den richtigen Prädikattyp und genau das gerade erzeugte Prädikat. Eine ältere gültige Attestierung desselben Typs darf einen fehlgeschlagenen aktuellen Upload nicht verdecken.

Warum es cosign-legacy gibt#

Der konkrete Bruch entstand beim Wechsel auf Cosign 3.1.3. Mit den verwendeten Aufrufen landeten Signaturen im OCI-Referrers-/Bundle-Pfad. Die im Lab eingesetzten Consumer, insbesondere Kyvernos verifyImages und zots Trust-Erweiterung, erwarteten dagegen weiterhin die bisherigen Signatur-Tags.

Das Ergebnis war kein kaputter kryptografischer Schlüssel, sondern ein Ablage- und Discovery-Problem: Der Consumer fand die Signatur nicht dort, wo er suchte.

Seit Broker v1.4.1 wird deshalb doppelt signiert:

WerkzeugZweck im Publisher
cosign 3.1.3Signatur im verwendeten modernen Referrers-/Bundle-Format
cosign-legacy 2.5.0zusätzliche Signatur über sha256-<digest>.sig

Das gilt für Images, Bundle-Indizes und Charts. Der Audit verwendet die gepinnte Legacy-Binary außerdem für Attestierungen im bisherigen .att-Schema.

cosign-legacy ist hier keine eigene Kryptografie und kein neu entwickelter Signierer. Es ist eine zweite, gezielt gepinnte Cosign-Version unter anderem Binarynamen. Die doppelte Signatur dient der Kompatibilität, nicht einem Vier-Augen-Prinzip: Beide verwenden denselben Vertrauensanker.

Daraus folgt auch nicht, dass „Cosign v3 kaputt“ wäre oder aktuelle Consumer grundsätzlich keine Referrers unterstützen könnten. Entscheidend ist die konkrete Kombination aus Producer, Aufrufform, Registry und Verifier. Die Legacy-Schicht kann entfallen, sobald die tatsächlich verwendeten Consumer den gewählten modernen Pfad zuverlässig lesen.

Rekor ist eine separate Entscheidung#

Signaturablage und Transparency-Log sind verschiedene Achsen. Der repositoryspezifische Audit erzeugt seine Attestierungen ausdrücklich mit --tlog-upload=false und prüft sie ohne Log-Anforderung.

Die allgemeinen Signieraufrufe des Publishers setzen diese Option dagegen nicht. Deshalb wäre die pauschale Aussage „die gesamte CI verwendet niemals Rekor“ durch den Code nicht gedeckt. Wer eine vollständig private beziehungsweise logfreie Pipeline betreiben will, muss das für sämtliche Signier- und Verifikationspfade versionsgerecht konfigurieren.

Bei festen Schlüsseln begegnet einem dafür auf der Verifikationsseite --insecure-ignore-tlog. Das schaltet die Transparency-Log-Prüfung aus, nicht die kryptografische Signaturprüfung und nicht TLS. Es sollte eine bewusste Trust-Entscheidung sein, kein reflexartiger Zusatz gegen jede Fehlermeldung.

Signierte Helm-Charts sind eigene Artefakte#

Ein Image und das Helm-Chart, das es deployt, sind nicht dasselbe Releaseobjekt. Das Chart kann zusätzliche Workloads, RBAC oder Webhooks installieren. Es verdient deshalb einen eigenen überprüfbaren Digest.

Der gemischte Publisher prüft zunächst alle Charts, bevor die Imageveröffentlichung beginnt. Für einen Release muss die Chartversion zur Releaseversion passen; diesen Vergleich erzwingt der Publisher. appVersion muss der Maintainer im Releasecommit passend setzen, sie wird dabei nicht automatisch abgeglichen. Bei Branch-Builds erhalten gemischte Repositories stattdessen Preview-Versionen wie 0.13.0-dev.g<sha>; die stabile Version wird dadurch nicht vorzeitig verbraucht.

Am Beispiel des Operators:

1Git-Release:    v0.13.0
2Image-Tag:      v0.13.0
3Chart-Version: 0.13.0
4appVersion:    v0.13.0

Nach helm push wird geprüft, ob das Registry-Manifest tatsächlich auf die vorbereitete Chart-Archivschicht mit den erwarteten Medientypen zeigt. Anschließend signiert und verifiziert der Publisher den Chart-Manifest-Digest. Erst danach werden die vorgesehenen Rolling-Aliases bewegt.

Existierende geschützte Versions-Tags werden nicht einfach überschrieben. Registryfehler gelten dabei nicht als „Artefakt fehlt“. Nur eindeutig erkannte Nichtvorhanden-Antworten erlauben die neue Veröffentlichung.

Diese Checks sind trotzdem keine atomare Registry-Transaktion. Zwei konkurrierende Publisher können zwischen Prüfung und Schreiben kollidieren. Für einen harten Schutz braucht es passende Registryregeln oder eine zuverlässig serialisierte Veröffentlichung.

Flux als verifizierender Consumer#

Im Lab zieht Flux das Chart über ein OCIRepository und prüft es mit dem öffentlichen Cosign-Schlüssel. Ein anonymisiertes Beispiel, hier zusätzlich auf einen konkreten Digest festgelegt:

 1apiVersion: source.toolkit.fluxcd.io/v1
 2kind: OCIRepository
 3metadata:
 4  name: example-operator
 5  namespace: flux-system
 6spec:
 7  interval: 1h
 8  url: oci://registry.example.net/charts/example-operator
 9  ref:
10    digest: "sha256:<CHART-MANIFEST-DIGEST>"
11  layerSelector:
12    mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip
13    operation: copy
14  verify:
15    provider: cosign
16    secretRef:
17      name: example-operator-cosign

Das referenzierte Secret enthält nur den öffentlichen Prüfschlüssel. Es muss im Namespace des OCIRepository liegen, beispielsweise mit einem Schlüssel cosign.pub. Der private Schlüssel bleibt beim Publisher.

Ein HelmRelease verwendet diese Quelle über spec.chartRef. Das Lab pinnt seine Chartquelle derzeit per Versionstag; der Digest im Beispiel zeigt die strengere, unmittelbar überprüfbare Auswahl. Die verwendete Flux-Version muss das gezeigte API und das Signaturformat unterstützen.

Ein gewöhnliches helm pull oder helm install führt nicht automatisch diese Cosign-Prüfung aus. Und selbst ein verifiziertes Chart ersetzt nicht die Signaturprüfung der darin referenzierten Images. Chart und Image sind zwei Vertrauenspfade.

Releases: erst bauen, dann erklären, was freigegeben wurde#

Für den hier untersuchten ci-build-Stand ist ein Release ein Commit, auf den genau ein passender annotierter Tag vMAJOR.MINOR.PATCH zeigt. Die Implementierung wertet dafür die gepeelten Tag-Refs aus. Ein Lightweight-Tag kann zwar ein Brokerereignis auslösen, wird von dieser Releaseerkennung aber nicht wie ein annotierter Tag behandelt. -rc1 zählt ebenfalls nicht als Release.

Außerdem betrachtet der Publisher Tags am Checkout-origin, nicht einfach die Bezeichnung des ursprünglichen Events. Das erklärt einen subtilen Wettlauf: Trifft der Release-Tag ein, während der main-Run noch testet, können später beide Runs denselben Release veröffentlichen wollen.

Die sichere Reihenfolge lautet deshalb:

  1. Den gewünschten Commit auf kanonischem main bereitstellen.
  2. Den vollständigen main-Run dieses Commits abwarten.
  3. Prüfen, dass der lokale Releasecommit exakt kanonischem rad/main entspricht.
  4. Einen neuen annotierten Versionstag erzeugen und ausdrücklich übertragen.
  5. Den Release-Run und seine konkreten Artefakte abnehmen.
  6. Erst danach den Radicle-Release-Datensatz veröffentlichen.

Für Schritt 3 und 4, nach einem aktualisierten rad/main und bestandenem Build:

1# Als Bash-Skript im Repository-Checkout ausführen.
2set -euo pipefail
3git fetch rad
4test -z "$(git status --porcelain)"
5test "$(git rev-parse HEAD)" = "$(git rev-parse rad/main)"
6
7v=v1.2.3  # neue, noch nicht verwendete Version
8git tag -a "$v" -m "$v: kurze Beschreibung der Änderung"
9git push rad "refs/tags/$v:refs/tags/$v"

Die Tag-Ref muss nach der geltenden Repository-Identität kanonisch verfügbar sein. In meinem Setup ist dafür die akzeptierte Regel für refs/tags/* entscheidend. Eine lediglich vorgeschlagene Identitätsänderung ist noch keine wirksame Konfiguration. Ein Push von main allein überträgt den Tag ebenfalls nicht automatisch.

Was v1.5.0 bei Tag-Ereignissen verbessert#

Der Brokerpatch löst annotierte Tagobjekte zum tatsächlichen Commit auf. Runs werden als whence.Tag statt als vorgetäuschte Branch-Runs mit einem Tagobjekt als Commit gespeichert. Das verbessert Job-Zuordnung, Anzeige und maschinelle Auswertung.

Wer eigene Tools für die Runhistorie hat, muss die neue Variante berücksichtigen. Auch ein Downgrade ist nicht blind sicher: Ein älterer Broker kann entsprechend gespeicherte neue Run-Datensätze nicht unbedingt lesen.

Der Replay-Guard ist nützlich — aber kein Release-Audit#

Neu ist ci-build --release-published. Der Guard vergleicht den Digest des Image-Release-Tags mit dem des kurzen Commit-Tags. Stimmen sie überein, kann ein erneut zugestelltes Tag-Ereignis ohne weiteren Build enden.

Für einen einfachen Image-Publisher lautet das Muster:

1shell: |
2  if ci-build --release-published; then
3    exit 0
4  fi
5  ci-build

Der wichtige Vorbehalt: Der Guard prüft nicht die gesamte Veröffentlichung. Er beweist weder Chart-Vollständigkeit noch vorhandene Signaturen, SBOMs, Provenance oder einen früher bestandenen Audit. Die Tag-/Digest-Gleichheit ist auch kein unabhängig verifizierter Herkunftsnachweis.

Beim Operator liegt der Guard vor den Tests. Ein früherer Run könnte also das Image bereits veröffentlicht haben und erst danach gescheitert sein; ein Replay könnte trotzdem grün übersprungen werden.

Deshalb ist „der neueste Run ist grün“ allein keine vollständige Release-Abnahme. Im Report muss erkennbar sein, ob wirklich geprüft oder lediglich übersprungen wurde. Für die Freigabe zählen die erwarteten Artefakte und deren Nachweise.

Bei einer Teilveröffentlichung lässt sich CI_BUILD_ARTIFACTS gezielt auf images oder charts setzen. Das ist ein Werkzeug für eine geprüfte Recovery, kein Anlass, bestehende Versionen zu löschen oder neu zu belegen.

Dabei nicht einfach die unveränderte Operator-Pipeline erneut starten: Ihr vorgeschalteter Replay-Guard könnte sie wieder überspringen. Der ausgewählte Publisher-Schritt muss im geprüften Checkout bewusst ohne diesen Guard ausgeführt werden. Eine Chart-only-Ergebnisdatei enthält außerdem kein Image und erfüllt daher nicht den Vertrag des Image-Audits. Chart-Recovery, Image-Audit und gegebenenfalls fehlende Attestierungen sind getrennte Schritte.

Radicle-Release und Registry-Release verbinden#

Ein Git-Tag und ein OCI-Tag erzeugen noch keinen erklärenden Radicle-Release. Dafür verwende ich zusätzlich rad-artifact, hier in Version 0.18.0. Dieses CLI muss gesondert verfügbar sein; ich setze es nicht als Bestandteil jeder Radicle-Installation voraus.

Der Release wird als Collaborative Object, kurz COB, in Radicle gespeichert. Er kann eine Revision, Inhaltskennungen, Fundorte und Metadaten zusammenführen.

Die Veröffentlichung übernimmt ein Delegate, nicht die CI-Node. Die Standardansicht des verwendeten Werkzeugs berücksichtigt Delegate- beziehungsweise lokal selbst verfasste Releases; ein vom Bot publizierter Datensatz wäre damit nicht automatisch für alle anderen ein offizieller Maintainer-Release. Den Bot allein für eine hübsche Releaseansicht zum Delegate zu machen wäre die falsche Reparatur.

Ein mögliches Bindeglied ist der im Repository gepflegte öffentliche Signierschlüssel. Er wird als echtes BLAKE3-adressiertes Artefakt registriert; Image- und Chart-Digests kommen als Metadaten hinzu:

 1# Als Bash-Skript und Delegate im Projektcheckout, NACH der Artefakt-Abnahme.
 2# Platzhalter durch die tatsächlich geprüften Digests ersetzen.
 3set -euo pipefail
 4v=v1.2.3
 5image='registry.example.net/examples/hello'
 6image_digest='sha256:<IMAGE-INDEX-DIGEST>'
 7
 8out=$(rad-artifact --no-input register deploy/cosign.pub \
 9  --revision "$v" --name cosign.pub --json)
10release=$(jq -er .releaseId <<<"$out")
11cid=$(jq -er .cid <<<"$out")
12
13rad-artifact --no-input metadata set \
14  --release "$release" --cid "$cid" \
15  image "$image:$v@$image_digest"
16
17rad-artifact --no-input location add \
18  --release "$release" --cid "$cid" \
19  'https://downloads.example.net/hello/v1.2.3/cosign.pub'
20
21rad sync --replicas 1
22rad-artifact show "$v" --pretty

Die angegebene HTTPS-Location muss vorher tatsächlich die registrierten Schlüsselbytes bereitstellen. register veröffentlicht Discovery-Metadaten, nicht automatisch einen Downloadserver. Alternativ kann das Werkzeug Artefakte über seinen eigenen Seeding-Pfad anbieten; auch dafür braucht es einen real betriebenen Dienst.

Für ein Chart kommt eine weitere Metadatenzeile mit dessen Registryreferenz und Manifest-Digest hinzu. Die Metadaten am Schlüsselartefakt sind beschreibende Verweise; sie verwandeln einen OCI-SHA256-Digest nicht in einen BLAKE3-CID des Images.

Ebenso darf der veröffentlichte Public Key nicht seine eigene Vertrauenswürdigkeit beweisen. Ein Consumer braucht einen bereits vertrauenswürdig zugeordneten Maintainer-/Repositorybezug oder einen unabhängig bestätigten Schlüssel. Einen beliebigen Key neben ein beliebiges Image zu legen ergibt noch keine sichere Lieferkette.

Was ein Consumer tatsächlich prüfen kann#

Mit einem vertrauenswürdig bezogenen Public Key und den abgenommenen Digests lässt sich die Veröffentlichung nachvollziehen. Die Beispielsyntax bezieht sich auf Cosign 3.1.3, verwendet bewusst Platzhalter und einen Fixed-Key-Vertrauenspfad ohne Transparency-Log-Anforderung:

 1set -euo pipefail
 2image=registry.example.net/tailnet/operator
 3index='sha256:<IMAGE-INDEX-DIGEST>'
 4key=deploy/cosign.pub
 5
 6cosign verify --key "$key" --insecure-ignore-tlog \
 7  "$image@$index"
 8
 9crane manifest "$image@$index" |
10  jq -r '.manifests[] |
11    [.platform.os, .platform.architecture, .digest] | @tsv'

Aus dem geprüften Index wird der passende Plattform-Digest gewählt:

1child='sha256:<CHILD-DIGEST-AUS-DEM-INDEX>'
2cosign verify-attestation --key "$key" --insecure-ignore-tlog \
3  --type spdxjson "$image@$child"

Das Chart wird separat geprüft:

1chart=registry.example.net/charts/tailnet-operator
2chart_digest='sha256:<CHART-MANIFEST-DIGEST>'
3cosign verify --key "$key" --insecure-ignore-tlog \
4  "$chart@$chart_digest"

Bei privaten Registries kommt eine separat eingerichtete Registry-Authentifizierung hinzu. Keine Passwörter in Beispielskripte oder Release-Metadaten schreiben.

Die Provenance steckt in der Base64-kodierten Payload des verifizierten DSSE-Umschlags. Auch die Audit-Zusammenfassung ist darin nochmals kodiert. Der folgende Schritt verifiziert zuerst, gleicht Subject, Commit und Release ab und macht die verbleibenden Angaben lesbar:

 1# Fortsetzung im selben Bash-Skript; Erwartungen aus dem abgenommenen Release.
 2expected_commit='<VOLLSTAENDIGER-GIT-COMMIT>'
 3expected_release='v0.13.0'
 4work=$(mktemp -d)
 5trap 'rm -rf -- "$work"' EXIT
 6
 7cosign verify-attestation --key "$key" --insecure-ignore-tlog \
 8  --type slsaprovenance1 "$image@$index" >"$work/provenance.json"
 9
10jq -e -s --arg image "$image" --arg digest "${index#sha256:}" \
11  --arg commit "$expected_commit" --arg release "$expected_release" '
12  [.[] | if type == "array" then .[] else . end |
13    select(.payloadType == "application/vnd.in-toto+json") |
14    .payload | @base64d | fromjson |
15    select(.predicateType == "https://slsa.dev/provenance/v1") |
16    select(.subject | length == 1 and
17      .[0].name == $image and .[0].digest.sha256 == $digest) |
18    select(.predicate.buildDefinition.externalParameters |
19      .commit == $commit and .releaseTag == $release)] |
20  if length == 0 then error("Keine passende verifizierte Provenance")
21  else .[] | {
22    source: .predicate.buildDefinition.externalParameters.source,
23    builder: .predicate.runDetails.builder,
24    audit: [.predicate.runDetails.byproducts[]? |
25      select(.name == "post-publication-audit") |
26      .content | @base64d | fromjson]
27  } end
28' "$work/provenance.json"

Die Ausgabe ist noch keine allgemeingültige Akzeptanzpolicy. Source-RID und Builder müssen zum vertrauten Projekt passen; bei einem Release müssen die erwarteten Plattformen und die verlangte Befundpolitik erfüllt sein. Fehlende Auditangaben sind kein stillschweigender Erfolg. Wer daraus ein automatisches Gate macht, muss auch diese Erwartungen ausdrücklich prüfen.

Eine gültige Signatur auf der falschen Aussage ist immer noch die falsche Aussage. Und nach der Prüfung muss derselbe Digest konsumiert werden, nicht ein inzwischen verschobener Tag.

Betrieb: drei verschiedene Fehlerklassen auseinanderhalten#

Wenn nichts baut, würde ich nicht sofort den Dockerfile zerlegen.

SymptomZuerst prüfen
Kein Run entstandenRepo im lokalen CI-Store, Node-Verbindung, Delegate-Ursprung, Filter, Pipeline-Datei
Run vorhanden, Build hängtAdapterlog, Ressourcen, Buildah-Storage, Netzwerkabhängigkeiten
Build erfolgreich, Consumer lehnt abexakter Digest, Signaturformat, Public Key, Registry-Authentifizierung
Image vorhanden, Run rotChart-, Signier- oder Post-Publication-Audit nach dem Image-Push
Neuester Run grün, Nachweise fehlenReplay-Guard: ausgeführt oder nur übersprungen?

Für gespeicherte Runs und Queuezustand reichen zunächst die direkten Werkzeuge:

1cibtool --db /ci-broker/cib/cib.db run list
2cibtool --db /ci-broker/cib/cib.db run show <ADAPTER-RUN-ID>
3cibtool --db /ci-broker/cib/cib.db event list

Ein während einer Node-/Broker-Unterbrechung verpasstes Live-Ereignis wird nicht automatisch dadurch nachgeliefert, dass sein Git-Tag später im Store liegt. Das ist von bereits gespeicherten Queueeinträgen zu unterscheiden.

Für einen geprüften manuellen Wiederanstoß, im Brokerkontext:

1cibtool --db /ci-broker/cib/cib.db trigger \
2  --node <DELEGATE-NID> \
3  --repo <REPOSITORY-NAME> \
4  --ref main \
5  --commit <COMMIT>

Der angegebene Ursprung muss zum Delegate-Filter passen. Der Zugriff auf dieses administrative CLI und die Queue ist selbst privilegiert: Es ist kein öffentlicher Trigger-Endpunkt, dem man beliebige Herkunftsangaben glauben sollte.

Meine task radicle:ci-*-Befehle sind Komfortwrapper um solche Operationen und kubectl exec. Sie erleichtern den Lab-Betrieb, sind aber nicht nötig, um das Modell zu verstehen oder anderswo umzusetzen.

Private Quellen brauchen mehr als ein privates Repo#

Für private Repositories müssen Replikation, Reports, Badges, Registry und gegebenenfalls Transparency-Logs gemeinsam betrachtet werden. Auch ein bloßer Repositoryname im öffentlichen Badge kann bereits eine unerwünschte Offenlegung sein.

Zum hier beschriebenen Stand blockiert mein Node-Setup private Repositories ausdrücklich als Reaktion auf die veröffentlichte Radicle-Protokoll-Sicherheitsmeldung . Historische Private-Seeding-Beispiele sind deshalb keine aktuelle Freigabeempfehlung. Vor dem Einsatz mit vertraulichen Quellen gehört die Sicherheitslage der tatsächlich verwendeten Version geprüft.

Meine Empfehlung für den eigenen Aufbau#

Ich würde den Weg nicht mit Multi-Arch, Charts und Attestierungen gleichzeitig beginnen:

  1. Ein öffentliches Testrepository und ein vertrauenswürdiger Delegate. Erst Ereignis, Checkout und Report beweisen.
  2. Eine native Architektur und ein einfaches Image. Registry- und Signaturpfad bis zum Consumer prüfen.
  3. Unveränderliche Releaseversionen. Tagreihenfolge, Kollisionen und manuelle Recovery verstehen.
  4. SBOM und Scanpolicy pro Repository. Klar entscheiden, wann gewarnt, wann abgebrochen und was aufbewahrt wird.
  5. Charts und Release-Metadaten ergänzen. Artefakte getrennt adressieren und die vollständige Veröffentlichung abnehmen.

Der Gewinn einer eigenen Radicle-CI ist nicht, eine zentrale Forge mit möglichst vielen selbstgebauten Diensten nachzustellen. Er ist, die Übergänge selbst bestimmen zu können: welcher Code laufen darf, welcher Schlüssel wofür steht und welche Evidenz einen Release begleitet.

Heute endet meine Pipeline deshalb nicht mehr bei „das Image liegt in der Registry“. Sie liefert verifizierbare Digests, Signaturen, plattformbezogene SBOMs und einen beschriebenen Buildzusammenhang. Ihre Grenzen bleiben sichtbar: vertrauter nativer Runner, nicht atomare Veröffentlichung und ein nachgelagerter Audit, den ein grüner Replay-Run nicht ersetzen darf.

Das ist weniger bequem als ein einziges grünes Häkchen. Aber für Menschen, die ihre Forge bereits selbst betreiben, ist es die interessantere Eigenschaft: Man kann nachvollziehen, was dieses Häkchen tatsächlich belegt.

Quellen und Ausgangspunkte#

Stand: 9. Oktober 2026, Broker v1.5.0 (97020f3), Operator-Beispiel v0.13.0 und veröffentlichte Lab-Konfiguration. Die Beispiele beschreiben diese Implementierung, nicht einen allgemeingültigen Standard für jede Radicle-CI.