Larger projects
Promotions
Promotions copy definitions from one set to another. A change made in dev moves to staging and then production without being written three times. Each step is previewed first and lands as an ordinary diff, so a release goes through the same review as any other change.
Before you start#
Promotions need a project with one set per release lane. This creates dev, staging and production sets, each with its own checkoutFlow feature:
npx @featurevisor/cli@latest init \ --example=environmentsBoth sets must pass lint. promote checks them before it plans anything, then lints the merged destination again before writing a file.
Preview a promotion#
Without --apply, promote only shows what would change:
npx featurevisor promote \ --from=dev \ --to=stagingMode: previewConflict policy: sourceUpdated sets/staging/features/checkoutFlow.yml sets/staging/targets/all.yml sets/staging/tests/features/checkoutFlow.spec.ymlConflicts feature checkoutFlow description feature checkoutFlow rules.everyone.percentage target all description test features.checkoutFlow assertionsA conflict is a value both sets have that differs, such as the percentage of the everyone rule. By default dev's value wins, as Resolve conflicts explains.
A promoted feature brings what it depends on: its segments and their attributes, the features its conditions reference, its schemas and its tests. Add --show-unchanged to list the files that would stay as they are.
Apply it#
npx featurevisor promote \ --from=dev \ --to=staging \ --applyThe promoted files are ordinary changes. Review the diff, then lint and test the destination before you commit:
npx featurevisor lint --set=stagingnpx featurevisor test --set=stagingIf writing fails partway, the files already written stay written. Check the diff before running it again.
How definitions merge#
A definition missing from the destination is created. One that exists in both is merged:
- Values that differ are conflicts, settled by the conflict policy.
- Rules and keyed overrides are matched by
key.
The first matching rule decides, so promotion keeps the destination's order. Rules both sets have are updated in place, and a rule only the destination has keeps its slot. In the promotion above, staging's internal rule stays first, and its everyone rule takes dev's percentage. A new rule from the source goes in before the rule that follows it in the source, so it never lands after a catch all it was meant to precede.
Choose what to promote#
By default every definition in the source is a candidate. Narrow it with a selector:
npx featurevisor promote \ --from=dev \ --to=staging \ --target=web--target=webselects what that target would put in its datafile, and promotes the target too.--tag=webselects features and global variables with that tag.--include-features="checkout*"selects features by key, with*matching any characters.--exclude-features="experimental*"leaves matching features out.
Each selector can be repeated. A definition must match every --target, --tag and --include-features given, and --exclude-features removes keys last. What the selection depends on still comes along, so promoting chatFileUpload brings liveChat too, even if no selector matched it.
An unknown target or tag fails and lists the ones that exist. A selection that matches nothing fails too, unless you pass --allow-empty.
Tests and scenarios come along only when every feature and global variable they reference is being promoted or already exists in the destination. A target a test refers to is created in the destination if it is missing.
Resolve conflicts#
--conflicts decides what happens when both sets have a value and they differ:
| Policy | Result |
|---|---|
source | The source value wins. This is the default. |
destination | The destination keeps its value. |
fail | Nothing is written, and the conflicts are listed. |
npx featurevisor promote \ --from=dev \ --to=staging \ --conflicts=failTwo details matter for rules and overrides matched by key:
- An override that switches between
valueandmutatetakes one form or the other, never both. Itsconditionsexpression follows the selected conflict policy. - Reordering rules, or global variable overrides, is a conflict, because order decides which one applies and how mutations accumulate.
sourcetakes the source order,destinationkeeps the existing order, andfailstops. Rules and overrides that are protected or only in the destination keep their place, and when the destination's order wins, new global variable overrides go after the existing ones.
Protect what belongs to one lane#
Add promotable: false to something that a lane owns, such as production's rollout percentage or a test that only production runs. It takes precedence over --conflicts.
A whole definition#
At the top level, it protects the whole file:
description: Checkout flowpromotable: falsebucketBy: userIdrules: - key: everyone conditions: '*' percentage: 0| Source | Destination | Result |
|---|---|---|
promotable: false | Exists | Destination unchanged |
| Promotable | promotable: false | Destination unchanged |
promotable: false | Missing | Created, keeping promotable: false |
This works for attributes, segments, features, global variables, schemas, targets, test specs and scenarios. Definitions are matched by key, which comes from the file path.
One rule#
On a rule, it keeps that rule in its own lane. Dev's internal-testing rule is never copied out of dev:
description: Checkout flowbucketBy: userIdrules: - key: internal-testing promotable: false conditions: internal percentage: 100 - key: everyone conditions: '*' percentage: 50A destination rule with promotable: false is kept as it is when a rule with the same key arrives. With environments, rules are matched within each environment. Segments used only by an omitted rule are not promoted for it.
One override#
Feature variable overrides and global variable overrides can be protected one at a time. Once one override in a variable's list uses promotable, every override in that list needs a unique key:
description: Checkout flowbucketBy: userIdvariablesSchema: message: type: string defaultValue: Welcomerules: - key: everyone conditions: '*' percentage: 100 variableOverrides: message: - key: production-owner promotable: false conditions: internal value: Production previewA global variable's overrides work the same way. Production keeps its own support address:
description: Support email addresstype: stringoverrides: - key: production-owner promotable: false conditions: '*'Nested overrides are matched by key at every level, and protecting a parent protects everything under it.
One test assertion#
Production turns checkoutFlow on for the internal team and keeps it off for everyone else. Its spec protects the second fact, so promoting dev's spec cannot replace it. To protect a single assertion, give every assertion in the spec a key:
feature: checkoutFlowassertions: - key: production-rollout promotable: false description: Off for everyone else at: 50 context: userId: 8f2a team: sales expectedToBeEnabled: false - key: internal-team description: On for the internal team at: 50 context: userId: c4e1 team: engineering expectedToBeEnabled: trueKeys must exist in both sets' specs, since matching assertions by position would be unsafe, and promote stops with guidance if only one side has them. A matrix or children belong to their assertion and cannot be protected separately. A scenario has no assertions, so protect it at its top level. Assertion keys also give the Catalog stable links.
promotable is not supported on matrix cases, children, conditions, force entries, variations, variablesSchema entries or schema properties. Lint rejects it in most of them.
Allow only some directions#
By default, any set can be promoted to any other. promotionFlows allows only the directions you list:
module.exports = { sets: true, promotionFlows: [ { from: 'dev', to: 'staging' }, { from: 'staging', to: 'production' }, ],}promote --from=dev --to=production now fails, so changes pass through staging first.
Keep an audit record#
--audit writes a record of an applied promotion to .featurevisor/promotions/:
npx featurevisor promote \ --from=dev \ --to=staging \ --apply \ --audit=markdown--audit and --audit=json write JSON for scripts, and --audit=markdown writes a summary for people. Nothing is written without --apply.

