diff --git a/src/argv.ts b/src/argv.ts index d93cefe36..45be09108 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -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): Record { + const stripped: Record = {}; + 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): 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", diff --git a/src/index.ts b/src/index.ts index 3782339f7..894772f9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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) @@ -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); @@ -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, [])) diff --git a/tests/test-cases/gcl-variable-env/.gitlab-ci.yml b/tests/test-cases/gcl-variable-env/.gitlab-ci.yml new file mode 100644 index 000000000..a2e980867 --- /dev/null +++ b/tests/test-cases/gcl-variable-env/.gitlab-ci.yml @@ -0,0 +1,5 @@ +--- +test-job: + script: + - echo ${MY_VAR} + - echo ${ANOTHER_VAR} diff --git a/tests/test-cases/gcl-variable-env/integration.test.ts b/tests/test-cases/gcl-variable-env/integration.test.ts new file mode 100644 index 000000000..c90d4ce94 --- /dev/null +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -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 = { + "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 = {"GCL_VARIABLE_": "empty"}; + const stripped = stripGclVariableEnvVars(env); + expect(stripped).toEqual({}); + expect(env["GCL_VARIABLE_"]).toBeUndefined(); + }); + + test("skips null/undefined values", () => { + const env: Record = {"GCL_VARIABLE_FOO": undefined}; + const stripped = stripGclVariableEnvVars(env); + expect(stripped).toEqual({}); + }); + + test("returns empty object when no matches", () => { + const env: Record = {"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);