From ee84cbb618a8f68e6d803176c5fe4f052e068719 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 10 Mar 2026 12:54:27 -0400 Subject: [PATCH 1/4] feat(create-cli): add CI/CD setup step --- packages/create-cli/src/index.ts | 6 + packages/create-cli/src/lib/setup/ci.ts | 122 +++++++++++++ .../create-cli/src/lib/setup/ci.unit.test.ts | 167 ++++++++++++++++++ packages/create-cli/src/lib/setup/types.ts | 4 + packages/create-cli/src/lib/setup/wizard.ts | 21 +-- 5 files changed, 310 insertions(+), 10 deletions(-) create mode 100644 packages/create-cli/src/lib/setup/ci.ts create mode 100644 packages/create-cli/src/lib/setup/ci.unit.test.ts diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index 654b55736..d81518059 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -3,6 +3,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; import { + CI_PROVIDERS, CONFIG_FILE_FORMATS, type PluginSetupBinding, SETUP_MODES, @@ -39,6 +40,11 @@ const argv = await yargs(hideBin(process.argv)) choices: SETUP_MODES, describe: 'Setup mode (default: auto-detected from project)', }) + .option('ci', { + type: 'string', + choices: CI_PROVIDERS, + describe: 'CI/CD integration (github, gitlab, or skip)', + }) .check(parsed => { validatePluginSlugs(bindings, parsed.plugins); return true; diff --git a/packages/create-cli/src/lib/setup/ci.ts b/packages/create-cli/src/lib/setup/ci.ts new file mode 100644 index 000000000..648843a8b --- /dev/null +++ b/packages/create-cli/src/lib/setup/ci.ts @@ -0,0 +1,122 @@ +import { select } from '@inquirer/prompts'; +import { logger } from '@code-pushup/utils'; +import type { CiProvider, CliArgs, ConfigContext, Tree } from './types.js'; + +const GITHUB_WORKFLOW_PATH = '.github/workflows/code-pushup.yml'; +const GITLAB_CONFIG_PATH = '.gitlab-ci.yml'; +const GITLAB_CONFIG_SEPARATE_PATH = 'code-pushup.gitlab-ci.yml'; + +export async function promptCiProvider(cliArgs: CliArgs): Promise { + if (isCiProvider(cliArgs.ci)) { + return cliArgs.ci; + } + if (cliArgs.yes) { + return 'skip'; + } + return select({ + message: 'CI/CD integration:', + choices: [ + { name: 'GitHub Actions', value: 'github' }, + { name: 'GitLab CI/CD', value: 'gitlab' }, + { name: 'Skip', value: 'skip' }, + ], + default: 'skip', + }); +} + +export async function resolveCi( + tree: Tree, + provider: CiProvider, + context: ConfigContext, +): Promise { + switch (provider) { + case 'github': + await writeGitHubWorkflow(tree, context); + break; + case 'gitlab': + await writeGitLabConfig(tree); + break; + case 'skip': + break; + } +} + +async function writeGitHubWorkflow( + tree: Tree, + context: ConfigContext, +): Promise { + await tree.write(GITHUB_WORKFLOW_PATH, generateGitHubYaml(context)); +} + +function generateGitHubYaml({ mode, tool }: ConfigContext): string { + const lines = [ + 'name: Code PushUp', + '', + 'on:', + ' push:', + ' branches: [main]', + ' pull_request:', + ' branches: [main]', + '', + 'permissions:', + ' contents: read', + ' actions: read', + ' pull-requests: write', + '', + 'jobs:', + ' code-pushup:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - name: Clone repository', + ' uses: actions/checkout@v5', + ' - name: Set up Node.js', + ' uses: actions/setup-node@v6', + ' - name: Install dependencies', + ' run: npm ci', + ' - name: Code PushUp', + ' uses: code-pushup/github-action@v0', + ...(mode === 'monorepo' && tool != null + ? [' with:', ` monorepo: ${tool}`] + : []), + ]; + return `${lines.join('\n')}\n`; +} + +async function writeGitLabConfig(tree: Tree): Promise { + const filePath = await resolveGitLabFilePath(tree); + await tree.write(filePath, generateGitLabYaml()); + + if (filePath === GITLAB_CONFIG_SEPARATE_PATH) { + logger.warn( + [ + `Add the following to your ${GITLAB_CONFIG_PATH}:`, + ' include:', + ` - local: ${GITLAB_CONFIG_SEPARATE_PATH}`, + ].join('\n'), + ); + } +} + +function generateGitLabYaml(): string { + const lines = [ + 'workflow:', + ' rules:', + ' - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH', + " - if: $CI_PIPELINE_SOURCE == 'merge_request_event'", + '', + 'include:', + ' - https://gitlab.com/code-pushup/gitlab-pipelines-template/-/raw/latest/code-pushup.yml', + ]; + return `${lines.join('\n')}\n`; +} + +async function resolveGitLabFilePath(tree: Tree): Promise { + if (await tree.exists(GITLAB_CONFIG_PATH)) { + return GITLAB_CONFIG_SEPARATE_PATH; + } + return GITLAB_CONFIG_PATH; +} + +function isCiProvider(value: string | undefined): value is CiProvider { + return value === 'github' || value === 'gitlab' || value === 'skip'; +} diff --git a/packages/create-cli/src/lib/setup/ci.unit.test.ts b/packages/create-cli/src/lib/setup/ci.unit.test.ts new file mode 100644 index 000000000..b96c9b1ce --- /dev/null +++ b/packages/create-cli/src/lib/setup/ci.unit.test.ts @@ -0,0 +1,167 @@ +import { select } from '@inquirer/prompts'; +import { vol } from 'memfs'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import { logger } from '@code-pushup/utils'; +import { promptCiProvider, resolveCi } from './ci.js'; +import type { ConfigContext } from './types.js'; +import { createTree } from './virtual-fs.js'; + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), +})); + +describe('promptCiProvider', () => { + it.each(['github', 'gitlab', 'skip'] as const)( + 'should return %j when --ci %s is provided', + async ci => { + await expect(promptCiProvider({ ci })).resolves.toBe(ci); + expect(select).not.toHaveBeenCalled(); + }, + ); + + it('should return "skip" when --yes is provided', async () => { + await expect(promptCiProvider({ yes: true })).resolves.toBe('skip'); + expect(select).not.toHaveBeenCalled(); + }); + + it('should prompt interactively when no CLI arg or --yes', async () => { + vi.mocked(select).mockResolvedValue('github'); + + await expect(promptCiProvider({})).resolves.toBe('github'); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'CI/CD integration:', + default: 'skip', + }), + ); + }); +}); + +describe('resolveCi', () => { + const STANDALONE_CONTEXT: ConfigContext = { mode: 'standalone', tool: null }; + + describe('GitHub Actions', () => { + it('should create workflow without monorepo input in standalone mode', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'github', STANDALONE_CONTEXT); + await expect(tree.read('.github/workflows/code-pushup.yml')).resolves + .toMatchInlineSnapshot(` + "name: Code PushUp + + on: + push: + branches: [main] + pull_request: + branches: [main] + + permissions: + contents: read + actions: read + pull-requests: write + + jobs: + code-pushup: + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v5 + - name: Set up Node.js + uses: actions/setup-node@v6 + - name: Install dependencies + run: npm ci + - name: Code PushUp + uses: code-pushup/github-action@v0 + " + `); + }); + + it('should create workflow with monorepo input when in monorepo mode', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'github', { mode: 'monorepo', tool: 'nx' }); + await expect(tree.read('.github/workflows/code-pushup.yml')).resolves + .toMatchInlineSnapshot(` + "name: Code PushUp + + on: + push: + branches: [main] + pull_request: + branches: [main] + + permissions: + contents: read + actions: read + pull-requests: write + + jobs: + code-pushup: + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v5 + - name: Set up Node.js + uses: actions/setup-node@v6 + - name: Install dependencies + run: npm ci + - name: Code PushUp + uses: code-pushup/github-action@v0 + with: + monorepo: nx + " + `); + }); + }); + + describe('GitLab CI/CD', () => { + it('should create .gitlab-ci.yml when no file exists', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT); + + expect(tree.listChanges()).toPartiallyContain({ + path: '.gitlab-ci.yml', + type: 'CREATE', + }); + }); + + it('should create separate file and log include instruction when .gitlab-ci.yml already exists', async () => { + vol.fromJSON( + { + 'package.json': '{}', + '.gitlab-ci.yml': 'stages:\n - test\n', + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT); + + expect(tree.listChanges()).toPartiallyContain({ + path: 'code-pushup.gitlab-ci.yml', + type: 'CREATE', + }); + expect(tree.listChanges()).not.toPartiallyContain({ + path: '.gitlab-ci.yml', + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('code-pushup.gitlab-ci.yml'), + ); + }); + }); + + describe('skip', () => { + it('should make no changes when provider is skip', async () => { + vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'skip', STANDALONE_CONTEXT); + + expect(tree.listChanges()).toStrictEqual([]); + }); + }); +}); diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index 62dfb028b..e59e5b7fe 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,6 +1,9 @@ import type { PluginMeta } from '@code-pushup/models'; import type { MonorepoTool } from '@code-pushup/utils'; +export const CI_PROVIDERS = ['github', 'gitlab', 'skip'] as const; +export type CiProvider = (typeof CI_PROVIDERS)[number]; + export const CONFIG_FILE_FORMATS = ['ts', 'js', 'mjs'] as const; export type ConfigFileFormat = (typeof CONFIG_FILE_FORMATS)[number]; @@ -16,6 +19,7 @@ export type CliArgs = { 'config-format'?: string; mode?: SetupMode; plugins?: string[]; + ci?: string; 'target-dir'?: string; [key: string]: unknown; }; diff --git a/packages/create-cli/src/lib/setup/wizard.ts b/packages/create-cli/src/lib/setup/wizard.ts index 64350061e..cde4c6972 100644 --- a/packages/create-cli/src/lib/setup/wizard.ts +++ b/packages/create-cli/src/lib/setup/wizard.ts @@ -7,6 +7,7 @@ import { logger, toUnixPath, } from '@code-pushup/utils'; +import { promptCiProvider, resolveCi } from './ci.js'; import { computeRelativePresetImport, generateConfigSource, @@ -48,39 +49,39 @@ export async function runSetupWizard( ): Promise { const targetDir = cliArgs['target-dir'] ?? process.cwd(); - const { mode, tool } = await promptSetupMode(targetDir, cliArgs); + const context = await promptSetupMode(targetDir, cliArgs); const selectedBindings = await promptPluginSelection( bindings, targetDir, cliArgs, ); - const format = await promptConfigFormat(targetDir, cliArgs); - const packageJson = await readPackageJson(targetDir); - const isEsm = packageJson.type === 'module'; - const configFilename = resolveFilename('code-pushup.config', format, isEsm); + const ciProvider = await promptCiProvider(cliArgs); const resolved: ScopedPluginResult[] = await asyncSequential( selectedBindings, async binding => ({ scope: binding.scope ?? 'project', - result: await resolveBinding(binding, cliArgs, { mode, tool }), + result: await resolveBinding(binding, cliArgs, context), }), ); - const gitRoot = await getGitRoot(); - const tree = createTree(gitRoot); + const packageJson = await readPackageJson(targetDir); + const isEsm = packageJson.type === 'module'; + const configFilename = resolveFilename('code-pushup.config', format, isEsm); + const tree = createTree(await getGitRoot()); const writeContext: WriteContext = { tree, format, configFilename, isEsm }; - await (mode === 'monorepo' && tool != null - ? writeMonorepoConfigs(writeContext, resolved, targetDir, tool) + await (context.mode === 'monorepo' && context.tool != null + ? writeMonorepoConfigs(writeContext, resolved, targetDir, context.tool) : writeStandaloneConfig( writeContext, resolved.map(r => r.result), )); await resolveGitignore(tree); + await resolveCi(tree, ciProvider, context); logChanges(tree.listChanges()); From e5f97193dadc1e76b747f3ebab0a755aebbd3f4e Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 11 Mar 2026 12:02:38 -0400 Subject: [PATCH 2/4] fix(create-cli): update ci provider options and their validation --- packages/create-cli/src/index.ts | 2 +- packages/create-cli/src/lib/setup/ci.ts | 19 +++++++++++++------ .../create-cli/src/lib/setup/ci.unit.test.ts | 14 +++++++------- packages/create-cli/src/lib/setup/types.ts | 2 +- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index d81518059..f54aec268 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -43,7 +43,7 @@ const argv = await yargs(hideBin(process.argv)) .option('ci', { type: 'string', choices: CI_PROVIDERS, - describe: 'CI/CD integration (github, gitlab, or skip)', + describe: 'CI/CD integration (github, gitlab, or none)', }) .check(parsed => { validatePluginSlugs(bindings, parsed.plugins); diff --git a/packages/create-cli/src/lib/setup/ci.ts b/packages/create-cli/src/lib/setup/ci.ts index 648843a8b..86ed89d59 100644 --- a/packages/create-cli/src/lib/setup/ci.ts +++ b/packages/create-cli/src/lib/setup/ci.ts @@ -1,6 +1,12 @@ import { select } from '@inquirer/prompts'; import { logger } from '@code-pushup/utils'; -import type { CiProvider, CliArgs, ConfigContext, Tree } from './types.js'; +import { + CI_PROVIDERS, + type CiProvider, + type CliArgs, + type ConfigContext, + type Tree, +} from './types.js'; const GITHUB_WORKFLOW_PATH = '.github/workflows/code-pushup.yml'; const GITLAB_CONFIG_PATH = '.gitlab-ci.yml'; @@ -11,16 +17,16 @@ export async function promptCiProvider(cliArgs: CliArgs): Promise { return cliArgs.ci; } if (cliArgs.yes) { - return 'skip'; + return 'none'; } return select({ message: 'CI/CD integration:', choices: [ { name: 'GitHub Actions', value: 'github' }, { name: 'GitLab CI/CD', value: 'gitlab' }, - { name: 'Skip', value: 'skip' }, + { name: 'none', value: 'none' }, ], - default: 'skip', + default: 'none', }); } @@ -36,7 +42,7 @@ export async function resolveCi( case 'gitlab': await writeGitLabConfig(tree); break; - case 'skip': + case 'none': break; } } @@ -118,5 +124,6 @@ async function resolveGitLabFilePath(tree: Tree): Promise { } function isCiProvider(value: string | undefined): value is CiProvider { - return value === 'github' || value === 'gitlab' || value === 'skip'; + const validValues: readonly string[] = CI_PROVIDERS; + return value != null && validValues.includes(value); } diff --git a/packages/create-cli/src/lib/setup/ci.unit.test.ts b/packages/create-cli/src/lib/setup/ci.unit.test.ts index b96c9b1ce..95f06402c 100644 --- a/packages/create-cli/src/lib/setup/ci.unit.test.ts +++ b/packages/create-cli/src/lib/setup/ci.unit.test.ts @@ -11,7 +11,7 @@ vi.mock('@inquirer/prompts', () => ({ })); describe('promptCiProvider', () => { - it.each(['github', 'gitlab', 'skip'] as const)( + it.each(['github', 'gitlab', 'none'] as const)( 'should return %j when --ci %s is provided', async ci => { await expect(promptCiProvider({ ci })).resolves.toBe(ci); @@ -19,8 +19,8 @@ describe('promptCiProvider', () => { }, ); - it('should return "skip" when --yes is provided', async () => { - await expect(promptCiProvider({ yes: true })).resolves.toBe('skip'); + it('should return "none" when --yes is provided', async () => { + await expect(promptCiProvider({ yes: true })).resolves.toBe('none'); expect(select).not.toHaveBeenCalled(); }); @@ -31,7 +31,7 @@ describe('promptCiProvider', () => { expect(select).toHaveBeenCalledWith( expect.objectContaining({ message: 'CI/CD integration:', - default: 'skip', + default: 'none', }), ); }); @@ -154,12 +154,12 @@ describe('resolveCi', () => { }); }); - describe('skip', () => { - it('should make no changes when provider is skip', async () => { + describe('none', () => { + it('should make no changes when provider is none', async () => { vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME); const tree = createTree(MEMFS_VOLUME); - await resolveCi(tree, 'skip', STANDALONE_CONTEXT); + await resolveCi(tree, 'none', STANDALONE_CONTEXT); expect(tree.listChanges()).toStrictEqual([]); }); diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index e59e5b7fe..0124063fe 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -1,7 +1,7 @@ import type { PluginMeta } from '@code-pushup/models'; import type { MonorepoTool } from '@code-pushup/utils'; -export const CI_PROVIDERS = ['github', 'gitlab', 'skip'] as const; +export const CI_PROVIDERS = ['github', 'gitlab', 'none'] as const; export type CiProvider = (typeof CI_PROVIDERS)[number]; export const CONFIG_FILE_FORMATS = ['ts', 'js', 'mjs'] as const; From 60d9aebbb3a43743eeb04358af36e0b224edec74 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 11 Mar 2026 14:38:21 -0400 Subject: [PATCH 3/4] refactor(create-cli): improve CI yaml generation --- packages/create-cli/package.json | 1 + packages/create-cli/src/lib/setup/ci.ts | 54 +++++++++---- .../create-cli/src/lib/setup/ci.unit.test.ts | 77 ++++++++++++++++--- packages/utils/src/index.ts | 1 + packages/utils/src/lib/git/git.ts | 13 ++++ 5 files changed, 121 insertions(+), 25 deletions(-) diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 78f710ed6..bb261afb5 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -29,6 +29,7 @@ "@code-pushup/models": "0.116.0", "@code-pushup/utils": "0.116.0", "@inquirer/prompts": "^8.0.0", + "yaml": "^2.5.1", "yargs": "^17.7.2" }, "files": [ diff --git a/packages/create-cli/src/lib/setup/ci.ts b/packages/create-cli/src/lib/setup/ci.ts index 86ed89d59..9b556c404 100644 --- a/packages/create-cli/src/lib/setup/ci.ts +++ b/packages/create-cli/src/lib/setup/ci.ts @@ -1,5 +1,7 @@ import { select } from '@inquirer/prompts'; -import { logger } from '@code-pushup/utils'; +import path from 'node:path'; +import * as YAML from 'yaml'; +import { getGitDefaultBranch, logger, toUnixPath } from '@code-pushup/utils'; import { CI_PROVIDERS, type CiProvider, @@ -10,7 +12,9 @@ import { const GITHUB_WORKFLOW_PATH = '.github/workflows/code-pushup.yml'; const GITLAB_CONFIG_PATH = '.gitlab-ci.yml'; -const GITLAB_CONFIG_SEPARATE_PATH = 'code-pushup.gitlab-ci.yml'; +const GITLAB_CONFIG_SEPARATE_PATH = toUnixPath( + path.join('.gitlab', 'ci', 'code-pushup.gitlab-ci.yml'), +); export async function promptCiProvider(cliArgs: CliArgs): Promise { if (isCiProvider(cliArgs.ci)) { @@ -51,18 +55,22 @@ async function writeGitHubWorkflow( tree: Tree, context: ConfigContext, ): Promise { - await tree.write(GITHUB_WORKFLOW_PATH, generateGitHubYaml(context)); + await tree.write(GITHUB_WORKFLOW_PATH, await generateGitHubYaml(context)); } -function generateGitHubYaml({ mode, tool }: ConfigContext): string { +async function generateGitHubYaml({ + mode, + tool, +}: ConfigContext): Promise { + const branch = await getGitDefaultBranch(); const lines = [ 'name: Code PushUp', '', 'on:', ' push:', - ' branches: [main]', + ` branches: [${branch}]`, ' pull_request:', - ' branches: [main]', + ` branches: [${branch}]`, '', 'permissions:', ' contents: read', @@ -72,6 +80,7 @@ function generateGitHubYaml({ mode, tool }: ConfigContext): string { 'jobs:', ' code-pushup:', ' runs-on: ubuntu-latest', + ' name: Code PushUp', ' steps:', ' - name: Clone repository', ' uses: actions/checkout@v5', @@ -93,13 +102,7 @@ async function writeGitLabConfig(tree: Tree): Promise { await tree.write(filePath, generateGitLabYaml()); if (filePath === GITLAB_CONFIG_SEPARATE_PATH) { - logger.warn( - [ - `Add the following to your ${GITLAB_CONFIG_PATH}:`, - ' include:', - ` - local: ${GITLAB_CONFIG_SEPARATE_PATH}`, - ].join('\n'), - ); + await patchRootGitLabConfig(tree); } } @@ -116,6 +119,31 @@ function generateGitLabYaml(): string { return `${lines.join('\n')}\n`; } +async function patchRootGitLabConfig(tree: Tree): Promise { + const content = await tree.read(GITLAB_CONFIG_PATH); + if (content == null) { + return; + } + const doc = YAML.parseDocument(content); + if (!YAML.isMap(doc.contents)) { + logger.warn( + `Could not update ${GITLAB_CONFIG_PATH}. Add an include entry for ${GITLAB_CONFIG_SEPARATE_PATH} to your config.`, + ); + return; + } + const entry = { local: GITLAB_CONFIG_SEPARATE_PATH }; + const include = doc.get('include', true); + if (include == null) { + doc.set('include', doc.createNode([entry])); + } else if (YAML.isSeq(include)) { + include.add(doc.createNode(entry)); + } else { + const existing = doc.get('include'); + doc.set('include', doc.createNode([existing, entry])); + } + await tree.write(GITLAB_CONFIG_PATH, doc.toString()); +} + async function resolveGitLabFilePath(tree: Tree): Promise { if (await tree.exists(GITLAB_CONFIG_PATH)) { return GITLAB_CONFIG_SEPARATE_PATH; diff --git a/packages/create-cli/src/lib/setup/ci.unit.test.ts b/packages/create-cli/src/lib/setup/ci.unit.test.ts index 95f06402c..8059c3e7f 100644 --- a/packages/create-cli/src/lib/setup/ci.unit.test.ts +++ b/packages/create-cli/src/lib/setup/ci.unit.test.ts @@ -1,7 +1,6 @@ import { select } from '@inquirer/prompts'; import { vol } from 'memfs'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import { logger } from '@code-pushup/utils'; import { promptCiProvider, resolveCi } from './ci.js'; import type { ConfigContext } from './types.js'; import { createTree } from './virtual-fs.js'; @@ -10,6 +9,11 @@ vi.mock('@inquirer/prompts', () => ({ select: vi.fn(), })); +vi.mock('@code-pushup/utils', async importOriginal => ({ + ...(await importOriginal()), + getGitDefaultBranch: vi.fn().mockResolvedValue('main'), +})); + describe('promptCiProvider', () => { it.each(['github', 'gitlab', 'none'] as const)( 'should return %j when --ci %s is provided', @@ -64,6 +68,7 @@ describe('resolveCi', () => { jobs: code-pushup: runs-on: ubuntu-latest + name: Code PushUp steps: - name: Clone repository uses: actions/checkout@v5 @@ -100,6 +105,7 @@ describe('resolveCi', () => { jobs: code-pushup: runs-on: ubuntu-latest + name: Code PushUp steps: - name: Clone repository uses: actions/checkout@v5 @@ -127,13 +133,23 @@ describe('resolveCi', () => { path: '.gitlab-ci.yml', type: 'CREATE', }); + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "workflow: + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_PIPELINE_SOURCE == 'merge_request_event' + + include: + - https://gitlab.com/code-pushup/gitlab-pipelines-template/-/raw/latest/code-pushup.yml + " + `); }); - it('should create separate file and log include instruction when .gitlab-ci.yml already exists', async () => { + it('should append local include when .gitlab-ci.yml has include array', async () => { vol.fromJSON( { 'package.json': '{}', - '.gitlab-ci.yml': 'stages:\n - test\n', + '.gitlab-ci.yml': 'include:\n - local: .gitlab/ci/version.yml\n', }, MEMFS_VOLUME, ); @@ -141,16 +157,53 @@ describe('resolveCi', () => { await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT); - expect(tree.listChanges()).toPartiallyContain({ - path: 'code-pushup.gitlab-ci.yml', - type: 'CREATE', - }); - expect(tree.listChanges()).not.toPartiallyContain({ - path: '.gitlab-ci.yml', - }); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('code-pushup.gitlab-ci.yml'), + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "include: + - local: .gitlab/ci/version.yml + - local: .gitlab/ci/code-pushup.gitlab-ci.yml + " + `); + }); + + it('should wrap single include object into array and append', async () => { + vol.fromJSON( + { + 'package.json': '{}', + '.gitlab-ci.yml': 'include:\n local: .gitlab/ci/version.yml\n', + }, + MEMFS_VOLUME, + ); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT); + + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "include: + - local: .gitlab/ci/version.yml + - local: .gitlab/ci/code-pushup.gitlab-ci.yml + " + `); + }); + + it('should create include array when .gitlab-ci.yml has no include key', async () => { + vol.fromJSON( + { + 'package.json': '{}', + '.gitlab-ci.yml': 'stages:\n - test\n', + }, + MEMFS_VOLUME, ); + const tree = createTree(MEMFS_VOLUME); + + await resolveCi(tree, 'gitlab', STANDALONE_CONTEXT); + + await expect(tree.read('.gitlab-ci.yml')).resolves.toMatchInlineSnapshot(` + "stages: + - test + include: + - local: .gitlab/ci/code-pushup.gitlab-ci.yml + " + `); }); }); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index d32d747c4..62c8401f8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -79,6 +79,7 @@ export { } from './lib/git/git.commits-and-tags.js'; export { formatGitPath, + getGitDefaultBranch, getGitRoot, guardAgainstLocalChanges, safeCheckout, diff --git a/packages/utils/src/lib/git/git.ts b/packages/utils/src/lib/git/git.ts index 86da81c3f..517628477 100644 --- a/packages/utils/src/lib/git/git.ts +++ b/packages/utils/src/lib/git/git.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { type StatusResult, simpleGit } from 'simple-git'; +import { stringifyError } from '../errors.js'; import { logger } from '../logger.js'; import { toUnixPath } from '../transform.js'; @@ -7,6 +8,18 @@ export function getGitRoot(git = simpleGit()): Promise { return git.revparse('--show-toplevel'); } +export async function getGitDefaultBranch(git = simpleGit()): Promise { + try { + const head = await git.revparse('--abbrev-ref origin/HEAD'); + return head.replace(/^origin\//, ''); + } catch (error) { + logger.warn( + `Failed to get the default Git branch, falling back to main - ${stringifyError(error)}`, + ); + return 'main'; + } +} + export function formatGitPath(filePath: string, gitRoot: string): string { const absolutePath = path.isAbsolute(filePath) ? filePath From aae422e79153dec3e4a54cc869f7ae0133ea1424 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Wed, 11 Mar 2026 15:15:49 -0400 Subject: [PATCH 4/4] refactor(ci): simplify GitLab config path --- packages/create-cli/src/lib/setup/ci.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/create-cli/src/lib/setup/ci.ts b/packages/create-cli/src/lib/setup/ci.ts index 9b556c404..9d1007c70 100644 --- a/packages/create-cli/src/lib/setup/ci.ts +++ b/packages/create-cli/src/lib/setup/ci.ts @@ -1,7 +1,6 @@ import { select } from '@inquirer/prompts'; -import path from 'node:path'; import * as YAML from 'yaml'; -import { getGitDefaultBranch, logger, toUnixPath } from '@code-pushup/utils'; +import { getGitDefaultBranch, logger } from '@code-pushup/utils'; import { CI_PROVIDERS, type CiProvider, @@ -12,9 +11,7 @@ import { const GITHUB_WORKFLOW_PATH = '.github/workflows/code-pushup.yml'; const GITLAB_CONFIG_PATH = '.gitlab-ci.yml'; -const GITLAB_CONFIG_SEPARATE_PATH = toUnixPath( - path.join('.gitlab', 'ci', 'code-pushup.gitlab-ci.yml'), -); +const GITLAB_CONFIG_SEPARATE_PATH = '.gitlab/ci/code-pushup.gitlab-ci.yml'; export async function promptCiProvider(cliArgs: CliArgs): Promise { if (isCiProvider(cliArgs.ci)) {