Internal Documentation internal
TalkIDE internal documentation

Status: Accepted Datum: 2026-05-09 Oblast: Stopa B.7 / Publish flow / Versioning

Context

Stopa B.7 navazuje na B.5 / B.6

ADR-021 zavedl Ingress pro DEV (Preview) prostředí — {projectUuid}.talkide.app, trigger každý Mara commit, per-deploy DB. PROD (Published) cesta byla explicitně odložena do B.7 — K8sIngressProvisioner má hard guard env != "dev" → IllegalArgumentException, AppDeployer má stejnou validaci.

Stopa B.7 odblokuje PROD pipeline a přidá:

  1. State machine ProjectStatus s explicitním PUBLISHED stavem (rename z LIVE).
  2. Semver versioning per projekt — MAJOR.MINOR.PATCH zapsané přímo do user-app build.gradle.kts a package.json, sledované TalkIDE BE v nové tabulce versions.
  3. Change detection mechanismus — git post-commit hook v každém project working tree, který volá internal endpoint TalkIDE BE pro PATCH bump + state transition.
  4. Publish flow — uživatelská akce v UI, která validuje hosting kredit, triggerne PROD build (Kaniko, env=“prod”), PROD deploy a ingress provisioning, ohraničí verzi git tagem a překlopí projekt do PUBLISHED.

Versioning — proč semver, proč soubor

User-app má dva místa, kde verze žije:

  • TalkIDE BE — DB tabulka versions (audit trail, výběr verze pro publish, rollback)
  • User-app artefaktybuild.gradle.kts (Spring Boot bootJar), package.json (Vite build)

Verze v artefaktu musí korespondovat s verzí v DB — uživatel ji vidí ve své appce (např. footer “v0.1.4”), v Docker image tagu, v Kaniko buildu, v Spring Boot Actuator /actuator/info. Single source of truth = versions.semver, ale soubory musí být v sync, jinak by build artefakt neodpovídal DB stavu.

Initial verze 0.1.0 při Create Project — Mara při scaffoldingu zapíše hodnotu do obou souborů. PATCH bump je automatický (post-commit hook), MINOR/MAJOR je manuální user akce (odložené do jiné stopy, ADR jen předpokládá, že existuje).

