diff --git a/README.md b/README.md index f4fe3e5..11ad972 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ triggers in TEST, then move them up — never re-create them in PROD by hand ```sh norbix env promote --from TEST --to PROD # preview (the default): nothing changes -norbix env promote --from TEST --to PROD --yes # promote; prints the rollback anchor +norbix env promote --from TEST --to PROD --yes # promote; prints the rollback anchor (or "Nothing to promote") norbix env rollback --to PROD --version 279 # preview the undo; add --yes to run it norbix env built-ins repair # built-in templates under PROD's ids; --yes to run ``` diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 570fce3..35cca49 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -80,14 +80,16 @@ and promoted: 1. `norbix env promote --from TEST --to PROD --json` — a preview (the default), nothing changes. Show the user `summary`, `items` (deleted ones - with `recordCount`), `integrations` (`action`, `warning`, `realSender`) and + with `recordCount`), `integrations` (`action`, `warning`, `note`, `realSender`) and `notPromoted`. 2. After the user agreed: the same command with `--yes` (`--seed / --skip / --map id=targetId` only for integrations the user decided; `--confirm-record-loss` only when `requiresConfirmation` is true and the user accepts it — exit 3 `CM-ERRORS-ENVIRONMENTS-013` means it was missing). 3. Keep `fromVersion`: `norbix env rollback --to PROD --version ` - (preview, then `--yes`) undoes it. + (preview, then `--yes`) undoes it. `promoted: false` (with `summary.isNoOp`) + means nothing needed promoting: nothing was written and there is no + `fromVersion` — tell the user "Nothing to promote — PROD already matches TEST.". A content command run straight in PROD while TEST exists prints `Note: PROD is promoted from TEST — …` on stderr: make the change in TEST and diff --git a/src/commands/env/promote.ts b/src/commands/env/promote.ts index 8f8321a..3fc555d 100644 --- a/src/commands/env/promote.ts +++ b/src/commands/env/promote.ts @@ -20,9 +20,9 @@ Build and test in the lower environment, then move it up with this command — n A PREVIEW is the default: the Hub works out the plan and changes nothing (--dry-run says it explicitly). The preview lists every item by status — added, changed, unchanged, deleted (with the number of records a deleted collection holds), kept — one line per source integration with the choice that applies, and what is never promoted (records, users, files, …). A real run needs --yes. In a terminal you are asked after the preview instead. -Integrations: by default Fake providers are copied (seed) and everything else is left out (skip) — a database, a secret or a real sender is never copied unless you say so: --seed copies one, --skip leaves one out, --map = points the promoted content at an integration the target already has (same module). +Integrations: by default Fake providers are copied (seed) — unless the target already has its own Fake of that module (then skip, with a note) — and everything else is left out (skip) — a database, a secret or a real sender is never copied unless you say so: --seed copies one, --skip leaves one out, --map = points the promoted content at an integration the target already has (same module). -A real run that would hide records (a collection deleted in the target) refuses without --confirm-record-loss. The answer of a real run names its rollback anchor (fromVersion): \`norbix env rollback --to --version \` undoes it. +A real run that would hide records (a collection deleted in the target) refuses without --confirm-record-loss. The answer of a real run names its rollback anchor (fromVersion): \`norbix env rollback --to --version \` undoes it. A real run with nothing to change writes nothing and makes no anchor: it prints "Nothing to promote — PROD already matches TEST." and exits 0 (--json: promoted false, no fromVersion). --json prints the Hub's answer as it is (summary, items, integrations, notPromoted, requiresConfirmation, fromVersion, …).` diff --git a/src/lib/promotion.ts b/src/lib/promotion.ts index 44ee645..f015508 100644 --- a/src/lib/promotion.ts +++ b/src/lib/promotion.ts @@ -66,6 +66,8 @@ export interface PromotionIntegration { realSender?: boolean targets?: PromotionIntegrationTarget[] warning?: string + /** Why the default is what it is, e.g. "PROD already has its own Fake Email integration" (gateway 2026-10-21; not a warning). */ + note?: string } export interface PromotionNotPromoted { @@ -91,8 +93,16 @@ export interface PromotionResult { blockers?: PromotionRef[] warnings?: PromotionRef[] termChanges?: Array<{taxonomyId?: string; termId?: string; slug?: string; change?: string}> - fromVersion?: number + fromVersion?: number | null wasDryRun?: boolean + /** + * Promote answers (gateway 2026-10-21): true when the real run changed the target and wrote the + * rollback anchor; false on a preview and on a real run with nothing to change (no events, no + * fromVersion). Absent from older gateways and from rollback answers. + */ + promoted?: boolean + /** One sentence for people, e.g. "Nothing to promote — PROD already matches TEST." */ + message?: string } export interface BuiltInRepairEnv { @@ -258,6 +268,7 @@ export function renderIntegrations(integrations: PromotionIntegration[] = [], ro lines.push(` id in the target: ${i.targetId} (${how})`) } if (i.warning) lines.push(` warning: ${i.warning}`) + if (i.note) lines.push(` note: ${i.note}`) if (!rollback && (i.targets?.length ?? 0) > 0) { lines.push(` in target: ${i.targets!.map((t) => `${t.name ?? t.id} (${t.provider ?? '?'}${t.isDefault ? ', default' : ''}) ${t.id}`).join('; ')}`) } @@ -282,14 +293,30 @@ export interface RenderContext { rerun: string } +/** + * A real promote that changed nothing: the gateway says `promoted: false` (2026-10-21). An older + * gateway has no `promoted` field; then a no-op summary without a rollback anchor means the same. + */ +export function nothingPromoted(result: PromotionResult, ctx: Pick): boolean { + if (ctx.kind !== 'promote' || ctx.dryRun || (result.blockers?.length ?? 0) > 0) return false + if (result.promoted !== undefined) return result.promoted === false + return result.summary?.isNoOp === true && (result.fromVersion === undefined || result.fromVersion === null) +} + /** The whole preview / result as text, the way a person reads it. */ export function renderPromotion(result: PromotionResult, ctx: RenderContext): string { const s = result.summary ?? {} const what = ctx.kind === 'promote' ? `${ctx.source} → ${ctx.target}` : `rollback of ${ctx.target}` const lines: string[] = [] - lines.push(ctx.dryRun ? `Preview: ${what} — nothing was changed.` : ctx.kind === 'promote' ? `Promoted ${what}.` : `Rolled back ${ctx.target}.`) + const nothing = nothingPromoted(result, ctx) + if (nothing) { + // Nothing was written: no new version, no rollback anchor. + lines.push(`Nothing to promote — ${ctx.target} already matches ${ctx.source}.`) + } else { + lines.push(ctx.dryRun ? `Preview: ${what} — nothing was changed.` : ctx.kind === 'promote' ? `Promoted ${what}.` : `Rolled back ${ctx.target}.`) + if (s.isNoOp) lines.push(`Nothing to change — ${ctx.target} already matches.`) + } - if (s.isNoOp) lines.push(`Nothing to change — ${ctx.target} already matches.`) const at = s.recordsAtRisk ? ` (${plural(s.recordsAtRisk, 'record')} at risk)` : '' lines.push( `Summary: ${s.added ?? 0} added · ${s.changed ?? 0} changed · ${s.unchanged ?? 0} unchanged · ${s.deleted ?? 0} deleted${at} · ` + diff --git a/tests/__snapshots__/env-promote.test.ts.snap b/tests/__snapshots__/env-promote.test.ts.snap index 1b76001..3301d8b 100644 --- a/tests/__snapshots__/env-promote.test.ts.snap +++ b/tests/__snapshots__/env-promote.test.ts.snap @@ -14,6 +14,23 @@ To apply it: add --yes " `; +exports[`norbix env promote > a real run with nothing to change: says nothing was promoted, no rollback anchor, exit 0 1`] = ` +"Nothing to promote — PROD already matches TEST. +Summary: 0 added · 0 changed · 2 unchanged · 0 deleted · 0 kept · 0 skipped +Integrations: 0 seeded · 1 skipped · 0 mapped + +Content + UNCHANGED (2) + Schema faq, EmailTemplate Password reset + +Integrations (choice per source integration; ! = sends real e-mail / SMS / push) + Email Test Me Fake skip int_test_fake [fake] + note: PROD already has its own Fake Email integration + in target: Test Me (Fake, default) int_prod_fake + +" +`; + exports[`norbix env promote > previews by default: sends dryRun true, prints the readable plan, changes nothing, exit 0 1`] = ` "Preview: TEST → PROD — nothing was changed. Summary: 1 added · 1 changed · 2 unchanged · 1 deleted (1 record at risk) · 0 kept · 0 skipped diff --git a/tests/env-promote.test.ts b/tests/env-promote.test.ts index 8fe2f3f..2b68074 100644 --- a/tests/env-promote.test.ts +++ b/tests/env-promote.test.ts @@ -120,6 +120,60 @@ describe('norbix env promote', () => { expect(r.stdout).toContain('Rollback anchor: fromVersion 279. To undo: norbix env rollback --to PROD --version 279') }) + /** A second real run of the same content (gateway 2026-10-21): nothing written, no anchor. */ + const NOOP = { + summary: { + added: 0, changed: 0, unchanged: 2, deleted: 0, kept: 0, skipped: 0, recordsAtRisk: 0, + integrationsSeeded: 0, integrationsSkipped: 1, integrationsMapped: 0, integrationsRemoved: 0, isNoOp: true, + }, + items: [ + {type: 'Schema', id: 'sch_faq', name: 'faq', status: 'unchanged', matchedBy: 'id', builtIn: false}, + {type: 'EmailTemplate', id: 'tmpl_6kbXOl8AX1HdVMyFwURznj', name: 'Password reset', status: 'unchanged', matchedBy: 'id', builtIn: true}, + ], + integrations: [ + { + module: 'Email', id: 'int_test_fake', name: 'Test Me', provider: 'Fake', status: 'skipped', + defaultAction: 'skip', action: 'skip', holdsSecret: false, isFake: true, realSender: false, + targets: [{id: 'int_prod_fake', name: 'Test Me', provider: 'Fake', isDefault: true}], + note: 'PROD already has its own Fake Email integration', + }, + ], + notPromoted: [], requiresConfirmation: false, blockers: [], termChanges: [], warnings: [], + fromVersion: null, wasDryRun: false, promoted: false, message: 'Nothing to promote — PROD already matches TEST.', + } + + it('a real run with nothing to change: says nothing was promoted, no rollback anchor, exit 0', async () => { + const {run} = await project({[PROMOTE]: {item: NOOP}}) + + const r = await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--yes']) + + expect(r.code).toBe(0) + expect(r.stdout.split('\n')[0]).toBe('Nothing to promote — PROD already matches TEST.') + expect(r.stdout).not.toContain('Promoted TEST → PROD.') + expect(r.stdout).not.toContain('Rollback anchor') + expect(r.stdout).toMatchSnapshot() + }) + + it('a real no-op from an older gateway (no promoted field, no anchor) reads the same', async () => { + const {promoted: _promoted, message: _message, ...older} = NOOP + const {run} = await project({[PROMOTE]: {item: older}}) + + const r = await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--yes']) + + expect(r.code).toBe(0) + expect(r.stdout.split('\n')[0]).toBe('Nothing to promote — PROD already matches TEST.') + }) + + it('--json of a real no-op is the Hub answer: promoted false, fromVersion null', async () => { + const {run} = await project({[PROMOTE]: {item: NOOP}}) + + const r = await run(['env', 'promote', '--from', 'TEST', '--to', 'PROD', '--yes', '--json']) + + expect(r.code).toBe(0) + const item = (parseSingleJson(r.stdout) as {item: {promoted: boolean; fromVersion: unknown}}).item + expect({promoted: item.promoted, fromVersion: item.fromVersion}).toEqual({promoted: false, fromVersion: null}) + }) + it('a real run that would hide records: the plan, then exit 3 with the gateway code and the flag to add', async () => { const {run} = await project({ [PROMOTE]: {__status: 400, item: PLAN, responseStatus: {errorCode: 'CM-ERRORS-ENVIRONMENTS-013', message: 'Record loss not confirmed.'}},