joerglohrerde/docs/superpowers/specs/2026-04-15-publish-pipeline...

28 KiB
Raw Blame History

Publish-Pipeline für Nostr-Events — Design-Spec

Datum: 2026-04-15 (aktualisiert 2026-04-16: Blossom für alle Bilder, kein All-Inkl-rsync-Pfad mehr) Status: Entwurf, ausstehende User-Freigabe Scope: Toolchain, die Markdown-Posts aus content/posts/*/index.md in signierte Nostr-Events (kind:30023, NIP-23) umwandelt, zu Relays publiziert, und die zugehörigen Bilder zu Blossom hochlädt.

Designentscheidung 2026-04-16: Alle Bilder (auch die der 18 Altposts) werden zu Blossom hochgeladen. Kein rsync-Legacy-Pfad, kein image_source-Flag im Frontmatter. Die SPA rendert alle Posts über denselben Code-Pfad (Event-Text → Bild-URLs aus Blossom). Repo = Source-of-Truth für Content, Pipeline = Nostr-Export-Routine.

Diese Spec ist die Schwester-Spec zu 2026-04-15-nostr-page-design.md und teilt sich mit ihr den Event-Kontrakt für kind:30023 und die Konfiguration über kind:10002 / kind:10063.


1. Gesamtarchitektur

                       Auslöser
       ┌───────────────────┬───────────────────┐
       │                   │                   │
       ▼                   ▼                   ▼
  Lokaler CLI         GitHub Action       workflow_dispatch
  `deno task         (push auf main,     (--force-all, z. B.
   publish`          wenn content/       für Migration oder
                     posts/** geändert)  Reimport)
       │                   │                   │
       └───────────────────┴───────────────────┘
                           │
                           ▼
              ┌─────────────────────────────┐
              │ Publish-Pipeline (Deno)     │
              │ gemeinsame Library + CLI    │
              │                             │
              │ 1. Nostr-Kontext laden:     │
              │    • kind:10002 (Relays)    │
              │    • kind:10063 (Blossom)   │
              │ 2. Change-Detection         │
              │    (Git-Diff oder force)    │
              │ 3. Pro Post:                │
              │    a. Frontmatter parsen    │
              │    b. Bilder aus Ordner →   │
              │       Blossom upload        │
              │    c. Markdown body: bild-  │
              │       pfade → Blossom-URLs  │
              │    d. Event bauen           │
              │    e. Via NIP-46 signieren  │
              │    f. Zu Relays pushen      │
              └──────┬──────────────────────┘
                     │
          ┌──────────┼──────────────┐
          ▼          ▼              ▼
       Amber      Public        Blossom-
       (NIP-46    Nostr-        Server
        Signer    Relays        aus kind:10063
        via       aus           (primal,
        Relay)    kind:10002    später eigener)

Kernprinzipien

  • Deno als Runtime. Native TypeScript, Permissions-Modell, keine node_modules.
  • Gemeinsame Library + CLI. Kernlogik in Modulen, sowohl von lokaler CLI als auch von CI-Workflow importiert. Keine Duplikation.
  • Nostr als Source-of-Truth für Konfiguration. Relay-Liste aus kind:10002, Blossom-Serverliste aus kind:10063. Keine YAML-Config im Repo.
  • NIP-46 Bunker für Signaturen. Der private Schlüssel liegt nie in der Pipeline-Umgebung (nicht lokal, nicht in CI-Secrets). Bunker-Stufe Amber zum Start, Bunker-Stufe Optiplex nachrüstbar ohne Code-Change.
  • Git-Diff als Change-Detection. Pipeline publisht nur geänderte Posts. Override-Flag für Migration und Reimport.
  • State-los im Repo. Keine Lock-Files, kein Commit-zurück. CI ist read-only auf Repo-Content.
  • Idempotenz. Wiederholte Läufe ohne inhaltliche Änderung erzeugen keine neuen Events (Git-Diff filtert).

Kostenübersicht

  • Deno: 0 €.
  • Amber, Public-Relays, Public-Blossom: 0 €.
  • GitHub-Actions: im Free-Tier für persönliche Repos ausreichend.
  • All-Inkl: unverändert, bereits Premium-Tarif für SSH.
  • Zusatzkosten: keine.

Out-of-Scope

Diese Spec behandelt nicht:

  • Kommentare/Reactions auf Posts. Die kommen von Besuchern über die SPA via NIP-07 (siehe SPA-Spec §3). Publish-Pipeline publisht ausschließlich Autor-eigene kind:30023.
  • SPA-Deployment (SvelteKit-Bundle-Upload). Wird in einem separaten Deploy-Mechanismus behandelt oder als optionaler Subcommand nachgerüstet.
  • Domain-Verwaltung, TLS-Zertifikate, All-Inkl-Paketwahl. Infrastruktur-seitig außerhalb der Pipeline.

2. Pre-Flight-Setup

Bevor der erste Publish-Lauf erfolgen kann, müssen folgende Bedingungen einmalig manuell erfüllt sein. Der Subcommand deno task check verifiziert die Punkte und gibt klare Fehlermeldungen aus, wenn etwas fehlt.

2.1 Nostr-Identität

  • Pubkey des Autors: npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9
  • Privater Schlüssel (nsec) existiert nur in Amber auf dem Handy des Autors. Keine andere Instanz hält ihn.

2.2 NIP-46-Bunker-Pairing (Amber)

  1. Auf dem Handy: Amber öffnen, Account wählen.
  2. In Amber: „Generate Bunker URL" o. ä. — erzeugt eine bunker://<hex-pubkey>?relay=wss://...&secret=... URL.
  3. Im Handy-Amber: Permission-Regeln setzen:
    • kind:30023 signieren → auto-approve für die Publish-Pipeline-App
    • alle anderen Kinds → prompt (Sicherheitsnetz, sollte nicht aufschlagen)
  4. Bunker-URL in die Pipeline-Umgebung einfügen:
    • Lokal: in .env als BUNKER_URL=bunker://... (in .gitignore)
    • CI: als GitHub-Actions-Secret BUNKER_URL
  5. Amber muss während CI-Runs online sein (WLAN oder mobile Daten). Akku-Optimierung für Amber auf dem Handy deaktivieren.

2.3 Relay-Liste (kind:10002)

Einmalig manuell publizieren via Nostr-Client (z. B. nostrudel.ninja mit Amber-Login, oder direkt aus Amber).

Schema (NIP-65):

{
  "kind": 10002,
  "pubkey": "<hex>",
  "tags": [
    ["r", "wss://relay.damus.io"],
    ["r", "wss://nos.lol"],
    ["r", "wss://relay.nostr.band"],
    ["r", "wss://nostr.wine"]
  ],
  "content": "",
  "created_at": <unix>
}

Lese-Semantik der Pipeline (NIP-65):

  • ["r", <url>] ohne drittes Element → Relay ist sowohl Read als auch Write.
  • ["r", <url>, "read"] → nur Read; Pipeline ignoriert beim Publish.
  • ["r", <url>, "write"] → nur Write; Pipeline nutzt beim Publish.

Phase 1: alle Einträge ohne drittes Element (beides). Spätere Differenzierung möglich, ohne Code-Änderung.

Replaceable Event (kein d-Tag) — bei späteren Updates (z. B. eigener Relay hinzu) wird einfach ein neues kind:10002 publiziert, das das alte ersetzt.

2.4 Blossom-Serverliste (kind:10063)

Einmalig manuell publizieren. Phase-1-Inhalt: ein Server.

Schema (BUD-03):

{
  "kind": 10063,
  "pubkey": "<hex>",
  "tags": [
    ["server", "https://blossom.primal.net"]
  ],
  "content": "",
  "created_at": <unix>
}

Phase-5-Erweiterung (eigener Blossom-Server): zusätzliches ["server", "https://blossom.joerg-lohrer.de"] wird vorne in die Liste aufgenommen, neues Event publiziert.

2.5 deno task check

Dieser Subcommand verifiziert alle obigen Punkte:

  • BUNKER_URL gesetzt, Bunker antwortet auf Ping, Pubkey stimmt mit AUTHOR_PUBKEY_HEX überein.
  • kind:10002 auf Bootstrap-Relay gefunden, mindestens 1 Relay eingetragen.
  • kind:10063 auf Bootstrap-Relay gefunden, mindestens 1 Server eingetragen.
  • Blossom-Server aus kind:10063 antwortet auf HEAD / (Healthcheck).
  • Deno-Version und benötigte Permissions.

Bei jedem Fehler: klare Text-Meldung, was zu tun ist (z. B. „kind:10002 fehlt — publiziere es manuell mit folgendem Schema: ...").


3. Event-Kontrakt (normativ)

3.1 kind:30023 — Blog-Post (NIP-23)

Pflicht-Tags:

  • ["d", "<slug>"] — Slug-String, identisch mit Frontmatter slug:. Lowercase und URL-kompatibel (az, 09, -). Ist Teil des Tupels (pubkey, kind, d) für Replaceable-Semantik.
  • ["title", "<title-string>"] — aus Frontmatter title:.
  • ["published_at", "<unix-seconds>"] — aus Frontmatter date:, als Unix-Zeitstempel in Sekunden. Stabil über Edits hinweg — ändert sich nie.

Empfohlene Tags (wenn im Frontmatter vorhanden):

  • ["summary", "<summary>"] — aus Frontmatter description:.
  • ["image", "<absolute-url>"] — aus Frontmatter cover.image: (oder image:), transformiert zur absoluten URL gemäß Abschnitt 4.
  • ["t", "<tag>"] — ein Tag-Element pro Eintrag in Frontmatter tags:. Tag-Strings unverändert übernommen (Groß-/Kleinschreibung erhalten, weil Tag-Konvention im Nostr-Ökosystem case-sensitive ist).

Event-Header:

  • kind: 30023
  • pubkey: AUTHOR_PUBKEY_HEX
  • created_at: Unix-Zeitstempel des Signatur-Zeitpunkts (ändert sich bei jedem Edit).
  • content: Markdown-Body nach Bild-URL-Transformation (Abschnitt 4).

Nicht gemappt (Hugo-spezifische Frontmatter-Felder ohne Nostr-Entsprechung):

layout, cover.caption, cover.alt, author, lang, dir, toc, toc_label, toc_icon, comments, weight, menus, aliases, draft.

Draft-Behandlung: draft: true im Frontmatter → Pipeline publisht diesen Post nicht (überspringt ohne Fehler, loggt Info).

3.2 kind:10002 — NIP-65 Outbox-Relays

Siehe Abschnitt 2.3. Von der Publish-Pipeline nur gelesen (Bootstrap beim Start); nicht von der Pipeline publiziert.

3.3 kind:10063 — BUD-03 Blossom-Serverliste

Siehe Abschnitt 2.4. Von der Publish-Pipeline nur gelesen (vor Blossom-Upload); nicht von der Pipeline publiziert.

3.4 Signing

Alle ausgehenden Events werden via NIP-46 Bunker signiert (nicht NIP-07 — dieser Flow ist rein browserseitig und für die CLI nicht anwendbar). Implementierung via applesauce-signers Nip46Signer:

const signer = new Nip46Signer(BUNKER_URL)
await signer.getPublicKey() // initialisiert Verbindung
const signed = await signer.signEvent(unsignedEvent)

4. Markdown- und Bild-Transformation

4.1 Frontmatter-Parsing

YAML-Frontmatter zwischen ----Trennern. Parser: jsr:@std/yaml oder npm:gray-matter.

Slug kommt als lowercase String aus dem Frontmatter-Feld slug:. Ist bereits normalisiert (siehe Commit d17410f) — Pipeline muss nichts ableiten oder lowercasen.

Validierung:

  • title, date, slug müssen vorhanden sein; sonst harter Fehler für diesen Post.
  • slug muss regex ^[a-z0-9][a-z0-9-]*$ matchen; sonst harter Fehler.

4.2 Bild-URL-Transformation

Ziel: alle relativen Bild-Referenzen im Markdown-Body werden durch Blossom-URLs ersetzt. Ablauf:

  1. Pipeline sammelt alle Bilder aus dem Post-Ordner (Datei-Scan nach gängigen Bild-Extensions).
  2. Jedes Bild wird zu allen Servern aus kind:10063 hochgeladen (siehe §5).
  3. Blossom liefert eine hash-basierte URL zurück (Format: <server>/<sha256> oder <server>/<sha256>.<ext>).
  4. Pipeline baut eine Mapping-Tabelle <dateiname> → <blossom-url>.
  5. Markdown-Body wird traversiert, alle erkannten Bild-Patterns werden ersetzt:
    • ![alt](filename.png)![alt](<blossom-url>)
    • [![alt](filename.png)](link)[![alt](<blossom-url>)](link)
    • ![alt](filename.png =WxH)![alt](<blossom-url>) (Größen-Suffix entfernt; SPA skaliert per CSS)
  6. Wenn filename bereits ein Schema enthält (http://, https://, //), bleibt die URL unverändert — ist schon absolut.

Konsequenz: Es gibt nur einen Upload-Pfad (Blossom). Kein Legacy-Pfad mehr. Kein image_source-Flag, keine Datum-basierten URL-Strukturen.

4.3 Cover-Image-Tag

Das image-Tag im Event (für Listen-Previews/OG-Vorschau in Nostr-Clients) kommt aus dem Frontmatter:

  • Quelle: cover.image: (Hugo-Page-Bundle-Konvention); Fallback image: auf Top-Level.
  • Ist typischerweise ein relativer Dateiname, der als Bild auch im Post-Ordner liegt und damit ohnehin zu Blossom hochgeladen wird.
  • Wird nach dem Upload über die Mapping-Tabelle auf die Blossom-URL umgeschrieben.
  • Wenn der Wert bereits absolut ist (http/https), bleibt er unverändert.

5. Upload-Pfad

5.1 Blossom-Upload (einheitlich für alle Posts)

Betrifft: alle Bilder aller Posts, ohne Unterscheidung zwischen Alt- und Neu-Post.

Mechanik: BUD-01 HTTP-Upload zu allen Servern aus kind:10063-Liste, parallel.

Ablauf pro Post:

  1. Alle Dateien im Post-Ordner mit Bild-Extensions (.png, .jpg, .jpeg, .gif, .webp, .svg) sammeln.
  2. Hugo-generierte Resize-Varianten (*_hu_*.png etc.) werden ignoriert — das sind Derivate, keine Originale. Nur die Originaldateien, wie sie im Markdown referenziert werden, zählen.
  3. Pro Bild SHA-256 berechnen, zu allen Servern parallel hochladen.
  4. Mapping <filename> → <primary-blossom-url> aufbauen (primär = erster Server aus Liste).

Schritte pro Bild (intern):

  1. SHA256-Hash der Datei berechnen.
  2. Authorization-Event (kind:24242) bauen und via Bunker signieren (enthält Hash, Verb upload, Expiration).
  3. HTTP PUT /upload gegen alle Server gleichzeitig mit Auth-Header Nostr <base64-signed-event>.
  4. Antworten sammeln: pro Server entweder 200 { url, sha256, ... } oder Fehler.
  5. Erfolg: mindestens 1 Server hat die Datei akzeptiert. Optimal: alle.
  6. Markdown-URL nutzt die URL des ersten Servers aus der kind:10063-Liste (deterministisch, reproduzierbar).

Failure-Modi:

  • Alle Server lehnen ab → harter Fehler, Pipeline bricht für diesen Post ab.
  • Manche Server OK, manche Fehler → Warnung in Log, Pipeline fährt fort mit erfolgreichem Upload.

Retry: 2 Versuche pro Server mit exponentiellem Backoff.

Idempotenz: Blossom dedupliziert per SHA-256. Ein erneuter Upload derselben Datei ist ein No-Op (Server antwortet 200 mit derselben URL). Daher ist wiederholtes --force-all unproblematisch.

5.2 Kein Legacy-Upload mehr

Frühere Versionen dieser Spec sahen einen rsync-Pfad zu All-Inkl für Altposts vor. Das ist entfallen. Begründung:

  • Repo ist Source-of-Truth; alle Bilder liegen in content/posts/<ordner>/.
  • Einheitlicher Render-Pfad in der SPA (keine Sonderlogik für Altposts).
  • Blossom dedupliziert per Hash; wiederholter Upload ist billig.
  • Nach Cutover verwaisen die alten joerg-lohrer.de/YYYY/MM/DD/…-URLs — das ist akzeptiert, da sie nur in der weggehenden Hugo-Site referenziert sind.

6. Change-Detection und Workflow

6.1 Welche Posts werden publiziert?

Modus 1 — Git-Diff (Standard):

Pipeline vergleicht Dateiliste zwischen HEAD~1 (lokal) bzw. ${{ github.event.before }} (CI) und HEAD. Alle .md in content/posts/**/, die darin als A (added), M (modified) oder R (renamed) auftauchen, werden publiziert.

