Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
6 changes: 4 additions & 2 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fromVersion>`
(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
Expand Down
4 changes: 2 additions & 2 deletions src/commands/env/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> copies one, --skip <id> leaves one out, --map <id>=<target id> 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 <id> copies one, --skip <id> leaves one out, --map <id>=<target id> 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 <target> --version <fromVersion>\` 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 <target> --version <fromVersion>\` 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, …).`

Expand Down
33 changes: 30 additions & 3 deletions src/lib/promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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('; ')}`)
}
Expand All @@ -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<RenderContext, 'kind' | 'dryRun'>): 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} · ` +
Expand Down
17 changes: 17 additions & 0 deletions tests/__snapshots__/env-promote.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions tests/env-promote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'}},
Expand Down
Loading