Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@
"additionalProperties": false,
"required": [],
"properties": {
"extends": {
"$ref": "#/definitions/Identifier",
"description": "Name of another flavor to inherit patches and defaults from. The parent's patches are applied before this flavor's own patches."
},
"patches": {
"type": "array",
"items": {
Expand Down Expand Up @@ -447,6 +451,10 @@
"additionalProperties": false,
"required": [],
"properties": {
"extends": {
"$ref": "#/definitions/Identifier",
"description": "Name of another flavor (within the same component) to inherit patches from. The parent's patches are applied before this flavor's own patches."
},
"patches": {
"type": "array",
"items": {
Expand Down
5 changes: 4 additions & 1 deletion src/docker/resources/DockerImageResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,10 @@ class DockerImageResourceBuilder extends SentinelFileBasedBuilder<
async _mustBuild(
resource: ResourceInfo<DockerImageResourceConfig>,
): Promise<DockerImageSentinel | undefined> {
const flavor = this.monorepo.flavors[this.monorepo.currentFlavor];
const flavorName = this.monorepo.currentFlavor;
const flavor = this.monorepo.flavors[flavorName]
? this.monorepo.flavor(flavorName)
: undefined;
const trigger = resolveRebuildTrigger({
resource: resource.rebuildTrigger,
flavor: flavor?.defaults?.rebuildPolicy?.['docker/image'],
Expand Down
12 changes: 8 additions & 4 deletions src/monorepo/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
toIdentifedHash,
} from '@/monorepo';

import { resolveComponentFlavor } from './flavor.js';

export class Component implements ComponentConfig {
public readonly _rootDir?: string;
public readonly tasks: Tasks;
Expand All @@ -37,13 +39,15 @@ export class Component implements ComponentConfig {
}

flavor(name: string, mustExist = true): ComponentFlavorConfig {
const flavor = this.flavors[name];
if (!this.flavors[name]) {
if (mustExist) {
throw new Error(`Unknown flavor: ${name}`);
}

if (!flavor && mustExist) {
throw new Error(`Unknown flavor: ${name}`);
return undefined as unknown as ComponentFlavorConfig;
}

return flavor;
return resolveComponentFlavor(this.flavors, name);
}

cloneWith(config: Partial<ComponentConfig>): ComponentConfig {
Expand Down
10 changes: 3 additions & 7 deletions src/monorepo/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
ProjectFlavorConfig,
} from '@/config';

import { resolveProjectFlavor } from './flavor.js';

export class MonorepoConfig implements EMBConfig {
project: ProjectConfig;
defaults: DefaultsConfig;
Expand Down Expand Up @@ -42,13 +44,7 @@ export class MonorepoConfig implements EMBConfig {
}

flavor(name: string): ProjectFlavorConfig {
const flavor = this.flavors[name];

if (!flavor) {
throw new Error(`Unknown flavor: ${name}`);
}

return flavor;
return resolveProjectFlavor(this.flavors, name);
}

toJSON(): Required<EMBConfig> {
Expand Down
112 changes: 112 additions & 0 deletions src/monorepo/flavor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import deepMerge from '@fastify/deepmerge';

import {
ComponentFlavorConfig,
JsonPatchOperation,
ProjectFlavorConfig,
} from '@/config/schema.js';
import { CircularDependencyError, UnkownReferenceError } from '@/errors.js';

type FlavorLike = {
extends?: string;
patches?: JsonPatchOperation[];
};

function buildChain<T extends FlavorLike>(
flavors: Record<string, T>,
name: string,
subject: string,
): string[] {
const chain: string[] = [];
const visited = new Set<string>();
let current: string | undefined = name;

while (current !== undefined) {
if (visited.has(current)) {
const cycle = [...chain, current].join(' -> ');
throw new CircularDependencyError(
`Circular ${subject} inheritance detected: ${cycle}`,
[[...chain, current]],
);
}

const config: T | undefined = flavors[current];

if (!config) {
if (chain.length === 0) {
throw new UnkownReferenceError(
`Unknown ${subject}: ${current}`,
current,
);
}

const parent = chain.at(-1);
throw new UnkownReferenceError(
`Unknown parent ${subject}: '${current}' (extended from '${parent}')`,
current,
);
}

visited.add(current);
chain.push(current);
current = config.extends;
}

return chain.reverse();
}

export function resolveProjectFlavor(
flavors: Record<string, ProjectFlavorConfig>,
name: string,
): ProjectFlavorConfig {
const chain = buildChain(flavors, name, 'flavor');
const patches: JsonPatchOperation[] = [];
let defaults: ProjectFlavorConfig['defaults'] | undefined;
const merge = deepMerge();

for (const flavorName of chain) {
const f = flavors[flavorName];
if (f.patches) {
patches.push(...f.patches);
}

if (f.defaults) {
defaults = defaults
? (merge(defaults, f.defaults) as ProjectFlavorConfig['defaults'])
: f.defaults;
}
}

const resolved: ProjectFlavorConfig = {};
if (patches.length > 0) {
resolved.patches = patches;
}

if (defaults) {
resolved.defaults = defaults;
}

return resolved;
}

export function resolveComponentFlavor(
flavors: Record<string, ComponentFlavorConfig>,
name: string,
): ComponentFlavorConfig {
const chain = buildChain(flavors, name, 'component flavor');
const patches: JsonPatchOperation[] = [];

for (const flavorName of chain) {
const f = flavors[flavorName];
if (f.patches) {
patches.push(...f.patches);
}
}

const resolved: ComponentFlavorConfig = {};
if (patches.length > 0) {
resolved.patches = patches;
}

return resolved;
}
1 change: 1 addition & 0 deletions src/monorepo/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './component.js';
export * from './config.js';
export * from './flavor.js';
export * from './monorepo.js';
export * from './operations/index.js';
export * from './plugins/index.js';
Expand Down
4 changes: 4 additions & 0 deletions src/monorepo/monorepo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export class Monorepo {
return this._config.flavors;
}

flavor(name: string) {
return this._config.flavor(name);
}

get name() {
return this._config.project.name;
}
Expand Down
13 changes: 10 additions & 3 deletions src/monorepo/operations/resources/PublishResourcesOperation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,23 @@ export class PublishResourcesOperation extends AbstractOperation<
let targetResources: ResourceInfo[];
if (input.resources && input.resources.length > 0) {
// Resolve order using full collection, then filter to only publishable
const orderedResources = findRunOrder(input.resources, allResourcesCollection);
targetResources = orderedResources.filter((r) => publishableIds.has(r.id));
const orderedResources = findRunOrder(
input.resources,
allResourcesCollection,
);
targetResources = orderedResources.filter((r) =>
publishableIds.has(r.id),
);
} else {
// All publishable resources - resolve order using full collection
const orderedResources = findRunOrder(
publishableResources.map((r) => r.id),
allResourcesCollection,
);
// Filter to only publishable (dependencies are resolved but not published)
targetResources = orderedResources.filter((r) => publishableIds.has(r.id));
targetResources = orderedResources.filter((r) =>
publishableIds.has(r.id),
);
}

// Verify each resource's builder supports publish
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/docker/resources/DockerImageResource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ describe('Docker / DockerImageResource', () => {
},
},
flavors: {},
flavor(name: string) {
return (this as unknown as Monorepo).flavors[name];
},
} as unknown as Monorepo;

mockComponent = {
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/monorepo/component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,34 @@ describe('Monorepo / Component', () => {

expect(() => component.withFlavor('broken')).toThrow();
});

test('it applies patches from an extended flavor before the child patches', () => {
const component = new Component(
'api',
{
rootDir: 'api',
description: 'base',
flavors: {
production: {
patches: [
{ op: 'replace', path: '/rootDir', value: 'api-prod' },
{ op: 'replace', path: '/description', value: 'production' },
],
},
test: {
extends: 'production',
patches: [{ op: 'replace', path: '/description', value: 'test' }],
},
},
},
monorepo,
);

const flavored = component.withFlavor('test');

expect(flavored.rootDir).toBe('api-prod');
expect(flavored.config.description).toBe('test');
});
});

describe('#join()', () => {
Expand Down
Loading
Loading