Modus 2 — --force-all (Migration / Reimport):

Alle content/posts/**/*.md werden publiziert, unabhängig von Git-Diff. Verwendet für:

  • Initiale Migration der 18 Altposts (einmaliger lokaler Lauf).
  • Nachträgliches Reimport nach Schema-Änderungen.

Modus 3 — --post <slug> (Einzel-Post, für Debug):

Nur der Post mit dem angegebenen Slug wird verarbeitet.

6.2 Trigger

Lokal: deno task publish [--force-all | --post <slug> | --dry-run].

GitHub Action:

on:
  push:
    branches: [main]
    paths: ['content/posts/**']
  workflow_dispatch:
    inputs:
      force_all:
        type: boolean
        default: false
  • Push auf main mit Content-Änderung → automatischer Publish im Git-Diff-Modus.
  • Manual-Trigger via GitHub-UI → optional force_all=true, dann --force-all-Lauf.

6.3 Idempotenz und Doppelpublikationen

Bei ausschließlicher Nutzung einer Variante (nur lokal ODER nur CI) ist Git-Diff-Detection präzise.

Edge Case: Wenn du lokal einen Post publisht, ohne zu pushen, und später CI läuft (z. B. für eine Content-Änderung an einem anderen Post), bekommt CI keinen Diff für den schon-lokal-publizierten Post — keine Doppelpublikation. Wenn du pushst, sieht CI im Diff die Änderung und publisht den Post erneut (dank replaceable-Semantik ist das funktional harmlos, nur etwas Relay-Bandbreite-Waste).

