Parallel Development in Optimizely CMS SaaS: Shifting to a Schema Migration Mindset
Part 3 of the Parallel Development series.
The branch-scoped push script from Part 1 works. Run it on a feature branch where you've touched a handful of independent content types and it does exactly what it promises — pushes only your changes, leaves your teammate's work untouched, no --force required.
For most of your day-to-day work, that's enough. The majority of content type changes are leaf changes: you added a field to a page, you tweaked a block, you updated a template. The script handles all of those cleanly.
But as soon as you start moving past basic, isolated components and build toward a real content model—with custom contracts, shared property groups, and complex validation rules—the landscape changes. The script still works, but running selective push against a full, production-grade schema teaches you a few things worth mapping out before you hit them cold. None of this is a flaw in the selective push approach. These dependency cases exist in any code-first workflow with the CLI, whether you push selectively or all at once. What changes is whether you walk into them knowing what's happening, or whether you spend an afternoon confused at an API rejection.
This post maps the five hidden dependency cases, shows what each looks like in code, and proposes a repo structure plus a reviewable push workflow that makes them more manageable. These are starting points and open questions — not a finished solution. We'd genuinely like to hear how other teams are thinking about this.
First: "Why not just always full push?"
It's the fair question, and worth answering before anything else — because if full push were always fine, none of this would matter.
Full push is authoritative and safe against a clean, canonical branch. That's exactly why it's the right tool for your CI/CD pipeline pushing from main. But on a shared development instance where several people have unmerged work, full push is the opposite of safe: it syncs your entire local model, including stale or half-built types, over whatever your teammates have pushed. That's the collision Part 1 set out to solve.
And when the CLI suggests --force? That's not a fix. It's the CLI telling you the remote already has changes you're about to overwrite. The correct response is to sync with your team, not to force through. Force is how one developer silently clobbers another's content types.
So the real trade-off isn't "selective push (hard) vs full push (easy)." It's:
-
Independent leaf changes (the 80%) — selective push is fully automatic and strictly better. No contest.
-
The five dependency cases — selective push still beats full push on a shared instance, because a scoped-but-complete push includes what's needed and touches nothing else. The catch is you have to know what "complete" means. That's what this article tries to make knowable.
-
Key renames and deletions — these genuinely are "stop and coordinate" moments. The honest value here isn't a clever script; it's recognising you're in one, so you sync with the team and run a deliberate full push instead of forcing blindly.
That's the frame for everything below. This isn't about avoiding full push forever — it's about knowing which situation you're in.
The Five Hidden Dependencies
In Optimizely CMS SaaS, we've identified five distinct cases where a change to one file creates a dependency that requires other files to be pushed in the same payload. Before we look at how to handle them, here is the quick map of the territory:
-
Contract changes — Modifying an interface pulls in all content types implementing it.
-
Property group changes — Renaming or removing a group key affects every content type assigned to it.
-
Component embeds — Schema shifts on a component affect any content type using them as typed properties.
-
Validation constraints — Changes to referenced type keys break allowedTypes or restrictedTypes in other files.
-
Key renames — A key value change is a destructive delete-and-create that orphans content and breaks external references.
For each case, the "how to handle it" notes reflect our own thinking and what seems reasonable — not tested playbooks. Your project's structure and conventions will change what actually works.
Case 1 — Contract change → all implementing types
Contracts define a shared set of properties that multiple content types implement. Add a field to a contract, and every implementing type has a stale resolved schema on the server — because that field is now part of their schema too, whether they declared it explicitly or not.
This is transitive. If Contract A is implemented by Contract B, and Contract B is implemented by five page types, a change to Contract A means all six need to be in the same push.
Why isolated contract pushes fail
The temptation is to slice pushes into layers — a "foundation" config that covers contracts, a "types" config that covers pages and blocks. Different concerns, different cadence. The logic is sound.
Then you try it on a live instance where types already implement the contract:
# Fails if any content type already implements the contract on the target instance
npx @optimizely/cms-cli@latest push --config optimizely.config.foundation.mjs
The CLI errors out. The API rejects the payload.
Based on observed behaviour, when a contract changes on the server, the CMS appears to immediately recompile the fully resolved schema of every implementing type. If those types aren't in the same push payload, the API rejects the whole operation rather than leave the instance in a half-compiled state. This is the right call — it's protecting you. But it means contracts and their implementers can't travel separately once the contract is applied.
Note on code examples: The JSON and TypeScript shapes shown throughout this article are illustrative of the concepts. Always check the current CLI --help output and the REST API reference for exact syntax on your version.
// Contract — POST https://api.cms.optimizely.com/v1/contenttypes
{
"key": "categorizable",
"displayName": "Categorizable",
"properties": {
"category": { "type": "string", "displayName": "Category" },
"tags": { "type": "array", "displayName": "Tags", "items": { "type": "string" } }
}
}
// Content type implementing the contract
// Adding a field to 'categorizable' above means this type must be in the same push
{
"key": "articlePage",
"baseType": "_page",
"contracts": ["categorizable"],
"properties": {
"title": { "type": "string", "displayName": "Title" },
"body": { "type": "richText", "displayName": "Body" }
}
}
One possible approach: Scan all content-type files for any import or string reference to the changed contract's file name, and include matches in the push set. Whether that works reliably depends on how consistently your codebase names and imports contracts — a loosely structured project may need a more explicit dependency map. We haven't found a clean universal answer here.
Case 2 — Property group change → all types referencing that group
Property groups are defined in optimizely.config.mjs via buildConfig and referenced by key inside property definitions. Change or remove that key, and any type that assigns properties to it fails validation.
// optimizely.config.mjs
import { buildConfig } from '@optimizely/cms-sdk';
export default buildConfig({
components: ['./src/content-model/**/*.tsx'],
propertyGroups: [
{ key: 'seo', displayName: 'SEO Settings', sortOrder: 10 }
// Rename 'seo' here → every type referencing this key must be re-pushed
]
});
// PATCH https://api.cms.optimizely.com/v1/contenttypes/articlePage
{
"key": "articlePage",
"baseType": "_page",
"properties": {
"metaTitle": { "type": "string", "displayName": "Meta Title", "group": "seo" },
"metaDescription": { "type": "string", "displayName": "Meta Description", "group": "seo" }
}
}
Rename the group key without re-pushing articlePage and its metaTitle / metaDescription properties now point to a group that doesn't exist on the instance.
One possible approach: Diff optimizely.config.mjs, extract changed group keys, and search content-type files for those strings. Since the config change is usually deliberate and visible in review, this might be something you handle at review time rather than automating — though what's right depends entirely on your team's workflow and how often these keys change.
Case 3 — Component used as a typed property → the embedding type
A _component content type can be declared as a fixed typed property on another content type — not just dropped into a content area at runtime, but declared in the schema as a property of that specific type. Optimizely Graph registers both TeaserBlock (standalone) and TeaserBlockProperty (when embedded as a schema property) to support this.
When the component's schema changes, the type that embeds it is resolving a shape that has shifted.
📖 Content modeling — content properties · Define content types
// The component — POST https://api.cms.optimizely.com/v1/contenttypes
{
"key": "teaserBlock",
"baseType": "_component",
"properties": {
"heading": { "type": "string", "displayName": "Heading" },
"image": { "type": "contentReference", "displayName": "Image" }
// Add a new required field here → homePage below must be in the same push
}
}
// A page declaring the component as a typed property
{
"key": "homePage",
"baseType": "_page",
"properties": {
"hero": { "type": "content", "displayName": "Hero Teaser", "contentType": "teaserBlock" }
}
}
One possible approach: Scan content-type files for references to the changed component's name or key. The tricky part is distinguishing a typed schema embed from a regular rendering import — a flat folder makes this ambiguous, which is part of why the folder structure below is worth considering.
Case 4 — allowedTypes / restrictedTypes references → the referencing type
allowedTypes and restrictedTypes are validation constraints on content and contentReference properties. They name other types by their key. That reference is invisible to a git diff.
📖 Content property — allowedTypes · Define content types
// PATCH https://api.cms.optimizely.com/v1/contenttypes/landingPage
{
"key": "landingPage",
"baseType": "_page",
"properties": {
"relatedContent": {
"type": "contentReference",
"displayName": "Related Content",
"allowedTypes": ["articlePage", "blogPage"]
}
}
}
// If 'blogPage' is renamed to 'blogPostPage' —
// git diff shows BlogPage.tsx changed
// git diff does NOT show landingPage.tsx changed
// but landingPage now references a key that doesn't exist
Adding a new type to allowedTypes is completely safe — it's in your diff and selective push handles it automatically.
One possible approach: A pre-push step that reads all allowedTypes / restrictedTypes arrays and verifies every key they name still exists in the codebase could act as an early warning. Whether that's worth building depends on how often your model changes and how much a broken shared environment costs your team.
Case 5 — Key rename: unavoidable sometimes, never trivial
⚠️ Design your keys well upfront and you likely won't face this. But if you do, know exactly what happens.
The key of a content type, contract, or property is its permanent identity on the CMS instance. There is no rename operation — what actually happens is a delete of the old + create of the new. The CMS treats them as two completely different entities.
Consequences:
-
All content items created under the old key are orphaned
-
Every type that names the old key — in allowedTypes, restrictedTypes, typed contentType properties, contracts arrays — is now broken
-
Git diff shows only the file where the key string changed, nothing about files that reference it elsewhere
📖 Manage content types — key field
// BEFORE: 'blogPage' is the live key
{ "key": "blogPage", "baseType": "_page" }
{ "allowedTypes": ["articlePage", "blogPage"] } // another type, referencing it
// AFTER: key renamed to 'blogPostPage'
{ "key": "blogPostPage", "baseType": "_page" } // BlogPage.tsx — in your diff
{ "allowedTypes": ["articlePage", "blogPage"] } // LandingPage.tsx — NOT in your diff, now broken
The realistic minimum when a rename is unavoidable:
-
Search the full codebase for the old key string (grep -r "blogPage" src/ is the starting point)
-
Update every file that references it in the same branch
-
Run a coordinated full push — not a selective push
-
Plan content migration separately — existing content under the old key is not auto-migrated
displayName is free to rename at any time. key is permanent the moment it lands on an instance.
One direction worth exploring: detect the rename at diff time
Git has the information you need — you can pull the old version of any changed file and compare key values:
# Get the old content of a file as it was on the base branch
git show origin/main:src/content-model/pages/BlogPage.tsx
For each modified content-type file: extract the old key and the new key. If they differ, search the codebase for the old key and surface every file that still references it. That turns a silent, easy-to-miss breakage into a loud early warning. You still need the coordinated full push — but at least you know why before you push, rather than after.
We haven't battle-tested this in a complex repo. It's a direction that seems worth exploring, not a recommendation.
The Safe Alternative: The Editorial Deprecation Pattern
Instead of performing a destructive key rename, the industry-standard best practice is to deprecate the old field and introduce the new one alongside it. While CMS SaaS doesn't have a native "deprecated" JSON flag in the schema, you can enforce this through an editorial convention in your code:
-
Remove constraints: Set "required": false on the old field so editors are never blocked.
-
Warn editors in the UI: Update its displayName to include (Obsolete) and use the description to point to the new property.
-
Warn developers in the IDE: Add a JSDoc @deprecated comment to the TypeScript interface.
// src/content-model/components/TeaserBlock.tsx
export interface TeaserBlockType extends ContentType {
heading: string;
/**
* @deprecated Use 'heroImage' instead. This field is scheduled for removal in Q4.
*/
imageLink?: string; // Suffix with ? to make it optional in TS
}
export const TeaserBlockSchema = {
key: 'teaserBlock',
baseType: '_component',
displayName: 'Teaser Block',
properties: {
heading: { type: 'string', displayName: 'Heading', required: true },
imageLink: {
type: 'string',
displayName: 'Image Link (Obsolete)',
description: 'Deprecated. Use the Hero Image property instead.',
required: false, // Must be optional to prevent editor lock-out
},
},
};
This keeps the old schema active, preserves historical data on production, and gives both developers and editors loud warnings. Only after several release cycles (when audit logs confirm no content remains in the old field) do you coordinate with your team to delete the key.
Our suggested approach: Treat renames as deliberate, planned events requiring a full codebase audit before any push. The git-blob detection above is a reasonable middle ground — cheap to build, fails loudly. But no script makes a key rename safe on its own.
The Repo Structure That Makes All of This More Tractable
Every detection approach above — scanning for contract references, extracting group keys, comparing old and new key values — relies on being able to tell what kind of thing a changed file is from where it lives. In a flat folder where contracts, pages, blocks, and templates all coexist, that falls apart immediately.
A deliberate folder structure is what makes any of this tractable. It won't solve the dependency problem on its own, but it gives your scripts — and your reviewers — something to reason from.
src/
└── content-model/ # Everything the CLI pushes as schema
├── contracts/ # Shared interfaces — Case 1 blast radius
│ ├── Categorizable.ts # category + tags fields shared across types
│ └── IHeroImage.ts # heroImage + heroAlt fields
├── pages/ # _page base type
│ ├── ArticlePage.tsx
│ ├── LandingPage.tsx
│ └── BlogPage.tsx
├── components/ # _component base type (blocks AND elements)
│ ├── TeaserBlock.tsx
│ ├── HeroBlock.tsx
│ ├── HeadingElement.tsx # an "element" is just a component with restricted config
│ └── ParagraphElement.tsx
├── experiences/ # _experience base type
│ └── BlankExperience.tsx
└── sections/ # _section base type
└── BlankSection.tsx
src/
└── templates/ # Display templates — decoupled, own push lane
├── ArticlePage.style.ts # Wide / Narrow display variants for ArticlePage
├── TeaserBlock.style.ts # Card / Banner display variants for TeaserBlock
└── section-grid.style.ts # 1-col / 2-col / 3-col section layout options
A note on elements: in the JavaScript SDK, an element is not a separate base type — it's a _component with restricted configuration (it's meant to be used as a Visual Builder building block rather than a standalone block). Because it's still a component, it lives in the same folder and behaves identically for push-dependency purposes. Group by base type, not by editorial role.
Property groups don't get their own files either — they live in optimizely.config.mjs as the propertyGroups array. If your config grows large you can split it into an imported module, but the source of truth stays the config file.
What templates actually are
A template file registers display variations that editors can apply to content nodes in Visual Builder. They are pure style/visual configuration — they carry no content schema. This is why they can be pushed on their own lane without any dependency scanning.
A display template applies to a content type (or base type, or structural node) and exposes editor-selectable settings:
// src/templates/TeaserBlock.style.ts
// POST https://api.cms.optimizely.com/v1/displaytemplates
export const teaserBlockCardStyle = {
key: 'TeaserBlockCard',
displayName: 'Card',
contentType: 'teaserBlock', // applies to this specific content type
settings: {
ImagePosition: {
displayName: 'Image Position',
editor: 'select',
choices: {
Left: { displayName: 'Left', sortOrder: 1 },
Right: { displayName: 'Right', sortOrder: 2 },
Top: { displayName: 'Top', sortOrder: 3 }
}
}
}
};
// src/templates/section-grid.style.ts
// A structural node template — applies to all sections
export const sectionGridStyle = {
key: 'SectionGrid',
displayName: 'Grid',
nodeType: 'section', // applies to all section nodes
settings: {
Columns: {
displayName: 'Columns',
editor: 'select',
choices: {
One: { displayName: '1 column', sortOrder: 1 },
Two: { displayName: '2 columns', sortOrder: 2 },
Three: { displayName: '3 columns', sortOrder: 3 }
}
}
}
};
📖 Configure Visual Builder · Manage styles
The folder pays off in the script
With this structure, a script can classify a changed file from its path alone:
-
A file under content-model/contracts/ → run the Case 1 scan
-
A file under content-model/pages/ or content-model/components/ → run the Case 3 scan
-
optimizely.config.mjs changed → run the Case 2 check
-
A file under templates/ → push on the templates lane, no schema scan needed
It also opens the door to something more responsive: a file watcher during development that flags dependency-affecting changes the moment you save, before you ever run a push. We haven't built that — it's a direction the structure makes possible.
The honest caveat: no folder convention survives a team that doesn't follow it. The structure only works if it's agreed, documented, and enforced.
Shifting Our Mindset: Content Modeling as Schema Migration
When you build on a headless, code-first content platform, your content type files are not just React rendering files. They represent your live database schema, your API contract, and your editorial UI layout all at once.
In traditional software engineering, we treat database schemas with extreme discipline. You would never let a CI/CD build run a migration that drops database columns containing live customer data. You write versioned migrations, test them locally, and deploy them sequentially.
Content modeling in CMS SaaS requires the exact same engineering discipline.
This realization completely changes how we govern our code-first pipelines and why selective pushes exist:
-
Selective push is your local iteration tool. It is how developers move quickly on a shared sandbox without overwriting other unmerged work.
-
The script is your diagnostic blueprint. When a build fails or conflicts occur, the branch-diff script is what you run to instantly map out the exact footprint of your changes and see which contracts or components are dragging dependents in.
-
CI/CD is your schema safety gate. It must run strictly, never forcing, and failing loudly if anything is out of sync.
Let’s look at how to structure your CI/CD pipeline to act as this hard firewall.
CI/CD Guardrails: The "Fail Loudly, Never Force" Principle
In lower environments (like individual developer sandboxes), selective pushes and the propose-review-push workflow provide rapid feedback loops. But as your code travels up to shared environments, the governance must tighten.
|
Environment |
Purpose |
Push Strategy |
CLI Flags |
|
Local Sandbox |
Individual dev space |
Scoped / Selective push |
Normal |
|
Shared QA / Stage |
Team integration & content entry |
Scoped or Full Push |
Strictly no --force |
|
Production |
Live consumer experience |
Authoritative Full Push |
Strictly no --force |
Enforcing the Safety Gate
The @optimizely/cms-cli is designed to fail loudly. If there is a schema conflict — such as a missing implementing type for a modified contract, or an invalid group key reference — the command exits with a non-zero exit code (typically 1).
Any standard CI/CD engine (GitHub Actions, GitLab CI, Azure DevOps) will intercept this non-zero exit code and immediately fail the build step.
By running your staging and production pipelines without the --force flag, you ensure that if a developer checked in a breaking change without coordination, the build halts before a single production schema is modified or live data is orphaned.
Diagnostic Recovery Workflow
When the build fails, the team doesn't have to guess what broke. A developer runs the push-branch-changes.mjs script locally comparing the release branch to the target base (e.g., origin/main):
npm run cms:push:types release/q3 origin/main
The script outputs the precise dependency tree of the changed files. The team can instantly pinpoint: "Ah! Someone updated the contract, which dragged in ArticlePage. But ArticlePage on Stage has custom fields that are still locked in another draft. That's our conflict."
It turns a black-box build failure into an actionable, reviewable transaction map.
The Workflow: Propose, Review, Push
The script below doesn't push for you automatically. It detects, proposes, and hands you a reviewable config. You decide whether to push it.
The loop:
-
Run one command against your branch.
-
The script diffs your branch, classifies each changed file by its folder, and runs the relevant dependency scans for Cases 1–3.
-
It writes a temporary config listing the proposed push set — your changed files plus every dependent it found — and prints the list.
-
You review the list. Does it look right? Anything surprising, or anything missing you expected? For Cases 4–5 it flags what needs a human decision.
-
If you're satisfied, you run the push command it hands you.
This isn't overhead instead of a solution. It's a gate that makes a scoped push reviewable before it lands on a shared instance — something a blind full push can't give you.
Two commands, split by concern:
// package.json
"scripts": {
"cms:push:templates": "npx @optimizely/cms-cli@latest push --config optimizely.config.templates.mjs",
"cms:push:types": "node push-branch-changes.mjs"
}
Templates lane — display templates and Visual Builder styles. Zero schema risk. No review needed.
Types lane — content types and contracts together, via the propose-review-push script below.
One Rough Starting Point for the Script
This extends the script from Part 1 to attempt dependency detection for Cases 1–3. We're sharing it as a concept, not a drop-in solution. It makes simplifying assumptions — consistent folder structure, filename-based detection, flat directory scanning — that won't hold in every project. Treat it as something to adapt, not something to copy.
Cases 4 and 5 (key renames and deletions) are not automatable in any reliable way. The script blocks on file deletions to at least surface the problem. Key value renames it cannot detect at all without the git-blob comparison described in Case 5.
// push-branch-changes.mjs — adapt to your folder structure
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const baseBranch = process.argv[2] || 'origin/main';
const tempConfig = 'optimizely.config.temp.mjs';
// Match your actual folder structure. Note: elements live under components.
const PAGES_DIR = 'src/content-model/pages';
const COMPONENTS_DIR = 'src/content-model/components';
const CONTRACTS_DIR = 'src/content-model/contracts';
const ALL_TYPES_DIRS = [PAGES_DIR, COMPONENTS_DIR, CONTRACTS_DIR];
try {
console.log(`Diffing against ${baseBranch}...`);
const gitOutput = execSync(`git diff ${baseBranch} --name-only`, { encoding: 'utf8' });
const changedFiles = gitOutput
.split('\n').map(f => f.trim())
.filter(f => f.length > 0 && fs.existsSync(f));
if (changedFiles.length === 0) {
console.log('No changes. Nothing to push.');
process.exit(0);
}
// ── Block on deletions — requires human audit before any push
const deletedFiles = execSync(
`git diff ${baseBranch} --name-only --diff-filter=D`, { encoding: 'utf8' }
).split('\n').map(f => f.trim()).filter(Boolean);
const deletedTypes = deletedFiles.filter(f => ALL_TYPES_DIRS.some(d => f.startsWith(d)));
if (deletedTypes.length > 0) {
console.error('\n[BLOCKED] Deleted content type or contract files:');
deletedTypes.forEach(f => console.error(` - ${f}`));
console.error('\nAudit all referencing files before running a coordinated full push.\n');
process.exit(1);
}
const pushRegistry = new Set();
changedFiles
.filter(f => ALL_TYPES_DIRS.some(d => f.startsWith(d)))
.forEach(f => pushRegistry.add(f));
// All content-model files — used for scanning
const allTypeFiles = ALL_TYPES_DIRS.flatMap(dir =>
fs.existsSync(dir)
? fs.readdirSync(dir, { recursive: true })
.filter(f => f.endsWith('.tsx') || f.endsWith('.ts'))
.map(f => path.join(dir, f))
: []
);
// ── Case 1: Contract changes → scan for implementing types
const changedContracts = changedFiles.filter(f => f.startsWith(CONTRACTS_DIR));
if (changedContracts.length > 0) {
console.log('\n[Case 1] Contract changes — scanning for implementing types...');
const names = changedContracts.map(f => path.basename(f, path.extname(f)));
expandDependents(allTypeFiles, names, pushRegistry, 'implements contract');
}
// ── Case 2: Property group key changes → scan for referencing types
const changedConfig = changedFiles.find(f => f === 'optimizely.config.mjs');
if (changedConfig) {
const src = fs.readFileSync(changedConfig, 'utf8');
const groupKeys = [...src.matchAll(/key:\s*['"](\w[\w-]*)['"]\s*,\s*displayName/g)].map(m => m[1]);
if (groupKeys.length > 0) {
console.log(`\n[Case 2] Property group keys (${groupKeys.join(', ')}) — scanning referencing types...`);
expandDependents(allTypeFiles, groupKeys, pushRegistry, 'references group key');
}
}
// ── Case 3: Component/page changes → scan for types that embed them
const changedComponents = changedFiles.filter(f =>
f.startsWith(COMPONENTS_DIR) || f.startsWith(PAGES_DIR)
);
if (changedComponents.length > 0) {
const names = changedComponents.map(f => path.basename(f, path.extname(f)));
console.log('\n[Case 3] Component/page changes — scanning for embedding types...');
expandDependents(allTypeFiles, names, pushRegistry, 'embeds as typed property');
}
const finalPushList = Array.from(pushRegistry);
if (finalPushList.length === 0) {
console.log('No pushable schema changes found.');
process.exit(0);
}
// ── Propose: write the temp config and print it for review
console.log(`\nProposed push set (${finalPushList.length} files) — review before confirming:`);
finalPushList.forEach(f => console.log(` - ${f}`));
const filePaths = finalPushList.map(f => `'./${f}'`).join(',\n ');
fs.writeFileSync(tempConfig,
`import { buildConfig } from '@optimizely/cms-sdk';
export default buildConfig({ components: [\n ${filePaths}\n ] });
`, 'utf8');
console.log(`\nWrote ${tempConfig}. Review it, then push with:`);
console.log(` npx @optimizely/cms-cli@latest push --config ${tempConfig}\n`);
// The script stops here and hands you the command rather than pushing automatically.
// If you're confident in the detection for your project, you can execSync the push instead.
} catch (err) {
console.error('Failed:', err.message);
if (fs.existsSync(tempConfig)) fs.unlinkSync(tempConfig);
process.exit(1);
}
function expandDependents(allFiles, names, registry, reason) {
for (const filePath of allFiles) {
if (registry.has(filePath)) continue;
const source = fs.readFileSync(filePath, 'utf8');
const matched = names.find(name => source.includes(name));
if (matched) {
console.log(` -> Including ${filePath} (${reason}: '${matched}')`);
registry.add(filePath);
}
}
}
Known limitations worth being honest about: String scanning produces false positives in loosely structured projects — a rendering import looks identical to a schema embed reference. The property group regex is fragile if your config format differs. Key renames inside a still-present file are invisible to this script entirely. These aren't edge cases to dismiss — they're reasons to test this against your actual project before leaning on it.
Forward Looking: Maturing to a Shared Package
For larger enterprise projects or teams handling multiple concurrent front-end applications, a final maturity step is to extract the content model layer into its own npm package (such as @yourorg/content-model).
This moves your schema definitions into their own independent repository with strict versioning and ownership:
-
Strict Review Gates: The package can have its own package.json, its own CI validation, and its own CODEOWNERS file — meaning no developer can alter a contract or change a schema key without explicit approval from a content architect.
-
Semantic Versioning: Schema changes are published as versioned releases. A breaking key rename is mapped to a Major version bump, acting as a clear, standard signal to every consuming front-end application.
-
Isolate the Pipeline: The CI/CD push logic lives with the package that defines the models. The client applications simply consume the published TypeScript interfaces.
While a separate package adds publishing overhead that might be overkill for smaller single-team projects, it represents the logical north star for teams managing Optimizely CMS SaaS at true scale.
Design-Time Guidelines
The structure and script address what happens. These habits reduce how many cases happen in the first place.
-
Treat contracts like a public API. Get their shape right before multiple types implement them. Every field you add later drags every implementer into the same push. Small, focused contracts are cheaper to change than broad ones.
-
Freeze keys once a type lands on any shared instance. Key rename = delete + create. Change displayName freely — it's cosmetic. Treat key as permanent from day one.
-
Prefer additive changes. Adding a property is safe and localised. Deprecate and add rather than rename or delete.
-
Separate your content-model layer from your app code and templates. The folder structure is what makes any detection approach tractable.
-
Push in dependency order on fresh environments. Property groups first, then contracts, then types that implement them.
-
Route true breaking changes through a full push. Key renames, deletions, dropping a contract — these are coordination events. Merge, let CI/CD do the authoritative full push, plan content migration separately.
Summary
|
What changed |
Dependency created |
Direction to explore |
|
Field added to a contract |
All implementing types (transitive) |
Scan by contract folder + name — reliability varies |
|
Property group key changed |
All types using that group key |
Diff config + scan, or manual review at PR time |
|
Component schema changed |
All types embedding it as a typed property |
Scan by component folder + name |
|
Type added to allowedTypes |
None — stays in your diff |
No extra action needed |
|
Referenced type key renamed or deleted |
All types naming it anywhere |
Git-blob compare to detect + coordinated full push |
|
Environment |
Templates |
Content types + contracts |
Key renames |
|
Developer instances |
Full push of template config |
Propose-review-push (adapted to your project) |
Never selective |
|
Shared QA / Staging |
Full push on merge |
Full push on merge |
Full push after team coordination |
|
Production |
CI/CD on release |
CI/CD on release |
Coordinated release + content migration |
The Point
Part 1 gave you selective push — it works, and it's still the right approach for the vast majority of day-to-day content type changes.
This part maps the cases where it gets more complex: when a single-file change reaches further than the diff suggests. The five cases are real — the CMS validates them this way and you will hit them. The folder structure and propose-review-push workflow are directions we think are worth exploring, not a proven system.
We're publishing this partly because the answer isn't fully settled. If you've solved any of these more cleanly — or found that some of this doesn't hold up in practice — that's exactly the kind of feedback this series is for.
Know the cases. Structure the repo. Let the workflow propose, and you review.
Have you hit any of these dependency cases on a project? Particularly curious whether the folder-structure approach holds up in practice, and whether anyone has a cleaner answer for the key-rename detection. Comments open below.
Comments