Skip to content

Commit

Permalink
feat(create): add migration path for scheduled workflows
Browse files Browse the repository at this point in the history
CircleCI has deprecated scheduled workflows and have replaced them with
scheduled pipelines, and Tool Kit consequently only supports the latter.
We found that many teams were missing this change during migration and
as a result no longer have their nightly workflow running every night.
To avoid this in the future let's highlight that the migration is
necessary in the migration script, as well as offer to set up the new
workflow programmatically via the CircleCI API.
  • Loading branch information
ivomurrell committed Dec 13, 2022
1 parent 2126113 commit 2849198
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 47 deletions.
2 changes: 2 additions & 0 deletions core/create/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"js-yaml": "^4.1.0",
"komatsu": "^1.3.0",
"lodash": "^4.17.21",
"node-fetch": "^2.6.7",
"ordinal": "^1.0.3",
"pacote": "^13.0.3",
"prompts": "^2.4.1",
Expand All @@ -41,6 +42,7 @@
"@types/js-yaml": "^4.0.3",
"@types/lodash": "^4.14.185",
"@types/node": "^12.20.24",
"@types/node-fetch": "^2.6.2",
"@types/pacote": "^11.1.3",
"@types/prompts": "^2.0.14",
"cosmiconfig": "^7.0.1",
Expand Down
74 changes: 42 additions & 32 deletions core/create/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import confirmationPrompt from './prompts/confirmation'
import conflictsPrompt, { installHooks } from './prompts/conflicts'
import mainPrompt from './prompts/main'
import optionsPrompt from './prompts/options'
import scheduledPipelinePrompt from './prompts/scheduledPipeline'

const exec = promisify(_exec)

Expand Down Expand Up @@ -91,11 +92,12 @@ async function main() {
options: {}
}

const originalCircleConfig = await fs.readFile(circleConfigPath, 'utf8').catch(() => undefined)
// Start with the initial prompt which will get most of the information we
// need for the remainder of the execution
const { preset, additional, addEslintConfig, deleteConfig, uninstall } = await mainPrompt({
packageJson,
circleConfigPath,
originalCircleConfig,
eslintConfigPath
})

Expand All @@ -121,40 +123,48 @@ async function main() {
configFile
})