Akzeptable Redundanz. Spec dokumentiert es, aber keine aktive Mitigation.

6.4 Updates bestehender Posts

Ein Edit eines bereits publizierten Posts führt zu einem neuen kind:30023-Event mit:

  • Selbem d-Tag, selbem pubkey, selbem kind → ersetzt das alte Event (Replaceable-Semantik).
  • Selbem published_at (Datum aus Frontmatter, unverändert).
  • Neuem created_at (Signaturzeit).
  • Geändertem content und ggf. Tags.

Die Pipeline loggt explizit „UPDATE" vs. „NEU", indem sie vor dem Publish das Relay befragt, ob bereits ein Event für (pubkey, kind, dtag) existiert. Rein informativ; beide Pfade nutzen denselben Code.


7. Fehlerbehandlung und Retries

7.1 Relay-Publish

Pro Post wird das signierte Event an alle Relays aus der kind:10002-Liste parallel geschickt. Pro Relay:

  • Bis zu 2 Retries mit exponentiellem Backoff (1s, 3s).
  • Erfolg = Relay antwortet mit OK true.
  • Timeout pro Versuch: 10 Sekunden.

Erfolgskriterium pro Post: mindestens 2 von 4 Relays haben bestätigt. Weniger → harter Fehler, Post wird als „failed" markiert, Pipeline fährt mit nächstem Post fort, am Ende Exit-Code != 0.

