Add validation for empty container image
Related PR: - https://github.com/actions/runner/pull/4220 Relaxing schema non-empty-string for container/service image and moving to custom validation. This matches current production behavior which allows empty string at runtime, but not parse time.
This commit is contained in:
@@ -0,0 +1,314 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import {nullTrace} from "../../test-utils/null-trace.js";
|
||||||
|
import {parseWorkflow} from "../../workflows/workflow-parser.js";
|
||||||
|
import {convertWorkflowTemplate, ErrorPolicy} from "../convert.js";
|
||||||
|
|
||||||
|
async function getErrors(content: string): Promise<string[]> {
|
||||||
|
const result = parseWorkflow({name: "wf.yaml", content}, nullTrace);
|
||||||
|
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||||
|
errorPolicy: ErrorPolicy.TryConversion
|
||||||
|
});
|
||||||
|
return (template.errors ?? []).map((e: {Message: string}) => e.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoContainerErrors(errors: string[]): void {
|
||||||
|
const containerErrors = errors.filter(e => e.includes("Container image"));
|
||||||
|
expect(containerErrors).toHaveLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectContainerError(errors: string[], count = 1): void {
|
||||||
|
const containerErrors = errors.filter(e => e.includes("Container image cannot be empty"));
|
||||||
|
expect(containerErrors).toHaveLength(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("container image validation", () => {
|
||||||
|
describe("shorthand form", () => {
|
||||||
|
it("container: '' is silent for job container", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container: ''
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container: docker:// errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container: docker://
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container: valid-image passes", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container: ubuntu:16.04
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapping form", () => {
|
||||||
|
it("container image: '' errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container:
|
||||||
|
image: ''
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container image: docker:// errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container:
|
||||||
|
image: docker://
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container: {} (empty object, missing image) errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container: {}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container image: null errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container:
|
||||||
|
image:
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty image with expression in other field still errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container:
|
||||||
|
image: ''
|
||||||
|
options: \${{ matrix.opts }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("services shorthand", () => {
|
||||||
|
it("services svc: '' errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
svc: ''
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services svc: docker:// errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
svc: docker://
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("services mapping", () => {
|
||||||
|
it("services svc image: '' errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
svc:
|
||||||
|
image: ''
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services svc image: docker:// errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
svc:
|
||||||
|
image: docker://
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services svc: {} (empty object) errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
svc: {}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty image with expression sibling service still errors", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
svc1:
|
||||||
|
image: ''
|
||||||
|
svc2: \${{ matrix.svc }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectContainerError(errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expression safety", () => {
|
||||||
|
it("container: expression skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container: \${{ matrix.container }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container image: expression skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container:
|
||||||
|
image: \${{ matrix.image }}
|
||||||
|
options: --privileged
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container with expression key skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container:
|
||||||
|
\${{ vars.KEY }}: ubuntu
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services: expression skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services: \${{ fromJSON(inputs.services) }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services with expression alias key skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
\${{ matrix.alias }}: postgres
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services container with expression key skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
\${{ vars.KEY }}: postgres
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container with all expression fields skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
container:
|
||||||
|
image: \${{ matrix.image }}
|
||||||
|
options: \${{ matrix.options }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services svc: expression skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
db: \${{ matrix.db }}
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("services image: expression skips validation", async () => {
|
||||||
|
const errors = await getErrors(`on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: \${{ matrix.db_image }}
|
||||||
|
options: --health-cmd pg_isready
|
||||||
|
steps:
|
||||||
|
- run: echo hi`);
|
||||||
|
expectNoContainerErrors(errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,103 +3,164 @@ import {MappingToken, SequenceToken, StringToken, TemplateToken} from "../../tem
|
|||||||
import {isString} from "../../templates/tokens/type-guards.js";
|
import {isString} from "../../templates/tokens/type-guards.js";
|
||||||
import {Container, Credential} from "../workflow-template.js";
|
import {Container, Credential} from "../workflow-template.js";
|
||||||
|
|
||||||
export function convertToJobContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
|
const DOCKER_URI_PREFIX = "docker://";
|
||||||
|
|
||||||
|
function isEmptyImage(value: string): boolean {
|
||||||
|
const trimmed = value.startsWith(DOCKER_URI_PREFIX) ? value.substring(DOCKER_URI_PREFIX.length) : value;
|
||||||
|
return trimmed.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertToJobContainer(
|
||||||
|
context: TemplateContext,
|
||||||
|
container: TemplateToken,
|
||||||
|
isServiceContainer = false
|
||||||
|
): Container | undefined {
|
||||||
|
if (container.isExpression) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shorthand form
|
||||||
|
if (isString(container)) {
|
||||||
|
const image = container.assertString("container item");
|
||||||
|
if (!image || image.value.length === 0) {
|
||||||
|
if (isServiceContainer) {
|
||||||
|
context.error(container, "Container image cannot be empty");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmptyImage(image.value)) {
|
||||||
|
context.error(container, "Container image cannot be empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {image};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping form
|
||||||
|
const mapping = container.assertMapping("container item");
|
||||||
|
if (!mapping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let image: StringToken | undefined;
|
let image: StringToken | undefined;
|
||||||
let env: MappingToken | undefined;
|
let env: MappingToken | undefined;
|
||||||
let ports: SequenceToken | undefined;
|
let ports: SequenceToken | undefined;
|
||||||
let volumes: SequenceToken | undefined;
|
let volumes: SequenceToken | undefined;
|
||||||
let options: StringToken | undefined;
|
let options: StringToken | undefined;
|
||||||
|
let credentials: Credential | undefined;
|
||||||
|
let hasExpressionKey = false;
|
||||||
|
let hasExpression = false;
|
||||||
|
|
||||||
// Skip validation for expressions for now to match
|
for (const item of mapping) {
|
||||||
// behavior of the other parsers
|
if (item.key.isExpression) {
|
||||||
for (const [, token] of TemplateToken.traverse(container)) {
|
hasExpressionKey = true;
|
||||||
if (token.isExpression) {
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = item.key.assertString("container item key");
|
||||||
|
|
||||||
|
switch (key.value) {
|
||||||
|
case "image":
|
||||||
|
if (item.value.isExpression) {
|
||||||
|
hasExpression = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
image = item.value.assertString("container image");
|
||||||
|
break;
|
||||||
|
case "credentials":
|
||||||
|
if (!item.value.isExpression) {
|
||||||
|
credentials = convertCredentials(context, item.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "env":
|
||||||
|
if (!item.value.isExpression) {
|
||||||
|
env = item.value.assertMapping("container env");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ports":
|
||||||
|
if (!item.value.isExpression) {
|
||||||
|
ports = item.value.assertSequence("container ports");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "volumes":
|
||||||
|
if (!item.value.isExpression) {
|
||||||
|
volumes = item.value.assertSequence("container volumes");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "options":
|
||||||
|
if (!item.value.isExpression) {
|
||||||
|
options = item.value.assertString("container options");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
context.error(key, `Unexpected container item key: ${key.value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image
|
||||||
|
if (image) {
|
||||||
|
if (isEmptyImage(image.value)) {
|
||||||
|
context.error(image, "Container image cannot be empty");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
return {image, credentials, env, ports, volumes, options};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isString(container)) {
|
// No image key — skip error if expression keys could provide one
|
||||||
// Workflow uses shorthand syntax `container: image-name`
|
if (!hasExpressionKey && !hasExpression) {
|
||||||
image = container.assertString("container item");
|
|
||||||
return {image: image};
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapping = container.assertMapping("container item");
|
|
||||||
if (mapping)
|
|
||||||
for (const item of mapping) {
|
|
||||||
const key = item.key.assertString("container item key");
|
|
||||||
const value = item.value;
|
|
||||||
|
|
||||||
switch (key.value) {
|
|
||||||
case "image":
|
|
||||||
image = value.assertString("container image");
|
|
||||||
break;
|
|
||||||
case "credentials":
|
|
||||||
convertToJobCredentials(context, value);
|
|
||||||
break;
|
|
||||||
case "env":
|
|
||||||
env = value.assertMapping("container env");
|
|
||||||
for (const envItem of env) {
|
|
||||||
envItem.key.assertString("container env value");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "ports":
|
|
||||||
ports = value.assertSequence("container ports");
|
|
||||||
for (const port of ports) {
|
|
||||||
port.assertString("container port");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "volumes":
|
|
||||||
volumes = value.assertSequence("container volumes");
|
|
||||||
for (const volume of volumes) {
|
|
||||||
volume.assertString("container volume");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "options":
|
|
||||||
options = value.assertString("container options");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
context.error(key, `Unexpected container item key: ${key.value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!image) {
|
|
||||||
context.error(container, "Container image cannot be empty");
|
context.error(container, "Container image cannot be empty");
|
||||||
} else {
|
|
||||||
return {image, env, ports, volumes, options};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||||
const serviceList: Container[] = [];
|
if (services.isExpression) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceList: Container[] = [];
|
||||||
const mapping = services.assertMapping("services");
|
const mapping = services.assertMapping("services");
|
||||||
|
|
||||||
for (const service of mapping) {
|
for (const service of mapping) {
|
||||||
|
if (service.key.isExpression) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
service.key.assertString("service key");
|
service.key.assertString("service key");
|
||||||
const container = convertToJobContainer(context, service.value);
|
const container = convertToJobContainer(context, service.value, true);
|
||||||
if (container) {
|
if (container) {
|
||||||
serviceList.push(container);
|
serviceList.push(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return serviceList;
|
return serviceList;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertToJobCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
function convertCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||||
const mapping = value.assertMapping("credentials");
|
const mapping = value.assertMapping("credentials");
|
||||||
|
if (!mapping) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let username: StringToken | undefined;
|
let username: StringToken | undefined;
|
||||||
let password: StringToken | undefined;
|
let password: StringToken | undefined;
|
||||||
|
|
||||||
for (const item of mapping) {
|
for (const item of mapping) {
|
||||||
|
if (item.key.isExpression) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const key = item.key.assertString("credentials item");
|
const key = item.key.assertString("credentials item");
|
||||||
const value = item.value;
|
if (item.value.isExpression) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
switch (key.value) {
|
switch (key.value) {
|
||||||
case "username":
|
case "username":
|
||||||
username = value.assertString("credentials username");
|
username = item.value.assertString("credentials username");
|
||||||
break;
|
break;
|
||||||
case "password":
|
case "password":
|
||||||
password = value.assertString("credentials password");
|
password = item.value.assertString("credentials password");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
context.error(key, `credentials key ${key.value}`);
|
context.error(key, `credentials key ${key.value}`);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "container":
|
case "container":
|
||||||
convertToJobContainer(context, item.value);
|
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobContainer(context, item.value));
|
||||||
container = item.value;
|
container = item.value;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ export function convertJob(context: TemplateContext, jobKey: StringToken, token:
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "services":
|
case "services":
|
||||||
convertToJobServices(context, item.value);
|
handleTemplateTokenErrors(item.value, context, undefined, () => convertToJobServices(context, item.value));
|
||||||
services = item.value;
|
services = item.value;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -2345,7 +2345,7 @@
|
|||||||
"mapping": {
|
"mapping": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"image": {
|
"image": {
|
||||||
"type": "non-empty-string",
|
"type": "string",
|
||||||
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
"description": "Use `jobs.<job_id>.container.image` to define the Docker image to use as the container to run the action. The value can be the Docker Hub image or a registry name."
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -2390,7 +2390,7 @@
|
|||||||
"matrix"
|
"matrix"
|
||||||
],
|
],
|
||||||
"one-of": [
|
"one-of": [
|
||||||
"non-empty-string",
|
"string",
|
||||||
"container-mapping"
|
"container-mapping"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+1
@@ -91,3 +91,4 @@ yaml-schema-sequence.yml
|
|||||||
yaml-schema-str-flow-styles.yml
|
yaml-schema-str-flow-styles.yml
|
||||||
yaml-schema-string.yml
|
yaml-schema-string.yml
|
||||||
yaml-schema-timestamp.yml
|
yaml-schema-timestamp.yml
|
||||||
|
job-container-invalid.yml
|
||||||
|
|||||||
Reference in New Issue
Block a user