diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md index 1b4fda32c9..fda419fa55 100644 --- a/.agents/skills/pgpm/references/cli.md +++ b/.agents/skills/pgpm/references/cli.md @@ -175,6 +175,16 @@ Non-interactive init requires every question to be answered by flags; see `--name --fullName --email --username --repoName --license`, plus module `--moduleName --packageIdentifier --moduleDesc --access`. +When a module is created, `pgpm init` also adds its workspace-relative path to +each `jobs..strategy.matrix.package` list in the workspace's +`.github/workflows/*.yml` (sorted, in place). The workflow is parsed with `yaml` +to address that path, and only the matrix list's own byte range is rewritten, so +comments and formatting survive where they can be preserved. A matrix whose +comments cannot be preserved is left untouched. The list stays a plain YAML +array you can hand-edit; workflows without such a matrix — or whose matrix isn't +a plain list of strings — or that can't be read or written — are left alone +silently; the update is best-effort and never warns. + ### Workspace Inspection **pgpm ls** — List the pgpm modules in the current workspace diff --git a/pgpm/cli/__tests__/init.test.ts b/pgpm/cli/__tests__/init.test.ts index 24c5487da1..528837781c 100644 --- a/pgpm/cli/__tests__/init.test.ts +++ b/pgpm/cli/__tests__/init.test.ts @@ -3,7 +3,7 @@ process.env.PGPM_SKIP_UPDATE_CHECK = 'true'; process.env.PGPM_SKIP_SKILL_INSTALL = 'true'; import { PgpmPackage, TEMPLATE_REPOS } from '@pgpmjs/core'; -import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { sync as glob } from 'glob'; import { Inquirerer, ParsedArgs } from 'inquirerer'; import * as path from 'path'; @@ -538,6 +538,72 @@ describe('cmds:init', () => { expect(existsSync(path.join(modDir, 'pgpm.plan'))).toBe(true); expect(existsSync(path.join(modDir, 'package.json'))).toBe(true); }); + + it('adds each new module to the CI matrix, sorted', async () => { + const { mockInput, mockOutput } = environment; + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const wsName = 'ws-ci-matrix'; + const wsRoot = path.join(fixture.tempDir, wsName); + + await commands(withInitDefaults({ + _: ['init', 'workspace'], + cwd: fixture.tempDir, + name: wsName, + workspace: true + }), prompter, { + noTty: true, + input: mockInput, + output: mockOutput, + version: '1.0.0', + minimistOpts: {} + }); + + const workflow = path.join(wsRoot, '.github', 'workflows', 'ci.yml'); + mkdirSync(path.dirname(workflow), { recursive: true }); + writeFileSync(workflow, [ + 'jobs:', + ' test:', + ' strategy:', + ' matrix:', + ' # kept sorted by pgpm init', + ' package: []', + ' steps:', + ' - run: cd ./${{ matrix.package }} && pnpm test', + '' + ].join('\n')); + + for (const modName of ['zeta', 'alpha']) { + await commands(withInitDefaults({ + _: ['init'], + cwd: wsRoot, + moduleName: modName, + name: modName + }), prompter, { + noTty: true, + input: mockInput, + output: mockOutput, + version: '1.0.0', + minimistOpts: {} + }); + } + + expect(readFileSync(workflow, 'utf8')).toBe([ + 'jobs:', + ' test:', + ' strategy:', + ' matrix:', + ' # kept sorted by pgpm init', + ' package: [packages/alpha, packages/zeta]', + ' steps:', + ' - run: cd ./${{ matrix.package }} && pnpm test', + '' + ].join('\n')); + }); }); describe('--create-workspace flag', () => { diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index b47e5241cf..06928324bd 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -1,4 +1,5 @@ import { + addToCiMatrix, BoilerplateSkill, DEFAULT_TEMPLATE_REPO, DEFAULT_TEMPLATE_TOOL_NAME, @@ -866,6 +867,16 @@ async function handleModuleInit( }); } + if (resolvedWorkspacePath) { + const matrixFiles = addToCiMatrix( + resolvedWorkspacePath, + path.relative(resolvedWorkspacePath, modulePath) + ); + for (const file of matrixFiles) { + process.stdout.write(`Added ${modName} to the CI matrix in ${file}\n`); + } + } + const motdPath = path.join(modulePath, '.motd'); let motd = DEFAULT_MOTD; if (fs.existsSync(motdPath)) { diff --git a/pgpm/core/__tests__/core/ci-matrix.test.ts b/pgpm/core/__tests__/core/ci-matrix.test.ts new file mode 100644 index 0000000000..d7ffb76560 --- /dev/null +++ b/pgpm/core/__tests__/core/ci-matrix.test.ts @@ -0,0 +1,606 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { parseDocument, visit } from 'yaml'; + +import { addToCiMatrix, addToMatrixYaml } from '../../src/core/ci-matrix'; + +const workflow = (matrix: string) => `name: CI +jobs: + test: + strategy: + fail-fast: false + matrix: + # \`pgpm init\` keeps this list sorted. +${matrix} + steps: + - uses: actions/checkout@v4 + with: + package: not-a-matrix + - run: cd ./\${{ matrix.package }} && pnpm test +`; + +const flowWorkflow = workflow(' package: [packages/beta]'); + +const commentMultiset = (source: string): string[] => { + const doc = parseDocument(source); + const comments: string[] = []; + const add = (comment: string | null | undefined, multiline = false) => { + if (comment === null || comment === undefined) return; + const values = multiline ? comment.split('\n') : [comment]; + comments.push(...values.map((value) => value.trim())); + }; + + add(doc.comment); + add(doc.commentBefore, true); + if (doc.contents) { + visit(doc.contents, (_key, node) => { + if (!node || typeof node !== 'object') return; + const commented = node as { + comment?: string | null; + commentBefore?: string | null; + }; + add(commented.comment); + add(commented.commentBefore, true); + }); + } + return comments.sort(); +}; + +const commentInvariantCases: [string, string][] = [ + [ + 'flow, extra spaces', + `jobs: + test: + strategy: + matrix: + package: [ packages/beta , packages/delta ] +` + ], + [ + 'flow, multiline', + `jobs: + test: + strategy: + matrix: + package: [ + packages/beta, + packages/delta + ] +` + ], + [ + 'flow, trailing comma + comment', + `jobs: + test: + strategy: + matrix: + package: [packages/beta, packages/delta] # keep sorted +` + ], + [ + 'block, comment between items', + `jobs: + test: + strategy: + matrix: + package: + # the api + - packages/beta + - packages/delta +` + ], + [ + 'block, inline comments', + `jobs: + test: + strategy: + matrix: + package: + - packages/beta # api + - packages/delta # web +` + ], + [ + 'block, blank line between items', + `jobs: + test: + strategy: + matrix: + package: + - packages/beta + + - packages/delta +` + ], + [ + 'block, 2-space indent', + `jobs: + test: + strategy: + matrix: + package: + - packages/beta + - packages/delta +` + ], + [ + 'quoted with spaces in value', + `jobs: + test: + strategy: + matrix: + package: ["packages/my thing", 'packages/delta'] +` + ], + [ + 'block, quoted', + `jobs: + test: + strategy: + matrix: + package: + - "packages/beta" + - 'packages/delta' +` + ], + [ + 'flow, empty with spaces', + `jobs: + test: + strategy: + matrix: + package: [ ] +` + ], + [ + 'CRLF', + `jobs:\r + test:\r + strategy:\r + matrix:\r + package: [packages/beta]\r +` + ], + [ + 'matrix with other keys after', + `jobs: + test: + strategy: + matrix: + package: [packages/beta] + node: [20, 22] +` + ], + [ + 'block, other key after', + `jobs: + test: + strategy: + matrix: + package: + - packages/beta + node: [20] +` + ], + [ + 'anchor', + `jobs: + test: + strategy: + matrix: + package: &pkgs [packages/beta] +` + ], + [ + 'entry equals existing but quoted', + `jobs: + test: + strategy: + matrix: + package: ['packages/alpha', packages/beta] +` + ], + [ + 'comment after last block item', + `jobs: + test: + strategy: + matrix: + package: + - packages/beta + - packages/delta # last +` + ], + [ + 'comment above package key', + `jobs: + test: + strategy: + matrix: + # package list + package: [packages/beta, packages/delta] +` + ], + [ + 'hash inside quoted flow value', + `jobs: + test: + strategy: + matrix: + package: ["packages/#beta", packages/delta] +` + ], + [ + 'hash inside quoted block value', + `jobs: + test: + strategy: + matrix: + package: + - "packages/#beta" + - packages/delta +` + ], + [ + 'blank line and comment above first item', + `jobs: + test: + strategy: + matrix: + package: + + # first + - packages/beta + - packages/delta +` + ], + [ + 'two-line comment block above first item', + `jobs: + test: + strategy: + matrix: + package: + # first + # item + - packages/beta + - packages/delta +` + ], + [ + 'comment above item two', + `jobs: + test: + strategy: + matrix: + package: + - packages/beta + # second + - packages/delta +` + ], + [ + 'comment on last line', + `jobs: + test: + strategy: + matrix: + package: + - packages/beta + - packages/delta # end` + ], + [ + 'null matrix', + `jobs: + test: + strategy: + matrix: + package: null +` + ], + [ + 'commented multiline flow', + `jobs: + test: + strategy: + matrix: + package: [ + packages/beta, # beta + packages/delta + ] +` + ] +]; + +describe('addToMatrixYaml', () => { + it.each(commentInvariantCases)( + 'preserves the comment multiset for %s', + (_name, source) => { + const output = addToMatrixYaml(source, 'packages/alpha'); + expect(commentMultiset(output)).toEqual(commentMultiset(source)); + } + ); + + it('adds to a flow sequence in sorted order', () => { + expect(addToMatrixYaml(flowWorkflow, 'packages/alpha')).toBe( + workflow(' package: [packages/alpha, packages/beta]') + ); + }); + + it('fills an empty array', () => { + expect(addToMatrixYaml(workflow(' package: []'), 'packages/alpha')).toBe( + workflow(' package: [packages/alpha]') + ); + }); + + it('adds to a block sequence in sorted order', () => { + const source = workflow( + [' package:', ' - packages/beta', ' - packages/delta'].join('\n') + ); + expect(addToMatrixYaml(source, 'packages/charlie')).toBe( + workflow( + [ + ' package:', + ' - packages/beta', + ' - packages/charlie', + ' - packages/delta' + ].join('\n') + ) + ); + }); + + it('keeps trailing comments attached to their entries', () => { + const source = workflow( + [ + ' package:', + ' - packages/beta # api', + ' - packages/delta # web' + ].join('\n') + ); + expect(addToMatrixYaml(source, 'packages/alpha')).toBe( + workflow( + [ + ' package:', + ' - packages/alpha', + ' - packages/beta # api', + ' - packages/delta # web' + ].join('\n') + ) + ); + }); + + it('moves a comment line with its block entry', () => { + const source = workflow( + [ + ' package:', + ' # the api', + ' - packages/beta', + ' - packages/delta' + ].join('\n') + ); + expect(addToMatrixYaml(source, 'packages/alpha')).toBe( + workflow( + [ + ' package:', + ' - packages/alpha', + ' # the api', + ' - packages/beta', + ' - packages/delta' + ].join('\n') + ) + ); + }); + + it('moves an inter-item comment with its block entry', () => { + const source = workflow( + [ + ' package:', + ' - packages/beta', + ' # the web', + ' - packages/delta' + ].join('\n') + ); + expect(addToMatrixYaml(source, 'packages/alpha')).toBe( + workflow( + [ + ' package:', + ' - packages/alpha', + ' - packages/beta', + ' # the web', + ' - packages/delta' + ].join('\n') + ) + ); + }); + + it('preserves multiline flow formatting', () => { + const source = workflow( + [ + ' package: [', + ' packages/beta,', + ' packages/delta', + ' ]' + ].join('\n') + ); + expect(addToMatrixYaml(source, 'packages/alpha')).toBe( + workflow( + [ + ' package: [', + ' packages/alpha,', + ' packages/beta,', + ' packages/delta', + ' ]' + ].join('\n') + ) + ); + }); + + it('leaves a flow sequence with comments untouched', () => { + const source = workflow(' package: [packages/beta, packages/delta] # keep'); + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(source); + }); + + it('leaves a flow item with a comment untouched', () => { + const source = workflow(' package: [packages/beta, packages/delta] # keep'); + const commented = source.replace( + 'package: [packages/beta, packages/delta]', + `package: [packages/beta, # keep beta + packages/delta]` + ); + expect(addToMatrixYaml(commented, 'packages/alpha')).toBe(commented); + }); + + it('is a no-op when the entry is already listed', () => { + expect(addToMatrixYaml(flowWorkflow, 'packages/beta')).toBe(flowWorkflow); + }); + + it('keeps existing entries verbatim, quotes only what needs it', () => { + expect( + addToMatrixYaml(workflow(` package: ['packages/beta']`), 'packages/alpha') + ).toBe(workflow(` package: [packages/alpha, 'packages/beta']`)); + }); + + it('updates the matrix of every job that has one', () => { + const source = `name: CI +jobs: + test: + strategy: + matrix: + package: [packages/beta] + lint: + strategy: + matrix: + package: + - packages/beta +`; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(`name: CI +jobs: + test: + strategy: + matrix: + package: [packages/alpha, packages/beta] + lint: + strategy: + matrix: + package: + - packages/alpha + - packages/beta +`); + }); + + it('ignores a `package` key that is not a job matrix', () => { + const source = `name: CI +env: + package: packages/beta +jobs: + test: + steps: + - uses: some/action@v1 + with: + package: [packages/beta] +`; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(source); + }); + + it('leaves a matrix that is not a plain list of strings alone', () => { + const source = `name: CI +jobs: + test: + strategy: + matrix: + package: \${{ fromJSON(needs.discover.outputs.packages) }} +`; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(source); + }); + + it('leaves an unparseable workflow alone', () => { + const source = 'jobs:\n test:\n :\n - broken: [\n'; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(source); + }); + + it('keeps CRLF workflows unchanged outside the matrix edit', () => { + const source = `name: CI\r\njobs:\r\n test:\r\n strategy:\r\n matrix:\r\n package: [packages/beta]\r\n`; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe( + `name: CI\r\njobs:\r\n test:\r\n strategy:\r\n matrix:\r\n package: [packages/alpha, packages/beta]\r\n` + ); + }); + + it('keeps anchors and sibling matrix keys intact', () => { + const source = `jobs: + test: + strategy: + matrix: + package: &pkgs [packages/beta] + node: [20, 22] +`; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(`jobs: + test: + strategy: + matrix: + package: &pkgs [packages/alpha, packages/beta] + node: [20, 22] +`); + }); + + it('keeps quoted values containing spaces', () => { + const source = workflow( + ` package: ["packages/my thing", 'packages/delta']` + ); + expect(addToMatrixYaml(source, 'packages/alpha')).toBe( + workflow(` package: [packages/alpha, 'packages/delta', "packages/my thing"]`) + ); + }); +}); + +describe('addToCiMatrix', () => { + let workspace: string; + + beforeEach(() => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'pgpm-ci-matrix-')); + }); + + afterEach(() => { + fs.rmSync(workspace, { recursive: true, force: true }); + }); + + const writeWorkflow = (name: string, contents: string) => { + const dir = path.join(workspace, '.github', 'workflows'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), contents); + }; + + it('updates every workflow with a matrix and reports which changed', () => { + writeWorkflow('ci.yml', flowWorkflow); + writeWorkflow('release.yml', 'name: Release\njobs:\n publish:\n steps: []\n'); + + const changed = addToCiMatrix(workspace, path.join('packages', 'alpha')); + + expect(changed).toEqual(['.github/workflows/ci.yml']); + expect( + fs.readFileSync(path.join(workspace, '.github/workflows/ci.yml'), 'utf8') + ).toBe(workflow(' package: [packages/alpha, packages/beta]')); + }); + + it('does nothing when the workspace has no workflows', () => { + expect(addToCiMatrix(workspace, 'packages/alpha')).toEqual([]); + }); + + it('skips workflow directories without blocking other files', () => { + const dir = path.join(workspace, '.github', 'workflows'); + fs.mkdirSync(path.join(dir, 'ci.yml'), { recursive: true }); + writeWorkflow('other.yml', flowWorkflow); + + expect(addToCiMatrix(workspace, 'packages/alpha')).toEqual([ + '.github/workflows/other.yml' + ]); + }); + + it('does nothing when the workflows path is a file', () => { + const githubDir = path.join(workspace, '.github'); + fs.mkdirSync(githubDir, { recursive: true }); + fs.writeFileSync(path.join(githubDir, 'workflows'), 'not a directory'); + + expect(addToCiMatrix(workspace, 'packages/alpha')).toEqual([]); + }); +}); diff --git a/pgpm/core/package.json b/pgpm/core/package.json index e334bd334b..59a716ca49 100644 --- a/pgpm/core/package.json +++ b/pgpm/core/package.json @@ -69,6 +69,7 @@ "pg-env": "workspace:^", "pgsql-deparser": "^18.3.7", "pgsql-parser": "^18.2.7", + "yaml": "^2.9.0", "yanse": "^0.2.2" } } diff --git a/pgpm/core/src/core/ci-matrix.ts b/pgpm/core/src/core/ci-matrix.ts new file mode 100644 index 0000000000..0d7c54ff84 --- /dev/null +++ b/pgpm/core/src/core/ci-matrix.ts @@ -0,0 +1,281 @@ +import fs from 'fs'; +import path from 'path'; +import { + Document, + isMap, + isScalar, + isSeq, + parseDocument, + Scalar, + stringify, + visit, + YAMLSeq +} from 'yaml'; + +const WORKFLOW_DIR = path.join('.github', 'workflows'); +const MATRIX_KEY = 'package'; + +/** A matrix entry: its parsed value, source text, and attached comments. */ +interface Entry { + value: string; + text: string; + comment?: string | null; + commentBefore?: string | null; +} + +/** Column the node starts at, i.e. the indent of its first line. */ +const columnOf = (source: string, offset: number): number => + offset - (source.lastIndexOf('\n', offset - 1) + 1); + +/** Every `jobs..strategy.matrix.package` sequence in a workflow. */ +const findMatrixSeqs = (doc: Document): YAMLSeq[] => { + const jobs = doc.get('jobs', true); + if (!isMap(jobs)) return []; + + const seqs: YAMLSeq[] = []; + for (const job of jobs.items) { + if (!isScalar(job.key)) continue; + const seq = doc.getIn( + ['jobs', job.key.value as string, 'strategy', 'matrix', MATRIX_KEY], + true + ); + if (isSeq(seq)) seqs.push(seq); + } + return seqs; +}; + +/** + * The sequence's entries with their original source text (so quoting survives), + * or `null` if any item isn't a plain string scalar. + */ +const entriesOf = (seq: YAMLSeq, source: string): Entry[] | null => { + const entries: Entry[] = []; + for (const item of seq.items) { + if (!isScalar(item) || typeof (item as Scalar).value !== 'string') { + return null; + } + const range = (item as Scalar).range; + if (!range) return null; + const [start, end] = range; + entries.push({ + value: (item as Scalar).value as string, + text: source.slice(start, end).trim(), + comment: item.comment, + commentBefore: item.commentBefore + }); + } + return entries; +}; + +const commentLines = (comment: string | null | undefined): string[] => + comment ? comment.split('\n').filter((line) => line !== '') : []; + +/** Find standalone comment lines immediately before a node. */ +const commentBlockStart = ( + source: string, + offset: number +): number | undefined => { + let lineStart = source.lastIndexOf('\n', offset - 1) + 1; + let first: number | undefined; + while (lineStart > 0) { + const lineEnd = lineStart - (source[lineStart - 1] === '\n' ? 1 : 0); + const previousStart = source.lastIndexOf('\n', lineEnd - 1) + 1; + const line = source.slice(previousStart, lineEnd).replace(/\r$/, ''); + if (!/^\s*#/.test(line)) break; + first = previousStart; + lineStart = previousStart; + } + return first; +}; + +const renderCommentBefore = (comment: string | null | undefined, indent: string): string => + commentLines(comment) + .map((line) => `${indent}#${line}`) + .join('\n'); + +const commentsOf = (doc: Document): string[] => { + const comments: string[] = []; + const add = (comment: string | null | undefined, multiline = false) => { + if (comment === null || comment === undefined) return; + const values = multiline ? comment.split('\n') : [comment]; + comments.push(...values.map((value) => value.trim())); + }; + + add(doc.comment); + add(doc.commentBefore, true); + if (doc.contents) { + visit(doc.contents, (_key, node) => { + if (!node || typeof node !== 'object') return; + const commented = node as { + comment?: string | null; + commentBefore?: string | null; + }; + add(commented.comment); + add(commented.commentBefore, true); + }); + } + return comments.sort(); +}; + +/** Re-render a sequence in the style and at the indent it was written with. */ +const renderSeq = ( + seq: YAMLSeq, + entries: Entry[], + source: string, + leadingComment = false +): string => { + const texts = entries.map((entry) => entry.text); + if (seq.flow) { + const original = seq.range + ? source.slice(seq.range[0], seq.range[1]) + : ''; + if (!original.includes('\n')) return `[${texts.join(', ')}]`; + + const firstItem = seq.items[0]; + if (!isScalar(firstItem) || !firstItem.range) { + return `[${texts.join(', ')}]`; + } + const itemIndent = ' '.repeat(columnOf(source, firstItem.range[0])); + const closing = original.lastIndexOf(']'); + const closingIndent = ' '.repeat( + closing >= 0 ? columnOf(source, seq.range[0] + closing) : columnOf(source, seq.range[0]) + ); + return `[\n${texts + .map((text, index) => `${itemIndent}${text}${index === texts.length - 1 ? '' : ','}`) + .join('\n')}\n${closingIndent}]`; + } + + const indent = ' '.repeat(columnOf(source, seq.range[0])); + const rendered = entries.map((entry, index) => { + const before = renderCommentBefore(entry.commentBefore, indent); + const itemIndent = + index === 0 && !entry.commentBefore && !leadingComment ? '' : indent; + const item = `${itemIndent}- ${entry.text}${ + entry.comment ? ` #${entry.comment}` : '' + }`; + return before ? `${before}\n${item}` : item; + }); + return rendered.join('\n'); +}; + +/** + * Insert `entry` into every test matrix of a workflow, keeping the list sorted. + * Only the byte range of each matrix sequence is rewritten; the rest of the + * source passes through untouched, so comments and formatting survive. + */ +export const addToMatrixYaml = (source: string, entry: string): string => { + const doc = parseDocument(source); + if (doc.errors.length) return source; + + const added: Entry = { + value: entry, + text: stringify(entry).trim() + }; + const edits: { start: number; end: number; text: string }[] = []; + + for (const seq of findMatrixSeqs(doc)) { + const entries = entriesOf(seq, source); + if (!entries) continue; + if (entries.some((existing) => existing.value === entry)) continue; + if (!seq.range) continue; + if ( + seq.flow && + (entries.some((existing) => existing.comment || existing.commentBefore) || + seq.comment || + seq.commentBefore) + ) { + continue; + } + + const [originalStart, end] = seq.range; + const firstCommentStart = + !seq.flow && (seq.commentBefore || entries[0]?.commentBefore) + ? commentBlockStart(source, originalStart) + : undefined; + const entriesWithComments = + firstCommentStart === undefined || !seq.commentBefore || !entries[0] + ? entries + : [ + { + ...entries[0], + commentBefore: entries[0].commentBefore + ? `${seq.commentBefore}\n${entries[0].commentBefore}` + : seq.commentBefore + }, + ...entries.slice(1) + ]; + const next = [...entriesWithComments, added] + .filter( + (item, index, all) => + all.findIndex((other) => other.value === item.value) === index + ) + .sort((left, right) => left.value.localeCompare(right.value)); + const start = firstCommentStart ?? originalStart; + // a block sequence's range extends to the next token; keep that whitespace + const [trailing] = /\s*$/.exec(source.slice(originalStart, end)) as [string]; + const rendered = renderSeq(seq, next, source, firstCommentStart !== undefined); + const replacement = rendered + trailing; + const candidate = + source.slice(0, start) + replacement + source.slice(end); + const candidateDoc = parseDocument(candidate); + if ( + candidateDoc.errors.length || + commentsOf(candidateDoc).join('\0') !== commentsOf(doc).join('\0') + ) { + continue; + } + edits.push({ + start, + end, + text: replacement + }); + } + + // Last edit first, so earlier ranges keep their offsets. + return edits + .sort((left, right) => right.start - left.start) + .reduce( + (text, edit) => + text.slice(0, edit.start) + edit.text + text.slice(edit.end), + source + ); +}; + +/** + * Add a module to the test matrix of a workspace's CI workflows, keeping the + * list sorted. The matrix stays a plain, hand-editable array: workflows without + * a `jobs..strategy.matrix.package` sequence are left alone. + * Best-effort: unreadable or unwritable workflows are skipped, never thrown. + * + * Returns the workflow files that changed, relative to `workspacePath`. + */ +export const addToCiMatrix = ( + workspacePath: string, + modulePackagePath: string +): string[] => { + const dir = path.join(workspacePath, WORKFLOW_DIR); + const entry = modulePackagePath.split(path.sep).join('/'); + const changed: string[] = []; + + try { + if (!fs.existsSync(dir)) return changed; + + for (const file of fs.readdirSync(dir).sort()) { + if (!/\.ya?ml$/.test(file)) continue; + try { + const filePath = path.join(dir, file); + const original = fs.readFileSync(filePath, 'utf8'); + const updated = addToMatrixYaml(original, entry); + if (updated === original) continue; + fs.writeFileSync(filePath, updated); + changed.push(path.join(WORKFLOW_DIR, file).split(path.sep).join('/')); + } catch { + continue; + } + } + } catch { + return changed; + } + + return changed; +}; diff --git a/pgpm/core/src/index.ts b/pgpm/core/src/index.ts index 11cca2c2c7..2a5a4a76d9 100644 --- a/pgpm/core/src/index.ts +++ b/pgpm/core/src/index.ts @@ -1,5 +1,6 @@ export * from './core/boilerplate-scanner'; export * from './core/boilerplate-types'; +export * from './core/ci-matrix'; export * from './core/class/pgpm'; export * from './core/template-scaffold'; export * from './diff'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1bd6d9365..031b5a2879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3208,6 +3208,9 @@ importers: pgsql-parser: specifier: ^18.2.7 version: 18.2.7 + yaml: + specifier: ^2.9.0 + version: 2.9.0 yanse: specifier: ^0.2.2 version: 0.2.2