Log pro Relay: Status (OK / fail / timeout), Roundtrip-Zeit.

7.2 Blossom-Upload

Siehe Abschnitt 5.1. Pro Server 2 Retries, mindestens 1 Server muss akzeptieren.

7.3 Bunker-Signing

  • Timeout 30 Sekunden pro Signatur-Request (Handy-Wake-up berücksichtigen).
  • 1 Retry bei Timeout.
  • Fehler (Permission denied, Bunker offline) → harter Abbruch der gesamten Pipeline (ohne Signaturen geht nichts).

7.5 Logging

Pipeline schreibt pro Run ein strukturiertes JSON-Log:

{
  "run_id": "<uuid>",
  "started_at": "<iso>",
  "mode": "diff | force-all | post-single",
  "posts": [
    {
      "slug": "offenheit-das-wesentliche",
      "status": "success | failed | skipped-draft",
      "action": "new | update",
      "event_id": "<64-hex>",
      "relays_ok": ["wss://..."],
      "relays_failed": [],
      "blossom_servers_ok": [],
      "images_uploaded": 3,
      "duration_ms": 1234
    }
  ],
  "ended_at": "<iso>",
  "exit_code": 0
}
  • stdout: in menschenlesbarer Form gedruckt.
  • CI: zusätzlich als Artefakt publish-log.json hochgeladen (30 Tage Retention). Keine Repo-Commits zurück.
  • Lokal: zusätzlich in ./logs/publish-<timestamp>.json (lokal in .gitignore).

