Spec for the first two sub-projects of the automated FaceKom release + e2e pipeline. Written 2026-08-11 against three simultaneous in-flight releases (NÚSZ 1.9.11.48, CIB 1.9.11.102, Generali 1.9.11.19).

Read these first — this extends them, it does not replace them

devel-update-and-release-flow (Phases 0–5) · release-workflow-design-handover (the delta and the four open design questions) · /Users/levander/coding/facekom/RELEASE-WORKFLOW-HANDOVER.md (the NÚSZ cut).


0. The pipeline this belongs to

P0 resolve → P1 devel update → P2 breaking-change sweep → P3 ticket collection
→ P4 test-plan generation → P5 execute (tiers 1→2→3) → P6 TjK → P7 cut
→ P8 back-merge → P9 close the loop

Everything is prepare-and-gate. No phase commits, pushes, opens a PR, or tags. Each write-shaped step produces the artifact plus the exact command, and stops. Decided 2026-08-11; consistent with the standing no-auto-commit / no-auto-PR rules.

P8 and P9 are in the pipeline because release-workflow-design-handover §7 and §10 establish that nobody does them today — Raiffeisen .100 currently owes a back-merge, and its dev tickets sat Pending a day after merge.

Sub-project decomposition and build order

Sub-projectSizeInfraStatus
APartner manifestsmallnonethis spec
BBreaking-change sweepmediumnonethis spec
CTier-2 boot harnesslargedisposable stackroadmap
DTest-plan generatormediumnoneroadmap
ERelease driver (P0–P9 orchestrator)mediumnoneroadmap
FTier-3 browser suitelargefk-devroadmap

A and B are hermetic — they run on the Mac, need no box, and are the only pieces that pay off inside the three releases currently being cut.

The three test tiers (context for C–F, not built here)

Full treatment, with the per-runbook evidence and the not-automatable-in-principle list: facekom-test-tiers.

TierNeedsCoversToday
1 static / hermeticnothingthe clean-merge-broken-product class, lint, unit, config schemaB builds this
2 booted, non-browserapp runningflow registration, RPC, cron CLI, emails actually sending, exports, DBnothing — ~60% of the runbook
3 browserfull stack + mediacustomer/operator UI flows12/45 k6 files ported

Tier 2 dissolves the k6 seeding blocker

k6-e2e-harness-vuer-oss lists option (a) — “Node pre-seed step reusing helper.ts, handing k6 a fixtures JSON via open() — as the leanest fix for the 30 unportable tests. A tier-2 harness that boots the app to assert against it is that pre-seed step. One build, two problems. Argues for C before F.


A. Partner manifest

A.1 The problem

Partner facts live in four places that disagree, and none of them records which repos a partner ships:

SourceCountHoldsGap
.claude/scripts/tjk/partners.json14TjK shape/priorShape, YouTrack bindingno repos, no build path
client-registry22repo ↔ YT suffix ↔ build path, prosenot machine-readable, (confirm) entries
vuer_build/partner/<name>/39 dirslegacy build contextsbuild-only
vuer-release/projects/<partner>/14modern descriptors7 have zero release.json — defined, never built

The repo-set gap is live: CIB ships portal_css (that is where FKITDEV-9197 broke) while NÚSZ and Generali are vuer_oss + vuer_css only. Any sweep that assumes two repos misses CIB’s actual break.

A.2 Decision

Extend .claude/scripts/tjk/partners.json into the single partner manifest. Do not create a new file. It is already executable JSON, already consumed by /fk-tjk, already carries the YouTrack binding, and lives under .claude/scripts/ — which is where executable config belongs per the knowledge-placement rule. client-registry stays as the human-readable companion and stops being read by tooling.

A.3 Fields

What is actually in the file today (14 keys + _meta): display, ytProject, defaultEnv, priorShape, priorShapeVerified, fileStem; _meta.targetShape = "release-doc".

Two existing properties must survive the extension

  • An unknown key HALTS /fk-tjk on step one, by design. Adding partners is safe; loosening that fail-closed behaviour is not. NÚSZ was missing from the file entirely until 2026-08-11.
  • ytProject is pinned because the YouTrack shortName varies — MicroSec is MF, DÁP is ASSDAP. Do not derive it, do not overwrite it. The new ytProjects map is additional, for release scope.

Added per partner:

