Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow CircleCI workflow job options to be overridden #719

Merged
merged 2 commits into from
Nov 27, 2024
Merged
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
108 changes: 61 additions & 47 deletions lib/schemas/src/hooks/circleci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,79 @@ 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()
})
export const CircleCiExecutor = z
.object({
name: z.string(),
image: z.string()
})
.partial()
.required({ name: true })
apaleslimghost marked this conversation as resolved.
Show resolved Hide resolved
export type CircleCiExecutor = z.infer<typeof CircleCiExecutor>

export const CircleCiJob = z.object({
name: z.string(),
command: z.string()
})
export const CircleCiJob = z
.object({
name: z.string(),
command: z.string()
})
.partial()
.required({ name: true })
export type CircleCiJob = z.infer<typeof CircleCiJob>

export const CircleCiWorkflowJob = z.object({
name: z.string(),
requires: z.array(z.string()),
splitIntoMatrix: z.boolean().optional(),
runOnRelease: z.boolean().default(true),
custom: CircleCiCustom.optional()
})
export const CircleCiWorkflowJob = z
.object({
name: z.string(),
requires: z.array(z.string()),
splitIntoMatrix: z.boolean().optional(),
runOnRelease: z.boolean().default(true),
custom: CircleCiCustom.optional()
})
.partial()
.required({ name: true })
export type CircleCiWorkflowJob = z.infer<typeof CircleCiWorkflowJob>

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

export const CircleCiCustomConfig = CircleCiCustom
export type CircleCiCustomConfig = z.infer<typeof CircleCiCustomConfig>

