Skip to content

Commit

Permalink
fix(circleci): allow workflows with different jobs to be merged
Browse files Browse the repository at this point in the history
This was previously too strict and would return a conflict if two
hook installations wanted to modify the same workflow. This was quite
disruptive as most plugins want to modify the same 'tool-kit' workflow
and this shouldn't cause any issues as long as the plugins are affecting
different jobs in the workflow.
  • Loading branch information
ivomurrell committed Oct 2, 2024
1 parent bb1c084 commit 7ab3783
Show file tree
Hide file tree
Showing 2 changed files with 50 additions and 23 deletions.
9 changes: 6 additions & 3 deletions lib/schemas/src/hooks/circleci.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { z } from 'zod'

export const CircleCiCustom = z.record(z.unknown())
export type CircleCiCustom = z.infer<typeof CircleCiCustom>

export const CircleCiExecutor = z.object({
name: z.string(),
image: z.string()
Expand All @@ -17,19 +20,19 @@ export const CircleCiWorkflowJob = z.object({
requires: z.array(z.string()),
splitIntoMatrix: z.boolean().optional(),
runOnRelease: z.boolean().default(true),
custom: z.unknown().optional()
custom: CircleCiCustom.optional()
})
export type CircleCiWorkflowJob = z.infer<typeof CircleCiWorkflowJob>

export const CircleCiWorkflow = z.object({
name: z.string(),
jobs: z.array(CircleCiWorkflowJob),
runOnRelease: z.boolean().optional(),
custom: z.unknown().optional()
custom: CircleCiCustom.optional()
})
export type CircleCiWorkflow = z.infer<typeof CircleCiWorkflow>

export const CircleCiCustomConfig = z.record(z.unknown())
export const CircleCiCustomConfig = CircleCiCustom
export type CircleCiCustomConfig = z.infer<typeof CircleCiCustomConfig>

export const CircleCiSchema = z.object({
Expand Down
64 changes: 44 additions & 20 deletions plugins/circleci/src/circleci-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,22 @@ const isNotToolKitConfig = (config: string): boolean => !config.includes('tool-k

const isObject = (val: unknown): val is Record<string, unknown> => isPlainObject(val)

const rootOptionOverlaps = (root?: { name: string }[], other?: { name: string }[]): boolean => {
if (!root || !other) {
return false
}
const otherNames = other.map(({ name }) => name)
return root.map(({ name }) => name).some((name) => otherNames.includes(name))
}

const customOptionsOverlap = (
installation: Record<string, unknown>,
other: Record<string, unknown>
): boolean =>
Object.entries(installation).some(([key, value]) => {
installation?: Record<string, unknown>,
other?: Record<string, unknown>
): boolean => {
if (!installation || !other) {
return false
}
return Object.entries(installation).some(([key, value]) => {
if (key in other) {
const otherVal = other[key]
if (isObject(value) && isObject(otherVal)) {
Expand All @@ -213,19 +224,31 @@ const customOptionsOverlap = (
return false
}
})
}

const rootOptionOverlaps = (root: { name: string }[], other: { name: string }[]): boolean => {
const otherNames = other.map(({ name }) => name)
return root.map(({ name }) => name).some((name) => otherNames.includes(name))
const workflowOptionsOverlap = (installation?: CircleCiWorkflow[], other?: CircleCiWorkflow[]): boolean => {
if (!installation || !other) {
return false
}
return installation.some((installationWorkflow) => {
const otherWorkflow = other.find(({ name }) => installationWorkflow.name === name)
return (
otherWorkflow &&
((installationWorkflow.runOnRelease !== undefined &&
otherWorkflow.runOnRelease !== undefined &&
installationWorkflow.runOnRelease !== otherWorkflow.runOnRelease) ||
customOptionsOverlap(installationWorkflow.custom, otherWorkflow.custom) ||
rootOptionOverlaps(installationWorkflow.jobs, otherWorkflow.jobs))
)
})
}

const installationsOverlap = (
installation: HookInstallation<CircleCiOptions>,
other: HookInstallation<CircleCiOptions>
): boolean =>
customOptionsOverlap(installation.options?.custom ?? {}, other.options?.custom ?? {}) ||
rootOptionKeys.some((rootOption) =>
rootOptionOverlaps(installation.options?.[rootOption] ?? [], other.options?.[rootOption] ?? [])
const installationOptionsOverlap = (installation: CircleCiOptions, other: CircleCiOptions): boolean =>
customOptionsOverlap(installation.custom, other.custom) ||
workflowOptionsOverlap(installation.workflows, other.workflows) ||
rootOptionKeys.some(
(rootOption) =>
rootOption !== 'workflows' && rootOptionOverlaps(installation[rootOption], other[rootOption])
)

// classify installation as either mergeable or unmergeable, and mark any other
Expand All @@ -236,12 +259,12 @@ const partitionInstallations = (
currentlyUnmergeable: HookInstallation<CircleCiOptions>[]
): [HookInstallation<CircleCiOptions>[], HookInstallation<CircleCiOptions>[]] => {
const [noLongerMergeable, mergeable] = partition(currentlyMergeable, (other) =>
installationsOverlap(installation, other)
installationOptionsOverlap(installation.options, other.options)
)
const unmergeable = currentlyUnmergeable.concat(noLongerMergeable)

const overlapsWithUnmergeable = currentlyUnmergeable.some((other) =>
installationsOverlap(installation, other)
installationOptionsOverlap(installation.options, other.options)
)
if (noLongerMergeable.length > 0 || overlapsWithUnmergeable) {
unmergeable.push(installation)
Expand Down Expand Up @@ -393,7 +416,7 @@ export default class CircleCi extends Hook<typeof CircleCiSchema, CircleCIState>
// assumes a parent resolving one conflict resolves them all
if (isConflict(installation)) {
const [canHandle, cannotHandle] = partition(installation.conflicting, (other) =>
installationsOverlap(parentInstallation, other)
installationOptionsOverlap(parentInstallation.options, other.options)
)

mergeable.push(...canHandle)
Expand Down Expand Up @@ -426,15 +449,16 @@ export default class CircleCi extends Hook<typeof CircleCiSchema, CircleCIState>
})
)
}
this.generatedConfig = mergeWithConcatenatedArrays(
const generatedConfig = mergeWithConcatenatedArrays(
{},
this.options.disableBaseConfig ? {} : getBaseConfig(),
generated,
this.options.custom ?? {}
)
this.generatedConfig = generatedConfig
return generatedConfig
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.generatedConfig!
return this.generatedConfig
}

async isInstalled(): Promise<boolean> {
Expand Down

0 comments on commit 7ab3783

Please sign in to comment.