8. Modul- und Dateistruktur

publish/
├── deno.jsonc                    # Imports, Tasks, Permissions
├── .env.example                  # Dokumentation (Commit), keine Werte
├── .gitignore                    # .env, logs/
├── README.md                     # Quickstart
├── src/
│   ├── cli.ts                    # CLI-Entrypoint (mit `@std/cli`)
│   ├── core/
│   │   ├── config.ts             # BOOTSTRAP_RELAY, AUTHOR_PUBKEY_HEX
│   │   ├── frontmatter.ts        # parseFrontmatter(md): { fm, body }
│   │   ├── validation.ts         # validateSlug, validatePost
│   │   ├── markdown.ts           # transformImageUrls, stripSizeHints
│   │   ├── event.ts              # buildKind30023(fm, body)
│   │   ├── signer.ts             # NIP-46 Bunker-Wrapper
│   │   ├── relays.ts             # loadOutboxRelays, publishEvent
│   │   ├── blossom.ts            # loadServerList, uploadBlob
│   │   ├── change-detection.ts   # gitDiff, allPostFiles, forceMode
│   │   └── log.ts                # structured logger + JSON writer
│   └── subcommands/
│       ├── publish.ts            # Hauptbefehl, alle 3 Modi inkl. --dry-run
│       ├── check.ts              # Pre-Flight-Validation
│       └── validate-post.ts      # Einzel-Post-Check ohne Upload (nur Frontmatter/Bilder)
├── tests/
│   ├── frontmatter_test.ts
│   ├── validation_test.ts
│   ├── markdown_test.ts
│   ├── event_test.ts
│   ├── change-detection_test.ts
│   └── fixtures/
│       └── sample-post.md
└── .github/
    └── workflows/
        └── publish.yml           # CI-Workflow