FieldMeaningWhy it cannot be derived
repocustomization branch suffix — customization/<repo>variants: -instant, -v2, -f1
ytProjectsexplicit {cr, bug, sla, ass}Generali splits them: CR/BUG = GRALIA, SLA/ASS = GRALI
repos[]source repos this partner ships, each marked tagged or untaggedsee below
buildPathvuer-release | vuer_build | esignCIB is legacy, NÚSZ/Generali modern
releaseProjectproject dir name≠ repo, e.g. polgaribankpolgaribank-facekom
changelogPathcustomization/RELEASE.MD | RELEASE.md | nullvaries per partner; several have none
changelogStyleheading formNÚSZ ## 1.9.11.44, CIB ## [1.9.11.98] - 2026-03-10, MVM ### …, Raiffeisen # …
tagPrefixsource-tag prefix≠ partner key: Generali’s is generali-atvilagitas-. release_tickets.py takes this
topologycustomization-branch | release-branchthe release-workflow-design-handover §1 ambiguity, made explicit per partner

Not every repo a partner ships carries the partner tag

Verified 2026-08-11 by git ls-remote: vuer_oss and vuer_css each hold 102 cib-1.9.11.* tags, and portal_css holds none. CIB genuinely ships portal_cssFKITDEV-9197 broke there — but portal_css is on its own lifecycle (Travis, own audit gate) and is never tagged with the partner version. So repos[] is two facts, not one: which repos the sweep must scan (all of them — this is what catches the CIB break) versus which repos get the release tag (only the tagged ones). Conflating them makes A’s --selfcheck fail on every portal_css row and makes P7 look for a tag that will never exist.

Seed repos[] from the existing per-partner matrix

projects/facekom-v2/customization-clients.md already holds 22 clients × {repo, customization scope, bin scripts, CORE-override risk, scale, last activity}. It is the best existing input for A, and it records that CORE-override is Y for 21 of 22 partners — only instacash is config-only.

The version is NOT a manifest field

Release trains are per partner and numbers get renumbered after the fact (NÚSZ .47 → .48 because another team took .47). A version is not stable until the tag exists. Resolve it only from that partner’s open ASS<suffix> Assistance Type: Release issue, or from git ls-remote --tags. Never from the manifest, never from a sibling partner, never from conversation. This rule is already enforced in /fk-tjk and must not regress here.

A.4 Verification

partners.json --selfcheck (matching the existing convention on the TjK renderers) asserts every row against real state, so the manifest is checked rather than trusted:

  • repos[]git ls-remote --exit-code origin refs/heads/customization/<repo> in each named repo
  • buildPathvuer-release/projects/<releaseProject>/ exists, else vuer_build/partner/<repo>/
  • changelogPath — the file exists at the customization branch tip
  • ytProjects — each project id resolves via the read-only YouTrack client

--selfcheck is read-only and hits no write path.


B. Breaking-change sweep

B.1 What it detects

One failure class, four shapes. From devel-dependency-removal-breaks-partner-customization:

Git reconciles text, and the two sides never touch the same line. The merge is clean and the product is broken.

#devel sidepartner sidestatic?
B1dependency deleted from package.json as “unused”customization/ requires ityes
B2.js.ts renameextensionless require()yes
B3core file deleted / function refactored awayoverride binds by path or calls the old namemostly
B4dependency goes ESM-only (MJML v4→v5)CommonJS require() in templatesno — emits a runtime check
B5config key restructuredpartner prod config still on the old keyyes
B6new/changed migrationspartner has divergent migrationsyes

Phase 4 gate 4 already seeds this — B is its automation, not a new idea

devel-update-and-release-flow’s fourth release gate is “breaking-change / config sweep” and names three commands: git log <tag>..HEAD --grep='BREAKING' --grep='!:' -E, git diff --name-only <tag>..HEAD -- db/migrate/, and git diff <tag>..HEAD -- config/. B turns that manual gate into structured findings and adds the four detectors the gate does not name. Conventional-commit !: / BREAKING CHANGE: scanning comes free — the repos enforce conventional commits by ruleset, so the marker is reliable.

B.2 Input and output

Input: a merge commit, or a base..head range, on a customization branch — i.e. what P1 just produced, before it is pushed. That timing is the entire point.

Output: structured JSON findings, one record per hit:

