diff --git a/.changeset/repo-tools-ci-toldya-so.md b/.changeset/repo-tools-ci-toldya-so.md new file mode 100644 index 0000000000..20425fc631 --- /dev/null +++ b/.changeset/repo-tools-ci-toldya-so.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Added a `--ci` flag to the `generate-catalog-info` command. This flag behaves similarly to the same flag on `api-reports`: if `catalog-info.yaml` files would have been added or modified, then the process exits with status code `1`, and instructions are printed. diff --git a/packages/repo-tools/cli-report.md b/packages/repo-tools/cli-report.md index 9c87bd8dff..12d71f3be4 100644 --- a/packages/repo-tools/cli-report.md +++ b/packages/repo-tools/cli-report.md @@ -44,6 +44,7 @@ Usage: backstage-repo-tools generate-catalog-info [options] Options: --dry-run + --ci -h, --help ``` diff --git a/packages/repo-tools/src/commands/generate-catalog-info/generate-catalog-info.ts b/packages/repo-tools/src/commands/generate-catalog-info/generate-catalog-info.ts index 08f73898c8..16dd49770e 100644 --- a/packages/repo-tools/src/commands/generate-catalog-info/generate-catalog-info.ts +++ b/packages/repo-tools/src/commands/generate-catalog-info/generate-catalog-info.ts @@ -29,26 +29,34 @@ import { import { BackstagePackageJson, isBackstagePackage, + isRejected, + isFulfilled, readFile, writeFile, } from './utils'; import { CodeOwnersEntry } from 'codeowners-utils'; type CreateFixPackageInfoYamlsOptions = { + ci?: boolean; dryRun?: boolean; }; export default async (opts: CreateFixPackageInfoYamlsOptions) => { - const { dryRun = false } = opts; + const { dryRun = false, ci = false } = opts; const packages = await PackageGraph.listTargetPackages(); const codeowners = await loadCodeowners(); const limit = pLimit(10); - const results = await Promise.allSettled( + // If --ci is passed, don't make any changes to the file system; we're only + // interested in knowing if changes would have been made. + const isDryRun = ci ? true : dryRun; + const checkForChanges = ci; + + const results = await Promise.allSettled( packages.map(({ packageJson, dir }) => limit(async () => { if (!isBackstagePackage(packageJson)) { - return; + return ''; } // Check if there is already a corresponding catalog-info.yaml @@ -59,32 +67,30 @@ export default async (opts: CreateFixPackageInfoYamlsOptions) => { yamlString = await readFile(infoYamlPath, { encoding: 'utf-8' }); } catch (e) { if (e.code === 'ENOENT') { - await createCatalogInfoYaml({ + return await createCatalogInfoYaml({ yamlPath: infoYamlPath, packageJson, codeowners, - dryRun, + dryRun: isDryRun, }); - return; } throw e; } - await fixCatalogInfoYaml({ + return await fixCatalogInfoYaml({ yamlPath: infoYamlPath, packageJson, codeowners, yamlString, - dryRun, + dryRun: isDryRun, + ci, }); }), ), ); - const rejects = results.filter( - r => r.status === 'rejected', - ) as PromiseRejectedResult[]; + const rejects = results.filter(isRejected); if (rejects.length > 0) { // Problems encountered. Print details here. console.error( @@ -94,6 +100,25 @@ export default async (opts: CreateFixPackageInfoYamlsOptions) => { console.error(); process.exit(1); } + + if (checkForChanges) { + const instructions = results + .filter(isFulfilled) + .map(r => r.value) + .filter(r => r !== ''); + + // Non-empty instructions indicate changes would have been made. + if (instructions.length > 0) { + console.error( + '\ncatalog-info.yaml file(s) out of sync with CODEOWNERS and/or package.json (see instructions above)\n', + ); + process.exit(1); + } else { + console.error( + 'catalog-info.yaml file(s) in sync with CODEOWNERS and package.json', + ); + } + } }; type CreateOptions = { @@ -105,6 +130,7 @@ type CreateOptions = { type FixOptions = CreateOptions & { yamlString: string; + ci: boolean; }; type BackstagePackageEntity = Entity & { @@ -117,18 +143,23 @@ type BackstagePackageEntity = Entity & { }; }; -function createCatalogInfoYaml(options: CreateOptions) { +async function createCatalogInfoYaml(options: CreateOptions) { const { codeowners, dryRun, packageJson, yamlPath } = options; + const instruction = `Create ${relativePath('.', yamlPath)}`; const owner = getOwnerFromCodeowners(codeowners, yamlPath); const entity = createOrMergeEntity(packageJson, owner); - return dryRun - ? Promise.resolve(console.error(`Create ${relativePath('.', yamlPath)}`)) - : writeFile(yamlPath, YAML.dump(entity)); + if (dryRun) { + console.error(instruction); + } else { + await writeFile(yamlPath, YAML.dump(entity)); + } + + return instruction; } -function fixCatalogInfoYaml(options: FixOptions) { - const { codeowners, dryRun, packageJson, yamlPath, yamlString } = options; +async function fixCatalogInfoYaml(options: FixOptions) { + const { ci, codeowners, dryRun, packageJson, yamlPath, yamlString } = options; const possibleOwners = getPossibleCodeowners( codeowners, relativePath('.', yamlPath), @@ -156,12 +187,49 @@ function fixCatalogInfoYaml(options: FixOptions) { ? getOwnerFromCodeowners(codeowners, yamlPath) : yamlJson.spec?.owner; const newJson = createOrMergeEntity(packageJson, owner, yamlJson); - return dryRun - ? Promise.resolve(console.error(`Update ${relativePath('.', yamlPath)}`)) - : writeFile(yamlPath, yamlOverwrite(yamlString, newJson)); + const instructions = [`Update ${relativePath('.', yamlPath)}`]; + + // Show more detailed instructions when --ci flag is set. + if (ci) { + if (badOwner) { + instructions.push( + ` spec.owner cannot be "${ + yamlJson.spec.owner + }" because it must be one of (${possibleOwners.join( + ', ', + )}) as listed in CODEOWNERS`, + ); + } + + if (badTitle) { + instructions.push( + ` metadata.title cannot be "${yamlJson.metadata.title}" because it must be exactly "${packageJson.name}", the package.json name`, + ); + } + + if (badName) { + instructions.push( + ` metadata.name cannot be "${yamlJson.metadata.name}" because it must be exactly "${safeName}", as derived from package.json name`, + ); + } + + if (badType) { + instructions.push( + ` spec.type cannot be "${yamlJson.spec.type}" because it must be exactly "backstage-${packageJson.backstage.role}", as derived from package.json backstage.role`, + ); + } + } + + if (dryRun) { + console.error(instructions.join('\n')); + } else { + await writeFile(yamlPath, yamlOverwrite(yamlString, newJson)); + } + + return instructions.join('\n'); } - return Promise.resolve(); + return ''; } /** diff --git a/packages/repo-tools/src/commands/generate-catalog-info/utils.ts b/packages/repo-tools/src/commands/generate-catalog-info/utils.ts index f446f4e867..b00fbd57a8 100644 --- a/packages/repo-tools/src/commands/generate-catalog-info/utils.ts +++ b/packages/repo-tools/src/commands/generate-catalog-info/utils.ts @@ -40,3 +40,11 @@ export function isBackstagePackage( (packageJson as any)?.backstage?.role !== 'undefined' ); } + +export const isRejected = ( + input: PromiseSettledResult, +): input is PromiseRejectedResult => input.status === 'rejected'; + +export const isFulfilled = ( + input: PromiseSettledResult, +): input is PromiseFulfilledResult => input.status === 'fulfilled'; diff --git a/packages/repo-tools/src/commands/index.ts b/packages/repo-tools/src/commands/index.ts index 7aa574d291..c429a253f8 100644 --- a/packages/repo-tools/src/commands/index.ts +++ b/packages/repo-tools/src/commands/index.ts @@ -102,6 +102,10 @@ export function registerCommands(program: Command) { '--dry-run', 'Shows what would happen without actually writing any yaml.', ) + .option( + '--ci', + 'CI run checks that there are no changes to catalog-info.yaml files', + ) .description('Create or fix info yaml files for all backstage packages') .action( lazy(() =>