Tests: Deno-Standard-Test-Runner. Fokus auf Unit-Tests für pure Transformationen (frontmatter, markdown, event-bauen); Integration-Tests mit Mock-Relay und Mock-Bunker.


9. Testing-Strategie

9.1 Unit-Tests

  • parseFrontmatter: diverse Real-Beispiele aus den 18 Altposts, Edge Cases (Leerzeichen in Strings, YAML-Blocks).
  • validateSlug: Regex-Matching-Grenzen.
  • transformImageUrls: alle Markdown-Bild-Muster, Leerzeichen in Dateinamen, bereits absolute URLs.
  • buildKind30023: Frontmatter → Event-Objekt, Tag-Mapping, draft-Behandlung.
  • gitDiff: Mock git subprocess.

9.2 Integration-Tests

  • Mock-Relay (jsr:@welshman/relay-mock oder einfacher in-memory WebSocket-Mock).
  • Mock-Bunker: Test-Signer mit bekanntem Key.
  • Full-Flow: Sample-Post → signieren → publish gegen Mock-Relay → Event vom Mock abrufen → Inhalt vergleichen.

9.3 End-to-End (manuell, einmalig)

  • Auf Testnetz: Dedicated Test-Relay, Test-Pubkey, Test-Amber-Account.
  • Einen Sample-Post durchschieben, in Habla.news verifizieren.

