Environment promotion
ProPromote a configuration from one environment to another: where from and where to, in each environment's own colour, the diff of what changes, who has approved it and when, and what can and can't be rolled back. Nothing is promoted without a decision: Promote and Don't promote look the same and neither is chosen for the person. Promote stays locked, saying why, while approvals are missing, while the target has changed since the diff was taken, and, for production, until the person ticks that they've read what changes. The tick is for the change shown when it was given: when the diff or hash changes, or the target goes stale, it is cleared and the card says why. With the app's hash, the decision is tied to exactly this change.
Version 5.2.0· other versions
This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).
Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0 and 2.9.0: the page came later, or had another name then.
Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.
Preview
Loading the preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/environment-promotion
Brings in: bidi, button, change-diff, environment-band, heading-level, heading-scope, locale, locked, team, tokens, work, utils · npm: lucide-react@^1.67.0
Usage
"use client"
import * as React from "react"
import { EnvironmentPromotion, type PromotionDecision } from "@/components/candor/environment-promotion"
import type { FileDiff } from "@/lib/candor/work"
const files: FileDiff[] = [
{
path: "config/checkout.json",
change: "update",
language: "json",
hunks: [
{
header: "@@ -3,4 +3,4 @@",
lines: [
{ kind: "context", text: ' "currency": "EUR",', oldNo: 3, newNo: 3 },
{ kind: "remove", text: ' "paymentRetries": 2,', oldNo: 4 },
{ kind: "add", text: ' "paymentRetries": 3,', newNo: 4 },
{ kind: "remove", text: ' "receiptTemplate": "receipt-v2"', oldNo: 5 },
{ kind: "add", text: ' "receiptTemplate": "receipt-v3"', newNo: 5 },
],
},
],
},
]
export default function Example() {
const [state, setState] = React.useState<"pending" | "promoting" | "promoted" | "declined">("pending")
const [decision, setDecision] = React.useState<PromotionDecision>()
return (
<EnvironmentPromotion
className="max-w-2xl"
title="Checkout settings v14"
from={{ id: "stg", name: "Shop", detail: "staging", production: false, hue: 2, connection: "connected" }}
to={{ id: "prd", name: "Shop", detail: "production", production: true, hue: 1, connection: "connected" }}
files={files}
hash="9f2c41d07be35a18c6e0d2f4"
requestedBy={{ name: "Release agent", at: "2026-11-10T08:12:00Z" }}
approvals={{
needed: 2,
given: [
{ by: "Priya Shah", at: "2026-11-10T09:30:00Z", note: "Checked the retries against last month’s failures." },
{ by: "Jonas Berg", at: "2026-11-10T10:05:00Z" },
],
}}
rollback={{
can: ["The checkout settings, back to version 13"],
cannot: ["Receipts already sent with the new template", "Payments retried a third time"],
how: "Promote version 13 again from the history page.",
}}
state={state}
decision={decision}
onPromote={async () => {
setState("promoting")
await new Promise((r) => setTimeout(r, 900))
setDecision({ by: "You", at: new Date().toISOString() })
setState("promoted")
}}
onDecline={() => {
setDecision({ by: "You", at: new Date().toISOString() })
setState("declined")
}}
/>
)
}<EnvironmentPromotion>
| Prop | Type | Default | Description |
|---|---|---|---|
| titlerequired | string | — | What is being promoted, e.g. "Checkout settings v14". |
| fromrequired | Environment | — | The environment it comes from. |
| torequired | Environment | — | The environment it goes to. A production one is warned about and asks the person to tick that they've read the change. |
| filesrequired | FileDiff[] | — | The diff between the two, one entry per changed file or setting, drawn with ChangeDiff. `[]` when nothing differs. |
| hash | string | undefined | — | The change's hash from your server, shown and passed back to `onPromote`, so the server promotes only what was shown. |
| requestedBy | { name: string; at: string } | undefined | — | Who asked for it, named by the app (a person or an agent), and when (ISO). |
| approvalsrequired | { needed: number; given: PromotionApproval[] } | — | How many approvals your rules need, and those given. `needed: 0` when none are needed. Approvals count by person: each distinct `by` once, and none from `requestedBy`'s name, so a second entry from one person or the requester's own is listed and marked as not counted. Use the same name for a person in `by` and `requestedBy`. |
| rollbackrequired | PromotionRollback | — | What can and can't be rolled back. |
| stale | boolean | undefined | false | `to` changed since the diff was taken: Promote stays locked until the diff is taken again. |
| state | "pending" | "promoting" | "promoted" | "declined" | undefined | "pending" | Where the promotion is: waiting for a decision, under way, promoted or declined. |
| decision | PromotionDecision | undefined | — | Who promoted or declined it and when, once it's promoted or declined. |
| onPromote | ((hash: string | undefined) => void | Promise<void>) | undefined | — | Shows Promote. Called with `hash` when the person promotes. The button waits on a returned promise; if it throws or rejects, the card says promoting failed. Once it resolves, Promote stays locked until you pass a new `state`, `error` or change. |
| onDecline | (() => void) | undefined | — | Shows Don't promote. |
| error | string | undefined | — | Why the last attempt failed, in the app's words. |
| className | string | undefined | — | Extra classes for the outer element. |
| headingLevel | HeadingLevel | undefined | — | The level of the heading. Its sections sit one level below it. Defaults to one below the nearest `HeadingScope`, else 2. |
Strings
The fixed words, in English by default. Change any of them under environmentPromotion in <LocaleProvider strings={{ … }}>; the table is exported as environmentPromotionStrings.
| Key | English |
|---|---|
| heading | Promote {title} |
| from | From |
| to | To |
| placeLabel | {label}: |
| production | {name} is production: what changes there reaches real users. |
| requestedBy | Requested by {name} |
| changes | What changes |
| nothingDiffers | Nothing differs between {from} and {to}, so there’s nothing to promote. |
| stale | {to} changed since this diff was taken. Take the diff again before promoting. |
| approvals | Approvals |
| approvalsProgress | { one: "{given} of {count} approval", other: "{given} of {count} approvals" } |
| noApprovalNeeded | Your rules ask for no approval to promote to {to}. |
| approvedBy | Approved by {name} |
| notCountedOwn | Not counted: {name} asked for this promotion. |
| notCountedAgain | Not counted again: {name} has approved already. |
| notGiven | Not given yet |
| rollback | If it has to be rolled back |
| canRollBack | Can be rolled back |
| cantRollBack | Can’t be rolled back |
| nothingListed | Nothing listed. |
| how | How: |
| acknowledge | I’ve read what changes in {to} and what can’t be rolled back. |
| ackCleared | What changes isn’t what you ticked for any more, so the tick was cleared. Read it again, then tick. |
| change | Change |
| decline | Don’t promote |
| promote | Promote to {to} |
| promoting | Promoting… |
| needsApprovals | { one: "It needs {count} more approval first.", other: "It needs {count} more approvals first." } |
| needsAck | Tick that you’ve read what changes first. |
| needsDiff | Take the diff again first. |
| nothingToPromote | There’s nothing to promote. |
| busy | Wait for the promotion to finish. |
| waiting | Promotion requested. Waiting for the app to show the result. |
| promoted | Promoted to {to} by {name} |
| declined | Not promoted. {name} decided against it |
| promotedPlain | Promoted to {to} |
| declinedPlain | Not promoted |
| quoted | “{note}” |
| promoteFailed | Promoting failed. |
Types
- PromotionApproval
- One approval given for the promotion, as the app recorded it.
{ /** Who approved, by name, from the app's data. */ by: string; /** When (ISO). */ at: string; /** What they said, if anything. */ note?: string | undefined; }- PromotionRollback
- What can and can't be undone if the promotion goes wrong, in the app's words.
{ /** What a rollback puts back: "The settings, to version 13". */ can: string[]; /** What it doesn't: "Emails already sent with the new template". */ cannot: string[]; /** How to roll back, when it isn't obvious: "Promote version 13 again from the history page." */ how?: string | undefined; }- PromotionDecision
- Who decided and when, once the promotion is promoted or declined.
{ by: string; at: string; note?: string | undefined }
Types from other items
The props above take these. Each is defined by another item; import it from the path shown.
- Environment from @/lib/candor/team
- Where agents act: a CRM instance, a database, a repository, a project. The app names them; LocaleProvider's `nouns` sets what they're called ("project", "workspace").
{ id: string; /** The name shown everywhere, e.g. "Shop". */ name: string; /** What tells same-named ones apart, e.g. "dev-2" or "staging". */ detail?: string | undefined; /** Changes here reach real users, so components warn and ask for more care. */ production: boolean; /** A chart slot (1–5) so each environment keeps one colour everywhere it appears. */ hue?: 1 | 2 | 3 | 4 | 5 | undefined; connection: "connected" | "expired" | "not-connected"; }- FileDiff from @/lib/candor/work
{ path: string; /** What the file is, e.g. a field or a layout. */ entity?: EntityRef | undefined; change: "create" | "update" | "delete"; /** e.g. "xml", "ts", "python"; shown as a label. */ language?: string | undefined; hunks: DiffHunk[]; }- HeadingLevel from @/lib/candor/heading-level
- A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.
1 | 2 | 3 | 4 | 5 | 6- EntityRef from @/lib/candor/chat
{ kind: EntityKind; /** The identifier, e.g. "orders.status" or "billing.invoice.due_date". */ name: string; /** A friendlier label when there is one, e.g. "Region". */ label?: string | undefined; /** Where it lives, when more than one place is in scope, e.g. "Shop staging". */ location?: string | undefined; }- DiffHunk from @/lib/candor/work
{ header?: string | undefined; lines: DiffLine[] }