feat: add option to skip CI/CD input validation (#1933)
build / yamllint (push) Has been cancelled
build / test (push) Has been cancelled
build / CodeQL (push) Has been cancelled
build / smoke-test (1.2.23, 24) (push) Has been cancelled
build / smoke-test (1.2.23, 25) (push) Has been cancelled
build / smoke-test (1.3.10, 24) (push) Has been cancelled
build / smoke-test (1.3.10, 25) (push) Has been cancelled
build / typecheck (push) Has been cancelled
build / eslint (push) Has been cancelled
build / unused-deps (push) Has been cancelled

This commit is contained in:
Wyatt Paquin authored and GitHub committed 2026-09-11 12:53:19 +02:00
1 parent f3c3248969
commit 110c1d85e0
11 files changed
+184 -13

No files matched your search

+11
View File
@@ -42,6 +42,7 @@ Get rid of all those dev specific shell scripts and make files.
* [Remote File Variables](#remote-file-variables)
* [Project File Variables](#project-file-variables)
* [Decorators](#decorators)
* [CI/CD Inputs](#cicd-inputs)
* [Includes](#includes)
* [Artifacts](#artifacts)
* [Self Hosted Custom Ports](#self-hosted-custom-ports)
@@ -391,6 +392,16 @@ A global configuration is possible when setting the following flag
gitlab-ci-local --no-artifacts-to-source
```
### CI/CD Inputs
Input types, options, and regular expressions are validated by default. If an outdated component declares an input type that does not match its default or provided value, use `--skip-input-validation` to render the pipeline without these checks.
```shell
gitlab-ci-local --skip-input-validation --list
```
Required inputs and interpolation keys are still validated.
### Includes
Includes from external sources are only fetched once and cached. Use `--fetch-includes` to ensure that the latest external sources are always fetched.
+4
View File
@@ -171,6 +171,10 @@ export class Argv {
return this.map.get("inputsFile") ?? Argv.default.inputsFile;
}
get skipInputValidation (): boolean {
return this.map.get("skipInputValidation") ?? false;
}
get ignoresFile (): string {
return this.map.get("ignoresFile") ?? Argv.default.ignoresFile;
}
+5
View File
@@ -138,6 +138,11 @@ process.on("SIGUSR2", async () => {
requiresArg: true,
default: Argv.default.inputsFile,
})
.option("skip-input-validation", {
type: "boolean",
description: "Skip CI/CD input type, options, and regex validation",
requiresArg: false,
})
.option("ignores-file", {
type: "string",
description: "Path to an ignores file",
+2 -2
View File
@@ -1786,14 +1786,14 @@ If you know what you're doing and would like to suppress this warning, use one o
}
for (const file of files) {
const content = await Parser.loadYaml(file, {}, true, this.writeStreams);
const content = await Parser.loadYaml(file, {skipInputValidation: this.argv.skipInputValidation}, true, this.writeStreams);
contents = {
...contents,
...content,
};
}
} else if (include["artifact"]) {
const content = await Parser.loadYaml(`${cwd}/${stateDir}/artifacts/${include["job"]}/${include["artifact"]}`, {}, true, this.writeStreams);
const content = await Parser.loadYaml(`${cwd}/${stateDir}/artifacts/${include["job"]}/${include["artifact"]}`, {skipInputValidation: this.argv.skipInputValidation}, true, this.writeStreams);
contents = {
...contents,
...content,
+5 -5
View File
@@ -144,7 +144,7 @@ export class ParserIncludes {
}
for (const localFile of files) {
const mergedInputs = {...(value.inputs ?? {}), ...globalInputs};
const content = await Parser.loadYaml(localFile, {inputs: mergedInputs}, expandVariables, writeStreams);
const content = await Parser.loadYaml(localFile, {inputs: mergedInputs, skipInputValidation: argv.skipInputValidation}, expandVariables, writeStreams);
includeDatas = includeDatas.concat(await this.init(content, opts));
}
} else if (value["project"]) {
@@ -155,7 +155,7 @@ export class ParserIncludes {
const matches = globbySync(normalizedFile, {cwd: includeDir, absolute: true}).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
const filePaths = matches.length > 0 ? matches : [`${includeDir}/${normalizedFile}`];
for (const filePath of filePaths) {
const fileDoc = await Parser.loadYaml(filePath, {inputs: mergedInputs}, expandVariables, writeStreams);
const fileDoc = await Parser.loadYaml(filePath, {inputs: mergedInputs, skipInputValidation: argv.skipInputValidation}, expandVariables, writeStreams);
// Expand local includes inside a "project"-like include
fileDoc["include"] = this.expandInnerLocalIncludes(fileDoc["include"], value["project"], value["ref"], opts);
includeDatas = includeDatas.concat(await this.init(fileDoc, opts));
@@ -185,7 +185,7 @@ export class ParserIncludes {
const fileComponentInputs = isStructured ? (fileInputs[componentName] ?? {}) : {};
const cliComponentSpecificInputs = cliComponentInputs[componentName] ?? {};
const mergedInputs = {...(value.inputs ?? {}), ...globalInputs, ...fileComponentInputs, ...cliComponentSpecificInputs};
const fileDoc = await Parser.loadYaml(file, {inputs: mergedInputs, component}, expandVariables, writeStreams);
const fileDoc = await Parser.loadYaml(file, {inputs: mergedInputs, component, skipInputValidation: argv.skipInputValidation}, expandVariables, writeStreams);
if (!component.isLocal) {
// Expand local includes inside to a "project"-like include
fileDoc["include"] = this.expandInnerLocalIncludes(fileDoc["include"], component.projectPath, component.effectiveRef, opts);
@@ -196,14 +196,14 @@ export class ParserIncludes {
const fsUrl = Utils.fsUrl(`https://${domain}/${project}/-/raw/${ref}/${file}`);
const mergedInputs = {...(value.inputs ?? {}), ...globalInputs};
const fileDoc = await Parser.loadYaml(
`${cwd}/${stateDir}/includes/${fsUrl}`, {inputs: mergedInputs}, expandVariables, writeStreams,
`${cwd}/${stateDir}/includes/${fsUrl}`, {inputs: mergedInputs, skipInputValidation: argv.skipInputValidation}, expandVariables, writeStreams,
);
includeDatas = includeDatas.concat(await this.init(fileDoc, opts));
} else if (value["remote"]) {
const fsUrl = Utils.fsUrl(value["remote"]);
const mergedInputs = {...(value.inputs ?? {}), ...globalInputs};
const fileDoc = await Parser.loadYaml(
`${cwd}/${stateDir}/includes/${fsUrl}`, {inputs: mergedInputs}, expandVariables, writeStreams,
`${cwd}/${stateDir}/includes/${fsUrl}`, {inputs: mergedInputs, skipInputValidation: argv.skipInputValidation}, expandVariables, writeStreams,
);
includeDatas = includeDatas.concat(await this.init(fileDoc, opts));
} else {
+15 -4
View File
@@ -112,12 +112,12 @@ export class Parser {
const rootInputs = {...Utils.getGlobalFileInputs(fileInputs), ...inputs._cliGlobal};
let yamlDataList: any[] = [{stages: [".pre", "build", "test", "deploy", ".post"]}];
const gitlabCiData = await Parser.loadYaml(`${cwd}/${file}`, {inputs: rootInputs}, this.expandVariables, writeStreams);
const gitlabCiData = await Parser.loadYaml(`${cwd}/${file}`, {inputs: rootInputs, skipInputValidation: argv.skipInputValidation}, this.expandVariables, writeStreams);
yamlDataList = yamlDataList.concat(await ParserIncludes.init(gitlabCiData, {argv, cwd, stateDir, writeStreams, gitData, fetchIncludes, variables: expanded, expandVariables: this.expandVariables, maximumIncludes: argv.maximumIncludes, inputs}));
ParserIncludes.resetCount();
const gitlabCiLocalData = await Parser.loadYaml(`${cwd}/.gitlab-ci-local.yml`, {}, this.expandVariables, writeStreams);
const gitlabCiLocalData = await Parser.loadYaml(`${cwd}/.gitlab-ci-local.yml`, {skipInputValidation: argv.skipInputValidation}, this.expandVariables, writeStreams);
yamlDataList = yamlDataList.concat(await ParserIncludes.init(gitlabCiLocalData, {argv, cwd, stateDir, writeStreams, gitData, fetchIncludes, variables: expanded, expandVariables: this.expandVariables, maximumIncludes: argv.maximumIncludes, inputs}));
ParserIncludes.resetCount();
@@ -480,8 +480,19 @@ function validateInput (ctx: any) {
function parseIncludeInputs (ctx: any): {inputValue: any; inputType: InputType} {
validateInterpolationKey(ctx);
validateInterpolationFunctions(ctx);
validateInput(ctx);
return {inputValue: getInputValue(ctx), inputType: getExpectedInputType(ctx)};
if (!ctx.skipInputValidation) validateInput(ctx);
const inputValue = getInputValue(ctx);
const inputType = ctx.skipInputValidation ? getActualInputType(inputValue, ctx) : getExpectedInputType(ctx);
return {inputValue, inputType};
}
function getActualInputType (inputValue: any, ctx: any): InputType {
const {configFilePath, interpolationKey} = ctx;
const inputType = inputValue === null ? "null" : Array.isArray(inputValue) ? "array" : typeof inputValue;
assert(INCLUDE_INPUTS_SUPPORTED_TYPES.includes(inputType as InputType),
chalk`This GitLab CI configuration is invalid: \`{blueBright ${configFilePath}}\`: \`{blueBright ${interpolationKey}}\` input: provided value has unsupported type {blueBright ${inputType}}.`);
return inputType as InputType;
}
function getInputValue (ctx: any) {
@@ -0,0 +1,9 @@
---
spec:
inputs:
null_input:
default: null
---
scan-website:
script:
- echo $[[ inputs.null_input ]]
@@ -3,7 +3,10 @@ spec:
inputs:
number_input:
type: number
default: "1"
---
scan-website:
variables:
NUMBER_INPUT: $[[ inputs.number_input ]]
script:
- echo $[[ inputs.number_input]]
@@ -1,7 +1,5 @@
---
include:
- local: '/.gitlab-ci-input-template.yml'
inputs:
number_input: "1"
stages:
- test
@@ -0,0 +1,10 @@
---
spec:
inputs:
object_input:
default:
key: value
---
scan-website:
script:
- echo $[[ inputs.object_input ]]
@@ -1,5 +1,6 @@
import {WriteStreamsMock} from "../../../src/write-streams.js";
import {handler} from "../../../src/handler.js";
import {Parser} from "../../../src/parser.js";
import assert, {AssertionError} from "assert";
import {initSpawnSpy} from "../../mocks/utils.mock.js";
import {WhenStatics} from "../../mocks/when-statics.js";
@@ -201,6 +202,85 @@ test.concurrent("include-inputs inputs validation for number", async () => {
throw new Error("Error is expected but not thrown/caught");
});
test.concurrent("include-inputs can skip type validation for a mismatched default type", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/include-inputs/input-templates/type-validation/number",
preview: true,
skipInputValidation: true,
}, writeStreams);
const expected = `---
stages:
- .pre
- test
- .post
scan-website:
variables:
NUMBER_INPUT: '1'
script:
- echo 1`;
expect(writeStreams.stdoutLines[0]).toEqual(expected);
});
test.concurrent("include-inputs can skip validation and preserve a provided value's actual type", async () => {
const writeStreams = new WriteStreamsMock();
const templatePath =
"tests/test-cases/include-inputs/input-templates/type-validation/number/.gitlab-ci-input-template.yml";
const parsed = await Parser.loadYaml(
templatePath,
{
inputs: {
number_input: "1",
},
skipInputValidation: true,
},
true,
writeStreams,
);
expect(parsed["scan-website"].variables.NUMBER_INPUT).toBe("1");
expect(typeof parsed["scan-website"].variables.NUMBER_INPUT).toBe("string");
});
test.concurrent("include-inputs reports unsupported value types when validation is skipped", async () => {
try {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/include-inputs/input-templates/type-validation/object",
preview: true,
skipInputValidation: true,
}, writeStreams);
} catch (e: any) {
assert(e instanceof AssertionError, "e is not instanceof AssertionError");
expect(e.message).toContain("This GitLab CI configuration is invalid:");
expect(e.message).toContain(chalk`\`{blueBright object_input}\` input: provided value has unsupported type {blueBright object}.`);
return;
}
throw new Error("Error is expected but not thrown/caught");
});
test.concurrent("include-inputs reports null values when validation is skipped", async () => {
try {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/include-inputs/input-templates/type-validation/null",
preview: true,
skipInputValidation: true,
}, writeStreams);
} catch (e: any) {
assert(e instanceof AssertionError, "e is not instanceof AssertionError");
expect(e.message).toContain("This GitLab CI configuration is invalid:");
expect(e.message).toContain(chalk`\`{blueBright null_input}\` input: provided value has unsupported type {blueBright null}.`);
return;
}
throw new Error("Error is expected but not thrown/caught");
});
test.concurrent("include-inputs for type array", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
@@ -321,6 +401,26 @@ test.concurrent("include-inputs options validation", async () => {
throw new Error("Error is expected but not thrown/caught");
});
test.concurrent("include-inputs can skip options validation", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/include-inputs/input-templates/options-validation",
preview: true,
skipInputValidation: true,
}, writeStreams);
const expected = `---
stages:
- .pre
- test
- .post
scan-website:
script:
- echo fizz`;
expect(writeStreams.stdoutLines[0]).toEqual(expected);
});
test.concurrent("include-inputs options array1 validation", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
@@ -416,6 +516,26 @@ test.concurrent("include-inputs regex validation (invalid)", async () => {
throw new Error("Error is expected but not thrown/caught");
});
test.concurrent("include-inputs can skip regex validation", async () => {
const writeStreams = new WriteStreamsMock();
await handler({
cwd: "tests/test-cases/include-inputs/input-templates/regex-validation",
preview: true,
skipInputValidation: true,
}, writeStreams);
const expected = `---
stages:
- .pre
- test
- .post
deploy:
script:
- echo invalid-version`;
expect(writeStreams.stdoutLines[0]).toEqual(expected);
});
test.concurrent("include-inputs regex validation (valid)", async () => {
const writeStreams = new WriteStreamsMock();
await handler({