if (confirm) {
let config: ValidConfig | undefined
try {
// Carry out the proposed changes: install + uninstall packages, run
// --install logic etc.
config = await executeMigration(deleteConfig, addEslintConfig, configFile)
} catch (error) {
if (hasToolKitConflicts(error)) {
// Additional questions asked if we have any task conflicts, letting the
// user to specify the order they want tasks to run in.
config = await conflictsPrompt({
error: error as ToolkitErrorModule.ToolKitConflictError,
logger,
toolKitConfig,
configPath
})
} else {
throw error
}
if (!confirm) {
return
}
let config: ValidConfig | undefined
try {
// Carry out the proposed changes: install + uninstall packages, run
// --install logic etc.
config = await executeMigration(deleteConfig, addEslintConfig, configFile)
} catch (error) {
if (hasToolKitConflicts(error)) {
// Additional questions asked if we have any task conflicts, letting the
// user to specify the order they want tasks to run in.
config = await conflictsPrompt({
error: error as ToolkitErrorModule.ToolKitConflictError,
logger,
toolKitConfig,
configPath
})
} else {
throw error
}
}

// Only run final prompts if execution was successful (this also means these
// are skipped if the user cancels out of the conflict resolution prompt.)
if (config) {
// Give the user a chance to set any configurable options for the plugins
// they've installed.
const cancelled = await optionsPrompt({ logger, config, toolKitConfig, configPath })
// Suggest they delete the old n-gage makefile after verifying all its
// logic has been migrated to Tool Kit.
if (!cancelled) {
await makefileHint()
}
}
// Only run final prompts if execution was successful (this also means these
// are skipped if the user cancels out of the conflict resolution prompt.)
if (!config) {
return
}
// Give the user a chance to set any configurable options for the plugins
// they've installed.
const cancelled = await optionsPrompt({ logger, config, toolKitConfig, configPath })
if (cancelled) {
return
}

if (originalCircleConfig?.includes('triggers')) {
await scheduledPipelinePrompt()
}

// Suggest they delete the old n-gage makefile after verifying all its
// logic has been migrated to Tool Kit.
await makefileHint()
}

main().catch((error) => {
Expand Down
16 changes: 5 additions & 11 deletions core/create/src/prompts/main.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { styles } from '@dotcom-tool-kit/logger'
import type { PackageJson } from '@financial-times/package-json'
import { existsSync, promises as fs } from 'fs'
import path from 'path'
import { existsSync } from 'fs'
import prompt from 'prompts'

type PromptNames = 'preset' | 'additional' | 'addEslintConfig' | 'deleteConfig' | 'uninstall'

export interface MainParams {
packageJson: PackageJson
circleConfigPath: string
originalCircleConfig?: string
eslintConfigPath: string
}

export default async ({
packageJson,
circleConfigPath,
originalCircleConfig,
eslintConfigPath
}: MainParams): Promise<prompt.Answers<PromptNames>> =>
prompt(
Expand Down Expand Up @@ -64,14 +63,9 @@ export default async ({
{
name: 'deleteConfig',
// Skip prompt if CircleCI config doesn't exist
type: await fs
.access(circleConfigPath)
.then(() => 'confirm' as const)
.catch(() => null),
// This .relative() call feels redundant at the moment. Maybe we can just
// hard-code the config path?
type: originalCircleConfig ? ('confirm' as const) : null,
message: `Would you like a CircleCI config to be generated? This will overwrite the current config at ${styles.filepath(
path.relative('', circleConfigPath)
'.circleci/config.yml'
)}.`
},
{
Expand Down
76 changes: 76 additions & 0 deletions core/create/src/prompts/scheduledPipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { rootLogger as winstonLogger, styles } from '@dotcom-tool-kit/logger'
import fetch from 'node-fetch'
import prompt from 'prompts'

export default async (): Promise<void> => {
winstonLogger.info(
`It looks like you had scheduled workflows configured in your CircleCI config. These have been deprecated by CircleCI and replaced by scheduled pipelines, and Tool Kit only supports the latter. You can follow the official migration guide (${styles.URL(
'https://circleci.com/docs/migrate-scheduled-workflows-to-scheduled-pipelines/'
)}) to fix this manually, or you can set up the scheduled trigger for your nightly workflow automatically with a few additional details.`
)
const { confirm } = await prompt({
name: 'confirm',
type: 'confirm',
message: 'Would you like the scheduled pipeline to be set up automatically?'
})
if (confirm) {
let retry
let prevSlug: string | undefined
do {
const { token, slug } = await prompt([
{
name: 'token',
type: 'password',
message: `Please enter a CircleCI API token. You can generate a token at ${styles.URL(
'https://app.circleci.com/settings/user/tokens'
)}. There's no way to create temporary CircleCI tokens at the moment so please remember to go back and delete the token after you're done!`
},
{
name: 'slug',
type: 'text',
message:
"Please enter the name of your project as it shows in GitHub. For example, the Tool Kit repository is 'dotcom-tool-kit'",
initial: prevSlug
}
])
const scheduleName = 'nightly'
const resp = await fetch(`https://circleci.com/api/v2/project/gh/Financial-Times/${slug}/schedule`, {
method: 'POST',
headers: { 'Circle-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: scheduleName,
description: 'daily test of project via Tool Kit',
// show the creator of the daily pipeline as CircleCI rather than
// whoever ran this migration script
'attribution-actor': 'system',
parameters: {
branch: 'main'
},
timetable: {
'per-hour': 1,
'hours-of-day': [0],
'days-of-week': ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']
}
})
})
if (resp.ok) {
winstonLogger.info(
`New scheduled pipeline successfully created with name ${styles.code(
scheduleName
)}. Please remember to go back and delete the token now that you're done!`
)
return
}
retry = (
await prompt({
name: 'retry',
type: 'confirm',
message: `CircleCI API call returned an unsuccessful status code of ${styles.heading(
resp.status.toString()
)}. Would you like to re-enter the details and try again?`
})
).retry
prevSlug = slug
} while (retry)
}
}
18 changes: 14 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 2849198

Please sign in to comment.