From 926241084cfc51ff0437883034879d02053f052e Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:05:55 +0300 Subject: [PATCH 1/2] chore(deps): types 1.49.0, the Migrate golden handoff from the package The handoff golden now comes from MIGRATION_HANDOFF_GOLDEN in @contentrain/types, so the fixture copy is deleted. The handoff summary reads plan_hash and order_id when the document carries them, and the sync logs them so a stale handoff left by an earlier delivery can be traced. Older documents without them stay valid: unknown, never stale. --- package.json | 2 +- pnpm-lock.yaml | 10 +- server/utils/migration-handoff.ts | 8 ++ tests/fixtures/migrate-handoff-golden.json | 114 ------------------ .../migration-handoff-migrate-golden.test.ts | 25 ++-- 5 files changed, 32 insertions(+), 127 deletions(-) delete mode 100644 tests/fixtures/migrate-handoff-golden.json diff --git a/package.json b/package.json index a6d2c718..5b840890 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@aws-sdk/client-s3": "^3.1076.0", "@contentrain/mcp": "3.9.0", "@contentrain/query": "7.4.0", - "@contentrain/types": "1.46.0", + "@contentrain/types": "1.49.0", "@gitbeaker/rest": "^43.8.0", "@nuxt/eslint": "1.16.0", "@nuxt/image": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bf2d8c1..bc57203b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 7.4.0 version: 7.4.0 '@contentrain/types': - specifier: 1.46.0 - version: 1.46.0 + specifier: 1.49.0 + version: 1.49.0 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -713,8 +713,8 @@ packages: '@contentrain/types@1.30.0': resolution: {integrity: sha512-inJhFqAY25wIw4NvpqDXOn24PRbVEh43s6GGFQSRyBbbmGiWu+xD67D2UEqaEllaFNLSVQ0j2DpOjDj+P6ezQA==} - '@contentrain/types@1.46.0': - resolution: {integrity: sha512-ZbCBNvcpcnIMzjq7AvD12uW3ZGVuLZY0sDPhkGLme8AsZPw4kB+wFVreLSSb+oJ2Te5regwLyS5fpT4mzy3Pmg==} + '@contentrain/types@1.49.0': + resolution: {integrity: sha512-Zn4daw2q4llOJN6GPqws/weQEyKHf+rusaTOJzkT8RaedC3mCghnaHqrM3t/sgkW/4Ajcwsc9UrvrdHT8DOAZQ==} '@conventional-changelog/git-client@3.1.2': resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} @@ -7938,7 +7938,7 @@ snapshots: '@contentrain/types@1.30.0': {} - '@contentrain/types@1.46.0': {} + '@contentrain/types@1.49.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: diff --git a/server/utils/migration-handoff.ts b/server/utils/migration-handoff.ts index 5c6f2f15..1e65d6ea 100644 --- a/server/utils/migration-handoff.ts +++ b/server/utils/migration-handoff.ts @@ -195,6 +195,9 @@ export type MigrationHandoffIssue export interface MigrationHandoffSummary { siteUrl: string generatedAt: string + /** The producing run and the order it was delivered for, when the document carries them (older ones do not: unknown, never stale). */ + planHash?: string + orderId?: string content?: { models: number, entries: number, locales: string[] } capabilities: Array<{ key: string, disposition: string, detail?: string }> /** Capabilities that need a live service — the ones Studio can take over. */ @@ -240,6 +243,8 @@ export function summarizeMigrationHandoff(handoff: StoredMigrationHandoff): Migr return { siteUrl: handoff.site_url, generatedAt: handoff.generated_at, + ...(handoff.plan_hash ? { planHash: handoff.plan_hash } : {}), + ...(handoff.order_id ? { orderId: handoff.order_id } : {}), content: handoff.content_summary ? { models: handoff.content_summary.models, entries: handoff.content_summary.entries, locales: handoff.content_summary.locales ?? [] } : undefined, @@ -373,6 +378,9 @@ export async function syncMigrationHandoff(input: SyncMigrationHandoffInput): Pr const handoff = enrichMigrationHandoff(manifest, input.project) await useDatabaseProvider().setProjectMigrationHandoff(input.projectId, handoff as unknown as Record) + // Which run and order this handoff belongs to, so a stale one (left by an earlier delivery) can be traced from the log. + // eslint-disable-next-line no-console + if (handoff.plan_hash || handoff.order_id) console.info(`[migration-handoff] synced project ${input.projectId} plan_hash=${handoff.plan_hash ?? '-'} order_id=${handoff.order_id ?? '-'} generated_at=${handoff.generated_at}`) return { found: true, handoff, summary: summarizeMigrationHandoff(handoff), source } } diff --git a/tests/fixtures/migrate-handoff-golden.json b/tests/fixtures/migrate-handoff-golden.json deleted file mode 100644 index 067c1b79..00000000 --- a/tests/fixtures/migrate-handoff-golden.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "version": 1, - "site_url": "https://site.test", - "generated_at": "2026-09-09T10:00:00.000Z", - "content_summary": { - "models": 4, - "entries": 131, - "locales": [ - "tr" - ] - }, - "capabilities": [ - { - "key": "forms", - "disposition": "needs_runtime", - "detail": "Studio'ya bağlanınca yeni sitede çalışır; bağlanana dek yeni sitede form görünmez (contact-form-7)" - }, - { - "key": "comments", - "disposition": "needs_runtime", - "detail": "Studio'ya bağlanınca yeni sitede çalışır; bağlanana dek yeni sitede yorum görünmez", - "counts": { - "total": 2, - "mapped": 1, - "unresolved": 1 - } - }, - { - "key": "i18n", - "disposition": "migrated_static", - "detail": "dil başına aile, route'larda locale (polylang)", - "counts": { - "locales": 1 - } - }, - { - "key": "ecommerce", - "disposition": "needs_runtime", - "detail": "mağaza taşımaya dahil değil; WordPress sunucusunda kalır (woocommerce)" - } - ], - "comments": { - "total": 2, - "by_status": { - "0": 1, - "1": 1 - }, - "types": { - "comment": 2 - }, - "export": { - "format": "contentrain-comments@1" - }, - "threads_closed": [ - 11 - ], - "unresolved": [ - { - "comment_id": 2, - "post": 99, - "reason": "post has no entry mapping" - } - ] - }, - "offers": [ - { - "capability": "comments", - "provider": "studio_managed" - }, - { - "capability": "comments", - "provider": "keep_wordpress", - "warning": "yorumlar WordPress'te kalırsa eski sunucu canlı kalır — bakım, güvenlik ve barındırma maliyeti sürer" - }, - { - "capability": "forms", - "provider": "studio_managed" - }, - { - "capability": "forms", - "provider": "keep_wordpress", - "warning": "formlar WordPress'te kalırsa gönderimler eski sunucuya gider; sunucu ve form eklentisi canlı kalır" - }, - { - "capability": "ecommerce", - "provider": "keep_wordpress", - "warning": "mağaza WordPress'te kalırsa WooCommerce ve ödeme altyapısı eski sunucuda çalışmaya devam eder" - } - ], - "runtime": { - "base_url": "https://studio.test", - "project_id": "p1" - }, - "notes": [ - "derleme başarılı · kalite kapısı geçti", - "görsel sadakat: yazı medyanı 97.6 (1280 px)", - "6 aile · 7 route · 120 yazı · 9 sayfa", - "içerik mağazası: 4 model · 131 kayıt (.contentrain, @contentrain/wp-import) · KESİLDİ: media 3/12", - "çalışma zamanı adresleri: 128 route bağlandı · 1 BAĞLANAMADI", - "mağaza medyası: 12 URL yerel yola çevrildi · 300 eski-origin URL kaldı (medya taşıma: Studio)", - "form modeli: contact (4 alan, cf7) — Studio'da form bloğu açılınca gönderim alır", - "düzenlenebilirlik: 5/7 route içeriği veriden · 2 route DÜZENLENEMEZ (içerik chrome'a pişmiş) · 2 alan veride taşınıyor ama basılmıyor", - "aile genelliği: 3/5 aile canlıya karşı kanıtlandı · kanıtlanmayan: category (weak), page-solo-0 (single-sample)", - "bileşen mount noktaları: c-comments (comments, 1 aile) · c-contact (form, 1 aile) · Studio'ya bağlı: https://studio.test / p1", - "yorumlar: 2 (onaylı 1 · bekleyen 1) · dışa aktarım satır içi · 1 kapalı thread · 1 eşlenmeyen", - "yorum dışa aktarımı Git'e girmez — yorumlar teslim paketinde (makbuz), Studio'ya Migrate üzerinden aktarılır" - ], - "repository": { - "provider": "github", - "owner": "acme", - "name": "site", - "default_branch": "main" - } -} diff --git a/tests/unit/migration-handoff-migrate-golden.test.ts b/tests/unit/migration-handoff-migrate-golden.test.ts index adafb52d..904540f1 100644 --- a/tests/unit/migration-handoff-migrate-golden.test.ts +++ b/tests/unit/migration-handoff-migrate-golden.test.ts @@ -1,14 +1,12 @@ /** * The handoff Contentrain Migrate actually writes (E2 contract test). * - * `tests/fixtures/migrate-handoff-golden.json` is the document a Migrate run builds and stamps on delivery - * (copied byte for byte from Contentrain/migrate `packages/delivery/tests/fixtures/studio-golden-handoff.json`, - * regenerated there with `UPDATE_GOLDEN=1`). Studio's own tests used hand-built handoffs; this one runs the - * producer's output through the consumer's validate, split and summary, in CI (no env vars needed). + * `MIGRATION_HANDOFF_GOLDEN` (`@contentrain/types`) is the document a Migrate run builds and stamps on delivery, + * published with the contract itself so the producer and this consumer test the same bytes. Studio's own tests used + * hand-built handoffs; this one runs the producer's output through the consumer's validate, split and summary. */ -import { CAPABILITY_KEYS, MIGRATION_CONTRACT_VERSION, type MigrationHandoff } from '@contentrain/types' +import { CAPABILITY_KEYS, MIGRATION_CONTRACT_VERSION, MIGRATION_HANDOFF_GOLDEN } from '@contentrain/types' import { describe, expect, it } from 'vitest' -import golden from '../fixtures/migrate-handoff-golden.json' import { enrichMigrationHandoff, splitMigrationHandoff, @@ -16,7 +14,7 @@ import { validateMigrationHandoff, } from '../../server/utils/migration-handoff' -const handoff = golden as unknown as MigrationHandoff +const handoff = MIGRATION_HANDOFF_GOLDEN describe('the handoff Migrate writes, through Studio', () => { it('passes Studio validation', () => { @@ -46,6 +44,19 @@ describe('the handoff Migrate writes, through Studio', () => { expect(enrichMigrationHandoff(handoff, { repo_full_name: 'other/repo' }).repository).toEqual({ provider: 'github', owner: 'acme', name: 'site', default_branch: 'main' }) }) + it('the run and order the handoff was delivered for are read when present, and absent on an older document (unknown, not stale)', () => { + const summary = summarizeMigrationHandoff({ ...handoff }) + if (handoff.plan_hash) expect(summary.planHash).toBe(handoff.plan_hash) + if (handoff.order_id) expect(summary.orderId).toBe(handoff.order_id) + const { plan_hash: _p, order_id: _o, ...older } = handoff + expect(validateMigrationHandoff(older)).toBeNull() + expect(summarizeMigrationHandoff({ ...older })).not.toHaveProperty('planHash') + expect(summarizeMigrationHandoff({ ...older })).not.toHaveProperty('orderId') + const stamped = { ...handoff, plan_hash: '0123456789abcdef', order_id: `ord_${'a'.repeat(24)}` } + expect(validateMigrationHandoff(stamped)).toBeNull() + expect(summarizeMigrationHandoff({ ...stamped })).toMatchObject({ planHash: '0123456789abcdef', orderId: `ord_${'a'.repeat(24)}` }) + }) + it('the contract version is a range: the pinned version and below are read, above is refused', () => { expect(validateMigrationHandoff({ ...handoff, version: MIGRATION_CONTRACT_VERSION })).toBeNull() expect(validateMigrationHandoff({ ...handoff, version: MIGRATION_CONTRACT_VERSION + 1 })).toEqual({ code: 'unsupported_version', detail: String(MIGRATION_CONTRACT_VERSION + 1) }) From e3c12c4e0e44f3da75b83ab754ff75efa734de5f Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:15:40 +0300 Subject: [PATCH 2/2] fix(migrate): shape-check handoff plan_hash and order_id before use Both stamps come from the customer's repository. Only 16 hex chars for plan_hash and ord_ plus 24 hex chars for order_id are read, the same shapes Migrate's validateHandoff accepts. Anything else (a non-string, a value with a newline) is unknown and never reaches the summary or the log. The golden test now asserts the stamps unconditionally. --- server/utils/migration-handoff.ts | 20 ++++++++++++++++--- .../migration-handoff-migrate-golden.test.ts | 15 ++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/server/utils/migration-handoff.ts b/server/utils/migration-handoff.ts index 1e65d6ea..becc6f04 100644 --- a/server/utils/migration-handoff.ts +++ b/server/utils/migration-handoff.ts @@ -219,6 +219,18 @@ function commentsSourceOf(handoff: StoredMigrationHandoff): HandoffCommentsSourc return { kind: 'none' } } +/** + * The run and order stamps come from the customer's repository, so they are shape-checked (the same shapes Migrate's + * `validateHandoff` accepts) before they reach the summary or the log: anything else reads as unknown. + */ +export function readPlanHash(value: unknown): string | undefined { + return typeof value === 'string' && /^[0-9a-f]{16}$/.test(value) ? value : undefined +} + +export function readOrderId(value: unknown): string | undefined { + return typeof value === 'string' && /^ord_[0-9a-f]{24}$/.test(value) ? value : undefined +} + export function summarizeMigrationHandoff(handoff: StoredMigrationHandoff): MigrationHandoffSummary { const capabilities = (handoff.capabilities ?? []).map(c => ({ key: String(c.key), disposition: String(c.disposition), ...(c.detail ? { detail: c.detail } : {}) })) const needsRuntime = capabilities.filter(c => c.disposition === 'needs_runtime').map(c => c.key) @@ -243,8 +255,8 @@ export function summarizeMigrationHandoff(handoff: StoredMigrationHandoff): Migr return { siteUrl: handoff.site_url, generatedAt: handoff.generated_at, - ...(handoff.plan_hash ? { planHash: handoff.plan_hash } : {}), - ...(handoff.order_id ? { orderId: handoff.order_id } : {}), + ...(readPlanHash(handoff.plan_hash) ? { planHash: readPlanHash(handoff.plan_hash) } : {}), + ...(readOrderId(handoff.order_id) ? { orderId: readOrderId(handoff.order_id) } : {}), content: handoff.content_summary ? { models: handoff.content_summary.models, entries: handoff.content_summary.entries, locales: handoff.content_summary.locales ?? [] } : undefined, @@ -379,8 +391,10 @@ export async function syncMigrationHandoff(input: SyncMigrationHandoffInput): Pr const handoff = enrichMigrationHandoff(manifest, input.project) await useDatabaseProvider().setProjectMigrationHandoff(input.projectId, handoff as unknown as Record) // Which run and order this handoff belongs to, so a stale one (left by an earlier delivery) can be traced from the log. + const planHash = readPlanHash(handoff.plan_hash) + const orderId = readOrderId(handoff.order_id) // eslint-disable-next-line no-console - if (handoff.plan_hash || handoff.order_id) console.info(`[migration-handoff] synced project ${input.projectId} plan_hash=${handoff.plan_hash ?? '-'} order_id=${handoff.order_id ?? '-'} generated_at=${handoff.generated_at}`) + if (planHash || orderId) console.info(`[migration-handoff] synced project ${input.projectId} plan_hash=${planHash ?? '-'} order_id=${orderId ?? '-'} generated_at=${handoff.generated_at}`) return { found: true, handoff, summary: summarizeMigrationHandoff(handoff), source } } diff --git a/tests/unit/migration-handoff-migrate-golden.test.ts b/tests/unit/migration-handoff-migrate-golden.test.ts index 904540f1..a5fcb429 100644 --- a/tests/unit/migration-handoff-migrate-golden.test.ts +++ b/tests/unit/migration-handoff-migrate-golden.test.ts @@ -46,8 +46,10 @@ describe('the handoff Migrate writes, through Studio', () => { it('the run and order the handoff was delivered for are read when present, and absent on an older document (unknown, not stale)', () => { const summary = summarizeMigrationHandoff({ ...handoff }) - if (handoff.plan_hash) expect(summary.planHash).toBe(handoff.plan_hash) - if (handoff.order_id) expect(summary.orderId).toBe(handoff.order_id) + expect(handoff.plan_hash).toMatch(/^[0-9a-f]{16}$/) + expect(handoff.order_id).toMatch(/^ord_[0-9a-f]{24}$/) + expect(summary.planHash).toBe(handoff.plan_hash) + expect(summary.orderId).toBe(handoff.order_id) const { plan_hash: _p, order_id: _o, ...older } = handoff expect(validateMigrationHandoff(older)).toBeNull() expect(summarizeMigrationHandoff({ ...older })).not.toHaveProperty('planHash') @@ -57,6 +59,15 @@ describe('the handoff Migrate writes, through Studio', () => { expect(summarizeMigrationHandoff({ ...stamped })).toMatchObject({ planHash: '0123456789abcdef', orderId: `ord_${'a'.repeat(24)}` }) }) + it('a stamp that is not the shape Migrate writes (non-string, newline, wrong form) is unknown, never passed to the summary or the log', () => { + for (const bad of [42, ['0123456789abcdef'], '0123456789abcdef\nforged=1', 'ZZZZ', '']) { + const summary = summarizeMigrationHandoff({ ...handoff, plan_hash: bad, order_id: bad } as never) + expect(summary).not.toHaveProperty('planHash') + expect(summary).not.toHaveProperty('orderId') + } + expect(summarizeMigrationHandoff({ ...handoff, order_id: `ord_${'a'.repeat(24)}\nx` } as never)).not.toHaveProperty('orderId') + }) + it('the contract version is a range: the pinned version and below are read, above is refused', () => { expect(validateMigrationHandoff({ ...handoff, version: MIGRATION_CONTRACT_VERSION })).toBeNull() expect(validateMigrationHandoff({ ...handoff, version: MIGRATION_CONTRACT_VERSION + 1 })).toEqual({ code: 'unsupported_version', detail: String(MIGRATION_CONTRACT_VERSION + 1) })