import { Rule, SchematicsException, Tree } from '@angular-devkit/schematics'; import { Schema } from './schema'; type PackageManager = 'npm' | 'pnpm' | 'yarn'; interface PackageManagerCommands { install: string; playwrightInstall: string; run: (script: string) => string; setupStep?: string; cache?: string; } export function ci(options: Schema): Rule { return (tree: Tree) => { const workflowPath = buildWorkflowPath(options.name); if (tree.exists(workflowPath) && !options.force) { throw new SchematicsException(`${workflowPath} already exists. Re-run with --force to overwrite it.`); } const packageJson = readJson(tree, '/package.json'); const scripts = packageJson.scripts ?? {}; const packageManager = detectPackageManager(tree); const workflow = buildWorkflow( packageManager, Object.keys(scripts), hasPlaywrightSetup(packageJson), tree.exists('/package-lock.json') ); if (tree.exists(workflowPath)) { tree.overwrite(workflowPath, workflow); } else { tree.create(workflowPath, workflow); } }; } function buildWorkflowPath(name = 'ci'): string { const normalizedName = name.trim().replace(/\.ya?ml$/, ''); if (!normalizedName || normalizedName.includes('/') || normalizedName.includes('\\')) { throw new SchematicsException('CI workflow name must be a file name without path separators.'); } return `/.github/workflows/${normalizedName}.yml`; } function detectPackageManager(tree: Tree): PackageManager { if (tree.exists('/pnpm-lock.yaml')) { return 'pnpm'; } if (tree.exists('/yarn.lock')) { return 'yarn'; } return 'npm'; } function buildWorkflow( packageManager: PackageManager, scriptNames: string[], shouldInstallPlaywright: boolean, hasNpmLockfile: boolean ): string { const commands = getPackageManagerCommands(packageManager, hasNpmLockfile); const runSteps = buildRunSteps(commands, scriptNames); const setupPackageManagerStep = commands.setupStep ? `${commands.setupStep}\n` : ''; const cacheConfig = commands.cache ? ` cache: ${commands.cache}\n` : ''; const playwrightInstallStep = shouldInstallPlaywright ? ` - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: \${{ runner.os }}-playwright-\${{ hashFiles('package-lock.json', 'pnpm-lock.yaml', 'yarn.lock') }} restore-keys: | \${{ runner.os }}-playwright- - name: Install Playwright browsers run: ${commands.playwrightInstall} ` : ''; return `name: CI on: push: branches: [main] pull_request: jobs: verify: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Security guard — innerHTML / bypassSecurityTrust* run: | matches=$(grep -rnE "\\[innerHTML\\]|bypassSecurityTrust" --include='*.ts' --include='*.html' src 2>/dev/null | grep -v "security-reviewed:" || true) if [ -n "$matches" ]; then echo "$matches" echo "::error::Found [innerHTML] or DomSanitizer.bypassSecurityTrust* without a 'security-reviewed:' comment on the same line explaining why it's safe. See angular.dev/best-practices/security." exit 1 fi ${setupPackageManagerStep} - name: Setup Node uses: actions/setup-node@v4 with: node-version: 24 ${cacheConfig} - name: Install dependencies run: ${commands.install} ${playwrightInstallStep} ${runSteps} `; } function getPackageManagerCommands(packageManager: PackageManager, hasNpmLockfile: boolean): PackageManagerCommands { if (packageManager === 'pnpm') { return { install: 'pnpm install --frozen-lockfile', playwrightInstall: 'pnpm exec playwright install --with-deps chromium', run: (script) => `pnpm run ${script}`, setupStep: ` - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9`, cache: 'pnpm', }; } if (packageManager === 'yarn') { return { install: 'yarn install --frozen-lockfile', playwrightInstall: 'yarn playwright install --with-deps chromium', run: (script) => `yarn ${script}`, cache: 'yarn', }; } return { install: hasNpmLockfile ? 'npm ci' : 'npm install', playwrightInstall: 'npx playwright install --with-deps chromium', run: (script) => `npm run ${script}`, cache: hasNpmLockfile ? 'npm' : undefined, }; } function hasPlaywrightSetup(packageJson: Record): boolean { const scripts = packageJson.scripts ?? {}; const dependencies = packageJson.dependencies ?? {}; const devDependencies = packageJson.devDependencies ?? {}; return Boolean(scripts.e2e || dependencies['@playwright/test'] || devDependencies['@playwright/test']); } function buildRunSteps(commands: PackageManagerCommands, scriptNames: string[]): string { const scripts = new Set(scriptNames); const verificationScripts = scripts.has('verify') ? ['verify'] : ['build', 'test', 'e2e'].filter((script) => scripts.has(script)); if (verificationScripts.length === 0) { return ` - name: Check package scripts run: echo "No verify, build, test, or e2e script found in package.json." `; } return verificationScripts .map( (script) => ` - name: Run ${script} run: ${commands.run(script)} ` ) .join(''); } function readJson(tree: Tree, path: string): Record { const content = tree.readText(path); return JSON.parse(content) as Record; }