From 2fad719dbc951bcc219ed2436ea056a4ec629c00 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 17 Sep 2026 07:36:12 +0000 Subject: [PATCH 1/5] feat(pgpm): init adds each new module to the workspace CI matrix, sorted --- .agents/skills/pgpm/references/cli.md | 5 + pgpm/cli/__tests__/init.test.ts | 68 +++++++++++++- pgpm/cli/src/commands/init/index.ts | 11 +++ pgpm/core/__tests__/core/ci-matrix.test.ts | 104 +++++++++++++++++++++ pgpm/core/src/core/ci-matrix.ts | 97 +++++++++++++++++++ pgpm/core/src/index.ts | 1 + 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 pgpm/core/__tests__/core/ci-matrix.test.ts create mode 100644 pgpm/core/src/core/ci-matrix.ts diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md index 1b4fda32c9..e96db094bb 100644 --- a/.agents/skills/pgpm/references/cli.md +++ b/.agents/skills/pgpm/references/cli.md @@ -175,6 +175,11 @@ 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 +the `package:` matrix of every workflow under the workspace's +`.github/workflows/` (sorted, in place). The list stays a plain YAML array you +can hand-edit; workflows without a `package:` list are left alone. + ### 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..4413196e2a --- /dev/null +++ b/pgpm/core/__tests__/core/ci-matrix.test.ts @@ -0,0 +1,104 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { addToCiMatrix, addToMatrixYaml } from '../../src/core/ci-matrix'; + +const flowWorkflow = `name: CI +jobs: + test: + strategy: + matrix: + # \`pgpm init\` keeps this list sorted. + package: [packages/beta] + steps: + - run: pnpm test +`; + +describe('addToMatrixYaml', () => { + it('adds to a flow sequence in sorted order', () => { + expect(addToMatrixYaml(flowWorkflow, 'packages/alpha')).toContain( + ' package: [packages/alpha, packages/beta]' + ); + }); + + it('keeps comments and the rest of the workflow', () => { + const updated = addToMatrixYaml(flowWorkflow, 'packages/alpha'); + expect(updated).toContain('# `pgpm init` keeps this list sorted.'); + expect(updated).toContain(' - run: pnpm test'); + }); + + it('fills an empty array', () => { + expect(addToMatrixYaml(' package: []\n', 'packages/alpha')).toBe( + ' package: [packages/alpha]\n' + ); + }); + + it('adds to a block sequence in sorted order', () => { + const source = ` matrix: + package: + - packages/beta + - packages/delta +`; + expect(addToMatrixYaml(source, 'packages/charlie')).toBe(` matrix: + package: + - packages/beta + - packages/charlie + - packages/delta +`); + }); + + it('is a no-op when the entry is already listed', () => { + expect(addToMatrixYaml(flowWorkflow, 'packages/beta')).toBe(flowWorkflow); + }); + + it('leaves quoted entries unquoted but keeps their values', () => { + expect(addToMatrixYaml(` package: ['packages/beta']\n`, 'packages/alpha')).toBe( + ' package: [packages/alpha, packages/beta]\n' + ); + }); + + it('leaves a workflow without the key alone', () => { + const source = 'name: CI\njobs:\n test:\n steps:\n - run: pnpm test\n'; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(source); + }); + + it('leaves a `package` mapping that is not a sequence alone', () => { + const source = 'package:\n name: something\n'; + expect(addToMatrixYaml(source, 'packages/alpha')).toBe(source); + }); +}); + +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') + ).toContain('package: [packages/alpha, packages/beta]'); + }); + + it('does nothing when the workspace has no workflows', () => { + expect(addToCiMatrix(workspace, 'packages/alpha')).toEqual([]); + }); +}); diff --git a/pgpm/core/src/core/ci-matrix.ts b/pgpm/core/src/core/ci-matrix.ts new file mode 100644 index 0000000000..739a186d88 --- /dev/null +++ b/pgpm/core/src/core/ci-matrix.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; +import path from 'path'; + +const WORKFLOW_DIR = path.join('.github', 'workflows'); +const MATRIX_KEY = 'package'; + +const parseFlowEntries = (raw: string): string[] => + raw + .split(',') + .map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + +const sortEntries = (entries: string[]): string[] => + [...new Set(entries)].sort((left, right) => left.localeCompare(right)); + +/** + * Add a module to the `package:` matrix of a workspace's CI workflows, keeping + * the list sorted. The list stays a plain, hand-editable array: workflows + * without one, or without the key, are left alone. + * + * Returns the workflow files that changed, relative to `workspacePath`. + */ +export const addToCiMatrix = ( + workspacePath: string, + modulePackagePath: string +): string[] => { + const dir = path.join(workspacePath, WORKFLOW_DIR); + if (!fs.existsSync(dir)) return []; + + const entry = modulePackagePath.split(path.sep).join('/'); + const changed: string[] = []; + + for (const file of fs.readdirSync(dir).sort()) { + if (!/\.ya?ml$/.test(file)) continue; + 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('/')); + } + + return changed; +}; + +/** + * Insert `entry` into the first `package:` sequence of a workflow, in either + * flow (`package: [a, b]`) or block form, preserving indentation, comments and + * everything else in the file. + */ +export const addToMatrixYaml = (source: string, entry: string): string => { + const lines = source.split('\n'); + + for (let index = 0; index < lines.length; index += 1) { + const flow = lines[index].match( + new RegExp(`^(\\s*)${MATRIX_KEY}:\\s*\\[(.*)\\]\\s*$`) + ); + if (flow) { + const [, indent, raw] = flow; + const entries = parseFlowEntries(raw); + if (entries.includes(entry)) return source; + const next = sortEntries([...entries, entry]); + lines[index] = `${indent}${MATRIX_KEY}: [${next.join(', ')}]`; + return lines.join('\n'); + } + + const block = lines[index].match(new RegExp(`^(\\s*)${MATRIX_KEY}:\\s*$`)); + if (!block) continue; + + const [, indent] = block; + const items: { line: number; value: string }[] = []; + let cursor = index + 1; + let itemIndent: string | undefined; + + while (cursor < lines.length) { + const item = lines[cursor].match(/^(\s*)-\s*(.*?)\s*$/); + if (!item || item[1].length <= indent.length) break; + if (itemIndent === undefined) itemIndent = item[1]; + if (item[1] !== itemIndent) break; + items.push({ line: cursor, value: item[2].replace(/^['"]|['"]$/g, '') }); + cursor += 1; + } + + // A `package:` key with nothing under it is a mapping we don't understand; + // only rewrite a sequence we fully parsed. + if (!items.length) continue; + + const values = items.map((item) => item.value); + if (values.includes(entry)) return source; + const next = sortEntries([...values, entry]); + const rendered = next.map((value) => `${itemIndent}- ${value}`); + lines.splice(items[0].line, items.length, ...rendered); + return lines.join('\n'); + } + + return source; +}; 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'; From ef214c9d285c789ffe3fe1bbffbf315757e042ea Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 17 Sep 2026 08:05:49 +0000 Subject: [PATCH 2/5] refactor(pgpm): locate the CI matrix with the yaml parser, scoped to jobs.*.strategy.matrix.package --- .agents/skills/pgpm/references/cli.md | 9 +- pgpm/core/__tests__/core/ci-matrix.test.ts | 112 +++++++++---- pgpm/core/package.json | 1 + pgpm/core/src/core/ci-matrix.ts | 174 +++++++++++++-------- pnpm-lock.yaml | 3 + 5 files changed, 202 insertions(+), 97 deletions(-) diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md index e96db094bb..e03f78e06b 100644 --- a/.agents/skills/pgpm/references/cli.md +++ b/.agents/skills/pgpm/references/cli.md @@ -176,9 +176,12 @@ Non-interactive init requires every question to be answered by flags; see `--moduleName --packageIdentifier --moduleDesc --access`. When a module is created, `pgpm init` also adds its workspace-relative path to -the `package:` matrix of every workflow under the workspace's -`.github/workflows/` (sorted, in place). The list stays a plain YAML array you -can hand-edit; workflows without a `package:` list are left alone. +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. 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 — are left alone. ### Workspace Inspection diff --git a/pgpm/core/__tests__/core/ci-matrix.test.ts b/pgpm/core/__tests__/core/ci-matrix.test.ts index 4413196e2a..816e643db9 100644 --- a/pgpm/core/__tests__/core/ci-matrix.test.ts +++ b/pgpm/core/__tests__/core/ci-matrix.test.ts @@ -4,67 +4,117 @@ import path from 'path'; import { addToCiMatrix, addToMatrixYaml } from '../../src/core/ci-matrix'; -const flowWorkflow = `name: CI +const workflow = (matrix: string) => `name: CI jobs: test: strategy: + fail-fast: false matrix: # \`pgpm init\` keeps this list sorted. - package: [packages/beta] +${matrix} steps: - - run: pnpm test + - uses: actions/checkout@v4 + with: + package: not-a-matrix + - run: cd ./\${{ matrix.package }} && pnpm test `; +const flowWorkflow = workflow(' package: [packages/beta]'); + describe('addToMatrixYaml', () => { it('adds to a flow sequence in sorted order', () => { - expect(addToMatrixYaml(flowWorkflow, 'packages/alpha')).toContain( - ' package: [packages/alpha, packages/beta]' + expect(addToMatrixYaml(flowWorkflow, 'packages/alpha')).toBe( + workflow(' package: [packages/alpha, packages/beta]') ); }); - it('keeps comments and the rest of the workflow', () => { - const updated = addToMatrixYaml(flowWorkflow, 'packages/alpha'); - expect(updated).toContain('# `pgpm init` keeps this list sorted.'); - expect(updated).toContain(' - run: pnpm test'); - }); - it('fills an empty array', () => { - expect(addToMatrixYaml(' package: []\n', 'packages/alpha')).toBe( - ' package: [packages/alpha]\n' + expect(addToMatrixYaml(workflow(' package: []'), 'packages/alpha')).toBe( + workflow(' package: [packages/alpha]') ); }); it('adds to a block sequence in sorted order', () => { - const source = ` matrix: + 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('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 - - packages/delta `; - expect(addToMatrixYaml(source, 'packages/charlie')).toBe(` matrix: + 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 - - packages/charlie - - packages/delta `); }); - it('is a no-op when the entry is already listed', () => { - expect(addToMatrixYaml(flowWorkflow, 'packages/beta')).toBe(flowWorkflow); - }); - - it('leaves quoted entries unquoted but keeps their values', () => { - expect(addToMatrixYaml(` package: ['packages/beta']\n`, 'packages/alpha')).toBe( - ' package: [packages/alpha, packages/beta]\n' - ); + 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 workflow without the key alone', () => { - const source = 'name: CI\njobs:\n test:\n steps:\n - run: pnpm test\n'; + 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 a `package` mapping that is not a sequence alone', () => { - const source = 'package:\n name: something\n'; + it('leaves an unparseable workflow alone', () => { + const source = 'jobs:\n test:\n :\n - broken: [\n'; expect(addToMatrixYaml(source, 'packages/alpha')).toBe(source); }); }); @@ -95,7 +145,7 @@ describe('addToCiMatrix', () => { expect(changed).toEqual(['.github/workflows/ci.yml']); expect( fs.readFileSync(path.join(workspace, '.github/workflows/ci.yml'), 'utf8') - ).toContain('package: [packages/alpha, packages/beta]'); + ).toBe(workflow(' package: [packages/alpha, packages/beta]')); }); it('does nothing when the workspace has no workflows', () => { diff --git a/pgpm/core/package.json b/pgpm/core/package.json index 0d05c0ea43..f3642409f0 100644 --- a/pgpm/core/package.json +++ b/pgpm/core/package.json @@ -69,6 +69,7 @@ "pg-env": "workspace:^", "pgsql-deparser": "^18.3.6", "pgsql-parser": "^18.2.6", + "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 index 739a186d88..b3e6ce21bd 100644 --- a/pgpm/core/src/core/ci-matrix.ts +++ b/pgpm/core/src/core/ci-matrix.ts @@ -1,22 +1,123 @@ import fs from 'fs'; import path from 'path'; +import { + Document, + isMap, + isScalar, + isSeq, + parseDocument, + Scalar, + stringify, + YAMLSeq +} from 'yaml'; const WORKFLOW_DIR = path.join('.github', 'workflows'); const MATRIX_KEY = 'package'; -const parseFlowEntries = (raw: string): string[] => - raw - .split(',') - .map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')) - .filter(Boolean); +/** A matrix entry: its parsed value plus the source text that produced it. */ +interface Entry { + value: string; + text: string; +} -const sortEntries = (entries: string[]): string[] => - [...new Set(entries)].sort((left, right) => left.localeCompare(right)); +/** 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 [start, end] = (item as Scalar).range ?? []; + entries.push({ + value: (item as Scalar).value as string, + text: source.slice(start, end).trim() + }); + } + return entries; +}; + +/** Re-render a sequence in the style and at the indent it was written with. */ +const renderSeq = (seq: YAMLSeq, entries: Entry[], source: string): string => { + const texts = entries.map((entry) => entry.text); + if (seq.flow) return `[${texts.join(', ')}]`; + const indent = ' '.repeat(columnOf(source, seq.range[0])); + return texts + .map((text, index) => `${index === 0 ? '' : indent}- ${text}`) + .join('\n'); +}; /** - * Add a module to the `package:` matrix of a workspace's CI workflows, keeping - * the list sorted. The list stays a plain, hand-editable array: workflows - * without one, or without the key, are left alone. + * 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; + + const next = [...entries, added] + .filter( + (item, index, all) => + all.findIndex((other) => other.value === item.value) === index + ) + .sort((left, right) => left.value.localeCompare(right.value)); + + const [start, end] = seq.range; + // a block sequence's range extends to the next token; keep that whitespace + const [trailing] = /\s*$/.exec(source.slice(start, end)) as [string]; + edits.push({ + start, + end, + text: renderSeq(seq, next, source) + trailing + }); + } + + // 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. * * Returns the workflow files that changed, relative to `workspacePath`. */ @@ -42,56 +143,3 @@ export const addToCiMatrix = ( return changed; }; - -/** - * Insert `entry` into the first `package:` sequence of a workflow, in either - * flow (`package: [a, b]`) or block form, preserving indentation, comments and - * everything else in the file. - */ -export const addToMatrixYaml = (source: string, entry: string): string => { - const lines = source.split('\n'); - - for (let index = 0; index < lines.length; index += 1) { - const flow = lines[index].match( - new RegExp(`^(\\s*)${MATRIX_KEY}:\\s*\\[(.*)\\]\\s*$`) - ); - if (flow) { - const [, indent, raw] = flow; - const entries = parseFlowEntries(raw); - if (entries.includes(entry)) return source; - const next = sortEntries([...entries, entry]); - lines[index] = `${indent}${MATRIX_KEY}: [${next.join(', ')}]`; - return lines.join('\n'); - } - - const block = lines[index].match(new RegExp(`^(\\s*)${MATRIX_KEY}:\\s*$`)); - if (!block) continue; - - const [, indent] = block; - const items: { line: number; value: string }[] = []; - let cursor = index + 1; - let itemIndent: string | undefined; - - while (cursor < lines.length) { - const item = lines[cursor].match(/^(\s*)-\s*(.*?)\s*$/); - if (!item || item[1].length <= indent.length) break; - if (itemIndent === undefined) itemIndent = item[1]; - if (item[1] !== itemIndent) break; - items.push({ line: cursor, value: item[2].replace(/^['"]|['"]$/g, '') }); - cursor += 1; - } - - // A `package:` key with nothing under it is a mapping we don't understand; - // only rewrite a sequence we fully parsed. - if (!items.length) continue; - - const values = items.map((item) => item.value); - if (values.includes(entry)) return source; - const next = sortEntries([...values, entry]); - const rendered = next.map((value) => `${itemIndent}- ${value}`); - lines.splice(items[0].line, items.length, ...rendered); - return lines.join('\n'); - } - - return source; -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 203668a478..cd3ea268eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3209,6 +3209,9 @@ importers: pgsql-parser: specifier: ^18.2.6 version: 18.2.6 + yaml: + specifier: ^2.9.0 + version: 2.9.0 yanse: specifier: ^0.2.2 version: 0.2.2 From 2730821f9a9999fba1acdba9d7f061a8c67469ca Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 17 Sep 2026 08:36:57 +0000 Subject: [PATCH 3/5] fix(pgpm): preserve CI matrix comments and flow formatting --- .agents/skills/pgpm/references/cli.md | 7 +- pgpm/core/__tests__/core/ci-matrix.test.ts | 134 +++++++++++++++++++++ pgpm/core/src/core/ci-matrix.ts | 130 +++++++++++++++++--- 3 files changed, 254 insertions(+), 17 deletions(-) diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md index e03f78e06b..1ee19b57f5 100644 --- a/.agents/skills/pgpm/references/cli.md +++ b/.agents/skills/pgpm/references/cli.md @@ -179,9 +179,10 @@ 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. 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 — are left alone. +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 — are left alone. ### Workspace Inspection diff --git a/pgpm/core/__tests__/core/ci-matrix.test.ts b/pgpm/core/__tests__/core/ci-matrix.test.ts index 816e643db9..4d6b810dff 100644 --- a/pgpm/core/__tests__/core/ci-matrix.test.ts +++ b/pgpm/core/__tests__/core/ci-matrix.test.ts @@ -50,6 +50,107 @@ describe('addToMatrixYaml', () => { ); }); + 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); }); @@ -117,6 +218,39 @@ jobs: 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', () => { diff --git a/pgpm/core/src/core/ci-matrix.ts b/pgpm/core/src/core/ci-matrix.ts index b3e6ce21bd..1464f94f8b 100644 --- a/pgpm/core/src/core/ci-matrix.ts +++ b/pgpm/core/src/core/ci-matrix.ts @@ -14,10 +14,12 @@ import { const WORKFLOW_DIR = path.join('.github', 'workflows'); const MATRIX_KEY = 'package'; -/** A matrix entry: its parsed value plus the source text that produced it. */ +/** 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. */ @@ -51,23 +53,91 @@ const entriesOf = (seq: YAMLSeq, source: string): Entry[] | null => { if (!isScalar(item) || typeof (item as Scalar).value !== 'string') { return null; } - const [start, end] = (item as Scalar).range ?? []; + 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() + 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 countComments = (entries: Entry[]): number => + entries.reduce( + (count, entry) => + count + commentLines(entry.commentBefore).length + (entry.comment ? 1 : 0), + 0 + ); + /** Re-render a sequence in the style and at the indent it was written with. */ -const renderSeq = (seq: YAMLSeq, entries: Entry[], source: string): string => { +const renderSeq = ( + seq: YAMLSeq, + entries: Entry[], + source: string, + leadingComment = false +): string => { const texts = entries.map((entry) => entry.text); - if (seq.flow) return `[${texts.join(', ')}]`; + 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])); - return texts - .map((text, index) => `${index === 0 ? '' : indent}- ${text}`) - .join('\n'); + 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'); }; /** @@ -79,28 +149,60 @@ 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 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 next = [...entries, added] + 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, end] = seq.range; + const start = firstCommentStart ?? originalStart; // a block sequence's range extends to the next token; keep that whitespace - const [trailing] = /\s*$/.exec(source.slice(start, end)) as [string]; + const [trailing] = /\s*$/.exec(source.slice(originalStart, end)) as [string]; + const rendered = renderSeq(seq, next, source, firstCommentStart !== undefined); + const sourceComments = countComments(entriesWithComments); + const renderedComments = countComments(next); + if (sourceComments !== renderedComments) continue; edits.push({ start, end, - text: renderSeq(seq, next, source) + trailing + text: rendered + trailing }); } From 8bca2500ca375b57f4262408a5cc3dfbe0227670 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 17 Sep 2026 08:40:49 +0000 Subject: [PATCH 4/5] fix(pgpm): validate preserved CI matrix comments --- pgpm/core/__tests__/core/ci-matrix.test.ts | 300 +++++++++++++++++++++ pgpm/core/src/core/ci-matrix.ts | 45 +++- 2 files changed, 335 insertions(+), 10 deletions(-) diff --git a/pgpm/core/__tests__/core/ci-matrix.test.ts b/pgpm/core/__tests__/core/ci-matrix.test.ts index 4d6b810dff..3d3a77031f 100644 --- a/pgpm/core/__tests__/core/ci-matrix.test.ts +++ b/pgpm/core/__tests__/core/ci-matrix.test.ts @@ -1,6 +1,7 @@ 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'; @@ -21,7 +22,306 @@ ${matrix} 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]') diff --git a/pgpm/core/src/core/ci-matrix.ts b/pgpm/core/src/core/ci-matrix.ts index 1464f94f8b..f56e741e34 100644 --- a/pgpm/core/src/core/ci-matrix.ts +++ b/pgpm/core/src/core/ci-matrix.ts @@ -8,6 +8,7 @@ import { parseDocument, Scalar, stringify, + visit, YAMLSeq } from 'yaml'; @@ -92,12 +93,29 @@ const renderCommentBefore = (comment: string | null | undefined, indent: string) .map((line) => `${indent}#${line}`) .join('\n'); -const countComments = (entries: Entry[]): number => - entries.reduce( - (count, entry) => - count + commentLines(entry.commentBefore).length + (entry.comment ? 1 : 0), - 0 - ); +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 = ( @@ -196,13 +214,20 @@ export const addToMatrixYaml = (source: string, entry: string): string => { // 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 sourceComments = countComments(entriesWithComments); - const renderedComments = countComments(next); - if (sourceComments !== renderedComments) continue; + 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: rendered + trailing + text: replacement }); } From 689657b763fefef11fc25853c47880e3d7b33d4a Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 17 Sep 2026 22:45:07 +0000 Subject: [PATCH 5/5] fix(pgpm): never let CI matrix update fail init --- .agents/skills/pgpm/references/cli.md | 3 ++- pgpm/core/__tests__/core/ci-matrix.test.ts | 18 ++++++++++++++ pgpm/core/src/core/ci-matrix.ts | 29 ++++++++++++++-------- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md index 1ee19b57f5..fda419fa55 100644 --- a/.agents/skills/pgpm/references/cli.md +++ b/.agents/skills/pgpm/references/cli.md @@ -182,7 +182,8 @@ 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 — are left alone. +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 diff --git a/pgpm/core/__tests__/core/ci-matrix.test.ts b/pgpm/core/__tests__/core/ci-matrix.test.ts index 3d3a77031f..d7ffb76560 100644 --- a/pgpm/core/__tests__/core/ci-matrix.test.ts +++ b/pgpm/core/__tests__/core/ci-matrix.test.ts @@ -585,4 +585,22 @@ describe('addToCiMatrix', () => { 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/src/core/ci-matrix.ts b/pgpm/core/src/core/ci-matrix.ts index f56e741e34..0d7c54ff84 100644 --- a/pgpm/core/src/core/ci-matrix.ts +++ b/pgpm/core/src/core/ci-matrix.ts @@ -245,6 +245,7 @@ export const addToMatrixYaml = (source: string, entry: string): string => { * 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`. */ @@ -253,19 +254,27 @@ export const addToCiMatrix = ( modulePackagePath: string ): string[] => { const dir = path.join(workspacePath, WORKFLOW_DIR); - if (!fs.existsSync(dir)) return []; - const entry = modulePackagePath.split(path.sep).join('/'); const changed: string[] = []; - for (const file of fs.readdirSync(dir).sort()) { - if (!/\.ya?ml$/.test(file)) continue; - 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('/')); + 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;