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á:
- State machine
ProjectStatuss explicitním PUBLISHED stavem (rename zLIVE). - Semver versioning per projekt —
MAJOR.MINOR.PATCHzapsané přímo do user-appbuild.gradle.ktsapackage.json, sledované TalkIDE BE v nové tabulceversions. - 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.
- 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 artefakty —
build.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.hostingCreditUsdmechanismus byl nahrazen postpaid billing (ADR-026 DP-7, be#142).EnforceHostingBudgetUseCasenově kontrolujehosting_billing_account.status(SUSPENDED = 402), ne prepaid balance. SloupcehostingCreditUsd/hostingCreditInitialUsdodstraně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:
| Z | Do | Trigger |
|---|---|---|
DRAFT | BUILDING | User klikne Publish (první publish) |
BUILDING | PUBLISHED | Build + deploy + ingress success |
BUILDING | DRAFT | Build/deploy fail, projekt nikdy nebyl PUBLISHED |
BUILDING | UPDATED | Build/deploy fail, projekt byl dříve PUBLISHED |
PUBLISHED | UPDATED | Nový commit (post-commit hook detekuje change) |
UPDATED | BUILDING | User 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 naversions+MAX(created_at)). Authoritative value je vversionstabulce. Post-alpha review: zda nahradit derived column (PostgreSQLGENERATED ALWAYS AS) nebo materialized view, pokud cache drift způsobí bugy. Pro alpha acceptable — write path je single-threadedProjectChangedUseCase, 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 (autorbot@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éhogit checkout v<semver>poskytne working tree s correct verzí vbuild.gradle.kts+package.json(version = "X.Y.Z"). Kaniko build z toho working tree pak vyrobí Docker image s tagem<slug>:X.Y.Zmatchujícím semver vversionsrowu. 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 zshapř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ýmpublished_at. Uniqueness pro initial bump path se zajišťuje application-side vProjectChangedUseCase. Místo unique constraintu je zaveden běžný indexidx_versions_project_semverpro fast lookup.
-
-
User-app working tree:
<project-root>/build.gradle.ktslineversion = "X.Y.Z"<project-root>/package.jsonfield"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).
- nový optional sloupec
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í:
- (Pre-state) User / Mara / Kai vytvoří functional commit → sha A (např.
feat: add login form). Working tree má starou verzi vbuild.gradle.ktspackage.json(0.1.3). 0a. Post-commit hook (autor !=bot@talkide.app, message neobsahuje[skip-bump]) spustí webhookPOST /api/internal/project-changedse sha A.
- Resolve project podle slugu (404 pokud neexistuje).
- Bumpne PATCH v
projects.current_version(0.1.3→0.1.4). - Zapíše novou verzi do
build.gradle.kts+package.json(in-place file edit, regexversion\s*=\s*"[^"]*"resp. JSON parse). - Pokud
projects.status == PUBLISHED→ setstatus := UPDATED. - Provede follow-up commit → sha B (post-bump commit):
Tento commit obsahuje sha A jako parent + bumped soubory. Working tree po commitu má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)version = "0.1.4". - 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. - 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 envuser.emailpři bump commitu. Hook filtrujegit log -1 --pretty=%aepřed voláním webhooku. - Message marker:
[skip-bump]na konci commit message. Hook filtrujegit 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:
- Resolve project + ownership check.
- 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>" - 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.appauthor[skip-bump]message) → hook ho ignoruje, žádný re-trigger, žádná smyčka. Stejný dual-marker mechanismus jako vCreateProjectUseCaseinitial commitu (sekce 9.1) — konzistentní napříč všemi BE-driven commity.
- Response
{ "sha": "abc...", "version": "0.1.4" }(BE počká na hook completion; timeout 5s, pokud hook neproběhne, vrátí degraded response bezversion).
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):
- Validace ownership a stavu:
- Slug → projekt; user ID musí matchovat
projects.owner_id. versions.project_idmusí matchovat resolved projekt (zabránit cross-project leak).projects.status ∈ {DRAFT, PUBLISHED, UPDATED}—ARCHIVED/PAUSED/BUILDINGvrátí409 Conflictscode: CONFLICT_PROJECT_STATUS.
- Slug → projekt; user ID musí matchovat
- Hosting billing enforcement (⚠️ SUPERSEDED — viz DP-7 a be#142) —
EnforceHostingBudgetUseCase:AnalogEnforceBudgetUseCase(existing, naaiCreditUsd).Estimovaná cena = fixed$0.50per Publish (placeholder).PokuduserBudget.hostingCreditUsd < 0.50→402 Payment Required- Aktuálně (po be#142): kontroluje
hosting_billing_account.status == SUSPENDED→402 Payment Requiredscode: HOSTING_BUDGET_EXCEEDED. Postpaid, žádný credit balance.
- State transition:
status := BUILDING, persist (umožní FE/SSE okamžité feedback). - PROD build —
BuildService.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.DRAFTpokudprod_image_tag IS NULL),versions.publishedzůstáváfalse, error v SSE eventupublish.failed.
- Kaniko build s tagem
- PROD deploy —
DeployAppUseCase.deploy(projectId, env="prod", imageTag, versionId):AppDeployer.deployApp(env="prod")— odblokovat hardcoded guard z B.4 (ADR-017 sekce 6) naenv ∈ {"dev", "prod"}.IngressProvisioner.provisionIngress(env="prod")— odblokovat guard z B.5 (ADR-021 sekce 6); proprodhost ={slug}.talkide.appmísto{projectUuid}.talkide.app.- Failure handling stejně jako u buildu.
- 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, autorbot@talkide.app) — viz sekce 2 pozn. ke schématu. Tag tedy pointuje na commit, ze kteréhogit checkout v0.1.4poskytne working tree sversion = "0.1.4"vbuild.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). - Finalize DB:
projects.status := PUBLISHEDprojects.prod_image_tag := <imageTag>versions.published := true,versions.published_at := now()- Předchozí published verze stejného projektu:
published := falsezůstává? — NE, ponecháme historickypublished=truepro audit. UI bere “active published” jakoWHERE published=true ORDER BY published_at DESC LIMIT 1. Viz Open question.
- 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
| Komponenta | ADR ref | Změna |
|---|---|---|
AppDeployer (interface) | ADR-017 §6 | Odblokovat 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) |
K8sAppDeployer | ADR-017 | PROD deployment jméno app-{slug}-prod, label env=prod |
IngressProvisioner (interface) | ADR-021 §6 | Odblokovat guard env != "dev"; pro env == "prod" host = {slug}.talkide.app |
K8sIngressProvisioner | ADR-021 §7 | Ingress jméno app-{slug}-prod, host {slug}.talkide.app |
BuildService | ADR-019 | Nový param env: String; image tag pro prod = <slug>:<semver> (ne sha) |
builds tabulka | ADR-019 | Nový sloupec env VARCHAR(8) NOT NULL DEFAULT 'dev', version_id BIGINT NULL |
projects tabulka | ADR-013 / ADR-021 | Nové sloupce current_version VARCHAR(32) NOT NULL DEFAULT '0.1.0', prod_image_tag TEXT NULL |
CreateProjectUseCase | – | Rozšíř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):
- (existing) Persist
ProjectEntitysestatus=DRAFT+current_version=0.1.0. - (existing)
LocalGitProvisioner.init()— git init prázdný repo +chmod +x .git/hooks/post-commitinstalace post-commit hooku (UID 1000+ kvůli NFS root squash z CLAUDE.md). - (existing) Vygenerování template souborů (build.gradle.kts, package.json, src/, …)
z BE templates. BE templates obsahují
version = "0.1.0"vbuild.gradle.ktsa"version": "0.1.0"vpackage.json(musí být v BE template assets — verze není patchovaná, je přímo v šablonách). - (existing) Initial commit přes
commit-changes.sh:- autor =
bot@talkide.app(NEmara@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ý duplicateversionsrow vytvořený asynchronně přes webhook.
- autor =
- 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 (autorbot@talkide.app). Working tree v té sha už obsahujeversion = "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
-
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):
ProjectChangedUseCaseběží 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 na0.1.4, zapíše soubory + bump commit + INSERTversions, 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 na0.1.5, zapíše soubory + bump commit- INSERT
versions. Žádný race window mezi čtením a zápisem, žádné duplikáty.
- INSERT
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
versionsrowu, bez UPDATEprojects.current_version). Recovery: re-triggerProjectChangedUseCaseručně, nebo evidovat jako alpha-acceptance risk (viz edge case #2 ohledně sync atomicity + saga/outbox pattern jako pre-stable blocker). - První concurrent webhook získá lock, přečte
-
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ý
versionsrow, žá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.ktsversion !=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
ProjectChangedUseCasepro 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. -
versions.shaMUSÍ být sha post-bump commitu (autorbot@talkide.app, messagechore: 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-changeddostává v payloadusha= HEAD před bumpem (= sha A); junior implementátor ji může omylem uložit doversions.shamísto aby si pogit commit(krok 5 sekce 3) zavolalgit rev-parse HEADpro získání sha B.Důsledek chyby:
git checkout v<semver>checkoutne working tree se starou verzí vbuild.gradle.kts+package.json(před bumpem). Kaniko build z toho working tree vyrobí Docker image s wrong semver vversionline — image pushnutý jako<slug>:0.1.4má interněversion = "0.1.3". Spring Boot Actuator/infovrá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.semverpro danýversions.sha. Stejně propackage.json(grep '"version"'). -
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). -
Concurrent Publish — dva user clicky během 100ms.
projects.status := BUILDINGje v transakci s předchozí podmínkoustatus IN (DRAFT, PUBLISHED, UPDATED)— second transaction vrátí409 CONFLICT_PROJECT_STATUS(status už je BUILDING). -
Rollback-jako-publish — user vybere starou verzi
0.1.2(současná0.1.5, published0.1.4). Flow je identický — builddemo:0.1.2, deploy, git tagv0.1.2(pokud neexistuje).current_versionse NEMĚNÍ na0.1.2(zůstává0.1.5— to je working tree).prod_image_tagukazuje na0.1.2. UI musí jasně rozlišit “working version” (current_version) vs “live version” (prod_image_tag → semver). -
Bot author email kolize — pokud user nastaví git config
user.email = bot@talkide.appmanuá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 vversionstabulce majíimage_tag = null(= “image už není v registry, rebuild on demand zversions.sha”). Žádná separátní GC logika — registry je inherently bounded na 2 tagy per project.
-
R2 (Q2) —
published=truejako audit historie (multiple rows). Schéma povoluje multiple rows spublished=trueper project. Latest published =SELECT * FROM versions WHERE project_id=? AND published=true ORDER BY published_at DESC LIMIT 1. Rollback-publish vytvoří novýversionsrow 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 byla0.1.2publishnutá” =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 vProjectChangedUseCase. Pro fast lookup je k dispozici non-unique indexidx_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 sgit checkout v<semver>→ rebuild → push → deploy. - Žádný nový tag, žádný conflict, žádný reject při re-publish.
- Implementační detail v
PublishServicestep 6: pre-checkgit tag --list→ if non-empty, skipgit taginvocation. Žádný--forceflag (chrání proti accidental tag move).
- První publish dané verze:
-
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-infraissue (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.gzpř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í
-
DB↔FS↔Git tří-stranný sync není atomický. Selhání mezi krokem 5 a 6
ProjectChangedUseCasezanechá 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). -
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. -
Fixed estimate
$0.50per 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). -
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; rawgit logzůstává pro debugging. -
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).
-
Tagging vyžaduje sha v
versions. Sha je zapsán po bump commitu = sha post-bump commitu (autorbot@talkide.app, sha B). Když publishujeme0.1.4, tagujemeversions.sha, což je bot bump commit obsahující bumpedbuild.gradle.ktspackage.json. Tag tedy pointuje na přesný commit, ze kterého byl buildnut image —git checkout v0.1.4poskytne working tree sversion = "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:
- Přidá sloupce
projects.current_version(default'0.1.0'),projects.prod_image_tag(null). - Přidá tabulku
versions(prázdná). - Přidá sloupce
builds.env(default'dev'),builds.version_id(null). - 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 → PUBLISHEDrename (DB migration + enum + tests) versionstabulka + repository + DTOProjectChangedUseCase+/api/internal/project-changedendpoint- Post-commit hook scaffolding v
CreateProjectUseCase CommitProjectUseCase+POST /api/v1/projects/{id}/commitendpointEnforceHostingBudgetUseCasePublishService+POST /api/v1/projects/{slug}/publishendpointAppDeployer/IngressProvisionerenv guard removal + PROD logicBuildServiceenv param +<slug>:<semver>tagging- SSE event types
version.bumped,publish.succeeded,publish.failed
- State machine
- Reference ADRs:
- ADR-013 — Git versioning strategy (žádný external remote; lokální tags OK)
- ADR-017 —
AppDeployer(B.4) — env guard, naming convention - ADR-019 —
BuildService(B.0.2) — Kaniko, image tagging - ADR-021 —
IngressProvisioner(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-commitpř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)
- Mara:
- 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.)
Thanks for the feedback.