Skip to content

Commit fec2e80

Browse files
chonchiogRenier
andauthored
Fix environment-scoped variables with matrix jobs (#1726)
* Fix environment-scoped variables with matrix jobs When using parallel matrix jobs with dynamic environment names (e.g., `environment: name: $CLUSTER`), all matrix jobs after the first one received the wrong environment-scoped variables. Root cause: When `jobData.environment` is an object, it was assigned by reference instead of being cloned. The subsequent mutation of `this.environment.name` during variable expansion would affect all matrix job instances sharing the same `jobData` object. Fix: Clone the environment object using spread operator when it's an object, similar to how `predefinedVariables` is handled in parser.ts line 182. Fixes #1725 * Add regression test for environment object cloning fix (#1725) This test verifies that the environment object is properly cloned (using spread operator) to prevent shared mutation between matrix jobs. The test reads src/job.ts and checks for the spread operator pattern, ensuring the fix cannot be accidentally removed. * Fix linting: add YAML document start and fix object spacing --------- Co-authored-by: Renier <renier.velazco@lmco.com>
1 parent 8359f8e commit fec2e80

File tree

4 files changed

+211
-1
lines changed

4 files changed

+211
-1
lines changed

src/job.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export class Job {
162162
this.allowFailure = jobData.allow_failure ?? false;
163163
this.dependencies = jobData.dependencies || null;
164164
this.rules = jobData.rules || null;
165-
this.environment = typeof jobData.environment === "string" ? {name: jobData.environment} : jobData.environment;
165+
this.environment = typeof jobData.environment === "string" ? {name: jobData.environment} : (jobData.environment ? {...jobData.environment} : jobData.environment);
166166

167167
const matrixVariables = opt.matrixVariables ?? {};
168168
const fileVariables = Utils.findEnvMatchedVariables(variablesFromFiles, this.fileVariablesDir);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
CI_ENVIRONMENT_URL:
3+
values:
4+
cluster-a: "https://cluster-a.example.com"
5+
cluster-b: "https://cluster-b.example.com"
6+
cluster-c: "https://cluster-c.example.com"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
# Test case for issue #1725: Environment-scoped variables with matrix jobs
3+
# Each matrix job should get its own environment-specific variable value
4+
5+
run-all-clusters:
6+
stage: test
7+
parallel:
8+
matrix:
9+
- CLUSTER:
10+
- cluster-a
11+
- cluster-b
12+
- cluster-c
13+
environment:
14+
name: $CLUSTER
15+
script:
16+
- echo "CLUSTER=${CLUSTER}"
17+
- echo "URL=${CI_ENVIRONMENT_URL}"
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import {WriteStreamsMock} from "../../../src/write-streams.js";
2+
import {handler} from "../../../src/handler.js";
3+
import chalk from "chalk-template";
4+
import {initSpawnSpy} from "../../mocks/utils.mock.js";
5+
import {WhenStatics} from "../../mocks/when-statics.js";
6+
import {Parser} from "../../../src/parser.js";
7+
import {Argv} from "../../../src/argv.js";
8+
9+
beforeAll(() => {
10+
initSpawnSpy(WhenStatics.all);
11+
});
12+
13+
/**
14+
* Unit test for issue #1725: Verify that environment object cloning works correctly.
15+
*
16+
* This test directly verifies the fix logic: when jobData.environment is an object,
17+
* it should be cloned (not assigned by reference) to prevent mutation side effects.
18+
*
19+
* The bug: Without cloning, all matrix jobs share the same environment object.
20+
* When the first job expands "$CLUSTER" to "cluster-a", it mutates the shared object,
21+
* so subsequent jobs see "cluster-a" instead of "$CLUSTER".
22+
*/
23+
test("parallel-matrix-environment - environment object cloning prevents mutation", () => {
24+
// Simulate what the Job constructor does with environment
25+
const jobData = {
26+
environment: {name: "$CLUSTER"},
27+
};
28+
29+
// THE FIX: Clone the environment object instead of assigning by reference
30+
// Fixed code: this.environment = {...jobData.environment}
31+
// Buggy code: this.environment = jobData.environment
32+
const cloneEnvironment = (env: any) => {
33+
return typeof env === "string" ? {name: env} : (env ? {...env} : env);
34+
};
35+
36+
// Simulate creating two jobs from the same jobData
37+
const job1Env = cloneEnvironment(jobData.environment);
38+
const job2Env = cloneEnvironment(jobData.environment);
39+
40+
// With the fix, they should be different object references
41+
expect(job1Env).not.toBe(job2Env);
42+
expect(job1Env).not.toBe(jobData.environment);
43+
expect(job2Env).not.toBe(jobData.environment);
44+
45+
// Simulate job1 expanding its environment name (mutating its own copy)
46+
job1Env.name = "cluster-a";
47+
48+
// With the fix, job2's environment should still have the original template
49+
expect(job2Env.name).toBe("$CLUSTER");
50+
// And the original jobData should be unchanged
51+
expect(jobData.environment.name).toBe("$CLUSTER");
52+
});
53+
54+
/**
55+
* This test verifies what happens WITHOUT the fix (buggy behavior).
56+
* It demonstrates the bug that the fix addresses.
57+
*/
58+
test("parallel-matrix-environment - demonstrates bug without cloning", () => {
59+
const jobData = {
60+
environment: {name: "$CLUSTER"},
61+
};
62+
63+
// BUGGY CODE: Direct assignment without cloning
64+
const buggyAssign = (env: any) => {
65+
return typeof env === "string" ? {name: env} : env; // No spread!
66+
};
67+
68+
const job1Env = buggyAssign(jobData.environment);
69+
const job2Env = buggyAssign(jobData.environment);
70+
71+
// Without cloning, they ARE the same reference
72+
expect(job1Env).toBe(job2Env);
73+
expect(job1Env).toBe(jobData.environment);
74+
75+
// When job1 "expands" its environment name...
76+
job1Env.name = "cluster-a";
77+
78+
// ...it corrupts job2's environment AND the original jobData!
79+
expect(job2Env.name).toBe("cluster-a"); // BUG: Should be "$CLUSTER"
80+
expect(jobData.environment.name).toBe("cluster-a"); // BUG: Original mutated
81+
});
82+
83+
/**
84+
* Regression test for issue #1725: Verify the fix is present in job.ts
85+
*
86+
* This test reads the actual source code and verifies that the environment
87+
* object is cloned using the spread operator. This prevents accidental
88+
* removal of the fix.
89+
*/
90+
test("parallel-matrix-environment - job.ts contains environment cloning fix", async () => {
91+
const fs = await import("fs");
92+
const path = await import("path");
93+
const {fileURLToPath} = await import("url");
94+
95+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
96+
const jobTsPath = path.resolve(__dirname, "../../../src/job.ts");
97+
const content = fs.readFileSync(jobTsPath, "utf-8");
98+
99+
// The fix: environment object should be cloned with spread operator
100+
// Look for the pattern: {...jobData.environment} or { ...jobData.environment }
101+
const hasSpreadOperator = /\{\s*\.\.\.jobData\.environment\s*\}/.test(content);
102+
103+
expect(hasSpreadOperator).toBe(true);
104+
});
105+
106+
/**
107+
* Test for issue #1725: Environment-scoped variables don't work with matrix jobs
108+
* due to shared jobData mutation.
109+
*
110+
* When using parallel matrix jobs with dynamic environment names (e.g., environment: name: $CLUSTER),
111+
* each matrix job should have its own expanded environment name, not the first job's value.
112+
*
113+
* The bug was that jobData.environment was assigned by reference, so when the first job
114+
* expanded $CLUSTER to "cluster-a", it mutated the shared object, causing all subsequent
115+
* jobs to see "cluster-a" instead of their own cluster name.
116+
*/
117+
test("parallel-matrix-environment - each job gets correct environment name", async () => {
118+
const writeStreams = new WriteStreamsMock();
119+
await handler({
120+
cwd: "tests/test-cases/parallel-matrix-environment",
121+
job: ["run-all-clusters"],
122+
}, writeStreams);
123+
124+
// Each matrix job should have its environment name expanded to its own CLUSTER value
125+
// The bug caused all jobs to get the first job's expanded environment name
126+
const expected = [
127+
chalk`{blueBright run-all-clusters: [cluster-a]} environment: \{ name: {bold cluster-a} \}`,
128+
chalk`{blueBright run-all-clusters: [cluster-b]} environment: \{ name: {bold cluster-b} \}`,
129+
chalk`{blueBright run-all-clusters: [cluster-c]} environment: \{ name: {bold cluster-c} \}`,
130+
];
131+
expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected));
132+
133+
// Verify we don't have the bug where all jobs get the first job's environment name
134+
const buggyOutput = [
135+
chalk`{blueBright run-all-clusters: [cluster-b]} environment: \{ name: {bold cluster-a} \}`,
136+
chalk`{blueBright run-all-clusters: [cluster-c]} environment: \{ name: {bold cluster-a} \}`,
137+
];
138+
expect(writeStreams.stdoutLines).not.toEqual(expect.arrayContaining(buggyOutput));
139+
});
140+
141+
/**
142+
* Direct test for issue #1725: Verify that each matrix job has its own independent
143+
* environment object after parsing.
144+
*
145+
* This test directly checks the Parser's job list to ensure environment names
146+
* are correctly expanded for each matrix job variant.
147+
*
148+
* Note: The actual bug manifested in production with Homebrew-installed v4.64.1
149+
* where all matrix jobs received the first job's environment URL. The fix ensures
150+
* the environment object is cloned (using spread operator) instead of assigned
151+
* by reference.
152+
*/
153+
test("parallel-matrix-environment - parser creates jobs with independent environment names", async () => {
154+
const writeStreams = new WriteStreamsMock();
155+
const argv = await Argv.build({
156+
cwd: "tests/test-cases/parallel-matrix-environment",
157+
}, writeStreams);
158+
159+
const parser = await Parser.create(argv, writeStreams, 1, []);
160+
161+
// Find the matrix jobs
162+
const matrixJobs = parser.jobs.filter(job => job.name.startsWith("run-all-clusters:"));
163+
164+
// Should have 3 matrix jobs
165+
expect(matrixJobs.length).toBe(3);
166+
167+
// The fix ensures each job has its OWN environment object (not shared)
168+
const env1 = matrixJobs[0].environment;
169+
const env2 = matrixJobs[1].environment;
170+
const env3 = matrixJobs[2].environment;
171+
172+
// Environment objects must be independent (not the same reference)
173+
expect(env1).not.toBe(env2);
174+
expect(env2).not.toBe(env3);
175+
expect(env1).not.toBe(env3);
176+
177+
// Each job should have its own environment name matching its CLUSTER variable
178+
const envNames = matrixJobs.map(job => job.environment?.name).sort();
179+
expect(envNames).toEqual(["cluster-a", "cluster-b", "cluster-c"]);
180+
181+
// Verify each job's environment name matches its matrix variable
182+
for (const job of matrixJobs) {
183+
const clusterMatch = job.name.match(/\[([^\]]+)\]/);
184+
const expectedCluster = clusterMatch ? clusterMatch[1] : null;
185+
expect(job.environment?.name).toBe(expectedCluster);
186+
}
187+
});

0 commit comments

Comments
 (0)