9.4 Pre-Flight-Check als Test

deno task check wird auch von CI vor jedem Publish-Run ausgeführt. Failed Check → Pipeline bricht ab bevor irgendwas publiziert wird.


10. GitHub-Actions-Workflow

name: Publish Nostr Events

on:
  push:
    branches: [main]
    paths: ['content/posts/**']
  workflow_dispatch:
    inputs:
      force_all:
        type: boolean
        default: false

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # für git-diff

      - uses: denoland/setup-deno@v1
        with:
          deno-version: v2.x

      - name: Pre-Flight Check
        env:
          BUNKER_URL: ${{ secrets.BUNKER_URL }}
          AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
          BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
        run: deno task check

      - name: Publish
        env:
          BUNKER_URL: ${{ secrets.BUNKER_URL }}
          AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
          BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
          GITHUB_EVENT_BEFORE: ${{ github.event.before }}
        run: |
          if [ "${{ inputs.force_all }}" = "true" ]; then
            deno task publish --force-all
          else
            deno task publish
          fi          

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: publish-log
          path: ./logs/publish-*.json
          retention-days: 30

11. Beziehung zur SPA-Spec

Diese Publish-Pipeline und die SPA sind komplementär, aber voneinander entkoppelt:

Gemeinsame Verträge (normativ festgelegt in dieser Spec, Abschnitt 3):

  • kind:30023 Event-Schema — Publish produziert, SPA konsumiert.
  • kind:10002 Relay-Liste — Publish liest, SPA liest.
  • kind:10063 Blossom-Liste — Publish liest beim Upload, SPA liest für Bild-Fallback (zukünftig).
  • Alle Bild-URLs zeigen auf Blossom (hash-basiert) — einheitlich für alle Posts.

Unabhängige Entwicklung möglich:

  • Publish kann gegen Mock-Relay und Mock-Bunker entwickelt und getestet werden, ohne dass die SPA existiert.
  • SPA kann gegen manuell via nak o. ä. geschriebene Test-Events entwickelt werden, ohne dass die Publish-Pipeline existiert.

Abhängigkeit beim Cutover (SPA-Migrationsschritte C + D):

  • SPA kann erst live gehen, wenn die 18 Altposts als Events auf Relays liegen.
  • Schritt C der SPA-Migration bedeutet konkret: einmaliger lokaler Lauf deno task publish --force-all mit dem vollständigen Altbestand. Dieser Schritt liegt zeitlich vor Schritt D (dem tatsächlichen Cutover der Hauptdomain).
  • Voraussetzung ist, dass die Publish-Pipeline zu diesem Zeitpunkt vollständig implementiert und durch deno task check validiert ist.

Laufender Betrieb:

  • Neue Posts: Markdown committen, CI triggert Publish.
  • SPA zeigt neuen Post beim nächsten Seitenreload an (Relay-Abfrage ist live).
  • Zwei unabhängige Deploy-Zyklen (Publish bei Content-Änderung, SPA-Bundle bei Code-Änderung) ohne Kopplung.

12. Risiken und Mitigationen

