Skip to content

Commit 5898e76

Browse files
inistorclaude
andauthored
feat: support needs[].parallel.matrix and matrix expressions (#1848)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c50ff6c commit 5898e76

18 files changed

Lines changed: 824 additions & 4 deletions

File tree

src/data-expander.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export function needsComplex (data: any) {
116116
...(data.project ? {project: data.project} : {}),
117117
...(data.ref ? {ref: data.ref} : {}),
118118
...(data.optional ? {optional: data.optional} : {}),
119+
...(data.parallel ? {parallel: data.parallel} : {}),
119120
};
120121

121122
// In needs:project/needs:pipeline, `optional` is not an allowed property

src/executor.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {Job} from "./job.js";
33
import assert, {AssertionError} from "node:assert";
44
import {Argv} from "./argv.js";
55
import pMap from "p-map";
6+
import {matrixSelectorMatches} from "./parallel.js";
67

78
export class Executor {
89

@@ -87,7 +88,16 @@ export class Executor {
8788
const toWaitFor = [];
8889
assert(job.needs != null, chalk`${job.name}.needs cannot be null in getNeededToWaitFor`);
8990
for (const need of job.needs) {
90-
const baseJobs = jobs.filter(j => j.baseName === need.job);
91+
let baseJobs = jobs.filter(j => j.baseName === need.job);
92+
if (need.parallel?.matrix && baseJobs.length > 0) {
93+
if (baseJobs.every(j => j.matrixVariables == null)) {
94+
throw new AssertionError({message: chalk`{blueBright ${job.name}} uses needs.parallel.matrix targeting {blueBright ${need.job}}, but {blueBright ${need.job}} has no parallel:matrix configuration`});
95+
}
96+
baseJobs = baseJobs.filter(j => matrixSelectorMatches(j.matrixVariables, need.parallel!.matrix));
97+
if (baseJobs.length === 0 && !need.optional) {
98+
throw new AssertionError({message: chalk`{blueBright ${job.name}} needs.parallel.matrix selector for {blueBright ${need.job}} matched zero permutations`});
99+
}
100+
}
91101
for (const j of baseJobs) {
92102
if (j.when === "never" && !need.optional) {
93103
throw new AssertionError({message: chalk`{blueBright ${j.name}} is when:never, but its needed by {blueBright ${job.name}}`});

src/job.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface Need {
6161
ref?: string;
6262
pipeline?: string;
6363
project?: string;
64+
parallel?: {matrix: {[key: string]: string | number | (string | number)[]}[]};
6465
}
6566

6667
const isGlob = (str: string) => /[*?{}(|)[\]]/.test(str);
@@ -98,6 +99,7 @@ export class Job {
9899
readonly argv: Argv;
99100
readonly name: string;
100101
readonly baseName: string;
102+
readonly matrixVariables: {[key: string]: string} | null;
101103
readonly dependencies: string[] | null;
102104
readonly environment?: {name: string; url: string | null; deployment_tier: string | null; action: string | null};
103105
readonly jobId: number;
@@ -150,6 +152,7 @@ export class Job {
150152
this.gitData = opt.gitData;
151153
this.name = opt.name;
152154
this.baseName = opt.baseName;
155+
this.matrixVariables = opt.matrixVariables;
153156
this.jobId = this.generateJobId();
154157
this.jobData = opt.data;
155158
this.pipelineIid = opt.pipelineIid;

src/parallel.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,106 @@
1-
import assert from "node:assert";
1+
import chalk from "chalk-template";
2+
import assert, {AssertionError} from "node:assert";
23
import deepExtend from "deep-extend";
4+
import {Need} from "./job.js";
5+
6+
type MatrixSelector = NonNullable<Need["parallel"]>["matrix"];
7+
type MatrixVariables = {[key: string]: string};
38

49
export function isPlainParallel (jobData: any) {
510
return Number.isInteger(jobData.parallel);
611
}
712

13+
// Expand a parallel.matrix-shaped selector into concrete {key: value} bindings.
14+
// Mirrors GitLab's expansion: scalars are wrapped to single-element arrays, and
15+
// arrays produce a cartesian product across keys within an entry.
16+
export function expandMatrixSelector (matrix: MatrixSelector): MatrixVariables[] {
17+
const expanded: MatrixVariables[] = [];
18+
for (const entry of matrix) {
19+
let inner: MatrixVariables[] = [{}];
20+
for (const [key, raw] of Object.entries(entry)) {
21+
const values = Array.isArray(raw) ? raw : [raw];
22+
const next: MatrixVariables[] = [];
23+
for (const clone of inner) {
24+
for (const v of values) {
25+
next.push({...clone, [key]: String(v)});
26+
}
27+
}
28+
inner = next;
29+
}
30+
expanded.push(...inner);
31+
}
32+
return expanded;
33+
}
34+
35+
// Returns true if `producerVars` matches at least one expanded selector entry.
36+
// Match semantics: every key in the selector entry must equal the producer's value;
37+
// the producer may have extra keys not mentioned in the selector.
38+
export function matrixSelectorMatches (producerVars: MatrixVariables | null, matrix: MatrixSelector): boolean {
39+
if (!producerVars) return false;
40+
return expandMatrixSelector(matrix).some(sel =>
41+
Object.entries(sel).every(([k, v]) => String(producerVars[k]) === String(v)),
42+
);
43+
}
44+
45+
// Find the consumer's `need` (if any) that this `producer` job satisfies.
46+
// Used by both the executor (waitFor) and the artifacts producer pipeline so
47+
// the two paths apply the same matching rules.
48+
export function findMatchingNeed (consumerNeeds: ReadonlyArray<Need> | null, producerName: string, producerBaseName: string): Need | undefined {
49+
return consumerNeeds?.find(n => n.job === producerName || n.job === producerBaseName);
50+
}
51+
52+
// `$[[ matrix.IDENTIFIER ]]` — see https://docs.gitlab.com/ci/yaml/matrix_expressions/.
53+
// Identifier charset matches upstream gitlab-org/gitlab's MatrixInterpolator regex
54+
// `[a-zA-Z0-9_-]+` (letters, digits, underscore, hyphen).
55+
const MATRIX_EXPR_RE = /\$\[\[\s*matrix\.([a-zA-Z0-9_-]+)\s*\]\]/g;
56+
const MATRIX_EXPR_TEST_RE = /\$\[\[\s*matrix\./;
57+
58+
function valueContainsMatrixExpr (v: unknown): boolean {
59+
if (Array.isArray(v)) return v.some(valueContainsMatrixExpr);
60+
if (typeof v !== "string") return false;
61+
return MATRIX_EXPR_TEST_RE.test(v);
62+
}
63+
64+
// Detect whether any need's parallel.matrix selector contains a `$[[ matrix.X ]]` expression.
65+
// Used to surface a clear error if a non-parallel-matrix consumer references such expressions.
66+
export function needsContainMatrixExpressions (needs: Need[]): boolean {
67+
return needs.some(n =>
68+
n.parallel?.matrix?.some(entry => Object.values(entry).some(valueContainsMatrixExpr)) ?? false,
69+
);
70+
}
71+
72+
function substituteMatrixExpr (value: unknown, consumerVars: MatrixVariables, jobName: string): unknown {
73+
if (Array.isArray(value)) return value.map(v => substituteMatrixExpr(v, consumerVars, jobName));
74+
if (typeof value !== "string") return value;
75+
return value.replace(MATRIX_EXPR_RE, (_, key) => {
76+
if (!(key in consumerVars)) {
77+
// Wording mirrors upstream gitlab-org/gitlab's MatrixInterpolator error
78+
// ("'X' does not exist in matrix configuration") so log greps line up.
79+
throw new AssertionError({message: chalk`{blueBright ${jobName}}: '{yellow ${key}}' does not exist in matrix configuration`});
80+
}
81+
return consumerVars[key];
82+
});
83+
}
84+
85+
// Substitute `$[[ matrix.X ]]` references inside each need's parallel.matrix
86+
// selector, using the consumer permutation's own matrix bindings. Returns a
87+
// shallow-cloned needs array (only entries with parallel.matrix are rebuilt);
88+
// non-substituted fields on each need are shared with the input by reference.
89+
export function resolveNeedsMatrixExpressions (needs: Need[], consumerVars: MatrixVariables, jobName: string): Need[] {
90+
return needs.map(n => {
91+
if (!n?.parallel?.matrix) return n;
92+
return {
93+
...n,
94+
parallel: {
95+
...n.parallel,
96+
matrix: n.parallel.matrix.map(entry =>
97+
Object.fromEntries(Object.entries(entry).map(([k, v]) => [k, substituteMatrixExpr(v, consumerVars, jobName)])),
98+
) as MatrixSelector,
99+
},
100+
};
101+
});
102+
}
103+
8104
export function matrixVariablesList (jobData: any, jobName: string): {[key: string]: string}[] | null[] {
9105
if (isPlainParallel(jobData)) {
10106
return new Array(jobData.parallel).fill(null);

src/parser.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import prettyHrtime from "pretty-hrtime";
77
import {Job} from "./job.js";
88
import * as DataExpander from "./data-expander.js";
99
import {Utils} from "./utils.js";
10-
import assert from "node:assert";
10+
import assert, {AssertionError} from "node:assert";
1111
import {Validator} from "./validator.js";
1212
import * as parallel from "./parallel.js";
1313
import {GitData} from "./git-data.js";
@@ -193,10 +193,22 @@ export class Parser {
193193
matrixJobName = `${jobName}: [${nodeIndex}/${parallelMatrixVariablesList.length}]`;
194194
}
195195

196+
// Resolve `$[[ matrix.X ]]` expressions in needs.parallel.matrix per-permutation
197+
// (https://docs.gitlab.com/ci/yaml/matrix_expressions/). Each consumer permutation
198+
// gets its own substituted needs so producers can be matched 1:1.
199+
let permutationJobData = jobData;
200+
if (jobData.needs && parallel.needsContainMatrixExpressions(jobData.needs) && !parallelMatrixVariables) {
201+
throw new AssertionError({message: chalk`{blueBright ${matrixJobName}} uses $[[ matrix.X ]] expressions in needs.parallel.matrix but is not parallelized with parallel:matrix`});
202+
}
203+
if (parallelMatrixVariables && jobData.needs?.some((n: any) => n.parallel?.matrix)) {
204+
const resolvedNeeds = parallel.resolveNeedsMatrixExpressions(jobData.needs, parallelMatrixVariables, matrixJobName);
205+
permutationJobData = {...jobData, needs: resolvedNeeds};
206+
}
207+
196208
const job = new Job({
197209
argv,
198210
writeStreams,
199-
data: jobData,
211+
data: permutationJobData,
200212
name: matrixJobName,
201213
baseName: jobName,
202214
globalVariables: gitlabData.variables,

src/producers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {Utils} from "./utils.js";
22
import {Job} from "./job.js";
3+
import {findMatchingNeed, matrixSelectorMatches} from "./parallel.js";
34

45
export class Producers {
56

@@ -30,6 +31,10 @@ export class Producers {
3031
if (potential.artifacts == null) continue;
3132
if (potential.when == "never") continue;
3233
if (!producerSet.has(potential.name) && !producerSet.has(potential.baseName)) continue;
34+
35+
const matchedNeed = findMatchingNeed(job.needs, potential.name, potential.baseName);
36+
if (matchedNeed?.parallel?.matrix && !matrixSelectorMatches(potential.matrixVariables, matchedNeed.parallel.matrix)) continue;
37+
3338
producers.push(potential);
3439
}
3540
return producers.map(producer => {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
# Each producer permutation creates a unique artifact file. Each consumer
3+
# permutation, via $[[ matrix.X ]], should pull ONLY its matching producer's
4+
# artifact — proving the artifact cascade respects the matrix selector.
5+
stages: [build, test]
6+
7+
build:
8+
stage: build
9+
parallel:
10+
matrix:
11+
- PROVIDER: [aws, gcp]
12+
script:
13+
- echo "$PROVIDER-data" > "tag-$PROVIDER.txt"
14+
artifacts:
15+
paths:
16+
- "tag-*.txt"
17+
18+
test:
19+
stage: test
20+
parallel:
21+
matrix:
22+
- PROVIDER: [aws, gcp]
23+
needs:
24+
- job: build
25+
parallel:
26+
matrix:
27+
- PROVIDER: $[[ matrix.PROVIDER ]]
28+
script:
29+
- cat tag-*.txt
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {WriteStreamsMock} from "../../../src/write-streams.js";
2+
import {handler} from "../../../src/handler.js";
3+
import chalk from "chalk-template";
4+
5+
test.concurrent("needs-parallel-matrix-artifacts cascades only the matching producer's artifacts to each consumer permutation", async () => {
6+
const writeStreams = new WriteStreamsMock();
7+
await handler({
8+
cwd: "tests/test-cases/needs-parallel-matrix-artifacts",
9+
shellIsolation: true,
10+
stateDir: ".gitlab-ci-local-needs-parallel-matrix-artifacts",
11+
}, writeStreams);
12+
13+
// Positive: each test permutation reads its own producer's artifact via `cat tag-*.txt`.
14+
expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining([
15+
chalk`{blueBright test: [aws] } {greenBright >} aws-data`,
16+
chalk`{blueBright test: [gcp] } {greenBright >} gcp-data`,
17+
]));
18+
19+
// Negative: the other matrix permutation's artifact must NOT have leaked into
20+
// the consumer's working dir. If it did, `cat tag-*.txt` would emit both lines.
21+
expect(writeStreams.stdoutLines).not.toEqual(expect.arrayContaining([
22+
chalk`{blueBright test: [aws] } {greenBright >} gcp-data`,
23+
]));
24+
expect(writeStreams.stdoutLines).not.toEqual(expect.arrayContaining([
25+
chalk`{blueBright test: [gcp] } {greenBright >} aws-data`,
26+
]));
27+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
stages: [build, test]
3+
4+
# Producer is NOT parallelized — using needs.parallel.matrix on it must error.
5+
build-job:
6+
stage: build
7+
script:
8+
- echo "build"
9+
10+
test-job:
11+
stage: test
12+
needs:
13+
- job: build-job
14+
parallel:
15+
matrix:
16+
- NAME: foo
17+
script:
18+
- echo "test"
19+
20+
# Producer uses plain `parallel: <integer>` (no matrix) — selector still must error.
21+
build-int:
22+
stage: build
23+
parallel: 3
24+
script:
25+
- echo "build-int $CI_NODE_INDEX/$CI_NODE_TOTAL"
26+
27+
test-plain-parallel:
28+
stage: test
29+
needs:
30+
- job: build-int
31+
parallel:
32+
matrix:
33+
- NAME: foo
34+
script:
35+
- echo "test-plain-parallel"
36+
37+
# Producer is parallel:matrix but the selector matches zero permutations.
38+
build-foo:
39+
stage: build
40+
parallel:
41+
matrix:
42+
- NAME: [foo, bar]
43+
script:
44+
- echo "build-foo $NAME"
45+
46+
test-zero-match:
47+
stage: test
48+
needs:
49+
- job: build-foo
50+
parallel:
51+
matrix:
52+
- NAME: nope
53+
script:
54+
- echo "test-zero-match"
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {WriteStreamsMock} from "../../../src/write-streams.js";
2+
import {handler} from "../../../src/handler.js";
3+
import chalk from "chalk-template";
4+
5+
test.concurrent("needs-parallel-matrix-error <test-job> rejects when producer has no parallel:matrix", async () => {
6+
const writeStreams = new WriteStreamsMock();
7+
await expect(handler({
8+
cwd: "tests/test-cases/needs-parallel-matrix-error",
9+
job: ["test-job"],
10+
needs: true,
11+
}, writeStreams)).rejects.toThrow(
12+
chalk`{blueBright test-job} uses needs.parallel.matrix targeting {blueBright build-job}, but {blueBright build-job} has no parallel:matrix configuration`,
13+
);
14+
});
15+
16+
test.concurrent("needs-parallel-matrix-error <test-plain-parallel> rejects when producer is plain parallel:<int>", async () => {
17+
const writeStreams = new WriteStreamsMock();
18+
await expect(handler({
19+
cwd: "tests/test-cases/needs-parallel-matrix-error",
20+
job: ["test-plain-parallel"],
21+
needs: true,
22+
}, writeStreams)).rejects.toThrow(
23+
chalk`{blueBright test-plain-parallel} uses needs.parallel.matrix targeting {blueBright build-int}, but {blueBright build-int} has no parallel:matrix configuration`,
24+
);
25+
});
26+
27+
test.concurrent("needs-parallel-matrix-error <test-zero-match> rejects when selector matches zero permutations", async () => {
28+
const writeStreams = new WriteStreamsMock();
29+
await expect(handler({
30+
cwd: "tests/test-cases/needs-parallel-matrix-error",
31+
job: ["test-zero-match"],
32+
needs: true,
33+
}, writeStreams)).rejects.toThrow(
34+
chalk`{blueBright test-zero-match} needs.parallel.matrix selector for {blueBright build-foo} matched zero permutations`,
35+
);
36+
});

0 commit comments

Comments
 (0)