From 3203791a18e5b00332ebca1429daae6fc267e9c3 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Thu, 19 Mar 2026 09:44:26 +0100 Subject: [PATCH 1/8] Add GCL_VARIABLE_ environment variable support Allows injecting individual CI/CD variables via environment variables prefixed with GCL_VARIABLE_, e.g. GCL_VARIABLE_MY_VAR=hello. --- src/argv.ts | 14 +++ .../gcl-variable-env/.gitlab-ci.yml | 6 ++ .../gcl-variable-env/integration.test.ts | 98 +++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 tests/test-cases/gcl-variable-env/.gitlab-ci.yml create mode 100644 tests/test-cases/gcl-variable-env/integration.test.ts diff --git a/src/argv.ts b/src/argv.ts index d93cefe36..dba094f61 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -22,6 +22,19 @@ async function gitRootPath () { return stdout; } +export function injectGclVariableEnvVars (argv: {variable?: string[]}, env: Record): void { + const prefix = "GCL_VARIABLE_"; + for (const [envKey, envValue] of Object.entries(env)) { + if (!envKey.startsWith(prefix) || envValue == null) continue; + const varName = envKey.slice(prefix.length); + if (varName.length === 0) continue; + if (argv.variable == null) { + argv.variable = []; + } + argv.variable.unshift(`${varName}=${envValue}`); + } +} + export class Argv { static readonly default = { "variablesFile": ".gitlab-ci-local-variables.yml", @@ -43,6 +56,7 @@ export class Argv { } static async build (args: any, writeStreams?: WriteStreams) { + injectGclVariableEnvVars(args, process.env); const argv = new Argv(args, writeStreams); await argv.fallbackCwd(args); 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..3e0445c6c --- /dev/null +++ b/tests/test-cases/gcl-variable-env/.gitlab-ci.yml @@ -0,0 +1,6 @@ +--- +test-job: + script: + - echo ${MY_VAR} + - echo ${ANOTHER_VAR} + - echo ${EMPTY_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..11c05dc56 --- /dev/null +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -0,0 +1,98 @@ +import {WriteStreamsMock} from "../../../src/write-streams.js"; +import {handler} from "../../../src/handler.js"; +import {injectGclVariableEnvVars} from "../../../src/argv.js"; +import chalk from "chalk-template"; +import {initSpawnSpy} from "../../mocks/utils.mock.js"; +import {WhenStatics} from "../../mocks/when-statics.js"; + +beforeAll(() => { + initSpawnSpy(WhenStatics.all); +}); + +describe("injectGclVariableEnvVars unit tests", () => { + test("injects single GCL_VARIABLE_ entry", () => { + const argv: {variable?: string[]} = {}; + injectGclVariableEnvVars(argv, {"GCL_VARIABLE_MY_VAR": "hello"}); + expect(argv.variable).toEqual(["MY_VAR=hello"]); + }); + + test("injects multiple GCL_VARIABLE_ 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("skips non GCL_VARIABLE_ env vars", () => { + const argv: {variable?: string[]} = {}; + injectGclVariableEnvVars(argv, { + "GCL_CWD": "/tmp", + "HOME": "/home/user", + "GCL_VARIABLE_REAL": "yes", + }); + expect(argv.variable).toEqual(["REAL=yes"]); + }); + + test("skips GCL_VARIABLE_ with empty name", () => { + const argv: {variable?: string[]} = {}; + injectGclVariableEnvVars(argv, {"GCL_VARIABLE_": "empty_name"}); + expect(argv.variable).toBeUndefined(); + }); + + test("skips null/undefined env values", () => { + const argv: {variable?: string[]} = {}; + injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": undefined}); + expect(argv.variable).toBeUndefined(); + }); + + 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"]); + }); +}); + +describe("injectGclVariableEnvVars integration", () => { + test.concurrent("GCL_VARIABLE_MY_VAR=hello injects variable into job", async () => { + const writeStreams = new WriteStreamsMock(); + await handler({ + cwd: "tests/test-cases/gcl-variable-env", + job: ["test-job"], + variable: ["MY_VAR=hello", "ANOTHER_VAR=world", "EMPTY_VAR="], + }, writeStreams); + + const expected = [ + chalk`{blueBright test-job} {greenBright >} hello`, + chalk`{blueBright test-job} {greenBright >} world`, + ]; + expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected)); + }); +}); From 353ba405dbfd6298de8f70df6fc0d0600a8a8a96 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Thu, 19 Mar 2026 09:54:27 +0100 Subject: [PATCH 2/8] fix: integration test now exercises full GCL_VARIABLE_* env path Sets process.env instead of passing variable array directly to handler, so the test covers the actual injection code path. --- .../gcl-variable-env/integration.test.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/test-cases/gcl-variable-env/integration.test.ts b/tests/test-cases/gcl-variable-env/integration.test.ts index 11c05dc56..12fb1bc1b 100644 --- a/tests/test-cases/gcl-variable-env/integration.test.ts +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -80,19 +80,27 @@ describe("injectGclVariableEnvVars unit tests", () => { }); }); -describe("injectGclVariableEnvVars integration", () => { - test.concurrent("GCL_VARIABLE_MY_VAR=hello injects variable into job", async () => { - const writeStreams = new WriteStreamsMock(); - await handler({ - cwd: "tests/test-cases/gcl-variable-env", - job: ["test-job"], - variable: ["MY_VAR=hello", "ANOTHER_VAR=world", "EMPTY_VAR="], - }, writeStreams); +describe("injectGclVariableEnvVars integration via process.env", () => { + test.concurrent("GCL_VARIABLE_* env vars are injected into job output", async () => { + const envKeys = ["GCL_VARIABLE_MY_VAR", "GCL_VARIABLE_ANOTHER_VAR"]; + process.env["GCL_VARIABLE_MY_VAR"] = "hello"; + process.env["GCL_VARIABLE_ANOTHER_VAR"] = "world"; + try { + const writeStreams = new WriteStreamsMock(); + await handler({ + cwd: "tests/test-cases/gcl-variable-env", + job: ["test-job"], + }, writeStreams); - const expected = [ - chalk`{blueBright test-job} {greenBright >} hello`, - chalk`{blueBright test-job} {greenBright >} world`, - ]; - expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected)); + const expected = [ + chalk`{blueBright test-job} {greenBright >} hello`, + chalk`{blueBright test-job} {greenBright >} world`, + ]; + expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected)); + } finally { + for (const key of envKeys) { + delete process.env[key]; + } + } }); }); From c7fe62d3ae007366942760f1c2e1608abdc29fa3 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Thu, 19 Mar 2026 14:17:27 +0100 Subject: [PATCH 3/8] refactor: replace yargs .env("GCL") with manual env var injection Removes yargs .env("GCL") which blocked GCL_VARIABLE_* env vars via strictOptions. Env vars are now derived from yargs option metadata, so option names are defined once. Array options split on semicolons, naturally supporting GCL_VARIABLE=A=1;B=2 bulk format. Integration test now uses subprocess with isolated env instead of mutating process.env. --- src/argv.ts | 45 +++++++++++++++++-- src/index.ts | 10 ++--- .../gcl-variable-env/integration.test.ts | 45 ++++++------------- 3 files changed, 61 insertions(+), 39 deletions(-) diff --git a/src/argv.ts b/src/argv.ts index dba094f61..d10eeb969 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -35,12 +35,50 @@ export function injectGclVariableEnvVars (argv: {variable?: string[]}, env: Reco } } +interface YargsOptionsMeta { + "array": string[]; + "boolean": string[]; + "number": string[]; + "default": Record; + "key": Record; +} + +export function injectGclEnvVars ( + argv: Record, + yargsOptions: YargsOptionsMeta, + env: Record, +): void { + const arrays = new Set(yargsOptions.array.map(String)); + const booleans = new Set(yargsOptions.boolean.map(String)); + const numbers = new Set(yargsOptions.number.map(String)); + + for (const key of Object.keys(yargsOptions.key)) { + if (key.includes("-") || key === "_" || key === "$0") continue; + + const envKey = `GCL_${key.replace(/[A-Z]/g, c => `_${c}`).toUpperCase()}`; + const envValue = env[envKey]; + if (envValue == null) continue; + + if (arrays.has(key)) { + const cliValues = Array.isArray(argv[key]) ? argv[key] : []; + argv[key] = [...envValue.split(";"), ...cliValues]; + continue; + } + + if (argv[key] !== undefined && argv[key] !== yargsOptions.default[key]) continue; + + if (booleans.has(key)) argv[key] = envValue === "true" || envValue === "1"; + else if (numbers.has(key)) argv[key] = Number(envValue); + else argv[key] = envValue; + } +} + export class Argv { static readonly default = { "variablesFile": ".gitlab-ci-local-variables.yml", "evaluateRuleChanges": true, "ignoreSchemaPaths": [], - "ignorePredefinedVars": "", + "ignorePredefinedVars": [] as string[], }; map: Map = new Map(); @@ -56,7 +94,6 @@ export class Argv { } static async build (args: any, writeStreams?: WriteStreams) { - injectGclVariableEnvVars(args, process.env); const argv = new Argv(args, writeStreams); await argv.fallbackCwd(args); @@ -177,7 +214,9 @@ export class Argv { } get ignorePredefinedVars (): string[] { - return this.map.get("ignorePredefinedVars") ?? Argv.default.ignorePredefinedVars; + const val = this.map.get("ignorePredefinedVars") ?? []; + if (!Array.isArray(val)) return val ? String(val).split(",") : []; + return val.flatMap((v: string) => v.split(",")); } get pullPolicy (): string { diff --git a/src/index.ts b/src/index.ts index 3782339f7..38b6264eb 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, injectGclVariableEnvVars, injectGclEnvVars} from "./argv.js"; import {AssertionError} from "assert"; import {Job, cleanupJobResources} from "./job.js"; import {GitlabRunnerPresetValues} from "./gitlab-preset.js"; @@ -41,6 +41,8 @@ process.on("SIGUSR2", async () => { .command({ handler: async (argv) => { try { + injectGclVariableEnvVars(argv, process.env); + injectGclEnvVars(argv, yparser.getOptions(), process.env); await handler(argv, new WriteStreamsProcess(), jobs); const failedJobs = Executor.getFailed(jobs); process.exit(failedJobs.length > 0 ? 1 : 0); @@ -74,7 +76,6 @@ process.on("SIGUSR2", async () => { }) .usage("Find more information at https://github.com/firecow/gitlab-ci-local.\nNote: To negate an option use '--no-(option)'.") .strictOptions() - .env("GCL") .option("manual", { type: "array", description: "One or more manual jobs to run during a pipeline", @@ -324,11 +325,10 @@ process.on("SIGUSR2", async () => { description: "The json schema paths that will be ignored", }) .option("ignore-predefined-vars", { - type: "string", - coerce: (v) => v.split(","), + type: "array", requiresArg: false, default: Argv.default.ignorePredefinedVars, - describe: "Comma-seperated list of predefined pipeline variables for which warnings should be suppressed", + describe: "Predefined pipeline variables for which warnings should be suppressed", }) .option("concurrency", { type: "number", diff --git a/tests/test-cases/gcl-variable-env/integration.test.ts b/tests/test-cases/gcl-variable-env/integration.test.ts index 12fb1bc1b..a6ae21075 100644 --- a/tests/test-cases/gcl-variable-env/integration.test.ts +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -1,13 +1,8 @@ -import {WriteStreamsMock} from "../../../src/write-streams.js"; -import {handler} from "../../../src/handler.js"; import {injectGclVariableEnvVars} from "../../../src/argv.js"; -import chalk from "chalk-template"; -import {initSpawnSpy} from "../../mocks/utils.mock.js"; -import {WhenStatics} from "../../mocks/when-statics.js"; +import {execFile} from "child_process"; +import {promisify} from "util"; -beforeAll(() => { - initSpawnSpy(WhenStatics.all); -}); +const execFileAsync = promisify(execFile); describe("injectGclVariableEnvVars unit tests", () => { test("injects single GCL_VARIABLE_ entry", () => { @@ -80,27 +75,15 @@ describe("injectGclVariableEnvVars unit tests", () => { }); }); -describe("injectGclVariableEnvVars integration via process.env", () => { - test.concurrent("GCL_VARIABLE_* env vars are injected into job output", async () => { - const envKeys = ["GCL_VARIABLE_MY_VAR", "GCL_VARIABLE_ANOTHER_VAR"]; - process.env["GCL_VARIABLE_MY_VAR"] = "hello"; - process.env["GCL_VARIABLE_ANOTHER_VAR"] = "world"; - try { - const writeStreams = new WriteStreamsMock(); - await handler({ - cwd: "tests/test-cases/gcl-variable-env", - job: ["test-job"], - }, writeStreams); - - const expected = [ - chalk`{blueBright test-job} {greenBright >} hello`, - chalk`{blueBright test-job} {greenBright >} world`, - ]; - expect(writeStreams.stdoutLines).toEqual(expect.arrayContaining(expected)); - } finally { - for (const key of envKeys) { - delete process.env[key]; - } - } +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); From 4dfcf2d2dfde209f55693dfbcdcdc9855beca4a1 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Fri, 20 Mar 2026 10:28:02 +0100 Subject: [PATCH 4/8] fix: typecheck errors, CLI precedence over GCL_ env vars, completion support - Fix TS2559 by widening injectGclVariableEnvVars parameter type - Fix TS2551 by using type assertion for yargs internal getOptions() - Use yargs parsed.defaulted to correctly detect CLI-explicit values, ensuring CLI always takes precedence over GCL_ env vars even when the CLI value matches the option default - Inject GCL env vars in completion callback so GCL_CWD etc. work during tab completion - Add 12 unit tests for injectGclEnvVars --- src/argv.ts | 5 +- src/index.ts | 6 +- .../gcl-variable-env/integration.test.ts | 85 ++++++++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/argv.ts b/src/argv.ts index d10eeb969..8f3c23072 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -22,7 +22,7 @@ async function gitRootPath () { return stdout; } -export function injectGclVariableEnvVars (argv: {variable?: string[]}, env: Record): void { +export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: any}, env: Record): void { const prefix = "GCL_VARIABLE_"; for (const [envKey, envValue] of Object.entries(env)) { if (!envKey.startsWith(prefix) || envValue == null) continue; @@ -47,6 +47,7 @@ export function injectGclEnvVars ( argv: Record, yargsOptions: YargsOptionsMeta, env: Record, + defaulted: Record = {}, ): void { const arrays = new Set(yargsOptions.array.map(String)); const booleans = new Set(yargsOptions.boolean.map(String)); @@ -65,7 +66,7 @@ export function injectGclEnvVars ( continue; } - if (argv[key] !== undefined && argv[key] !== yargsOptions.default[key]) continue; + if (argv[key] !== undefined && !defaulted[key]) continue; if (booleans.has(key)) argv[key] = envValue === "true" || envValue === "1"; else if (numbers.has(key)) argv[key] = Number(envValue); diff --git a/src/index.ts b/src/index.ts index 38b6264eb..28f918c4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,8 +41,9 @@ process.on("SIGUSR2", async () => { .command({ handler: async (argv) => { try { + const defaulted: Record = (yparser as any).parsed?.defaulted ?? {}; injectGclVariableEnvVars(argv, process.env); - injectGclEnvVars(argv, yparser.getOptions(), process.env); + injectGclEnvVars(argv, (yparser as any).getOptions(), process.env, defaulted); await handler(argv, new WriteStreamsProcess(), jobs); const failedJobs = Executor.getFailed(jobs); process.exit(failedJobs.length > 0 ? 1 : 0); @@ -365,6 +366,9 @@ process.on("SIGUSR2", async () => { if (current.startsWith("-")) { completionFilter(); } else { + const completionDefaulted: Record = (yparser as any).parsed?.defaulted ?? {}; + injectGclVariableEnvVars(yargsArgv, process.env); + injectGclEnvVars(yargsArgv, (yparser as any).getOptions(), process.env, completionDefaulted); 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/integration.test.ts b/tests/test-cases/gcl-variable-env/integration.test.ts index a6ae21075..e7adc42d3 100644 --- a/tests/test-cases/gcl-variable-env/integration.test.ts +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -1,4 +1,4 @@ -import {injectGclVariableEnvVars} from "../../../src/argv.js"; +import {injectGclVariableEnvVars, injectGclEnvVars} from "../../../src/argv.js"; import {execFile} from "child_process"; import {promisify} from "util"; @@ -75,6 +75,89 @@ describe("injectGclVariableEnvVars unit tests", () => { }); }); +describe("injectGclEnvVars unit tests", () => { + const baseOptions = { + array: ["volume"], + boolean: ["quiet"], + number: ["concurrency"], + default: {quiet: false, concurrency: 0, cwd: "."}, + key: {quiet: true, concurrency: true, cwd: true, volume: true, _: true, $0: true, "some-kebab": true}, + }; + + test("injects string env var", () => { + const argv: Record = {cwd: ".", quiet: false}; + injectGclEnvVars(argv, baseOptions, {"GCL_CWD": "/tmp/test"}, {cwd: true, quiet: true}); + expect(argv.cwd).toBe("/tmp/test"); + }); + + test("injects boolean env var", () => { + const argv: Record = {quiet: false}; + injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": "true"}, {quiet: true}); + expect(argv.quiet).toBe(true); + }); + + test("injects boolean env var from '1'", () => { + const argv: Record = {quiet: false}; + injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": "1"}, {quiet: true}); + expect(argv.quiet).toBe(true); + }); + + test("injects number env var", () => { + const argv: Record = {concurrency: 0}; + injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {concurrency: true}); + expect(argv.concurrency).toBe(4); + }); + + test("splits array env var on semicolons", () => { + const argv: Record = {volume: []}; + injectGclEnvVars(argv, baseOptions, {"GCL_VOLUME": "/a:/b;/c:/d"}, {volume: true}); + expect(argv.volume).toEqual(["/a:/b", "/c:/d"]); + }); + + test("merges array env var with CLI values", () => { + const argv: Record = {volume: ["/cli:/path"]}; + injectGclEnvVars(argv, baseOptions, {"GCL_VOLUME": "/env:/path"}, {}); + expect(argv.volume).toEqual(["/env:/path", "/cli:/path"]); + }); + + test("CLI explicit value takes precedence over env", () => { + const argv: Record = {concurrency: 8}; + injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {}); + expect(argv.concurrency).toBe(8); + }); + + test("CLI explicit value takes precedence even when matching default", () => { + const argv: Record = {concurrency: 0}; + injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {}); + expect(argv.concurrency).toBe(0); + }); + + test("env overrides when value was defaulted", () => { + const argv: Record = {concurrency: 0}; + injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {concurrency: true}); + expect(argv.concurrency).toBe(4); + }); + + test("skips keys with hyphens", () => { + const argv: Record = {}; + injectGclEnvVars(argv, baseOptions, {"GCL_SOME_KEBAB": "val"}, {}); + expect(argv["some-kebab"]).toBeUndefined(); + }); + + test("skips _ and $0 keys", () => { + const argv: Record = {_: [], $0: "bin"}; + injectGclEnvVars(argv, baseOptions, {"GCL__": "x", "GCL_$0": "y"}, {}); + expect(argv._).toEqual([]); + expect(argv.$0).toBe("bin"); + }); + + test("skips undefined env values", () => { + const argv: Record = {quiet: false}; + injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": undefined}, {quiet: true}); + expect(argv.quiet).toBe(false); + }); +}); + 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: { From 7bbb580a9e98806abc02c2be3c9fc57d8d885b2e Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Fri, 20 Mar 2026 10:37:49 +0100 Subject: [PATCH 5/8] fix: restore .env("GCL") and strip GCL_VARIABLE_* before yargs parse Keep yargs .env("GCL") + .strictOptions() for standard options so that validation, coercion, precedence, and unknown-option rejection all work as before. Strip GCL_VARIABLE_* entries from process.env before yargs sees them (avoiding strictOptions conflict), then inject them into argv.variable in the handler. --- src/argv.ts | 57 ++----- src/index.ts | 12 +- .../gcl-variable-env/integration.test.ts | 152 +++++------------- 3 files changed, 62 insertions(+), 159 deletions(-) diff --git a/src/argv.ts b/src/argv.ts index 8f3c23072..90bd5212c 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -22,12 +22,22 @@ async function gitRootPath () { return stdout; } -export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: any}, env: Record): void { +export function stripGclVariableEnvVars (env: Record): Record { const prefix = "GCL_VARIABLE_"; - for (const [envKey, envValue] of Object.entries(env)) { - if (!envKey.startsWith(prefix) || envValue == null) continue; + const stripped: Record = {}; + for (const key of Object.keys(env)) { + if (!key.startsWith(prefix) || env[key] == null) continue; + if (key.length <= prefix.length) continue; + stripped[key] = env[key]!; + delete env[key]; + } + return stripped; +} + +export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: any}, gclVars: Record): void { + const prefix = "GCL_VARIABLE_"; + for (const [envKey, envValue] of Object.entries(gclVars)) { const varName = envKey.slice(prefix.length); - if (varName.length === 0) continue; if (argv.variable == null) { argv.variable = []; } @@ -35,45 +45,6 @@ export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: stri } } -interface YargsOptionsMeta { - "array": string[]; - "boolean": string[]; - "number": string[]; - "default": Record; - "key": Record; -} - -export function injectGclEnvVars ( - argv: Record, - yargsOptions: YargsOptionsMeta, - env: Record, - defaulted: Record = {}, -): void { - const arrays = new Set(yargsOptions.array.map(String)); - const booleans = new Set(yargsOptions.boolean.map(String)); - const numbers = new Set(yargsOptions.number.map(String)); - - for (const key of Object.keys(yargsOptions.key)) { - if (key.includes("-") || key === "_" || key === "$0") continue; - - const envKey = `GCL_${key.replace(/[A-Z]/g, c => `_${c}`).toUpperCase()}`; - const envValue = env[envKey]; - if (envValue == null) continue; - - if (arrays.has(key)) { - const cliValues = Array.isArray(argv[key]) ? argv[key] : []; - argv[key] = [...envValue.split(";"), ...cliValues]; - continue; - } - - if (argv[key] !== undefined && !defaulted[key]) continue; - - if (booleans.has(key)) argv[key] = envValue === "true" || envValue === "1"; - else if (numbers.has(key)) argv[key] = Number(envValue); - else argv[key] = envValue; - } -} - export class Argv { static readonly default = { "variablesFile": ".gitlab-ci-local-variables.yml", diff --git a/src/index.ts b/src/index.ts index 28f918c4a..a9d04aef3 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, injectGclVariableEnvVars, injectGclEnvVars} 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,9 +42,7 @@ process.on("SIGUSR2", async () => { .command({ handler: async (argv) => { try { - const defaulted: Record = (yparser as any).parsed?.defaulted ?? {}; - injectGclVariableEnvVars(argv, process.env); - injectGclEnvVars(argv, (yparser as any).getOptions(), process.env, defaulted); + injectGclVariableEnvVars(argv, gclVariableEnvVars); await handler(argv, new WriteStreamsProcess(), jobs); const failedJobs = Executor.getFailed(jobs); process.exit(failedJobs.length > 0 ? 1 : 0); @@ -77,6 +76,7 @@ process.on("SIGUSR2", async () => { }) .usage("Find more information at https://github.com/firecow/gitlab-ci-local.\nNote: To negate an option use '--no-(option)'.") .strictOptions() + .env("GCL") .option("manual", { type: "array", description: "One or more manual jobs to run during a pipeline", @@ -366,9 +366,7 @@ process.on("SIGUSR2", async () => { if (current.startsWith("-")) { completionFilter(); } else { - const completionDefaulted: Record = (yparser as any).parsed?.defaulted ?? {}; - injectGclVariableEnvVars(yargsArgv, process.env); - injectGclEnvVars(yargsArgv, (yparser as any).getOptions(), process.env, completionDefaulted); + 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/integration.test.ts b/tests/test-cases/gcl-variable-env/integration.test.ts index e7adc42d3..880693e30 100644 --- a/tests/test-cases/gcl-variable-env/integration.test.ts +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -1,17 +1,56 @@ -import {injectGclVariableEnvVars, injectGclEnvVars} from "../../../src/argv.js"; +import {stripGclVariableEnvVars, injectGclVariableEnvVars} from "../../../src/argv.js"; import {execFile} from "child_process"; import {promisify} from "util"; const execFileAsync = promisify(execFile); -describe("injectGclVariableEnvVars unit tests", () => { - test("injects single GCL_VARIABLE_ entry", () => { +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("skips GCL_VARIABLE_ with empty name", () => { + const env: Record = {"GCL_VARIABLE_": "empty"}; + const stripped = stripGclVariableEnvVars(env); + expect(stripped).toEqual({}); + expect(env["GCL_VARIABLE_"]).toBe("empty"); + }); + + 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 GCL_VARIABLE_ entries", () => { + test("injects multiple entries", () => { const argv: {variable?: string[]} = {}; injectGclVariableEnvVars(argv, { "GCL_VARIABLE_VAR1": "one", @@ -34,28 +73,6 @@ describe("injectGclVariableEnvVars unit tests", () => { expect(argv.variable).toEqual(["SAME=from_env", "SAME=from_cli"]); }); - test("skips non GCL_VARIABLE_ env vars", () => { - const argv: {variable?: string[]} = {}; - injectGclVariableEnvVars(argv, { - "GCL_CWD": "/tmp", - "HOME": "/home/user", - "GCL_VARIABLE_REAL": "yes", - }); - expect(argv.variable).toEqual(["REAL=yes"]); - }); - - test("skips GCL_VARIABLE_ with empty name", () => { - const argv: {variable?: string[]} = {}; - injectGclVariableEnvVars(argv, {"GCL_VARIABLE_": "empty_name"}); - expect(argv.variable).toBeUndefined(); - }); - - test("skips null/undefined env values", () => { - const argv: {variable?: string[]} = {}; - injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": undefined}); - expect(argv.variable).toBeUndefined(); - }); - test("handles empty value", () => { const argv: {variable?: string[]} = {}; injectGclVariableEnvVars(argv, {"GCL_VARIABLE_FOO": ""}); @@ -75,89 +92,6 @@ describe("injectGclVariableEnvVars unit tests", () => { }); }); -describe("injectGclEnvVars unit tests", () => { - const baseOptions = { - array: ["volume"], - boolean: ["quiet"], - number: ["concurrency"], - default: {quiet: false, concurrency: 0, cwd: "."}, - key: {quiet: true, concurrency: true, cwd: true, volume: true, _: true, $0: true, "some-kebab": true}, - }; - - test("injects string env var", () => { - const argv: Record = {cwd: ".", quiet: false}; - injectGclEnvVars(argv, baseOptions, {"GCL_CWD": "/tmp/test"}, {cwd: true, quiet: true}); - expect(argv.cwd).toBe("/tmp/test"); - }); - - test("injects boolean env var", () => { - const argv: Record = {quiet: false}; - injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": "true"}, {quiet: true}); - expect(argv.quiet).toBe(true); - }); - - test("injects boolean env var from '1'", () => { - const argv: Record = {quiet: false}; - injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": "1"}, {quiet: true}); - expect(argv.quiet).toBe(true); - }); - - test("injects number env var", () => { - const argv: Record = {concurrency: 0}; - injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {concurrency: true}); - expect(argv.concurrency).toBe(4); - }); - - test("splits array env var on semicolons", () => { - const argv: Record = {volume: []}; - injectGclEnvVars(argv, baseOptions, {"GCL_VOLUME": "/a:/b;/c:/d"}, {volume: true}); - expect(argv.volume).toEqual(["/a:/b", "/c:/d"]); - }); - - test("merges array env var with CLI values", () => { - const argv: Record = {volume: ["/cli:/path"]}; - injectGclEnvVars(argv, baseOptions, {"GCL_VOLUME": "/env:/path"}, {}); - expect(argv.volume).toEqual(["/env:/path", "/cli:/path"]); - }); - - test("CLI explicit value takes precedence over env", () => { - const argv: Record = {concurrency: 8}; - injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {}); - expect(argv.concurrency).toBe(8); - }); - - test("CLI explicit value takes precedence even when matching default", () => { - const argv: Record = {concurrency: 0}; - injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {}); - expect(argv.concurrency).toBe(0); - }); - - test("env overrides when value was defaulted", () => { - const argv: Record = {concurrency: 0}; - injectGclEnvVars(argv, baseOptions, {"GCL_CONCURRENCY": "4"}, {concurrency: true}); - expect(argv.concurrency).toBe(4); - }); - - test("skips keys with hyphens", () => { - const argv: Record = {}; - injectGclEnvVars(argv, baseOptions, {"GCL_SOME_KEBAB": "val"}, {}); - expect(argv["some-kebab"]).toBeUndefined(); - }); - - test("skips _ and $0 keys", () => { - const argv: Record = {_: [], $0: "bin"}; - injectGclEnvVars(argv, baseOptions, {"GCL__": "x", "GCL_$0": "y"}, {}); - expect(argv._).toEqual([]); - expect(argv.$0).toBe("bin"); - }); - - test("skips undefined env values", () => { - const argv: Record = {quiet: false}; - injectGclEnvVars(argv, baseOptions, {"GCL_QUIET": undefined}, {quiet: true}); - expect(argv.quiet).toBe(false); - }); -}); - 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: { From 267ed48c61e90d1b3e4b7f93e577744a481aebd4 Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Fri, 20 Mar 2026 10:46:59 +0100 Subject: [PATCH 6/8] fix: strip GCL_VARIABLE_ (empty suffix) from env, add ignorePredefinedVars tests Always delete GCL_VARIABLE_* entries from process.env before yargs parses, including the bare GCL_VARIABLE_ key, so strictOptions() does not reject it. Add tests for Argv.ignorePredefinedVars getter. --- src/argv.ts | 5 ++-- .../gcl-variable-env/integration.test.ts | 29 +++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/argv.ts b/src/argv.ts index 90bd5212c..e3784cd11 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -27,8 +27,9 @@ export function stripGclVariableEnvVars (env: Record const stripped: Record = {}; for (const key of Object.keys(env)) { if (!key.startsWith(prefix) || env[key] == null) continue; - if (key.length <= prefix.length) continue; - stripped[key] = env[key]!; + if (key.length > prefix.length) { + stripped[key] = env[key]!; + } delete env[key]; } return stripped; diff --git a/tests/test-cases/gcl-variable-env/integration.test.ts b/tests/test-cases/gcl-variable-env/integration.test.ts index 880693e30..f073e45ff 100644 --- a/tests/test-cases/gcl-variable-env/integration.test.ts +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -1,6 +1,7 @@ -import {stripGclVariableEnvVars, injectGclVariableEnvVars} from "../../../src/argv.js"; +import {stripGclVariableEnvVars, injectGclVariableEnvVars, Argv} from "../../../src/argv.js"; import {execFile} from "child_process"; import {promisify} from "util"; +import {WriteStreamsMock} from "../../../src/write-streams.js"; const execFileAsync = promisify(execFile); @@ -23,11 +24,11 @@ describe("stripGclVariableEnvVars", () => { expect(env["GCL_CWD"]).toBe("/tmp"); }); - test("skips GCL_VARIABLE_ with empty name", () => { + 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_"]).toBe("empty"); + expect(env["GCL_VARIABLE_"]).toBeUndefined(); }); test("skips null/undefined values", () => { @@ -92,6 +93,28 @@ describe("injectGclVariableEnvVars", () => { }); }); +describe("Argv.ignorePredefinedVars", () => { + test("returns empty array by default", async () => { + const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env"}, new WriteStreamsMock()); + expect(argv.ignorePredefinedVars).toEqual([]); + }); + + test("handles array input", async () => { + const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env", ignorePredefinedVars: ["VAR1", "VAR2"]}, new WriteStreamsMock()); + expect(argv.ignorePredefinedVars).toEqual(["VAR1", "VAR2"]); + }); + + test("splits comma-separated values in array elements", async () => { + const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env", ignorePredefinedVars: ["VAR1,VAR2"]}, new WriteStreamsMock()); + expect(argv.ignorePredefinedVars).toEqual(["VAR1", "VAR2"]); + }); + + test("handles string input for backwards compatibility", async () => { + const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env", ignorePredefinedVars: "VAR1,VAR2"}, new WriteStreamsMock()); + expect(argv.ignorePredefinedVars).toEqual(["VAR1", "VAR2"]); + }); +}); + 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: { From 2a314f163901f3c60ec1247939fb07c491345dbc Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Fri, 20 Mar 2026 10:52:35 +0100 Subject: [PATCH 7/8] revert: remove unrelated ignore-predefined-vars changes --- src/argv.ts | 6 ++--- src/index.ts | 5 ++-- .../gcl-variable-env/integration.test.ts | 25 +------------------ 3 files changed, 6 insertions(+), 30 deletions(-) diff --git a/src/argv.ts b/src/argv.ts index e3784cd11..edb05ca44 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -51,7 +51,7 @@ export class Argv { "variablesFile": ".gitlab-ci-local-variables.yml", "evaluateRuleChanges": true, "ignoreSchemaPaths": [], - "ignorePredefinedVars": [] as string[], + "ignorePredefinedVars": "", }; map: Map = new Map(); @@ -187,9 +187,7 @@ export class Argv { } get ignorePredefinedVars (): string[] { - const val = this.map.get("ignorePredefinedVars") ?? []; - if (!Array.isArray(val)) return val ? String(val).split(",") : []; - return val.flatMap((v: string) => v.split(",")); + return this.map.get("ignorePredefinedVars") ?? Argv.default.ignorePredefinedVars; } get pullPolicy (): string { diff --git a/src/index.ts b/src/index.ts index a9d04aef3..894772f9c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -326,10 +326,11 @@ process.on("SIGUSR2", async () => { description: "The json schema paths that will be ignored", }) .option("ignore-predefined-vars", { - type: "array", + type: "string", + coerce: (v) => v.split(","), requiresArg: false, default: Argv.default.ignorePredefinedVars, - describe: "Predefined pipeline variables for which warnings should be suppressed", + describe: "Comma-seperated list of predefined pipeline variables for which warnings should be suppressed", }) .option("concurrency", { type: "number", diff --git a/tests/test-cases/gcl-variable-env/integration.test.ts b/tests/test-cases/gcl-variable-env/integration.test.ts index f073e45ff..c90d4ce94 100644 --- a/tests/test-cases/gcl-variable-env/integration.test.ts +++ b/tests/test-cases/gcl-variable-env/integration.test.ts @@ -1,7 +1,6 @@ -import {stripGclVariableEnvVars, injectGclVariableEnvVars, Argv} from "../../../src/argv.js"; +import {stripGclVariableEnvVars, injectGclVariableEnvVars} from "../../../src/argv.js"; import {execFile} from "child_process"; import {promisify} from "util"; -import {WriteStreamsMock} from "../../../src/write-streams.js"; const execFileAsync = promisify(execFile); @@ -93,28 +92,6 @@ describe("injectGclVariableEnvVars", () => { }); }); -describe("Argv.ignorePredefinedVars", () => { - test("returns empty array by default", async () => { - const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env"}, new WriteStreamsMock()); - expect(argv.ignorePredefinedVars).toEqual([]); - }); - - test("handles array input", async () => { - const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env", ignorePredefinedVars: ["VAR1", "VAR2"]}, new WriteStreamsMock()); - expect(argv.ignorePredefinedVars).toEqual(["VAR1", "VAR2"]); - }); - - test("splits comma-separated values in array elements", async () => { - const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env", ignorePredefinedVars: ["VAR1,VAR2"]}, new WriteStreamsMock()); - expect(argv.ignorePredefinedVars).toEqual(["VAR1", "VAR2"]); - }); - - test("handles string input for backwards compatibility", async () => { - const argv = await Argv.build({cwd: "tests/test-cases/gcl-variable-env", ignorePredefinedVars: "VAR1,VAR2"}, new WriteStreamsMock()); - expect(argv.ignorePredefinedVars).toEqual(["VAR1", "VAR2"]); - }); -}); - 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: { From cc65b41c8568c66d4dd864cb140da72bd348b79c Mon Sep 17 00:00:00 2001 From: Mads Jon Nielsen Date: Fri, 20 Mar 2026 11:06:59 +0100 Subject: [PATCH 8/8] fix: extract shared prefix constant, use ??= operator, narrow types --- src/argv.ts | 18 +++++++++--------- .../test-cases/gcl-variable-env/.gitlab-ci.yml | 1 - 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/argv.ts b/src/argv.ts index edb05ca44..45be09108 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -22,12 +22,14 @@ 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 prefix = "GCL_VARIABLE_"; const stripped: Record = {}; for (const key of Object.keys(env)) { - if (!key.startsWith(prefix) || env[key] == null) continue; - if (key.length > prefix.length) { + if (!key.startsWith(GCL_VARIABLE_PREFIX) || env[key] == null) continue; + if (key.length > GCL_VARIABLE_PREFIX.length) { stripped[key] = env[key]!; } delete env[key]; @@ -35,13 +37,11 @@ export function stripGclVariableEnvVars (env: Record return stripped; } -export function injectGclVariableEnvVars (argv: {variable?: string[]; [key: string]: any}, gclVars: Record): void { - const prefix = "GCL_VARIABLE_"; +// 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(prefix.length); - if (argv.variable == null) { - argv.variable = []; - } + const varName = envKey.slice(GCL_VARIABLE_PREFIX.length); + argv.variable ??= []; argv.variable.unshift(`${varName}=${envValue}`); } } diff --git a/tests/test-cases/gcl-variable-env/.gitlab-ci.yml b/tests/test-cases/gcl-variable-env/.gitlab-ci.yml index 3e0445c6c..a2e980867 100644 --- a/tests/test-cases/gcl-variable-env/.gitlab-ci.yml +++ b/tests/test-cases/gcl-variable-env/.gitlab-ci.yml @@ -3,4 +3,3 @@ test-job: script: - echo ${MY_VAR} - echo ${ANOTHER_VAR} - - echo ${EMPTY_VAR}