Risiko Wahrsch. Auswirkung Mitigation
Amber offline während CI mittel hoch (Pipeline bricht ab) Clear Error; Nutzer retriggert manuell nachdem Handy verfügbar
Bunker-Secret leakt (Repo-Secret) niedrig mittel Secret rotierbar: in Amber Pairing löschen, neu pairen, Secret aktualisieren
kind:10002 versehentlich überschrieben (Relay-Liste leer) niedrig hoch check-Subcommand prüft vor jedem Run; Pipeline bricht bei leerer Liste ab
Relay-Zensur (Events werden gelöscht) niedrig mittel Multi-Relay-Push; zusätzlich bezahltes nostr.wine als Durability-Anker
Git-Diff übersieht Post (Rebase, Force-Push) niedrig niedrig --force-all als Fallback, dokumentiert
Blossom-Server löscht Bild mittel mittel Multi-Upload zu mehreren Servern sobald kind:10063 erweitert ist; nak blossom mirror als Ausgleich
Blossom-Server komplett weg, kein Mirror niedrig hoch eigener Blossom-Server auf Optiplex (Phase 5) als dauerhafter Anker
Privater Schlüssel-Recovery niedrig katastrophal Amber hat Backup-Mechanismus; nsec zusätzlich offline auf Hardware sichern

13. Evolutionspfad

Jetzt (Bunker-Stufe Amber, Phase 1 Blossom):

  • Handy mit Amber als einziger Signer, online während Publish-Runs.
  • Ein Blossom-Server in kind:10063 (primal).
  • Alle Bilder (auch die der 18 Altposts) auf Blossom.
  • Relay-Liste mit 4 Public-Relays.

Bunker-Stufe Optiplex (sobald Proxmox-Container läuft):

  • Self-hosted Bunker (z. B. nak bunker als Container), 24/7 online.
  • Connection-URL im Secret rotieren; Amber bleibt als Backup/manueller Signer.
  • Keine Code-Änderung in der Pipeline.

Phase 5 Blossom (eigener Blossom-Server auf Optiplex):

  • Zusätzlicher server-Tag in kind:10063 (https://blossom.joerg-lohrer.de).
  • Neue Posts werden automatisch auch dorthin hochgeladen (Multi-Upload).
  • Markdown-URL zeigt auf Primär-Server (= erster Eintrag in Liste). Soll das der eigene sein: Liste entsprechend ordnen.

Optional später:

  • deno task mirror — Subcommand, der bestehende Bilder (z. B. vom ersten Server) auch zu später hinzugefügten Servern spiegelt. Hilft bei Blossom-Server-Wechsel.

14. Success-Kriterien Phase 1

  • deno task check ohne Fehler.
  • 18 Altposts via einmaligem deno task publish --force-all publiziert.
  • Jeder Post in mindestens 2 Public-Relays abrufbar, in Habla.news korrekt gerendert.
  • Alle Bilder auf Blossom erreichbar (Hash-URL liefert die Datei).
  • Ein neuer Test-Post via CI auf main-Push publiziert in unter 90 Sekunden ab Push.
  • publish-log.json enthält aussagekräftige Einträge pro Post.
  • Pipeline läuft ohne nsec-Exposition in irgendeiner Umgebung.

Anhang: Begriffe

  • NIP-23: Nostr-Langform-Events, kind:30023, replaceable per d-Tag.
  • NIP-46: Nostr-Remote-Signer-Protokoll (Bunker). Signatur-Anfrage und -Antwort verschlüsselt über Relays.
  • NIP-65: Outbox-Model, kind:10002, definiert Read/Write-Relays pro Autor.
  • BUD-01: Blossom-Upload-Definition: PUT /upload mit Nostr-Auth-Header.
  • BUD-03: Blossom-User-Description-03, kind:10063 mit Server-Liste.
  • Amber: Android-App, die als NIP-46-Signer fungiert.
  • Replaceable Event: Ersetzt vorherige Events mit gleichem (pubkey, kind, d)-Tupel auf dem Relay.