Skip to content
24 changes: 24 additions & 0 deletions src/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,30 @@ async function gitRootPath () {
return stdout;
}

const GCL_VARIABLE_PREFIX = "GCL_VARIABLE_";

// Removes GCL_VARIABLE_* entries from env (mutates) and returns the removed entries
export function stripGclVariableEnvVars (env: Record<string, string | undefined>): Record<string, string> {
const stripped: Record<string, string> = {};
for (const key of Object.keys(env)) {
if (!key.startsWith(GCL_VARIABLE_PREFIX) || env[key] == null) continue;
if (key.length > GCL_VARIABLE_PREFIX.length) {
stripped[key] = env[key]!;
}
delete env[key];
}
return stripped;
}

// Prepends env vars so CLI --variable (later in array) takes precedence via last-wins
export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: unknown}, gclVars: Record<string, string>): void {
for (const [envKey, envValue] of Object.entries(gclVars)) {
const varName = envKey.slice(GCL_VARIABLE_PREFIX.length);
argv.variable ??= [];
argv.variable.unshift(`${varName}=${envValue}`);
}
}

export class Argv {
static readonly default = {
"variablesFile": ".gitlab-ci-local-variables.yml",
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as state from "./state.js";
import {WriteStreamsProcess, WriteStreamsMock} from "./write-streams.js";
import {handler} from "./handler.js";
import {Executor} from "./executor.js";
import {Argv} from "./argv.js";
import {Argv, stripGclVariableEnvVars, injectGclVariableEnvVars} from "./argv.js";
import {AssertionError} from "assert";
import {Job, cleanupJobResources} from "./job.js";
import {GitlabRunnerPresetValues} from "./gitlab-preset.js";
Expand All @@ -33,6 +33,7 @@ process.on("SIGUSR2", async () => {
});

(() => {
const gclVariableEnvVars = stripGclVariableEnvVars(process.env);
const yparser = yargs(process.argv.slice(2));
yparser.parserConfiguration({"greedy-arrays": false})
.showHelpOnFail(false)
Expand All @@ -41,6 +42,7 @@ process.on("SIGUSR2", async () => {
.command({
handler: async (argv) => {
try {
injectGclVariableEnvVars(argv, gclVariableEnvVars);
await handler(argv, new WriteStreamsProcess(), jobs);
const failedJobs = Executor.getFailed(jobs);
process.exit(failedJobs.length > 0 ? 1 : 0);
Expand Down Expand Up @@ -365,6 +367,7 @@ process.on("SIGUSR2", async () => {
if (current.startsWith("-")) {
completionFilter();
} else {
injectGclVariableEnvVars(yargsArgv, gclVariableEnvVars);
Argv.build({...yargsArgv, autoCompleting: true})
.then(argv => state.getPipelineIid(argv.cwd, argv.stateDir).then(pipelineIid => ({argv, pipelineIid})))
.then(({argv, pipelineIid}) => Parser.create(argv, new WriteStreamsMock(), pipelineIid, []))
Expand Down
5 changes: 5 additions & 0 deletions tests/test-cases/gcl-variable-env/.gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
test-job:
script:
- echo ${MY_VAR}
- echo ${ANOTHER_VAR}
106 changes: 106 additions & 0 deletions tests/test-cases/gcl-variable-env/integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {stripGclVariableEnvVars, injectGclVariableEnvVars} from "../../../src/argv.js";
import {execFile} from "child_process";
import {promisify} from "util";

const execFileAsync = promisify(execFile);

describe("stripGclVariableEnvVars", () => {
test("strips GCL_VARIABLE_* entries and returns them", () => {
const env: Record<string, string | undefined> = {
"HOME": "/home/user",
"GCL_CWD": "/tmp",
"GCL_VARIABLE_MY_VAR": "hello",
"GCL_VARIABLE_OTHER": "world",
};
const stripped = stripGclVariableEnvVars(env);
expect(stripped).toEqual({
"GCL_VARIABLE_MY_VAR": "hello",
"GCL_VARIABLE_OTHER": "world",
});
expect(env["GCL_VARIABLE_MY_VAR"]).toBeUndefined();
expect(env["GCL_VARIABLE_OTHER"]).toBeUndefined();
expect(env["HOME"]).toBe("/home/user");
expect(env["GCL_CWD"]).toBe("/tmp");
});

test("strips GCL_VARIABLE_ with empty name from env but does not return it", () => {
const env: Record<string, string | undefined> = {"GCL_VARIABLE_": "empty"};
const stripped = stripGclVariableEnvVars(env);
expect(stripped).toEqual({});
expect(env["GCL_VARIABLE_"]).toBeUndefined();
});

test("skips null/undefined values", () => {
const env: Record<string, string | undefined> = {"GCL_VARIABLE_FOO": undefined};
const stripped = stripGclVariableEnvVars(env);
expect(stripped).toEqual({});
});

test("returns empty object when no matches", () => {
const env: Record<string, string | undefined> = {"HOME": "/home/user", "GCL_CWD": "/tmp"};
const stripped = stripGclVariableEnvVars(env);
expect(stripped).toEqual({});
});
});

describe("injectGclVariableEnvVars", () => {
test("injects single entry", () => {
const argv: {variable?: string[]} = {};
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_MY_VAR": "hello"});
expect(argv.variable).toEqual(["MY_VAR=hello"]);
});

test("injects multiple entries", () => {
const argv: {variable?: string[]} = {};
injectGclVariableEnvVars(argv, {
"GCL_VARIABLE_VAR1": "one",
"GCL_VARIABLE_VAR2": "two",
});
expect(argv.variable).toEqual(expect.arrayContaining(["VAR1=one", "VAR2=two"]));
expect(argv.variable).toHaveLength(2);
});

test("prepends to existing variable array", () => {
const argv: {variable?: string[]} = {variable: ["EXISTING=val"]};
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_NEW": "injected"});
expect(argv.variable).toEqual(["NEW=injected", "EXISTING=val"]);
});

test("CLI --variable takes precedence over GCL_VARIABLE_", () => {
const argv: {variable?: string[]} = {variable: ["SAME=from_cli"]};
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_SAME": "from_env"});
// "SAME=from_env" is prepended, "SAME=from_cli" comes last and wins
expect(argv.variable).toEqual(["SAME=from_env", "SAME=from_cli"]);
});

test("handles empty value", () => {
const argv: {variable?: string[]} = {};
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": ""});
expect(argv.variable).toEqual(["FOO="]);
});

test("handles value with equals sign", () => {
const argv: {variable?: string[]} = {};
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": "key=value"});
expect(argv.variable).toEqual(["FOO=key=value"]);
});

test("does not split on semicolons", () => {
const argv: {variable?: string[]} = {};
injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": "a;b;c"});
expect(argv.variable).toEqual(["FOO=a;b;c"]);
});
});

test("GCL_VARIABLE_* env vars are injected into job output via CLI", async () => {
const {stdout} = await execFileAsync("bun", ["src/index.ts", "test-job", "--cwd", "tests/test-cases/gcl-variable-env"], {
env: {
...process.env,
GCL_VARIABLE_MY_VAR: "hello",
GCL_VARIABLE_ANOTHER_VAR: "world",
},
});

expect(stdout).toContain("hello");
expect(stdout).toContain("world");
}, 30_000);
Loading