Skip to content

Commit 185f386

Browse files
authored
Add GCL_VARIABLE_<name> env var support, replace yargs .env("GCL") (#1805)
1 parent 23d7cbc commit 185f386

File tree

4 files changed

+139
-1
lines changed

4 files changed

+139
-1
lines changed

src/argv.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,30 @@ async function gitRootPath () {
2222
return stdout;
2323
}
2424

25+
const GCL_VARIABLE_PREFIX = "GCL_VARIABLE_";
26+
27+
// Removes GCL_VARIABLE_* entries from env (mutates) and returns the removed entries
28+
export function stripGclVariableEnvVars (env: Record<string, string | undefined>): Record<string, string> {
29+
const stripped: Record<string, string> = {};
30+
for (const key of Object.keys(env)) {
31+
if (!key.startsWith(GCL_VARIABLE_PREFIX) || env[key] == null) continue;
32+
if (key.length > GCL_VARIABLE_PREFIX.length) {
33+
stripped[key] = env[key]!;
34+
}
35+
delete env[key];
36+
}
37+
return stripped;
38+
}
39+
40+
// Prepends env vars so CLI --variable (later in array) takes precedence via last-wins
41+
export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: unknown}, gclVars: Record<string, string>): void {
42+
for (const [envKey, envValue] of Object.entries(gclVars)) {
43+
const varName = envKey.slice(GCL_VARIABLE_PREFIX.length);
44+
argv.variable ??= [];
45+
argv.variable.unshift(`${varName}=${envValue}`);
46+
}
47+
}
48+
2549
export class Argv {
2650
static readonly default = {
2751
"variablesFile": ".gitlab-ci-local-variables.yml",

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as state from "./state.js";
66
import {WriteStreamsProcess, WriteStreamsMock} from "./write-streams.js";
77
import {handler} from "./handler.js";
88
import {Executor} from "./executor.js";
9-
import {Argv} from "./argv.js";
9+
import {Argv, stripGclVariableEnvVars, injectGclVariableEnvVars} from "./argv.js";
1010
import {AssertionError} from "assert";
1111
import {Job, cleanupJobResources} from "./job.js";
1212
import {GitlabRunnerPresetValues} from "./gitlab-preset.js";
@@ -33,6 +33,7 @@ process.on("SIGUSR2", async () => {
3333
});
3434

3535
(() => {
36+
const gclVariableEnvVars = stripGclVariableEnvVars(process.env);
3637
const yparser = yargs(process.argv.slice(2));
3738
yparser.parserConfiguration({"greedy-arrays": false})
3839
.showHelpOnFail(false)
@@ -41,6 +42,7 @@ process.on("SIGUSR2", async () => {
4142
.command({
4243
handler: async (argv) => {
4344
try {
45+
injectGclVariableEnvVars(argv, gclVariableEnvVars);
4446
await handler(argv, new WriteStreamsProcess(), jobs);
4547
const failedJobs = Executor.getFailed(jobs);
4648
process.exit(failedJobs.length > 0 ? 1 : 0);
@@ -365,6 +367,7 @@ process.on("SIGUSR2", async () => {
365367
if (current.startsWith("-")) {
366368
completionFilter();
367369
} else {
370+
injectGclVariableEnvVars(yargsArgv, gclVariableEnvVars);
368371
Argv.build({...yargsArgv, autoCompleting: true})
369372
.then(argv => state.getPipelineIid(argv.cwd, argv.stateDir).then(pipelineIid => ({argv, pipelineIid})))
370373
.then(({argv, pipelineIid}) => Parser.create(argv, new WriteStreamsMock(), pipelineIid, []))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
test-job:
3+
script:
4+
- echo ${MY_VAR}
5+
- echo ${ANOTHER_VAR}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import {stripGclVariableEnvVars, injectGclVariableEnvVars} from "../../../src/argv.js";
2+
import {execFile} from "child_process";
3+
import {promisify} from "util";
4+
5+
const execFileAsync = promisify(execFile);
6+
7+
describe("stripGclVariableEnvVars", () => {
8+
test("strips GCL_VARIABLE_* entries and returns them", () => {
9+
const env: Record<string, string | undefined> = {
10+
"HOME": "/home/user",
11+
"GCL_CWD": "/tmp",
12+
"GCL_VARIABLE_MY_VAR": "hello",
13+
"GCL_VARIABLE_OTHER": "world",
14+
};
15+
const stripped = stripGclVariableEnvVars(env);
16+
expect(stripped).toEqual({
17+
"GCL_VARIABLE_MY_VAR": "hello",
18+
"GCL_VARIABLE_OTHER": "world",
19+
});
20+
expect(env["GCL_VARIABLE_MY_VAR"]).toBeUndefined();
21+
expect(env["GCL_VARIABLE_OTHER"]).toBeUndefined();
22+
expect(env["HOME"]).toBe("/home/user");
23+
expect(env["GCL_CWD"]).toBe("/tmp");
24+
});
25+
26+
test("strips GCL_VARIABLE_ with empty name from env but does not return it", () => {
27+
const env: Record<string, string | undefined> = {"GCL_VARIABLE_": "empty"};
28+
const stripped = stripGclVariableEnvVars(env);
29+
expect(stripped).toEqual({});
30+
expect(env["GCL_VARIABLE_"]).toBeUndefined();
31+
});
32+
33+
test("skips null/undefined values", () => {
34+
const env: Record<string, string | undefined> = {"GCL_VARIABLE_FOO": undefined};
35+
const stripped = stripGclVariableEnvVars(env);
36+
expect(stripped).toEqual({});
37+
});
38+
39+
test("returns empty object when no matches", () => {
40+
const env: Record<string, string | undefined> = {"HOME": "/home/user", "GCL_CWD": "/tmp"};
41+
const stripped = stripGclVariableEnvVars(env);
42+
expect(stripped).toEqual({});
43+
});
44+
});
45+
46+
describe("injectGclVariableEnvVars", () => {
47+
test("injects single entry", () => {
48+
const argv: {variable?: string[]} = {};
49+
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_MY_VAR": "hello"});
50+
expect(argv.variable).toEqual(["MY_VAR=hello"]);
51+
});
52+
53+
test("injects multiple entries", () => {
54+
const argv: {variable?: string[]} = {};
55+
injectGclVariableEnvVars(argv, {
56+
"GCL_VARIABLE_VAR1": "one",
57+
"GCL_VARIABLE_VAR2": "two",
58+
});
59+
expect(argv.variable).toEqual(expect.arrayContaining(["VAR1=one", "VAR2=two"]));
60+
expect(argv.variable).toHaveLength(2);
61+
});
62+
63+
test("prepends to existing variable array", () => {
64+
const argv: {variable?: string[]} = {variable: ["EXISTING=val"]};
65+
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_NEW": "injected"});
66+
expect(argv.variable).toEqual(["NEW=injected", "EXISTING=val"]);
67+
});
68+
69+
test("CLI --variable takes precedence over GCL_VARIABLE_", () => {
70+
const argv: {variable?: string[]} = {variable: ["SAME=from_cli"]};
71+
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_SAME": "from_env"});
72+
// "SAME=from_env" is prepended, "SAME=from_cli" comes last and wins
73+
expect(argv.variable).toEqual(["SAME=from_env", "SAME=from_cli"]);
74+
});
75+
76+
test("handles empty value", () => {
77+
const argv: {variable?: string[]} = {};
78+
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": ""});
79+
expect(argv.variable).toEqual(["FOO="]);
80+
});
81+
82+
test("handles value with equals sign", () => {
83+
const argv: {variable?: string[]} = {};
84+
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": "key=value"});
85+
expect(argv.variable).toEqual(["FOO=key=value"]);
86+
});
87+
88+
test("does not split on semicolons", () => {
89+
const argv: {variable?: string[]} = {};
90+
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": "a;b;c"});
91+
expect(argv.variable).toEqual(["FOO=a;b;c"]);
92+
});
93+
});
94+
95+
test("GCL_VARIABLE_* env vars are injected into job output via CLI", async () => {
96+
const {stdout} = await execFileAsync("bun", ["src/index.ts", "test-job", "--cwd", "tests/test-cases/gcl-variable-env"], {
97+
env: {
98+
...process.env,
99+
GCL_VARIABLE_MY_VAR: "hello",
100+
GCL_VARIABLE_ANOTHER_VAR: "world",
101+
},
102+
});
103+
104+
expect(stdout).toContain("hello");
105+
expect(stdout).toContain("world");
106+
}, 30_000);

0 commit comments

Comments
 (0)