detector      B1|B2|B3|B4
severity      break | drift | needs-runtime-check
repo          vuer_oss | vuer_css | portal_css | …
develChange   what devel did (path, symbol, version delta)
partnerUsage  file:line under customization/ that depends on it
confirmed     static-proven | needs-runtime-check
suggestedFix  e.g. restore "multer": "1.4.5-lts.2"

D later consumes this JSON directly; needs-runtime-check records become mandatory items in the generated runbook rather than pass/fail results.

B.3 The detectors

B1 — dependency removal. Removed names from git diff <merge>^1..<merge> -- package.json, then per name git grep for require('<pkg>') and from '<pkg>' under customization/. Two extra checks that are the actual value:

  • hoisting probe — resolves in node_modules but absent from package.json ⇒ a finding, not a pass. Undeclared-but-hoisted is unowned.
  • major drift — CIB declared ^10.0.0, ran the hoisted 14.0.1 while “working”.

Must run per repo in repos[] — the CIB break was in portal_css, which a two-repo assumption skips.

B2 — .js.ts renames. git diff --diff-filter=R for renames that change extension, then grep extensionless requires of those basenames across the whole tree, not just customization/ — the NÚSZ occurrences were in cron.js and bin/remove-old-video-files.js. n/no-missing-require catches this when lint runs, but yarn lint ignores test/* and root *.ts is unlinted, so the grep is the backstop.

B3 — deleted / refactored core. git diff --diff-filter=D for deletions, then whether anything under customization/ requires the deleted path. The harder half is refactor-away (FKITDEV-9194’s auth()): symbols exported at base, absent at head, that customization/ calls by name.

B4 — module-system flips. A dependency whose major bumped and whose new package is ESM-only ("type": "module" or an ESM-only exports map). Then, across the whole tree — core and customization/ alike, since letter definitions live in both — which files require() it, and does any such file mix a top-level import with a bare require(?

B4's failure mode is silence

That mix makes Node treat the file as ESM, require is undefined, the letter type never registers, and the email silently never sends — with green CI. Unit tests do not boot EmailService. B4 therefore never emits a pass; it emits a mandatory runtime check (“trigger each email, confirm it sends”) into the plan.

B5 — config key restructuring. git diff <tag>..HEAD -- config/ plus the reader side: a key path that disappeared from the shipped configs while code reads a new path.

The canonical case is silent in every layer

devel restructured browsers.showOldBrowserWarningbrowsers.oldBrowserWarning.{show,delay}, and server/web/Template.js:123 reads the new path. Any partner whose prod config still carries the old key silently loses the old-browser warning — no error, no log line. docs/config/ was not updated either, so the JSON schema does not warn. Nothing anywhere catches this.

Same range, list new-but-default-off keys separately (safe, but they belong in the changelog): sftp, dataCleanupCron, transferRoomCron, documentRecognitionVersion, roomExportFilesExtendedName.

B6 — migrations. git diff --name-only <tag>..HEAD -- db/migrate/. Two outputs: new migrations in this range (a deploy-ordering fact the runbook needs), and partner-vs-core divergence — the count and identity of migrations the partner has that core does not.

Divergent migrations are not academic

nusz and cofidis carry 6 divergent migrations stamped 2017–2021 that back-fill out of order. Combined with db.syncOnStart: true (§ open questions) this is how an environment gets silently corrupted by a branch switch. B6 is what makes that visible before P5 touches a box.

B.4 Gotchas to bake in

These are not hypotheticals — each has already cost a session

  • rtk corrupts git output, and its failure mode is a false negative — which is fatal for a detector. It ate :server/socket/ from git show <rev>:<path> (bogus “unknown revision”), zeroes | wc -l and | grep -c, and — worse, found 2026-08-11 — returned the WRONG commit for git log -1 and garbled git show | grep into a false “no match”. Every B detector is a git query whose negative result means “clean”. A garbled grep therefore reports a partner as safe when it is broken. Non-negotiable: drive every git call from a Python subprocess args list, never a shell pipeline, and treat any detector result as unverified if it came through plain rtk. (rtk-mangles-curl-and-pipes, rtk-pipe-count-mangling)
  • Narrowed fetch refspecs. git fetch origin devel writes only FETCH_HEAD and leaves origin/devel stale, exit 0, success message. Use an explicit +refs/heads/X:refs/remotes/origin/X and cross-check with git ls-remote before trusting any origin/<branch>. (narrowed-fetch-refspec-stale-devel-merge)
  • Worktree node_modules drifts from the branch lock. Run yarn install --frozen-lockfile in the worktree first, or B1’s hoisting probe reports fiction. A base-commit worktree needs its own install — symlinking node_modules from the merged tree voided a first attempt when yarn.lock moved 800 lines.
  • Node ≥ 24 is mandatory. geoip-lite@2.0.3 declares engines.node >= 24.0.0, so yarn install --frozen-lockfile hard-fails on Node 22. package.json engines: >=22.18.0 is stale; CI pins 24. Without a correct install, B1 cannot probe hoisting at all.
  • yarn lint ignores test/*, root *.ts is unlinted, and nothing typechecks the k6 suite. Never treat a green lint as coverage.

B.5 Testing

Per the standing lazy-but-checked rule: one runnable check, no fixture authoring. Each detector must fire on its own known-historical merge:

DetectorHistorical case
B1FKITDEV-9197 — CIB / portal_css, multer hard-missing + uuid transitive
B2NÚSZ devel update — FFmpegService/JanusService .js.ts stragglers
B3FKITDEV-9194 — deleted core file / auth() refactored away
B4FKITDEV-8727 — MJML v4→v5 ESM-only
B5browsers.showOldBrowserWarningbrowsers.oldBrowserWarning.{show,delay}
B6nusz ↔ cofidis — 6 divergent migrations stamped 2017–2021

Real regressions, zero fixtures. A detector that stops firing on its historical case is broken.


Roadmap (C–F, not specified here)

  • C — tier-2 boot harness. The largest gap and the best value per hour. Boots the app, asserts flow registration, cron CLI completion, emails actually sending (Mailtrap), exports, DB state. Reuses test/tests/support/helper.ts. Side effect: unblocks the 30 stalled k6 tests.

    More prior art exists than assumed — C is smaller than it looks

    • vuer_oss/bin/comptest.js (on devel, documented by SYSTEM_CHECK.md) — a connectivity + diagnostics CLI: TCP/Redis/TLS probes, then RabbitMQ-RPC diagnostics from the running server, and it probes Janus + CV + CSS hosts directly. Flags --conn-only, --json, --verbose. The single best starting point for an automated smoke check--json means it is already machine-readable.
    • The standalone cron-harness pattern, proven on FKITDEV-8959 TC-8959-02: a Node script in /workspace/vuer_oss/bin/ (so ../server/… resolves) that bootstraps process-settings, installs a console Proxy logger, stubs services/crypto, authenticates sequelize, seeds by raw SQL, calls the real service methods, and SELECTs before/after.
    • .dev-e2e/run-e2e.sh (untracked, FKITDEV-8827 worktree) — spins a throwaway postgres:17-alpine on :5544 via OrbStack and emits a real dated CSV. /fk-tjk Phase 3 already looks for a .dev-e2e/.
    • Deterministic test credentials exist: test.security.tempTokenSms (conventionally 123456) and tempTokenEmail (mailToken) make the code fixed on every send. They ship only in test/testconfigs/*, not in a normal NODE_ENV=dev deploy — add to config/local.json and restart, since config caches at startup.

    Sizing caveat: RabbitMQ is fail-fast — a closed AMQP connection triggers process.exit(2) in both vuer_oss and vuer_css. There is no “app without a broker”. A disposable stack is Postgres + RabbitMQ at minimum, plus Redis and vuer_cv for anything touching photo/liveness.

    A second detector belongs here rather than in B: vuer_css has no jsdom — jest runs in the node environment with all DOM behaviour hand-mocked, which is exactly how the data-socketToken regression shipped green through 1083 passing tests. Verifying DOM-coupled behaviour means rendering the real twig (autoescape: true, matching server/web/WebServer.js:257) and parsing with a real HTML parser.

  • D — test-plan generator. B’s findings + P3’s ticket buckets + the manifest → a tiered runbook (auto / manual / cannot-validate-in-UAT), shaped to feed /fk-tjk. Replaces hand-writing runbooks like nusz-1.9.11.48-test-runbook.

  • E — release driver. Prepare-and-gate orchestration of P0–P9 over release_tickets.py, /fk-tjk, vuer-release-cut-recipe and the legacy path.

  • F — tier-3 browser. Finish the k6 port or reconsider the substrate. See the open blockers below.

Live state — verified by git ls-remote, 2026-08-11

All three in-flight releases are UNTAGGED. Latest source tags, identical across vuer_oss and vuer_css (1530 / 1514 tags; the per-partner sets match, so the “tag both repos identically” rule does hold in practice):

PartnerLatest live tagTargetvuer-release
nusznusz-1.9.11.47.48nusz@16 → next @17 (@8 skipped, never created)
generali-atvilagitasgenerali-atvilagitas-1.9.11.18.190 release.json — components only, never built
raiffeisenraiffeisen-1.9.11.99.100raiffeisen@13 exists
cibcib-1.9.11.101 (102 tags).102legacy vuer_build; portal_css has no partner tags

Three environment facts that will bite P5 (fk-dev), recorded now

  • fk-dev currently runs cofidis, repointed 2026-07-20 — not nusz. Isolation was verified by migration counts (vuer_oss_cofidis, 158 migrations, cofidis-only marker present; nusz vuer_oss still 153/0).
  • config/dev.json sets db.syncOnStart: trueserver/bootstrap/connection/db.js runs migrate → sync → migrate on every boot. Restarting on a partner branch before repointing the DB silently migrates the current database. Change config before the checkout, with node processes stopped first, to close the crash-restart window.
  • config/local.json is a single-file bind mount, so docker binds the inode. Restore with cat orig > file. sed -i, mv, and git checkout -- all swap the inode and the container silently keeps serving the stale file.

And the reason a smoke check must assert more than a 200: container user techteamer is uid 1000 (host ubuntu), not host ops (1001). The first cofidis smoke test over-claimed — page 200 with the right partner markers, but stylesheets 404’d because web/ was owned by 1001 while the app builds as 1000, so every write hit EACCES and the build wrapper still exited 0. Verify referenced assets resolve, not just that the HTML returns 200.

Stale local state

The local vuer-release clone is ~3 months behind (master @ 90319b0, 2026-05-14; nusz release dirs only to 12 against a live @16). Fetch before reasoning about it. Auto-memory recording NÚSZ at @14 is stale.

Open questions carried forward

  1. How do legacy images reach Harbor? Verified: vuer_build/build.sh contains no docker push and no docker login — it builds, tags locally, exports .tar. Confirmed legacy sequence is ./build.sh -b <partner>-1.9.11.NN then ./sign-partner.sh -t <tag> -k cosign.key, run native on fk-dev (github.key + cosign.key are gitignored → git add -f). Signing is not publishing, and the publish step is in no file yet read. This sits directly on the CIB release. (vuer-build-never-pushes)
  2. k6 has no camera on the docker path. k6.yml passes only CI_DOMAIN, never K6_BROWSER_ARGS, so no fake camera/mic and no --disable-web-security. For a video-identification product that is close to fatal for tier 3. (k6-e2e-harness-vuer-oss)
  3. fk-dev tests source, never images. Deploy is a bind-mount branch swap, so nothing on fk-dev exercises the UBI/Node/nginx image layer. Whatever validates images has to be separate.
  4. Tag ↔ tested SHA. fk-dev runs a branch tip; production ships a Harbor image built from a tag. Assert the tag lands on exactly the SHA that was deployed and tested, or “we tested this build” is not true.
  5. Raiffeisen’s topology (§1 of release-workflow-design-handover) is still unresolved, and the back-merge is still owed. The live raiffeisen@13 tag shows the modern pipeline is provisioned — that is evidence, not proof of how .100 ships.
  6. Gate ordering is contradicted by practice. The documented order is tag → smoke test → TjK (Phase 4 gates 3/5/6), but the Raiffeisen .100 TjK sections were written 2026-08-11 while the tag still does not exist. Treat the documented ordering as aspirational until decided.
  7. depcheck: blocking or warn-only? FKITDEV-8239 shipped it continue-on-error and outside build’s needs; the NÚSZ .48 handover reports it now behaves as a failing check. Read the live workflow.
  8. release_tickets.py only sees tickets in commit subjects — so a release’s headline item can land in neither bucket. Generali .19’s headline is the pure-core commit 6bdf66d16 (ASSGRALI-63 / FKITDEV-8887). Any P3 automation must cross-check the parent ASS* ticket’s changelog, and P4 must not treat the two buckets as complete.