export const CircleCiSchema = z.object({
executors: z
.array(CircleCiExecutor)
.optional()
.describe('an array of additional CircleCI executors to output in the generated config.'),
jobs: z
.array(CircleCiJob)
.optional()
.describe(
'an array of additional CircleCI jobs to output in the generated config. these are used for running Tool Kit commands. for running arbitrary shell commands, use `custom`.'
export const CircleCiSchema = z
.object({
executors: z
.array(CircleCiExecutor)
.optional()
.describe('an array of additional CircleCI executors to output in the generated config.'),
jobs: z
.array(CircleCiJob)
.optional()
.describe(
'an array of additional CircleCI jobs to output in the generated config. these are used for running Tool Kit commands. for running arbitrary shell commands, use `custom`.'
),
workflows: z
.array(CircleCiWorkflow)
.optional()
.describe(
'an array of additional CircleCI workflows to output in the generated config. these reference jobs defined in the `jobs` option.'
),
custom: CircleCiCustomConfig.optional().describe(
'arbitrary additional CircleCI configuration that will be merged into the Tool Kit-generated config.'
),
workflows: z
.array(CircleCiWorkflow)
.optional()
.describe(
'an array of additional CircleCI workflows to output in the generated config. these reference jobs defined in the `jobs` option.'
),
custom: CircleCiCustomConfig.optional().describe(
'arbitrary additional CircleCI configuration that will be merged into the Tool Kit-generated config.'
),
disableBaseConfig: z
.boolean()
.optional()
.describe(
'set to `true` to omit the Tool Kit CircleCI boilerplate. should be used along with `custom` to provide your own boilerplate.'
)
})
disableBaseConfig: z
.boolean()
.optional()
.describe(
'set to `true` to omit the Tool Kit CircleCI boilerplate. should be used along with `custom` to provide your own boilerplate.'
)
})
.partial()
.describe(`This hook automatically manages \`.circleci/config.yml\` in your repo to provide configuration for CircleCI workflows to run Tool Kit commands and tasks.
Options provided in your repository's \`.toolkitrc.yml\` for this hook are merged with any Tool Kit plugin that also provides options for the hook.
Expand Down
48 changes: 38 additions & 10 deletions plugins/circleci/src/circleci-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {
CircleCiOptions,
CircleCiSchema,
CircleCiWorkflow
CircleCiWorkflow,
CircleCiWorkflowJob
} from '@dotcom-tool-kit/schemas/lib/hooks/circleci'
import { type Conflict, isConflict } from '@dotcom-tool-kit/conflict'
import { Hook, type HookInstallation } from '@dotcom-tool-kit/base'
Expand Down Expand Up @@ -74,7 +75,7 @@ export type CircleCIState = CircleConfig
* hook's config into the larger state, and so each hook only needs to define
* the parts of the config they want to add to.
*/
export type CircleCIStatePartial = PartialDeep<CircleCIState>
export type CircleCIStatePartial = PartialDeep<CircleCIState, { recurseIntoArrays: true }>

// Make this function lazy so that the global options object will have been
// populated first.
Expand Down Expand Up @@ -226,6 +227,27 @@ const customOptionsOverlap = (
})
}

const areDefinedAndUnequal = <T>(a: T | undefined, b: T | undefined): boolean =>
a !== undefined && b !== undefined && a !== b

const workflowJobOptionsOverlap = (
installation?: CircleCiWorkflowJob[],
other?: CircleCiWorkflowJob[]
): boolean => {
if (!installation || !other) {
return false
}
return installation.some((installationWorkflowJob) => {
const otherWorkflowJob = other.find(({ name }) => installationWorkflowJob.name === name)
return (
otherWorkflowJob &&
(areDefinedAndUnequal(installationWorkflowJob.runOnRelease, otherWorkflowJob.runOnRelease) ||
areDefinedAndUnequal(installationWorkflowJob.splitIntoMatrix, otherWorkflowJob.splitIntoMatrix) ||
customOptionsOverlap(installationWorkflowJob.custom, otherWorkflowJob.custom))
)
})
}

const workflowOptionsOverlap = (installation?: CircleCiWorkflow[], other?: CircleCiWorkflow[]): boolean => {
if (!installation || !other) {
return false
Expand All @@ -234,11 +256,9 @@ const workflowOptionsOverlap = (installation?: CircleCiWorkflow[], other?: Circl
const otherWorkflow = other.find(({ name }) => installationWorkflow.name === name)
return (
otherWorkflow &&
((installationWorkflow.runOnRelease !== undefined &&
otherWorkflow.runOnRelease !== undefined &&
installationWorkflow.runOnRelease !== otherWorkflow.runOnRelease) ||
(areDefinedAndUnequal(installationWorkflow.runOnRelease, otherWorkflow.runOnRelease) ||
customOptionsOverlap(installationWorkflow.custom, otherWorkflow.custom) ||
rootOptionOverlaps(installationWorkflow.jobs, otherWorkflow.jobs))
workflowJobOptionsOverlap(installationWorkflow.jobs, otherWorkflow.jobs))
)
})
}
Expand Down Expand Up @@ -290,7 +310,15 @@ const mergeInstallations = (installations: HookInstallation<CircleCiOptions>[]):
const rootOptions = installations.flatMap<{ name: string }>(
(installation) => installation.options[rootKey] ?? []
)
return [rootKey, mergeRootOptions(rootOptions)]
const mergedOptions = mergeRootOptions(rootOptions)
if (rootKey === 'workflows') {
mergedOptions.forEach((workflow: CircleCiWorkflow) => {
if (workflow.jobs) {
workflow.jobs = mergeRootOptions(workflow.jobs)
}
})
}
return [rootKey, mergedOptions]
})
),
// squash all the custom options together
Expand Down Expand Up @@ -325,9 +353,9 @@ const mergeInstallationResults = (

const toolKitOrbPrefix = (job: string) => `tool-kit/${job}`

const generateJobs = (workflow: CircleCiWorkflow): Job[] => {
const generateJobs = (workflow: CircleCiWorkflow): Job[] | undefined => {
const runsOnMultipleNodeVersions = getNodeVersions().length > 1
return workflow.jobs.map((job) => {
return workflow.jobs?.map((job) => {
const splitIntoMatrix = runsOnMultipleNodeVersions && (job.splitIntoMatrix ?? true)
return {
[toolKitOrbPrefix(job.name)]: merge(
Expand All @@ -337,7 +365,7 @@ const generateJobs = (workflow: CircleCiWorkflow): Job[] => {
executor: 'node'
},
{
requires: job.requires.map((required) => {
requires: job.requires?.map((required) => {
if (required === 'checkout') {
return required
}
Expand Down
45 changes: 45 additions & 0 deletions plugins/circleci/test/circleci-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,5 +412,50 @@ describe('CircleCI config hook', () => {
}
])
})

it('should merge sibling plugins with different custom fields for a workflow job', () => {
const childInstallations: HookInstallation<CircleCiOptions>[] = [
{
plugin: { id: 'a', root: 'plugins/a' },
forHook: 'CircleCi',
hookConstructor: CircleCi,
options: {
jobs: [testJob],
workflows: [{ name: 'tool-kit', jobs: [{ ...testWorkflowJob, custom: { param1: 'a' } }] }]
}
},
{
plugin: { id: 'b', root: 'plugins/b' },
forHook: 'CircleCi',
hookConstructor: CircleCi,
options: {
workflows: [{ name: 'tool-kit', jobs: [{ name: testWorkflowJob.name, custom: { param2: 'b' } }] }]
}
}
]

const plugin = { id: 'p', root: 'plugins/p' }

expect(CircleCi.mergeChildInstallations(plugin, childInstallations)).toEqual([
{
plugin,
forHook: 'CircleCi',
hookConstructor: CircleCi,
options: expect.objectContaining({
workflows: expect.arrayContaining([
expect.objectContaining({
name: 'tool-kit',
jobs: expect.arrayContaining([
expect.objectContaining({
name: testWorkflowJob.name,
custom: expect.objectContaining({ param1: 'a', param2: 'b' })
})
])
})
])
})
}
])
})
})
})