Hosting kredit (⚠️ SUPERSEDED — viz DP-7 a be#142)

Pozn. 2026-05-23: Původní prepaid UserBudgetEntity.hostingCreditUsd mechanismus byl nahrazen postpaid billing (ADR-026 DP-7, be#142). EnforceHostingBudgetUseCase nově kontroluje hosting_billing_account.status (SUSPENDED = 402), ne prepaid balance. Sloupce hostingCreditUsd/hostingCreditInitialUsd odstraněny v be#142.

Existing pole UserBudgetEntity.hostingCreditUsd (z FUP / billing stopy) — separátní od aiCreditUsd. Publish je první akce, která hosting kredit konzumuje (DEV deploye jsou zatím bez billingu, jen AI kredit za Maru). Pre-deploy validation: fixed estimate $0.50 za Publish (placeholder pro alpha; reálné billing model přidá samostatná stopa).


Decision

1. State machine ProjectStatus — rename LIVE → PUBLISHED

Cílový enum:

DRAFT, BUILDING, PUBLISHED, UPDATED, PAUSED, ARCHIVED

Klíčové transitions pro Stopu B.7:

ZDoTrigger
DRAFTBUILDINGUser klikne Publish (první publish)
BUILDINGPUBLISHEDBuild + deploy + ingress success
BUILDINGDRAFTBuild/deploy fail, projekt nikdy nebyl PUBLISHED
BUILDINGUPDATEDBuild/deploy fail, projekt byl dříve PUBLISHED
PUBLISHEDUPDATEDNový commit (post-commit hook detekuje change)
UPDATEDBUILDINGUser klikne Publish (následný publish)

Stavy PAUSED a ARCHIVED zůstávají beze změn (řeší jiné stopy — pause/resume, archive/restore).

previous_status field při archivaci nyní podporuje PUBLISHED a UPDATED jako restorable hodnoty.

2. Versioning — semver, single source of truth = DB + sync do souborů

Formát: MAJOR.MINOR.PATCH (např. 0.1.4).

Initial verze: 0.1.0 při Create Project. Mara scaffolding šablona obsahuje version = "0.1.0" v build.gradle.kts a "version": "0.1.0" v package.json.

PATCH bump: každý commit (automatický, post-commit hook).

MINOR / MAJOR bump: manuální user akce (UI tlačítko, mimo scope tohoto ADR — pouze předpokládáme, že endpoint POST /api/v1/projects/{id}/version/bump existuje a deleguje na stejný flow jako PATCH bump).

Persistence:

  • TalkIDE BE DB:

    • projects.current_version (text, e.g. 0.1.4) — denormalizovaná cache pro fast read v project list (vyhne se JOINu na versions + MAX(created_at)). Authoritative value je v versions tabulce. Post-alpha review: zda nahradit derived column (PostgreSQL GENERATED ALWAYS AS) nebo materialized view, pokud cache drift způsobí bugy. Pro alpha acceptable — write path je single-threaded ProjectChangedUseCase, drift nemůže vzniknout v normal flow.

    • projects.prod_image_tag (text, e.g. registry.digitalocean.com/talkide/demo:0.1.3, nullable — null dokud není první Publish)

    • Nová tabulka versions:

      CREATE TABLE versions (
        id BIGSERIAL PRIMARY KEY,
        project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
        semver VARCHAR(32) NOT NULL,
        sha VARCHAR(40) NOT NULL,
        image_tag VARCHAR(255) NULL,
        created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
        published BOOLEAN NOT NULL DEFAULT false,
        published_at TIMESTAMPTZ NULL,
        build_id BIGINT NULL REFERENCES builds(id)
      );
      CREATE INDEX idx_versions_project_published ON versions(project_id, published);
      CREATE INDEX idx_versions_project_semver ON versions(project_id, semver);
      

      Poznámky ke schématu:

      • sha — SHA post-bump commitu (autor bot@talkide.app, message obsahuje [skip-bump]). NE sha původního functional commitu od user/Mara/Kai. Důvod: git tag v<semver> musí pointovat na sha, ze kterého git checkout v<semver> poskytne working tree s correct verzí v build.gradle.kts + package.json (version = "X.Y.Z"). Kaniko build z toho working tree pak vyrobí Docker image s tagem <slug>:X.Y.Z matchujícím semver v versions rowu. Pokud by se uložila sha A (functional commit před bumpem), checkout by dal working tree se starou verzí v souborech a build by vyrobil image s wrong semver tag — viz warning v sekci 10.
      • image_tag — container registry tag aktuálně publishnutého image (např. registry.digitalocean.com/talkide/demo:0.1.4). NULL pokud image byl GC-ován (= rebuild on demand z sha přes git tag při re-publish — viz R1).
      • Žádný UNIQUE (project_id, semver) constraint — multiple rows per (project_id, semver) jsou povoleny, protože rollback-publish scénář (R2) vytvoří nový row se stejným semver, jiným published_at. Uniqueness pro initial bump path se zajišťuje application-side v ProjectChangedUseCase. Místo unique constraintu je zaveden běžný index idx_versions_project_semver pro fast lookup.
  • User-app working tree:

    • <project-root>/build.gradle.kts line version = "X.Y.Z"
    • <project-root>/package.json field "version": "X.Y.Z"
  • Builds tabulka: nový sloupec env VARCHAR(8) NOT NULL DEFAULT 'dev' (dev / prod)

    • nový optional sloupec version_id BIGINT NULL REFERENCES versions(id).

3. Change detection — git post-commit hook

Každý project working tree dostane při scaffoldingu (Create Project) hook .git/hooks/post-commit (executable, owned by UID 1000+ kvůli NFS root squash z CLAUDE.md).

Hook script:

#!/usr/bin/env bash
# .git/hooks/post-commit
set -euo pipefail

SLUG="$(basename "$(git rev-parse --show-toplevel)")"
SHA="$(git rev-parse HEAD)"
MSG="$(git log -1 --pretty=%B)"
AUTHOR_EMAIL="$(git log -1 --pretty=%ae)"

# Skip bot bumps — viz sekce 4 (loop prevention)
if [[ "$AUTHOR_EMAIL" == "bot@talkide.app" ]]; then exit 0; fi
if [[ "$MSG" == *"[skip-bump]"* ]]; then exit 0; fi

curl -fsS -X POST http://127.0.0.1:9090/api/internal/project-changed \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg slug "$SLUG" --arg sha "$SHA" --arg msg "$MSG" \
        '{slug: $slug, sha: $sha, message: $msg}')" \
  || true   # nonblocking — pokud BE nedostupné, hook nesmí zablokovat commit

Edge case — initial commit z CreateProjectUseCase: initial scaffolding commit (sekce 9.1) má marker [skip-bump] v message i author bot@talkide.app. Hook filtruje obě podmínky (exit 0 při shodě), takže webhook /api/internal/project-changed neproběhne. Initial versions(semver=0.1.0, sha=<initial_commit_sha>, published=false) row je vytvořen synchronně přímo v CreateProjectUseCase (krok 5 sekce 9.1), nikoliv asynchronně přes webhook flow. Tím je zajištěno, že každý projekt má od první sekundy přesně jeden versions row pro 0.1.0.

Internal endpoint POST /api/internal/project-changed (localhost-only, stejný pattern jako /api/internal/deploy z B.4 — kein external auth, pouze 127.0.0.1 binding):

{ "slug": "demo", "sha": "abc123...", "message": "feat: add login form" }

Handler ProjectChangedUseCase provede atomickou transakci (jedno DB transaction

  • filesystem ops + následný git commit jako side effect).

Explicitní pořadí:

  1. (Pre-state) User / Mara / Kai vytvoří functional commit → sha A (např. feat: add login form). Working tree má starou verzi v build.gradle.kts
    • package.json (0.1.3). 0a. Post-commit hook (autor != bot@talkide.app, message neobsahuje [skip-bump]) spustí webhook POST /api/internal/project-changed se sha A.
  2. Resolve project podle slugu (404 pokud neexistuje).
  3. Bumpne PATCH v projects.current_version (0.1.30.1.4).
  4. Zapíše novou verzi do build.gradle.kts + package.json (in-place file edit, regex version\s*=\s*"[^"]*" resp. JSON parse).
  5. Pokud projects.status == PUBLISHED → set status := UPDATED.
  6. Provede follow-up commit → sha B (post-bump commit):
    git add build.gradle.kts package.json
    git -c user.email=bot@talkide.app -c user.name=TalkIDE \
        commit -m "chore: bump version to 0.1.4 [skip-bump]"
    SHA_B=$(git rev-parse HEAD)
    
    Tento commit obsahuje sha A jako parent + bumped soubory. Working tree po commitu má version = "0.1.4".
  7. Až teď vytvoří záznam v versions: (project_id, semver=0.1.4, sha=SHA_B, published=false). Pozor: ukládá se sha B (post-bump commit), NE sha A — viz sekce 2 pozn. ke schématu a sekce 10 warning.
  8. Vyšle SSE event version.bumped (project_id, new_version, status) do TalkIDE FE.

Atomicita: kroky 1-4 jsou v jedné JPA transakci, ta se však odloží commit dokud neproběhne krok 5 (git commit) — versions row v kroku 6 vyžaduje sha B z kroku 5, takže pořadí je: BEGIN tx → kroky 1-4 → flush → krok 5 (git commit) → krok 6 (INSERT versions) → COMMIT tx → krok 7 (SSE).

Pokud git commit (krok 5) selže, JPA transakce se rollbackuje (žádný versions row, žádný state transition); soubory zůstanou bumped na FS, ale bez DB záznamu — recovery script přepíše soubory zpět z projects.current_version. Řízené trade-off — viz Edge cases sekce 10.

4. Bump loop prevention — dual marker

Bump commit (krok 6) sám spustí post-commit hook. Bez prevence → nekonečná smyčka.

Dva nezávislé markers (defense in depth):

  • Author email: bot@talkide.app — git env user.email při bump commitu. Hook filtruje git log -1 --pretty=%ae před voláním webhooku.
  • Message marker: [skip-bump] na konci commit message. Hook filtruje git log -1 --pretty=%B.

Buďto-anebo logika — pokud user (omylem) commitne z konfigurace s bot@talkide.app jako own email, message marker jako fallback. Pokud pluje [skip-bump] v user message (omylem), author marker jako fallback. Současný útok obou markerů by vyžadoval, aby user spoofoval author email a přidal marker do své message — exotické a benigní (nedojde k bumpu).

5. Manuální commit z UI

User v TalkIDE editoru píše do working copy (NFS mount). Žádný auto-commit při save — naopak explicitní user gesto.

POST /api/v1/projects/{id}/commit          (autentizovaný, user-scoped)
{ "message": "fix: navbar alignment" }

CommitProjectUseCase:

  1. Resolve project + ownership check.
  2. ProcessBuilder (ne JGit — konzistence s Mara/Kai shellovými skripty):
    git -C <project-path> add .
    git -C <project-path> -c user.email=<user.email> -c user.name=<user.fullName> \
        commit -m "<message>"
    
  3. Post-commit hook automaticky triggerne /api/internal/project-changed → bump flow. Bump commit vytvořený v kroku 5 sekce 3 nese oba markery (bot@talkide.app author
    • [skip-bump] message) → hook ho ignoruje, žádný re-trigger, žádná smyčka. Stejný dual-marker mechanismus jako v CreateProjectUseCase initial commitu (sekce 9.1) — konzistentní napříč všemi BE-driven commity.
  4. Response { "sha": "abc...", "version": "0.1.4" } (BE počká na hook completion; timeout 5s, pokud hook neproběhne, vrátí degraded response bez version).

6. Mara/Kai agent commit — beze změny

Agent dnes commituje přes plugin shell skript (Anthropic Agent SDK tool-call). Stejný post-commit hook je zachycen — unified flow. Žádný speciální endpoint, žádné větvení v ProjectChangedUseCase. Author email agenta je mara@talkide.app resp. kai@talkide.app (NE bot@talkide.app!) — agent je první-class autor, jeho commity musí spustit bump.

7. Publish flow — POST /api/v1/projects/{slug}/publish

POST /api/v1/projects/{slug}/publish        (autentizovaný, user-scoped)
{ "versionId": 42 }

versionId = volba uživatele z dropdownu (default = nejnovější published=false záznam pro daný projekt). FE vyplní default automaticky; user může vybrat starší verzi (rollback-jako-publish).

PublishService.publishToProduction(slug, versionId):

  1. Validace ownership a stavu:
    • Slug → projekt; user ID musí matchovat projects.owner_id.
    • versions.project_id musí matchovat resolved projekt (zabránit cross-project leak).
    • projects.status ∈ {DRAFT, PUBLISHED, UPDATED}ARCHIVED/PAUSED/BUILDING vrátí 409 Conflict s code: CONFLICT_PROJECT_STATUS.
  2. Hosting billing enforcement (⚠️ SUPERSEDED — viz DP-7 a be#142) — EnforceHostingBudgetUseCase:
    • Analog EnforceBudgetUseCase (existing, na aiCreditUsd).
    • Estimovaná cena = fixed $0.50 per Publish (placeholder).
    • Pokud userBudget.hostingCreditUsd < 0.50402 Payment Required
    • Aktuálně (po be#142): kontroluje hosting_billing_account.status == SUSPENDED402 Payment Required s code: HOSTING_BUDGET_EXCEEDED. Postpaid, žádný credit balance.
  3. State transition: status := BUILDING, persist (umožní FE/SSE okamžité feedback).
  4. PROD buildBuildService.build(projectId, env="prod", versionId):
    • Kaniko build s tagem <slug>:<semver> (např. demo:0.1.4).
    • builds.env := 'prod', builds.version_id := versionId.
    • Failure → status zpět na UPDATED (resp. DRAFT pokud prod_image_tag IS NULL), versions.published zůstává false, error v SSE eventu publish.failed.
  5. PROD deployDeployAppUseCase.deploy(projectId, env="prod", imageTag, versionId):
    • AppDeployer.deployApp(env="prod") — odblokovat hardcoded guard z B.4 (ADR-017 sekce 6) na env ∈ {"dev", "prod"}.
    • IngressProvisioner.provisionIngress(env="prod") — odblokovat guard z B.5 (ADR-021 sekce 6); pro prod host = {slug}.talkide.app místo {projectUuid}.talkide.app.
    • Failure handling stejně jako u buildu.
  6. Git tag: po úspěšném deployi ProcessBuilder:
    git -C <project-path> tag v0.1.4 <versions.sha>
    
    <versions.sha> = sha B (post-bump commit, autor bot@talkide.app) — viz sekce 2 pozn. ke schématu. Tag tedy pointuje na commit, ze kterého git checkout v0.1.4 poskytne working tree s version = "0.1.4" v build.gradle.kts + package.json, matchující semver tagu i image tagu <slug>:0.1.4. Tag je lokální (žádný push; ADR-013 — žádný external git remote).
  7. Finalize DB:
    • projects.status := PUBLISHED
    • projects.prod_image_tag := <imageTag>
    • versions.published := true, versions.published_at := now()
    • Předchozí published verze stejného projektu: published := false zůstává? — NE, ponecháme historicky published=true pro audit. UI bere “active published” jako WHERE published=true ORDER BY published_at DESC LIMIT 1. Viz Open question.
  8. SSE event publish.succeeded (project_id, version, prod_url).

Response (synchronní 202 Accepted; build/deploy běží asynchronně):

{
  "publishId": "abc-123",
  "status": "BUILDING",
  "version": "0.1.4",
  "estimatedCostUsd": 0.50
}

8. Architektura — sequence diagrams

Commit flow (user nebo agent):

sequenceDiagram
    actor User
    participant FE
    participant BE
    participant Hook as post-commit hook
    participant DB
    participant FS as Project working tree

    User->>FE: Click "Commit"
    FE->>+BE: POST /api/v1/projects/{id}/commit
    BE->>FS: git add . && git commit (user identity) -> sha A
    FS-->>Hook: post-commit fires
    Hook->>BE: POST /api/internal/project-changed (sha A)
    BE->>BE: bump PATCH (0.1.3 -> 0.1.4)
    BE->>FS: write build.gradle.kts + package.json (version = 0.1.4)
    BE->>FS: git commit "chore: bump version to 0.1.4 [skip-bump]" (bot@talkide.app) -> sha B
    BE->>FS: git rev-parse HEAD -> sha B
    BE->>DB: INSERT versions (semver=0.1.4, sha=sha B, published=false)
    BE->>DB: UPDATE projects.current_version := 0.1.4
    alt status == PUBLISHED
        BE->>DB: UPDATE projects.status := UPDATED
    end
    BE-->>Hook: 200 OK
    BE->>FE: SSE version.bumped
    BE-->>-FE: 200 { sha, version }
    FE-->>User: Show new version in UI

Publish flow:

sequenceDiagram
    actor User
    participant FE
    participant BE as PublishService
    participant Budget as EnforceHostingBudget
    participant Build as BuildService (Kaniko)
    participant Deploy as DeployAppUseCase
    participant FS as Project working tree
    participant DB

    User->>FE: Click "Publish" (selects version)
    FE->>+BE: POST /api/v1/projects/{slug}/publish { versionId }
    BE->>BE: validate ownership + status
    BE->>+Budget: check hosting billing status (postpaid — be#142)
    alt insufficient credit
        Budget-->>BE: 402 HOSTING_BUDGET_EXCEEDED
        BE-->>FE: 402 Payment Required
    end
    Budget-->>-BE: ok
    BE->>DB: status := BUILDING
    BE-->>FE: 202 Accepted
    BE->>+Build: build(env="prod", versionId)
    Build-->>-BE: imageTag = demo:0.1.4
    BE->>+Deploy: deploy(env="prod", imageTag)
    Deploy->>Deploy: AppDeployer (env=prod) + IngressProvisioner ({slug}.talkide.app)
    Deploy-->>-BE: deployed
    BE->>FS: git tag v0.1.4
    BE->>DB: status := PUBLISHED, prod_image_tag, versions.published := true
    BE->>FE: SSE publish.succeeded
    FE-->>User: Show "Published at https://demo.talkide.app"

9. Změny v existujících komponentech

KomponentaADR refZměna
AppDeployer (interface)ADR-017 §6Odblokovat guard env != "dev"; nyní env ∈ {"dev", "prod"}; pro prod použít {slug}.talkide.app v service jméně (zůstává app-{slug}-{env} pattern)
K8sAppDeployerADR-017PROD deployment jméno app-{slug}-prod, label env=prod
IngressProvisioner (interface)ADR-021 §6Odblokovat guard env != "dev"; pro env == "prod" host = {slug}.talkide.app
K8sIngressProvisionerADR-021 §7Ingress jméno app-{slug}-prod, host {slug}.talkide.app
BuildServiceADR-019Nový param env: String; image tag pro prod = <slug>:<semver> (ne sha)
builds tabulkaADR-019Nový sloupec env VARCHAR(8) NOT NULL DEFAULT 'dev', version_id BIGINT NULL
projects tabulkaADR-013 / ADR-021Nové sloupce current_version VARCHAR(32) NOT NULL DEFAULT '0.1.0', prod_image_tag TEXT NULL
CreateProjectUseCaseRozšíření o version inicializaci — viz pod-bod 9.1 níže
Reserved slug validator (talkide-be#38)Beze změny — slug už je validován; PROD URL používá ten samý slug

9.1 CreateProjectUseCase — rozšíření o version inicializaci

Motivace: projekt je scaffoldován BE side v CreateProjectUseCase (ne Mara šablonou). Initial verze 0.1.0 je deterministická a musí proběhnout vždy stejně — proto je integrována přímo do CreateProjectUseCase jako součást atomické scaffolding transakce. Žádný separátní use-case, žádný post-scaffolding follow-up commit.

Logika (jedna DB transakce + 1 git commit, vše atomicky):

  1. (existing) Persist ProjectEntity se status=DRAFT + current_version=0.1.0.
  2. (existing) LocalGitProvisioner.init() — git init prázdný repo + chmod +x .git/hooks/post-commit instalace post-commit hooku (UID 1000+ kvůli NFS root squash z CLAUDE.md).
  3. (existing) Vygenerování template souborů (build.gradle.kts, package.json, src/, …) z BE templates. BE templates obsahují version = "0.1.0" v build.gradle.kts a "version": "0.1.0" v package.json (musí být v BE template assets — verze není patchovaná, je přímo v šablonách).
  4. (existing) Initial commit přes commit-changes.sh:
    • autor = bot@talkide.app (NE mara@talkide.app!)
    • message = chore: initialize project [skip-bump]
    • důvod: post-commit hook tento commit ignoruje díky oběma markerům současně (bot author + [skip-bump]) — žádný bump loop, žádný duplicate versions row vytvořený asynchronně přes webhook.
  5. NEW: synchronně v rámci stejné DB transakce:
    INSERT INTO versions (project_id, semver, sha, published, created_at)
    VALUES (?, '0.1.0', <initial_commit_sha>, false, now())
    
    <initial_commit_sha> = SHA initial commitu z kroku 4 (autor bot@talkide.app). Working tree v té sha už obsahuje version = "0.1.0" v souborech (krok 3), takže invariant “tag/sha → working tree má matching semver v souborech” platí od první sekundy života projektu.

Loop prevention: [skip-bump] marker + bot@talkide.app author oba aktivní — post-commit hook initial commit přeskočí (viz sekce 4). Žádný webhook call, žádné duplicitní vytvoření versions(0.1.0) rowu.

Failure mode: scaffolding je atomická — pokud cokoliv selže (template render, git init, git commit, INSERT versions), celá transakce se rollbackuje, FS cleanup smaže working tree. Žádný partial state.

10. Edge cases

  1. Race při paralelních commitech — user commit + Mara commit ve stejnou milisekundu. Git serializuje sám (file lock na .git/index); post-commit hooky běží sekvenčně na FS úrovni, ale dva hook procesy mohou paralelně volat webhook /api/internal/project-changed.

    Mechanism (application-side, ne DB constraint): ProjectChangedUseCase běží v transakci s pessimistic lock na project row:

    SELECT * FROM projects WHERE id = ? FOR UPDATE
    
    • První concurrent webhook získá lock, přečte current_version=0.1.3, bumpe na 0.1.4, zapíše soubory + bump commit + INSERT versions, COMMIT tx → release lock.
    • Druhý concurrent webhook čeká na lock; po release přečte current_version=0.1.4 (už updatovaný first transaction), bumpe na 0.1.5, zapíše soubory + bump commit
      • INSERT versions. Žádný race window mezi čtením a zápisem, žádné duplikáty.

    Proč ne unique constraint: R2 (sekce 11) explicitně odmítlo UNIQUE (project_id, semver) — schéma povoluje multiple rows per (project_id, semver) pro audit trail rollback-jako-publish scénářů. Race condition se proto řeší application-side přes pessimistic lock, ne DB-side přes unique constraint.

    Trade-off: serializace bumpu per-project. Akceptovatelné pro typickou frekvenci commitů (sekundy mezi commity); lock je drženy jen po dobu jedné transakce (~100ms vč. git commitu).

    Failure case: pokud transakce selže po kroku 5 (git bump commit už proběhl), working tree je v partial state — soubory + git commit hotové, ale DB rollback (bez versions rowu, bez UPDATE projects.current_version). Recovery: re-trigger ProjectChangedUseCase ručně, nebo evidovat jako alpha-acceptance risk (viz edge case #2 ohledně sync atomicity + saga/outbox pattern jako pre-stable blocker).

  2. Bump commit selže (krok 5 sekce 3) — git commit nelze provést (FS error, permission, repo lock). Pořadí v sekci 3 je nyní: BEGIN tx → kroky 1-4 (DB + FS edit) → flush → krok 5 (git commit) → krok 6 (INSERT versions) → COMMIT. Pokud krok 5 selže, JPA transakce se rollbackuje (žádný versions row, žádný state transition), ale soubory na FS zůstanou bumped (kroky 3 jsou filesystem side effect mimo JPA tx). Recovery script: detekuje “FS bumped but DB not bumped” (build.gradle.kts version != projects.current_version) → přepíše soubory zpět z DB, případně log + manual ops.

    Pro alpha akceptováno s logováním degraded states. Pre-stable blocker: zavést saga / outbox pattern v ProjectChangedUseCase pro recovery z partial failure (FS bumped ale DB rollback, nebo soubory sepsány ale bump commit failuje, nebo git commit OK ale DB INSERT failuje). Pattern: persist outbox row “pending FS+git sync” v té samé transakci, separate worker reconcileuje. Evidovat jako talkide-be issue před public alpha launch.

  3. versions.sha MUSÍ být sha post-bump commitu (autor bot@talkide.app, message chore: bump version to X.Y.Z [skip-bump]), NE sha původního functional commitu (sha A) od user/Mara/Kai. Implementační záminka pro chybu: webhook /api/internal/project-changed dostává v payloadu sha = HEAD před bumpem (= sha A); junior implementátor ji může omylem uložit do versions.sha místo aby si po git commit (krok 5 sekce 3) zavolal git rev-parse HEAD pro získání sha B.

    Důsledek chyby: git checkout v<semver> checkoutne working tree se starou verzí v build.gradle.kts + package.json (před bumpem). Kaniko build z toho working tree vyrobí Docker image s wrong semver v version line — image pushnutý jako <slug>:0.1.4 má interně version = "0.1.3". Spring Boot Actuator /info vrátí 0.1.3, footer “v0.1.3”, uživatel vidí špatnou verzi. Re-publish z git tagu bude reprodukovat tu samou chybu.

    Implementační test (povinný unit/integration test pro ProjectChangedUseCase):

    git -C <project-path> show v<semver>:build.gradle.kts | grep '^version'
    # Musí vrátit: version = "<semver>"
    

    Test musí asertit, že hodnota matchuje versions.semver pro daný versions.sha. Stejně pro package.json (grep '"version"').

  4. Publish selže v půlce — pokud build OK, deploy fail: status zpět na UPDATED, versions.published=false, image v registry zůstane (cleanup v separate stopě). User vidí error v SSE; může retry-publish (idempotent přes Build+Deploy idempotenci z B.4/B.5).

  5. Concurrent Publish — dva user clicky během 100ms. projects.status := BUILDING je v transakci s předchozí podmínkou status IN (DRAFT, PUBLISHED, UPDATED) — second transaction vrátí 409 CONFLICT_PROJECT_STATUS (status už je BUILDING).

  6. Rollback-jako-publish — user vybere starou verzi 0.1.2 (současná 0.1.5, published 0.1.4). Flow je identický — build demo:0.1.2, deploy, git tag v0.1.2 (pokud neexistuje). current_version se NEMĚNÍ na 0.1.2 (zůstává 0.1.5 — to je working tree). prod_image_tag ukazuje na 0.1.2. UI musí jasně rozlišit “working version” (current_version) vs “live version” (prod_image_tag → semver).

  7. Bot author email kolize — pokud user nastaví git config user.email = bot@talkide.app manuálně přes editor terminál, jeho commity se nebudou bumpnout. Akceptujeme jako user-error (alpha; produkce: zamknout git config přes editor UI nebo gitconfig override).

11. Resolutions (PM-schválené odpovědi na open questions)

  • R1 (Q1) — PROD images: jen latest-build + currently-published (varianta B). Container registry obsahuje max 2 PROD images per slug: <slug>:latest-build

    • <slug>:<currently-published-semver>. Při novém Publish: build → push → deploy → smazat předchozí published tag. Rollback (publish starší verze) = rebuild z git tagu přes Kaniko (~3–5 min, build cache zmírňuje latenci). Předpoklad: Kaniko build je deterministic (stejný source sha → stejný image, modulo timestamps v layer metadata — pro funkční ekvivalenci OK). Důsledek pro DB: versions.image_tag (sloupec definovaný ve schématu sekce 2, nullable) je nenulový pouze pro currently-published a latest-build verzi — ostatní verze v versions tabulce mají image_tag = null (= “image už není v registry, rebuild on demand z versions.sha”). Žádná separátní GC logika — registry je inherently bounded na 2 tagy per project.
  • R2 (Q2) — published=true jako audit historie (multiple rows). Schéma povoluje multiple rows s published=true per project. Latest published = SELECT * FROM versions WHERE project_id=? AND published=true ORDER BY published_at DESC LIMIT 1. Rollback-publish vytvoří nový versions row s odkazem na starší semver (published=true, published_at=now(), sha = původní sha té verze) — NE flip flag na existujícím rowu. Žádný atomic single-published flag, žádná deduplikace na insert. Audit query “kolikrát byla 0.1.2 publishnutá” = COUNT(*) WHERE semver='0.1.2' AND published=true.

    Pozn.: schéma sekce 2 reflektuje toto rozhodnutí — UNIQUE (project_id, semver) není v DDL přítomen; uniqueness pro initial bump path se zajišťuje application-side v ProjectChangedUseCase. Pro fast lookup je k dispozici non-unique index idx_versions_project_semver.

  • R3 (Q3) — Git tag idempotence: first-publish creates, re-publish references. Sémantika: tag v<semver> = perpetual marker “tato verze byla někdy publishnutá”.

    • První publish dané verze: git tag --list v<semver> vrátí prázdno → git tag v<semver> <sha> (vytvoří).
    • Re-publish stejné verze (rollback scenario): git tag --list v<semver> vrátí tag → skip create, pokračovat s git checkout v<semver> → rebuild → push → deploy.
    • Žádný nový tag, žádný conflict, žádný reject při re-publish.
    • Implementační detail v PublishService step 6: pre-check git tag --list → if non-empty, skip git tag invocation. Žádný --force flag (chrání proti accidental tag move).
  • R4 (Q4) — Cross-environment DB migrace + rollback: out-of-scope B.7 (alpha-acceptance).

    • Pro alpha (B.7): Liquibase auto-run při PROD deploy (default Spring Boot chování). Migration failure = pod stuck v CrashLoopBackOff, log + alarm + manual ops. Žádná automatická rollback strategie.
    • Blocker pre-stable (před public alpha launch): rollback strategie pro failed PROD migration — možnosti: Liquibase rollback scripts (pokud user píše rollback bloky), snapshot-restore z DO Managed Postgres backup, blue-green DB switch. Evidovat jako separátní talkide-infra issue (ne talkide-be — týká se DB platformy a backup infrastruktury).
    • Pro user-app data ztráty (např. drop column = ireversible loss): backup-before-publish (snapshot user-app DB do Spaces platform/db-backups/<slug>/<semver>.sql.gz před spuštěním Liquibase) — také evidovat jako post-MVP, mimo scope B.7.

Consequences

Pozitiva

  • Jednotný change-detection mechanism — git post-commit hook funguje pro user, Maru i Kai; žádné duplicate code paths.
  • Semver v souborech = visible v deployed app — Spring Boot Actuator /info, Vite-built FE bundle hash, footer “v0.1.4”; uživatel okamžitě vidí, kterou verzi má produkce.
  • Audit trail v versions — každý commit = jeden record; published flag + timestamp dává jasnou historii deployů.
  • Atomic state machine — DRAFT/UPDATED/PUBLISHED transitions jsou explicitní, testovatelné, žádný “mezistav” mimo BUILDING.
  • Hosting budget enforcement — symmetrie s AI budget; one-page billing UI.
  • Rollback as Publish — žádný separate rollback flow; výběr starší verze v dropdownu je první-class operace.
  • PROD i DEV paralelně — DEV preview URL zůstává funkční i po Publish; user může testovat WIP zatímco PROD běží stabilně.

Rizika a omezení

  1. DB↔FS↔Git tří-stranný sync není atomický. Selhání mezi krokem 5 a 6 ProjectChangedUseCase zanechá inkonzistenci (DB má novou verzi, git nemá bump commit). Recovery script řeší, ale operační overhead v alpha. Před public alpha: buďto WAL-style append-only log + replay nebo full saga pattern (mimo scope B.7).

  2. Post-commit hook má side-effect na ne-deterministický síťový volání. Pokud BE crashne během commit operace, hook selže silently (|| true); user nevidí varování. Acceptable v alpha (logy zachytí), produkce: SSE health check pre-commit.

  3. Fixed estimate $0.50 per Publish je placeholder. Reálný billing závisí na build-time + pod-runtime + bandwidth — vše post-MVP. Risk: user vyčerpá kredit, protože estimate undershootne real cost. Mitigation: konzervativní estimate (= max real cost first 100 publishes, ne mean).

  4. Bump commit zaplaví git history. Každý user commit = +1 bot commit. After 100 commits: 200 entries v git log. Mitigation: UI filtruje [skip-bump] v conversation timeline; raw git log zůstává pro debugging.

  5. No PROD-side migrations rollback. Pokud PROD deploy selže během Liquibase migrace, DB je v half-migrated stavu. Pod stuck v CrashLoopBackOff. Manual ops only v alpha (Open question Q4).

  6. Tagging vyžaduje sha v versions. Sha je zapsán po bump commitu = sha post-bump commitu (autor bot@talkide.app, sha B). Když publishujeme 0.1.4, tagujeme versions.sha, což je bot bump commit obsahující bumped build.gradle.kts

    • package.json. Tag tedy pointuje na přesný commit, ze kterého byl buildnut imagegit checkout v0.1.4 poskytne working tree s version = "0.1.4", reprodukovatelný build → image s tagem <slug>:0.1.4. Žádný off-by-one mezi tagem, semverem v souborech a image tagem. Detail viz sekce 10 bod 3.

Migrace existujících projektů

Liquibase changeset:

  1. Přidá sloupce projects.current_version (default '0.1.0'), projects.prod_image_tag (null).
  2. Přidá tabulku versions (prázdná).
  3. Přidá sloupce builds.env (default 'dev'), builds.version_id (null).
  4. UPDATE projects SET status='PUBLISHED' WHERE status='LIVE' (rename — pokud byly data v DEV/staging; v alpha pre-launch zatím žádné LIVE projekty).

One-shot script (out-of-band, manuálně po deploy migrace): pro každý existing project working tree zapsat 0.1.0 do souborů + nainstalovat .git/hooks/post-commit + vytvořit initial versions row se sha=current HEAD.


Alternatives Considered

Auto-commit při file save (zamítnuto)

Editor by automaticky commitoval po každém save (debounce 5s). Výhoda: žádné explicitní “Commit” tlačítko, lepší UX pro non-git users. Nevýhody:

  • Šum v git history — desítky commitů per minute na živé editaci
  • Nemožnost revize zpráv před commitem
  • Rozbije Mara workflow (Mara čte aktuální stav working tree, auto-commity způsobí race)

Zamítnuto. Manuální commit z UI je explicitnější + symetrický s git workflowem developerů.

Single source of truth = soubor (zamítnuto)

Verze žije jen v build.gradle.kts / package.json; DB tabulka versions zrušena. Výhoda: jeden zdroj pravdy. Nevýhody:

  • Audit trail “kdy bylo co publishnuto” vyžaduje parsovat git tags (drahé pro UI)
  • Cross-project selection (“ukaž mi všechny projekty published v posledním týdnu”) vyžaduje walk-all-tags
  • Build pipeline musí parsovat soubor pro versionId — neflexibilní (Kotlin DSL evaluation v BE = přiměřeně náročné)

Zamítnuto. Hybrid (DB master, soubor sync) je standard u dev-platforms (Heroku, Vercel).

Sequenční integer verze místo semver (zamítnuto)

Verze = 1, 2, 3, ... jako v původním version-flow.md. Výhoda: jednodušší. Nevýhody:

  • Nesouvisí s user-app artefaktem (Spring/Node očekávají semver)
  • Bez MAJOR/MINOR/PATCH semantiky — uživatel nepozná breaking change
  • Industry-standard je semver pro user-facing apps

Zamítnuto. Semver je free komunikace pro power-users + 1:1 mapping na artefakty.

Daemon proces místo post-commit hook (zamítnuto)

BE periodicky polluje project working trees na git log změny. Výhoda: žádný hook v project repo (čistší). Nevýhody:

  • Latence (poll interval = sekundy/minuty)
  • Wasted compute (poll i když žádné změny)
  • Stejný atomicity problem (just shifted in time)

Zamítnuto. Hook je event-driven, real-time, kompatibilní s git semantics.

Single endpoint pro Publish + Commit (zamítnuto)

POST /api/v1/projects/{slug}/publish by interně provedl commit + bump + build + deploy. Výhoda: jeden user click. Nevýhody:

  • User chce publishnout verzi z minulosti, ne current working tree
  • Commit + Publish jsou semanticky odlišné akce (prvně review, pak release)
  • Mara/Kai potřebují commit bez publishe — nelze sloučit

Zamítnuto. Separate Commit (bump) + Publish (release) je správná dekompozice.


Implementation Notes

  • Issue: talkide-be#24 — https://gitlab.com/talkide/talkide-be/-/work_items/24
  • Sub-issues (placeholder, doplnit při breakdown):
    • State machine LIVE → PUBLISHED rename (DB migration + enum + tests)
    • versions tabulka + repository + DTO
    • ProjectChangedUseCase + /api/internal/project-changed endpoint
    • Post-commit hook scaffolding v CreateProjectUseCase
    • CommitProjectUseCase + POST /api/v1/projects/{id}/commit endpoint
    • EnforceHostingBudgetUseCase
    • PublishService + POST /api/v1/projects/{slug}/publish endpoint
    • AppDeployer / IngressProvisioner env guard removal + PROD logic
    • BuildService env param + <slug>:<semver> tagging
    • SSE event types version.bumped, publish.succeeded, publish.failed
  • Reference ADRs:
    • ADR-013 — Git versioning strategy (žádný external remote; lokální tags OK)
    • ADR-017AppDeployer (B.4) — env guard, naming convention
    • ADR-019BuildService (B.0.2) — Kaniko, image tagging
    • ADR-021IngressProvisioner (B.5) — env guard, host pattern
  • Reserved slug validator (talkide-be#38) je závislost — slug se objeví v PROD URL {slug}.talkide.app, validator musí být hotov před prvním Publish.
  • Bump commit message konvence: chore: bump version to X.Y.Z [skip-bump] — fix marker [skip-bump] na konci, parser case-sensitive.
  • Hook permissions: chmod +x .git/hooks/post-commit při scaffoldingu; UID 1000+ z důvodu NFS root squash (CLAUDE.md).
  • Mara/Kai commit author:
    • Mara: mara@talkide.app
    • Kai: kai@talkide.app
    • Bot bump: bot@talkide.app (skip)
  • Resolutions (sekce 11) — všechny 4 původní open questions vyřešeny PM-em (R1–R4); implementační detaily přímo v sekci. Pre-stable blockery (sync saga, DB migration rollback, backup-before-publish) evidovat jako separátní issues mimo B.7.

Errata

(Bude doplněno při opravách nebo upřesněních.)


Was this page helpful?

Thanks for the feedback.