Skip to content

Commit

Permalink
Merge pull request #719 from Financial-Times/circleci-workflow-jobs
Browse files Browse the repository at this point in the history
Allow CircleCI workflow job options to be overridden
  • Loading branch information
ivomurrell authored Nov 27, 2024
2 parents c653f9b + e33e579 commit 031013f
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 57 deletions.
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 })
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' })
})
])
})
])
})
}
])
})
})
})

0 comments on commit 031013f

Please sign in to comment.