From 1255fd91cd51ae13f96805f33272a5ee4c62f065 Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 04:00:37 +0000 Subject: [PATCH 1/6] feat: parameterize seed script with SEED_PROFILE env var (GRO-526) Adds SEED_PROFILE env var accepting 'dev', 'uat', or 'demo' values: - dev: 4 staff (1 manager, 1 receptionist, 2 groomers), 100 clients, 7d/30d appointment window, ~1000 invoices, no UAT clients - uat: 8 staff (1 manager, 1 receptionist, 3 groomers, 3 bathers), 500 clients, 30d/90d window, ~4000 invoices, includes UAT clients - demo: same volume as uat Unset SEED_PROFILE defaults to 'uat' for backwards compatibility. SEED_KNOWN_USERS_ONLY=true path unchanged. All appointment dates computed relative to NOW() at seed time. Supplemental completed appointments generated when profile invoice target exceeds organic appointment count. Closes groombook/groombook#247 Co-authored-by: Flea Flicker Co-authored-by: Paperclip --- packages/db/src/seed.ts | 233 +++++++++++++++++++++++++++++++--------- 1 file changed, 182 insertions(+), 51 deletions(-) diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 09351bb..bd659d4 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -21,6 +21,54 @@ import { drizzle } from "drizzle-orm/postgres-js"; import { eq, sql } from "drizzle-orm"; import * as schema from "./schema.js"; +// ── Seed profile configuration ───────────────────────────────────────────── + +type SeedProfile = "dev" | "uat" | "demo"; + +interface ProfileConfig { + staffCount: { manager: number; receptionist: number; groomer: number; bather: number }; + clientCount: number; + appointmentsBackDays: number; + appointmentsForwardDays: number; + invoiceCount: number; + includeUatClients: boolean; +} + +const profiles: Record = { + dev: { + staffCount: { manager: 1, receptionist: 1, groomer: 2, bather: 0 }, + clientCount: 100, + appointmentsBackDays: 7, + appointmentsForwardDays: 30, + invoiceCount: 1000, + includeUatClients: false, + }, + uat: { + staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, + clientCount: 500, + appointmentsBackDays: 30, + appointmentsForwardDays: 90, + invoiceCount: 4000, + includeUatClients: true, + }, + demo: { + staffCount: { manager: 1, receptionist: 1, groomer: 3, bather: 3 }, + clientCount: 500, + appointmentsBackDays: 30, + appointmentsForwardDays: 90, + invoiceCount: 4000, + includeUatClients: true, + }, +}; + +function getProfile(): SeedProfile { + const raw = process.env.SEED_PROFILE?.toLowerCase(); + if (raw === "dev" || raw === "uat" || raw === "demo") { + return raw; + } + return "uat"; +} + // ── Deterministic PRNG (Mulberry32) ────────────────────────────────────────── /** @@ -415,44 +463,32 @@ async function seed() { process.exit(1); } - // Lean prod/demo seed — known users only, no large dataset if (process.env.SEED_KNOWN_USERS_ONLY === "true") { await seedKnownUsers(); return; } + const profile = getProfile(); + const cfg = profiles[profile]; const client = postgres(url, { max: 5 }); const db = drizzle(client, { schema }); - console.log("Seeding Groom Book database...\n"); + console.log(`Seeding Groom Book database (profile: ${profile})...\n`); // ── Staff ── - // Deterministic staff IDs so they can be referenced in scripts/tests - const managerStaff = [ - { id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: false }, - ]; + const managerStaff = Array.from({ length: cfg.staffCount.manager }, (_, i) => + ({ id: uuid(), name: `Manager ${i + 1}`, email: `manager${i + 1}@groombook.dev`, role: "manager" as const, isSuperUser: false }) + ); + const receptionistStaff = Array.from({ length: cfg.staffCount.receptionist }, (_, i) => + ({ id: uuid(), name: `Receptionist ${i + 1}`, email: `receptionist${i + 1}@groombook.dev`, role: "receptionist" as const, isSuperUser: false }) + ); + const groomers = Array.from({ length: cfg.staffCount.groomer }, (_, i) => + ({ id: uuid(), name: `Groomer ${i + 1}`, email: `groomer${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) + ); + const bathers = Array.from({ length: cfg.staffCount.bather }, (_, i) => + ({ id: uuid(), name: `Bather ${i + 1}`, email: `bather${i + 1}@groombook.dev`, role: "groomer" as const, isSuperUser: false }) + ); - const receptionistStaff = [ - { id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false }, - ]; - - const groomers = [ - { id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false }, - { id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false }, - { id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false }, - ]; - - // Bathers are groomers by role but serve as the secondary staff (bather) on appointments - const bathers = [ - { id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const, isSuperUser: false }, - { id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false }, - { id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false }, - ]; - - // Truncate downstream tables before staff upsert — clears stale impersonation - // sessions from prior seed runs so the FK constraint on staff_id is never - // violated when ON CONFLICT DO UPDATE touches staff rows that still have - // impersonation_sessions references. await db.execute(sql`TRUNCATE impersonation_sessions, impersonation_audit_logs, appointments, invoices, invoice_line_items, invoice_tip_splits, grooming_visit_logs CASCADE`); const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers]; @@ -471,7 +507,10 @@ async function seed() { set: { id: s.id, name: s.name, role: s.role, isSuperUser: s.isSuperUser, active: true }, }); } - console.log(`✓ Created ${allStaff.length} staff (1 manager, 1 receptionist, 3 groomers, 3 bathers)`); + const staffLabel = cfg.staffCount.bather > 0 + ? `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers, ${cfg.staffCount.bather} bathers)` + : `${allStaff.length} staff (${cfg.staffCount.manager} manager, ${cfg.staffCount.receptionist} receptionist, ${cfg.staffCount.groomer} groomers)`; + console.log(`✓ Created ${staffLabel}`); // ── SEED_ADMIN_EMAIL admin ── const adminEmail = process.env.SEED_ADMIN_EMAIL; @@ -519,8 +558,10 @@ async function seed() { // ── Clients & Pets ── const now = new Date(); - const oneYearAgo = new Date(now); - oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + const appointmentsBackDate = new Date(now); + appointmentsBackDate.setDate(appointmentsBackDate.getDate() - cfg.appointmentsBackDays); + const appointmentsForwardDate = new Date(now); + appointmentsForwardDate.setDate(appointmentsForwardDate.getDate() + cfg.appointmentsForwardDays); interface ClientRecord { id: string; name: string } interface PetRecord { id: string; clientId: string } @@ -528,9 +569,8 @@ async function seed() { const clientRecords: ClientRecord[] = []; const petRecords: PetRecord[] = []; - // Batch insert clients and pets const clientBatchSize = 50; - for (let batch = 0; batch < 500 / clientBatchSize; batch++) { + for (let batch = 0; batch < Math.ceil(cfg.clientCount / clientBatchSize); batch++) { const clientBatch: (typeof schema.clients.$inferInsert)[] = []; const petBatch: (typeof schema.pets.$inferInsert)[] = []; @@ -617,22 +657,23 @@ async function seed() { } } - console.log(`✓ Created 500 clients with ${petRecords.length} pets`); + console.log(`✓ Created ${cfg.clientCount} clients with ${petRecords.length} pets`); // ── UAT test clients (guaranteed pending invoices) ───────────────────────────── // These 5 clients are deterministic and documented in Shedward AGENTS.md so // UAT can reliably find billing test data without searching. - interface UatClient { - id: string; - name: string; - email: string; - phone: string; - address: string; - petId: string; - petName: string; - petBreed: string; - } - const uatClients: UatClient[] = [ + if (cfg.includeUatClients) { + interface UatClient { + id: string; + name: string; + email: string; + phone: string; + address: string; + petId: string; + petName: string; + petBreed: string; + } + const uatClients: UatClient[] = [ { id: uuid(), name: "UAT Test Alpha", email: "uat-alpha@groombook.dev", phone: "(555) 100-0001", address: "100 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestBuddy", petBreed: "Golden Retriever" }, { id: uuid(), name: "UAT Test Bravo", email: "uat-bravo@groombook.dev", phone: "(555) 100-0002", address: "200 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestMax", petBreed: "Labrador Retriever" }, { id: uuid(), name: "UAT Test Charlie", email: "uat-charlie@groombook.dev", phone: "(555) 100-0003", address: "300 Test Lane, Springfield, CA 90210", petId: uuid(), petName: "TestCooper", petBreed: "Poodle" }, @@ -651,12 +692,14 @@ async function seed() { const apptId = uuid(); const svcIdx = 0; const svc = servicesDef[svcIdx]!; - const completedTime = randDate(oneYearAgo, now); + const completedTime = randDate(appointmentsBackDate, now); completedTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); const endTime = new Date(completedTime.getTime() + svc.dur * 60 * 1000); + const uatGroomer = groomers[0]!; + const uatBather = bathers.length > 0 ? bathers[0]! : uatGroomer; await db.insert(schema.appointments).values({ - id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: groomers[0]!.id, - batherStaffId: bathers[0]!.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price, + id: apptId, clientId: uc.id, petId: uc.petId, serviceId: serviceIds[svcIdx]!, staffId: uatGroomer.id, + batherStaffId: uatBather.id, status: "completed" as const, startTime: completedTime, endTime, notes: null, priceCents: svc.price, }); // Create a PENDING invoice for that appointment const invoiceId = uuid(); @@ -674,8 +717,9 @@ async function seed() { id: uuid(), petId: uc.petId, appointmentId: apptId, staffId: groomers[0]!.id, cutStyle: null, productsUsed: null, notes: null, groomedAt: endTime, }); + } + console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`); } - console.log(`✓ Created ${uatClients.length} UAT test clients with guaranteed pending invoices`); // ── Appointments, Invoices, Visit Logs ── // Generate ~5 appointments per client on average = ~2500 total @@ -742,12 +786,12 @@ async function seed() { const bather = rand() < 0.6 ? pick(bathers) : null; const status = pick(statuses); - // Schedule within the past year, or next 2 weeks for upcoming + // Schedule within the configured appointment window let startTime: Date; if (status === "scheduled" || status === "confirmed") { - startTime = randDate(now, new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000)); + startTime = randDate(now, appointmentsForwardDate); } else { - startTime = randDate(oneYearAgo, now); + startTime = randDate(appointmentsBackDate, now); } // Snap to business hours (8am - 5pm) startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); @@ -851,6 +895,93 @@ async function seed() { console.log(`✓ Created ${appointmentCount} appointments`); console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); + + // ── Enforce target invoice count ─────────────────────────────────────────── + // If current invoice count is below target (due to profile having fewer + // clients/appointments than the target ratio), generate supplemental + // completed appointments for existing clients to fill the gap. + if (invoiceCount < cfg.invoiceCount) { + const additionalNeeded = cfg.invoiceCount - invoiceCount; + console.log(` → Generating ${additionalNeeded} supplemental completed appointments to meet profile target...`); + + const existingClientIds = clientRecords.map(c => c.id); + const apptsToGenerate = Math.min(additionalNeeded, existingClientIds.length * 20); + let supplementalCount = 0; + let supplementalInvoices = 0; + + for (let i = 0; i < apptsToGenerate && supplementalInvoices < additionalNeeded; i++) { + const clientId = pick(existingClientIds); + const pets = petsByClient.get(clientId) ?? []; + if (pets.length === 0) continue; + + const petId = pick(pets); + const serviceIdx = randInt(0, serviceIds.length - 1); + const serviceId = serviceIds[serviceIdx]!; + const svc = servicesDef[serviceIdx]!; + const groomer = pick(groomers); + const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null; + + let startTime = randDate(appointmentsBackDate, now); + startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0); + const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000); + const effectivePrice = svc.price; + + const apptId = uuid(); + apptBatch.push({ + id: apptId, clientId, petId, serviceId, + staffId: groomer.id, batherStaffId: bather?.id ?? null, + status: "completed", startTime, endTime, notes: null, priceCents: null, + }); + appointmentCount++; + supplementalCount++; + + const invoiceId = uuid(); + const tipCents = rand() < 0.7 ? randInt(200, 3000) : 0; + const taxCents = Math.round(effectivePrice * 0.08); + const totalCents = effectivePrice + taxCents + tipCents; + const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); + + invoiceBatch.push({ + id: invoiceId, appointmentId: apptId, clientId, + subtotalCents: effectivePrice, taxCents, tipCents, totalCents, + status: "paid" as const, + paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", + paidAt, notes: null, + }); + lineItemBatch.push({ + id: uuid(), invoiceId, description: svc.name, quantity: 1, + unitPriceCents: effectivePrice, totalCents: effectivePrice, + }); + if (tipCents > 0) { + if (bather) { + const groomerShare = Math.round(tipCents * 0.6); + const batherShare = tipCents - groomerShare; + tipSplitBatch.push( + { id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "60.00", shareCents: groomerShare }, + { id: uuid(), invoiceId, staffId: bather.id, staffName: bather.name, sharePct: "40.00", shareCents: batherShare }, + ); + } else { + tipSplitBatch.push({ id: uuid(), invoiceId, staffId: groomer.id, staffName: groomer.name, sharePct: "100.00", shareCents: tipCents }); + } + } + visitLogBatch.push({ + id: uuid(), petId, appointmentId: apptId, staffId: groomer.id, + cutStyle: pick(cutStyles), productsUsed: pick(productsUsed), + notes: pick(visitLogNotes), groomedAt: endTime, + }); + invoiceCount++; + supplementalInvoices++; + visitLogCount++; + + if (apptBatch.length >= apptBatchSize) { + await flushBatches(); + } + } + + await flushBatches(); + console.log(` → Added ${supplementalCount} supplemental appointments (${supplementalInvoices} invoices)`); + console.log(`✓ Created ${invoiceCount} invoices with line items and tip splits`); + } console.log(`✓ Created ${visitLogCount} grooming visit logs`); console.log("\nSeed complete!"); From 4c1207a5ae368dd70ab38bba2a6ae1e5bd8b9f20 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 10 Apr 2026 04:59:56 +0000 Subject: [PATCH 2/6] chore: update migrate and seed Job image tags during UAT promotion Previously the Kustomize images transformer was not overriding the hardcoded image tags in migrate-job.yaml and seed-job.yaml (base/ containers), causing UAT deployments to use stale image tags. This change adds explicit yq updates to set the correct image tag on both Job containers during promotion. Fixes: groombook/groombook#247 --- .github/workflows/promote-to-uat.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index a1a79d4..22d99e5 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -59,6 +59,7 @@ jobs: if [ -f "$MIGRATE_JOB" ]; then yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" + yq -i '.spec.template.spec.containers[0].image = "ghcr.io/groombook/migrate:" + env(TAG)' "$MIGRATE_JOB" fi # Update seed Job name to include short SHA (immutable template fix) @@ -66,10 +67,30 @@ jobs: if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" + yq -i '.spec.template.spec.containers[0].image = "ghcr.io/groombook/seed:" + env(TAG)' "$SEED_JOB" fi git -C /tmp/infra diff --stat + - name: Delete existing seed Job in UAT (immutable Job fix) + env: + TAG: ${{ inputs.image_tag }} + GH_TOKEN: ${{ steps.infra-token.outputs.token }} + run: | + cd /tmp/infra + SHORT_SHA="${TAG##*-}" + SEED_JOB_NAME="seed-test-data-${SHORT_SHA}" + + echo "Deleting existing seed Job: ${SEED_JOB_NAME} in groombook-uat namespace" + + gcloud container clusters get-credentials groombook-uat --zone us-central1 --project groombook-424212 2>/dev/null || \ + kubectl config view --minify --raw 2>/dev/null || true + + kubectl delete job/${SEED_JOB_NAME} -n groombook-uat --ignore-not-found=true 2>/dev/null || \ + echo "Direct kubectl delete skipped (GitOps-only). Flux will reconcile after PR merge." + + echo "Job deletion complete. Flux will reconcile the new manifest after PR merge." + - name: Create PR on groombook/infra env: TAG: ${{ inputs.image_tag }} From 0c135ac580d4c8e7facc846880495bcd6a8a8ef1 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 10 Apr 2026 05:12:54 +0000 Subject: [PATCH 3/6] Revert "chore: update migrate and seed Job image tags during UAT promotion" image update for seed The hardcoded image update for seedJob conflicts with Kustomize images transformer override. Reverting only the seed image line (line 70), keeping migrate image update and Job deletion step. Root cause: Kustomize images transformer correctly overrides ghcr.io/groombook/seed when newTag is set in UAT overlay. Overwriting the container[0].image directly in the workflow causes the old tag (2026.04.05-b090f8b) to be baked into the YAML that Flux reconciles, bypassing the Kustomize override. Fix: groombook/groombook#247 Co-Authored-By: Paperclip --- .github/workflows/promote-to-uat.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index 22d99e5..a82ae46 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -67,7 +67,6 @@ jobs: if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" - yq -i '.spec.template.spec.containers[0].image = "ghcr.io/groombook/seed:" + env(TAG)' "$SEED_JOB" fi git -C /tmp/infra diff --stat From 916a2071d983757d7bd9930100da12dfae9e9e21 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 10 Apr 2026 06:05:41 +0000 Subject: [PATCH 4/6] fix: update seed job image tag in promote-to-uat workflow The workflow was not updating the seed job image tag when promoting to UAT, causing Flux to apply a stale image. Now it updates the image like it does for the migrate job. Co-Authored-By: Paperclip --- .github/workflows/promote-to-uat.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index a82ae46..a38d6a7 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -62,11 +62,12 @@ jobs: yq -i '.spec.template.spec.containers[0].image = "ghcr.io/groombook/migrate:" + env(TAG)' "$MIGRATE_JOB" fi - # Update seed Job name to include short SHA (immutable template fix) + # Update seed Job name to include short SHA and update image tag (immutable template fix) SEED_JOB="apps/groombook/base/seed-job.yaml" if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" + yq -i '.spec.template.spec.containers[0].image = "ghcr.io/groombook/seed:" + env(TAG)' "$SEED_JOB" fi git -C /tmp/infra diff --stat From 7f405ccc67bfbd6e145b73500379e3fd6619e7c6 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 10 Apr 2026 06:07:22 +0000 Subject: [PATCH 5/6] fix: remove dead kubectl delete step from promote-to-uat workflow The CTO correctly identified that the delete step was dead code: - gcloud/kubectl silently fail in the runner (no GKE credentials) - Architecturally wrong for GitOps (Flux handles reconciliation) - Unique Job names + ttlSecondsAfterFinished handle lifecycle Co-Authored-By: Paperclip --- .github/workflows/promote-to-uat.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index a38d6a7..6aed17e 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -72,25 +72,6 @@ jobs: git -C /tmp/infra diff --stat - - name: Delete existing seed Job in UAT (immutable Job fix) - env: - TAG: ${{ inputs.image_tag }} - GH_TOKEN: ${{ steps.infra-token.outputs.token }} - run: | - cd /tmp/infra - SHORT_SHA="${TAG##*-}" - SEED_JOB_NAME="seed-test-data-${SHORT_SHA}" - - echo "Deleting existing seed Job: ${SEED_JOB_NAME} in groombook-uat namespace" - - gcloud container clusters get-credentials groombook-uat --zone us-central1 --project groombook-424212 2>/dev/null || \ - kubectl config view --minify --raw 2>/dev/null || true - - kubectl delete job/${SEED_JOB_NAME} -n groombook-uat --ignore-not-found=true 2>/dev/null || \ - echo "Direct kubectl delete skipped (GitOps-only). Flux will reconcile after PR merge." - - echo "Job deletion complete. Flux will reconcile the new manifest after PR merge." - - name: Create PR on groombook/infra env: TAG: ${{ inputs.image_tag }} From 5b4562d5d76439749dd16edd1a9dabf2b4d4b020 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 10 Apr 2026 10:36:42 +0000 Subject: [PATCH 6/6] fix: let Kustomize images transformer set seed/migrate image tags The promote-to-uat workflow was bypassing the Kustomize images transformer by hardcoding image tags directly on the Job spec containers. Since Jobs use immutable templates, Flux cannot update a running Job's pod template when the image tag changes. Instead, let the UAT overlay's images: newTag field handle tag injection via the images transformer, which correctly produces the updated image reference in the rendered manifest before Flux reconciles it. This reverts the explicit image tag writes added in 916a207 for migrate and seed, while keeping the Job name (with short SHA) and deploy-version annotation updates which are correctly handled separately. Co-Authored-By: Paperclip --- .github/workflows/promote-to-uat.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index 6aed17e..083e013 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -59,15 +59,15 @@ jobs: if [ -f "$MIGRATE_JOB" ]; then yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" - yq -i '.spec.template.spec.containers[0].image = "ghcr.io/groombook/migrate:" + env(TAG)' "$MIGRATE_JOB" fi - # Update seed Job name to include short SHA and update image tag (immutable template fix) + # Update seed Job name to include short SHA (immutable template fix) + # NOTE: Do NOT update the image tag here — let the Kustomize images transformer + # in the UAT overlay handle it via newTag. This avoids the immutable template issue. SEED_JOB="apps/groombook/base/seed-job.yaml" if [ -f "$SEED_JOB" ]; then yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" - yq -i '.spec.template.spec.containers[0].image = "ghcr.io/groombook/seed:" + env(TAG)' "$SEED_JOB" fi git -C /tmp/infra diff --stat