feat(pgpm): init adds each new module to the workspace CI matrix, sorted - #1845
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| * | ||
| * Returns the workflow files that changed, relative to `workspacePath`. | ||
| */ | ||
| export const addToCiMatrix = ( |
There was a problem hiding this comment.
explain how this works. explain also how we read/write yaml and ensure precision.
There was a problem hiding this comment.
How it works
addToCiMatrix(workspacePath, 'packages/<mod>') runs once, right after pgpm init has scaffolded a module. It lists <workspace>/.github/workflows/*.y(a)ml and hands each file to addToMatrixYaml(source, entry), which is a pure string -> string function. It writes a file back only if the string changed, and returns the list of files it touched (init prints one line per file).
addToMatrixYaml walks the file line by line looking for the first package: key and handles the two ways a YAML sequence can be written:
- flow —
^(\s*)package:\s*\[(.*)\]\s*$→ split the bracket contents on,, strip quotes, add the entry, dedupe, sort, and rewrite that one line as<same indent>package: [a, b, c]. - block —
^(\s*)package:\s*$→ collect the following- itemlines that are indented deeper than the key and all at the same indent; add/dedupe/sort;splicethe new- itemlines into exactly that range, reusing the original item indent.
Everything else in the file — comments, blank lines, other jobs, ${{ }} expressions, anchors — is never parsed, so it's never touched. Sorting is localeCompare; dedupe is a Set, so re-running init for an existing module is a no-op.
Why not js-yaml
We deliberately do not yaml.load() → mutate → yaml.dump(). js-yaml (and yaml round-trippers generally) throw away comments, re-quote strings, reflow >-/| blocks and re-indent the whole document — which would rewrite a file the user is supposed to own and hand-edit. Precision comes from only ever replacing the line(s) that make up the package: sequence; the rest of the buffer is passed through byte-for-byte (we split on \n and join on \n, so line endings are preserved too).
Guardrails / when it backs off (returns the input unchanged)
- no
.github/workflows/dir, or no file contains apackage:list package:followed by something that isn't a sequence (a mapping,${{ fromJSON(...) }}, a scalar) — we only rewrite a sequence we fully parsed- entry already present
Trade-off we accept: the matrix key is the literal name package (what the templates ship), and a block list ends at the first line that isn't a - item at that indent — so a comment between items would truncate the recognized range. Both are covered in ci-matrix.test.ts (flow/block/empty/quoted/no-op/non-sequence) plus an e2e in init.test.ts that inits zeta then alpha and asserts package: [packages/alpha, packages/zeta] with the comment above it intact.
There was a problem hiding this comment.
Superseded by ef214c9 — the hand-rolled line parser is gone.
Read: parseDocument(source) from the yaml package (already in the repo's dep tree; added to @pgpmjs/core). Any parse error → the file is returned unchanged. The matrix is located structurally: for each key under jobs, doc.getIn(['jobs', job, 'strategy', 'matrix', 'package'], true); it has to be a YAMLSeq whose items are all string scalars.
Write: we don't stringify the document (that would reflow comments, quoting and indentation across the whole workflow). Every yaml node carries range: [start, end] byte offsets into the original source, so we take the existing items' source text (quotes preserved as written), add the new entry (stringify(entry) so it's quoted only if YAML needs it), dedupe, sort with localeCompare, re-render the sequence in its original style (flow [a, b] or block - a, at the original indent), and splice that into the sequence's own [start, end] range. Multiple matrices are spliced last-to-first so offsets stay valid. Every other byte of the file is passed through untouched.
Tests in pgpm/core/__tests__/core/ci-matrix.test.ts assert full-file equality (not toContain) for flow, block, empty [], quoted entries, multi-job, unrelated package keys, ${{ }} matrices and unparseable YAML.
|
|
||
| for (let index = 0; index < lines.length; index += 1) { | ||
| const flow = lines[index].match( | ||
| new RegExp(`^(\\s*)${MATRIX_KEY}:\\s*\\[(.*)\\]\\s*$`) |
There was a problem hiding this comment.
how do we know it's the matrix key and not some other key for another workflow?
There was a problem hiding this comment.
Before ef214c9 we didn't — that regex matched any package: line, including one under a step's with: or env:. Fixed: the file is now parsed with the yaml package and the updater resolves jobs.<job>.strategy.matrix.package for each job via doc.getIn([...]). Only a YAML sequence at exactly that path is treated as a matrix; a package key anywhere else is ignored, and so is a matrix that isn't a plain list of strings (e.g. ${{ fromJSON(...) }}). Tests: "ignores a package key that is not a job matrix", "updates the matrix of every job that has one", "leaves a matrix that is not a plain list of strings alone".
…jobs.*.strategy.matrix.package
Summary
The generated workspace CI keeps a literal, hand-editable test matrix;
pgpm init(module) now maintains it instead of the matrix being computed at CI time withpgpm ls(the "magic" objected to in pgpm-boilerplates#53/#55).After a module is scaffolded (pgpm and pnpm/lerna/npm workspaces), init calls the new
addToCiMatrix(workspacePath, relativeModulePath)from@pgpmjs/core:<workspace>/.github/workflows/*.y(a)mlyamlpackage (new dep on@pgpmjs/core) and resolvesjobs.<job>.strategy.matrix.packagefor every job — apackage:key anywhere else (a step'swith:,env:) is never a matchpackages/<name>), dedupes, sorts withlocaleCompare, and splices the re-rendered sequence into its ownrangein the source; the rest of the file is passed through byte-for-byte, so comments, quoting and indentation survive (nostringifyround-trip)[a, b](single- or multi-line) or block- a— existing items' source text as written, and each item's own comments${{ fromJSON(...) }}), or the YAML doesn't parseAdded <mod> to the CI matrix in .github/workflows/ci.ymlper changed file.github/workflowsbeing a file) skips that file/dir and never failspgpm init— the module is already scaffolded by then, so a CI-file problem must not blow up the commandHand-edited arrays keep their comments
A user's matrix is theirs to edit, so per-item comments travel with their item through the sort rather than being dropped:
commentBefore/commentare read off each scalar and re-emitted with it; when the first item carries a leading comment block the replaced span is extended back over those lines so they can't be duplicated. A flow sequence containing comments is left untouched instead (re-rendering comments inside[...]isn't worth it).Backstop, so no shape can silently eat a comment: before an edit is accepted, the candidate result is re-parsed and its comment multiset compared with the original document's (
commentsOf, viayaml'svisit). Any loss, duplication, or parse failure → that matrix is left exactly as it was.Companion boilerplate change (ships
package: []+cd ./${{ matrix.package }}): constructive-io/pgpm-boilerplates#56. Note GitHub rejects an empty literal matrix, so a workspace with zero modules has an invalidci.ymluntil its firstpgpm init;pgpm init -wproduces a valid one immediately.Tests:
pgpm/core/__tests__/core/ci-matrix.test.ts— whole-file equality for flow/block/empty/quoted/multi-job/unrelated-key/expression/unparseable/CRLF/anchor, plus a table-driven case asserting the comment multiset is identical before and after across 25 hand-edited shapes (extra spaces, trailing comments, comment blocks above items, multi-line flow,#inside quoted values). E2E inpgpm/cli/__tests__/init.test.tsinitszetathenalphaand assertspackage: [packages/alpha, packages/zeta]with the surrounding comment preserved.Link to Devin session: https://app.devin.ai/sessions/dd4055c50bcf4b1e8e604211038aaa82
Open in Devin Desktop: https://app.devin.ai/desktop/session/dd4055c50bcf4b1e8e604211038aaa82?variant=devin
Requested by: @pyramation