Rename folders
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import {nullTrace} from "../test-utils/null-trace";
|
||||
import {parseWorkflow} from "../workflows/workflow-parser";
|
||||
import {convertWorkflowTemplate, ErrorPolicy} from "./convert";
|
||||
|
||||
function serializeTemplate(template: unknown): unknown {
|
||||
return JSON.parse(JSON.stringify(template));
|
||||
}
|
||||
|
||||
describe("convertWorkflowTemplate", () => {
|
||||
it("converts workflow with one job", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
events: {
|
||||
push: {}
|
||||
},
|
||||
jobs: [
|
||||
{
|
||||
id: "build",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "build",
|
||||
needs: undefined,
|
||||
outputs: undefined,
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [],
|
||||
type: "job"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow if expressions", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
if: \${{ true }}
|
||||
runs-on: ubuntu-latest
|
||||
deploy:
|
||||
if: true
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
events: {
|
||||
push: {}
|
||||
},
|
||||
jobs: [
|
||||
{
|
||||
id: "build",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "build",
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [],
|
||||
type: "job"
|
||||
},
|
||||
{
|
||||
id: "deploy",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "deploy",
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [],
|
||||
type: "job"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with empty needs", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:
|
||||
needs: # comment to preserve whitespace in test
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
errors: [
|
||||
{
|
||||
Message: "wf.yaml (Line: 4, Col: 12): Unexpected value ''"
|
||||
}
|
||||
],
|
||||
events: {
|
||||
push: {}
|
||||
},
|
||||
jobs: [
|
||||
{
|
||||
id: "build",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "build",
|
||||
needs: [],
|
||||
outputs: undefined,
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [],
|
||||
type: "job"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with needs errors", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
job1:
|
||||
needs: [unknown-job, job3]
|
||||
runs-on: ubuntu-latest
|
||||
job2:
|
||||
runs-on: ubuntu-latest
|
||||
job3:
|
||||
needs: job1
|
||||
runs-on: ubuntu-latest`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
errors: [
|
||||
{
|
||||
Message: "wf.yaml (Line: 4, Col: 13): Job 'job1' depends on unknown job 'unknown-job'."
|
||||
},
|
||||
{
|
||||
Message:
|
||||
"wf.yaml (Line: 4, Col: 26): Job 'job1' depends on job 'job3' which creates a cycle in the dependency graph."
|
||||
},
|
||||
{
|
||||
Message:
|
||||
"wf.yaml (Line: 9, Col: 12): Job 'job3' depends on job 'job1' which creates a cycle in the dependency graph."
|
||||
}
|
||||
],
|
||||
events: {
|
||||
push: {}
|
||||
},
|
||||
jobs: [
|
||||
{
|
||||
id: "job1",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "job1",
|
||||
needs: ["unknown-job", "job3"],
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [],
|
||||
type: "job"
|
||||
},
|
||||
{
|
||||
id: "job2",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "job2",
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [],
|
||||
type: "job"
|
||||
},
|
||||
{
|
||||
id: "job3",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "job3",
|
||||
needs: ["job1"],
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [],
|
||||
type: "job"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with invalid on", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test:
|
||||
options: 123
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hello`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
expect(template.jobs).not.toBeUndefined();
|
||||
expect(template.jobs).toHaveLength(1);
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
errors: [
|
||||
{
|
||||
Message: "wf.yaml (Line: 5, Col: 18): Unexpected value '123'"
|
||||
},
|
||||
{
|
||||
Message:
|
||||
"wf.yaml (Line: 5, Col: 18): Unexpected type 'NumberToken' encountered while reading 'input options'. The type 'SequenceToken' was expected."
|
||||
}
|
||||
],
|
||||
events: {},
|
||||
jobs: [
|
||||
{
|
||||
id: "build",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
name: "build",
|
||||
needs: undefined,
|
||||
outputs: undefined,
|
||||
"runs-on": "ubuntu-latest",
|
||||
steps: [
|
||||
{
|
||||
id: "__run",
|
||||
if: {
|
||||
expr: "success()",
|
||||
type: 3
|
||||
},
|
||||
run: "echo hello"
|
||||
}
|
||||
],
|
||||
type: "job"
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("converts workflow with invalid jobs", async () => {
|
||||
const result = parseWorkflow(
|
||||
{
|
||||
name: "wf.yaml",
|
||||
content: `on: push
|
||||
jobs:
|
||||
build:`
|
||||
},
|
||||
nullTrace
|
||||
);
|
||||
|
||||
const template = await convertWorkflowTemplate(result.context, result.value!, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
expect(template.jobs).not.toBeUndefined();
|
||||
expect(template.jobs).toHaveLength(0);
|
||||
expect(serializeTemplate(template)).toEqual({
|
||||
errors: [
|
||||
{
|
||||
Message: "wf.yaml (Line: 3, Col: 9): Unexpected value ''"
|
||||
},
|
||||
{
|
||||
Message:
|
||||
"wf.yaml (Line: 3, Col: 9): Unexpected type 'NullToken' encountered while reading 'job build'. The type 'MappingToken' was expected."
|
||||
}
|
||||
],
|
||||
events: {
|
||||
push: {}
|
||||
},
|
||||
jobs: []
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import {TemplateContext} from "../templates/template-context";
|
||||
import {TemplateToken, TemplateTokenError} from "../templates/tokens/template-token";
|
||||
import {FileProvider} from "../workflows/file-provider";
|
||||
import {parseFileReference} from "../workflows/file-reference";
|
||||
import {parseWorkflow} from "../workflows/workflow-parser";
|
||||
import {convertConcurrency} from "./converter/concurrency";
|
||||
import {convertOn} from "./converter/events";
|
||||
import {handleTemplateTokenErrors} from "./converter/handle-errors";
|
||||
import {convertJobs} from "./converter/jobs";
|
||||
import {convertReferencedWorkflow} from "./converter/referencedWorkflow";
|
||||
import {isReusableWorkflowJob} from "./type-guards";
|
||||
import {WorkflowTemplate} from "./workflow-template";
|
||||
|
||||
export enum ErrorPolicy {
|
||||
ReturnErrorsOnly,
|
||||
TryConversion
|
||||
}
|
||||
|
||||
export type WorkflowTemplateConverterOptions = {
|
||||
/**
|
||||
* The maximum depth of reusable workflows allowed in the workflow.
|
||||
* If this depth is exceeded, an error will be reported.
|
||||
* If {@link fetchReusableWorkflowDepth} is less than this value, the maximum depth
|
||||
* won't be enforced.
|
||||
* Default: 4
|
||||
*/
|
||||
maxReusableWorkflowDepth?: number;
|
||||
/**
|
||||
* The depth to fetch reusable workflows, up to {@link maxReusableWorkflowDepth}.
|
||||
* Currently only a fetch depth of 0 or 1 is supported.
|
||||
* Default: 0
|
||||
*/
|
||||
fetchReusableWorkflowDepth?: number;
|
||||
|
||||
/**
|
||||
* The error policy to use when converting the workflow.
|
||||
* By default, conversion will be skipped if there are errors in the {@link TemplateContext}.
|
||||
*/
|
||||
errorPolicy?: ErrorPolicy;
|
||||
};
|
||||
|
||||
const defaultOptions: Required<WorkflowTemplateConverterOptions> = {
|
||||
maxReusableWorkflowDepth: 4,
|
||||
fetchReusableWorkflowDepth: 0,
|
||||
errorPolicy: ErrorPolicy.ReturnErrorsOnly
|
||||
};
|
||||
|
||||
export async function convertWorkflowTemplate(
|
||||
context: TemplateContext,
|
||||
root: TemplateToken,
|
||||
fileProvider?: FileProvider,
|
||||
options: WorkflowTemplateConverterOptions = defaultOptions
|
||||
): Promise<WorkflowTemplate> {
|
||||
const result = {} as WorkflowTemplate;
|
||||
const opts = getOptionsWithDefaults(options);
|
||||
|
||||
if (context.errors.getErrors().length > 0 && opts.errorPolicy === ErrorPolicy.ReturnErrorsOnly) {
|
||||
result.errors = context.errors.getErrors().map(x => ({
|
||||
Message: x.message
|
||||
}));
|
||||
return result;
|
||||
}
|
||||
|
||||
if (fileProvider === undefined && opts.fetchReusableWorkflowDepth > 0) {
|
||||
context.error(root, new Error("A file provider is required to fetch reusable workflows"));
|
||||
}
|
||||
|
||||
try {
|
||||
const rootMapping = root.assertMapping("root");
|
||||
|
||||
for (const item of rootMapping) {
|
||||
const key = item.key.assertString("root key");
|
||||
|
||||
switch (key.value) {
|
||||
case "on":
|
||||
result.events = handleTemplateTokenErrors(root, context, {}, () => convertOn(context, item.value));
|
||||
break;
|
||||
|
||||
case "jobs":
|
||||
result.jobs = handleTemplateTokenErrors(root, context, [], () => convertJobs(context, item.value));
|
||||
break;
|
||||
|
||||
case "concurrency":
|
||||
handleTemplateTokenErrors(root, context, {}, () => convertConcurrency(context, item.value));
|
||||
result.concurrency = item.value;
|
||||
break;
|
||||
case "env":
|
||||
result.env = item.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Load referenced workflows
|
||||
for (const job of result.jobs || []) {
|
||||
if (isReusableWorkflowJob(job)) {
|
||||
if (opts.maxReusableWorkflowDepth === 0) {
|
||||
context.error(job.ref, new Error("Reusable workflows are not allowed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.fetchReusableWorkflowDepth === 0 || fileProvider === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await fileProvider.getFileContent(parseFileReference(job.ref.value));
|
||||
const workflow = parseWorkflow(file, context);
|
||||
if (!workflow.value) {
|
||||
continue;
|
||||
}
|
||||
convertReferencedWorkflow(context, workflow.value, job);
|
||||
} catch {
|
||||
context.error(job.ref, new Error("Unable to find reusable workflow"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateTokenError) {
|
||||
context.error(err.token, err);
|
||||
} else {
|
||||
// Report error for the root node
|
||||
context.error(root, err);
|
||||
}
|
||||
} finally {
|
||||
if (context.errors.getErrors().length > 0) {
|
||||
result.errors = context.errors.getErrors().map(x => ({
|
||||
Message: x.message
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getOptionsWithDefaults(options: WorkflowTemplateConverterOptions): Required<WorkflowTemplateConverterOptions> {
|
||||
return {
|
||||
maxReusableWorkflowDepth:
|
||||
options.maxReusableWorkflowDepth !== undefined
|
||||
? options.maxReusableWorkflowDepth
|
||||
: defaultOptions.maxReusableWorkflowDepth,
|
||||
fetchReusableWorkflowDepth:
|
||||
options.fetchReusableWorkflowDepth !== undefined
|
||||
? options.fetchReusableWorkflowDepth
|
||||
: defaultOptions.fetchReusableWorkflowDepth,
|
||||
errorPolicy: options.errorPolicy !== undefined ? options.errorPolicy : defaultOptions.errorPolicy
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {TemplateToken} from "../../templates/tokens/template-token";
|
||||
import {isString} from "../../templates/tokens/type-guards";
|
||||
import {ConcurrencySetting} from "../workflow-template";
|
||||
|
||||
export function convertConcurrency(context: TemplateContext, token: TemplateToken): ConcurrencySetting {
|
||||
const result: ConcurrencySetting = {};
|
||||
|
||||
if (token.isExpression) {
|
||||
return result;
|
||||
}
|
||||
if (isString(token)) {
|
||||
result.group = token;
|
||||
return result;
|
||||
}
|
||||
const concurrencyProperty = token.assertMapping("concurrency group");
|
||||
for (const property of concurrencyProperty) {
|
||||
const propertyName = property.key.assertString("concurrency group key");
|
||||
if (property.key.isExpression || property.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
switch (propertyName.value) {
|
||||
case "group":
|
||||
result.group = property.value.assertString("concurrency group");
|
||||
break;
|
||||
case "cancel-in-progress":
|
||||
result.cancelInProgress = property.value.assertBoolean("cancel-in-progress").value;
|
||||
break;
|
||||
default:
|
||||
context.error(propertyName, `Invalid property name: ${propertyName.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {MappingToken, SequenceToken, StringToken, TemplateToken} from "../../templates/tokens";
|
||||
import {isString} from "../../templates/tokens/type-guards";
|
||||
import {Container, Credential} from "../workflow-template";
|
||||
|
||||
export function convertToJobContainer(context: TemplateContext, container: TemplateToken): Container | undefined {
|
||||
let image: StringToken | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
let ports: SequenceToken | undefined;
|
||||
let volumes: SequenceToken | undefined;
|
||||
let options: StringToken | undefined;
|
||||
|
||||
// Skip validation for expressions for now to match
|
||||
// behavior of the other parsers
|
||||
for (const [_, token, __] of TemplateToken.traverse(container)) {
|
||||
if (token.isExpression) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isString(container)) {
|
||||
// Workflow uses shorthand syntax `container: image-name`
|
||||
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");
|
||||
} else {
|
||||
return {image, env, ports, volumes, options};
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToJobServices(context: TemplateContext, services: TemplateToken): Container[] | undefined {
|
||||
const serviceList: Container[] = [];
|
||||
|
||||
const mapping = services.assertMapping("services");
|
||||
for (const service of mapping) {
|
||||
service.key.assertString("service key");
|
||||
const container = convertToJobContainer(context, service.value);
|
||||
if (container) {
|
||||
serviceList.push(container);
|
||||
}
|
||||
}
|
||||
return serviceList;
|
||||
}
|
||||
|
||||
function convertToJobCredentials(context: TemplateContext, value: TemplateToken): Credential | undefined {
|
||||
const mapping = value.assertMapping("credentials");
|
||||
|
||||
let username: StringToken | undefined;
|
||||
let password: StringToken | undefined;
|
||||
|
||||
for (const item of mapping) {
|
||||
const key = item.key.assertString("credentials item");
|
||||
const value = item.value;
|
||||
|
||||
switch (key.value) {
|
||||
case "username":
|
||||
username = value.assertString("credentials username");
|
||||
break;
|
||||
case "password":
|
||||
password = value.assertString("credentials password");
|
||||
break;
|
||||
default:
|
||||
context.error(key, `credentials key ${key.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {username, password};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Constants for parsing and validating cron expressions
|
||||
|
||||
const MONTHS = {
|
||||
jan: 1,
|
||||
feb: 2,
|
||||
mar: 3,
|
||||
apr: 4,
|
||||
may: 5,
|
||||
jun: 6,
|
||||
jul: 7,
|
||||
aug: 8,
|
||||
sep: 9,
|
||||
oct: 10,
|
||||
nov: 11,
|
||||
dec: 12
|
||||
};
|
||||
|
||||
const DAYS = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6
|
||||
};
|
||||
|
||||
export const MINUTE_RANGE = {min: 0, max: 59};
|
||||
export const HOUR_RANGE = {min: 0, max: 23};
|
||||
export const DOM_RANGE = {min: 1, max: 31};
|
||||
export const MONTH_RANGE = {min: 1, max: 12, names: MONTHS};
|
||||
export const DOW_RANGE = {min: 0, max: 6, names: DAYS};
|
||||
@@ -0,0 +1,79 @@
|
||||
import {isValidCron, getCronDescription} from "./cron";
|
||||
|
||||
describe("cron", () => {
|
||||
describe("valid cron", () => {
|
||||
const valid = [
|
||||
["0 0 * * *", "every day at midnight"],
|
||||
["0 000 001 * *", "accepts leading zeros"],
|
||||
["15 * * * *", "accepts numbers in range"],
|
||||
["2,10 4,5 * * *", "accepts comma separated values"],
|
||||
["30 4-6 * * *", "accepts range"],
|
||||
["0 4-4 * * *", "accepts range with two equal values"],
|
||||
["20/15 * * * *", "accepts step with numerical values"],
|
||||
["30 5,17 * * *", "accepts numbers and ranges"],
|
||||
["28 */4 * * *", "accepts step with * and numerical value"],
|
||||
["28 5,*/4 * * *", "accepts comma separated value with step"],
|
||||
["28 5,*/4,6-8 * * *", "accepts comma separated value with step and range"],
|
||||
["0 0 * * SUN", "accepts day of week short name"],
|
||||
["0 0 * * SUN-TUE", "accepts day of week short name range"],
|
||||
["0 0 * * SUN-2", "accepts day of week range combined with number"],
|
||||
["0 2-4/5 * * *", "accepts range with step"],
|
||||
["0 0 * * *", "accepts multiple spaces"]
|
||||
];
|
||||
|
||||
for (const [cron, reason] of valid) {
|
||||
it(`${cron} should be valid: ${reason}`, () => {
|
||||
expect(isValidCron(cron)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("invalid cron", () => {
|
||||
const invalid = [
|
||||
["0 0 * *", "too few parts"],
|
||||
["0 0 * * * * *", "too many parts"],
|
||||
["0 -1 * * *", "should not accept negative numbers"],
|
||||
["0 1- * * *", "should not accept trailing -"],
|
||||
["0 /1 * * *", "should not accept leading / (empty value)"],
|
||||
["0 1/ * * *", "should not accept trailing / (empty value)"],
|
||||
["0 ,1 * * *", "should not accept leading , (empty value)"],
|
||||
["0 1, * * *", "should not accept trailing , (empty value)"],
|
||||
["0 5--5 * * *", "should not accept multiple -"],
|
||||
["0 *//5 * * *", "should not accept multiple /"],
|
||||
["0 */* * * *", "step start and size may not both be *"],
|
||||
["0 5/* * * *", "steps size may not be *"],
|
||||
["0 *-4 5-* *-* *", "range may not contain *"],
|
||||
["0 ,, * * *", "should not accept multiple ,"],
|
||||
[", , , , ,", "comma is not a valid part"],
|
||||
["0 ** * * *", "should not accept multiple *"],
|
||||
["0 0 * * BUN", "invalid short name"],
|
||||
["0 0 * SUN JAN", "short name in incorrect position"],
|
||||
["0 0 * * FRI-TUE", "should not accept short name range with start > end"],
|
||||
["0 12-4 * * *", "should not accept nuerical range with start > end"],
|
||||
["0 */0 * * *", "step size may not be 0"],
|
||||
["0 2/4-5 * * *", "step size may not be a range"],
|
||||
["0 2-4-6 * * *", "range may not contain multiple -"],
|
||||
["0 2/4/6 * * *", "step may not contain multiple /"],
|
||||
["0 * * */FEB */TUE", "step size may not be a short name"]
|
||||
];
|
||||
|
||||
for (const [cron, reason] of invalid) {
|
||||
it(`${cron} should be invalid: ${reason}`, () => {
|
||||
expect(isValidCron(cron)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("getCronDescription", () => {
|
||||
it(`Produces a sentence for valid cron`, () => {
|
||||
expect(getCronDescription("0 * * * *")).toEqual(
|
||||
"Runs every hour\n\n" +
|
||||
"Actions schedules run at most every 5 minutes. [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)"
|
||||
);
|
||||
});
|
||||
|
||||
it(`Returns nothing for invalid cron`, () => {
|
||||
expect(getCronDescription("* * * * * *")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import cronstrue from "cronstrue";
|
||||
|
||||
import {MONTH_RANGE, HOUR_RANGE, MINUTE_RANGE, DOM_RANGE, DOW_RANGE} from "./cron-constants";
|
||||
|
||||
type Range = {
|
||||
min: number;
|
||||
max: number;
|
||||
names?: Record<string, number>;
|
||||
};
|
||||
|
||||
export function isValidCron(cron: string): boolean {
|
||||
// https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule
|
||||
|
||||
const parts = cron.split(/ +/);
|
||||
if (parts.length != 5) {
|
||||
return false;
|
||||
}
|
||||
const [minutes, hours, dom, months, dow] = parts;
|
||||
|
||||
return (
|
||||
validateCronPart(minutes, MINUTE_RANGE) &&
|
||||
validateCronPart(hours, HOUR_RANGE) &&
|
||||
validateCronPart(dom, DOM_RANGE) &&
|
||||
validateCronPart(months, MONTH_RANGE) &&
|
||||
validateCronPart(dow, DOW_RANGE)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCronDescription(cronspec: string): string | undefined {
|
||||
if (!isValidCron(cronspec)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let desc = "";
|
||||
try {
|
||||
desc = cronstrue.toString(cronspec, {
|
||||
dayOfWeekStartIndexZero: true,
|
||||
monthStartIndexZero: false,
|
||||
use24HourTimeFormat: true,
|
||||
// cronstrue sets the description as the error if throwExceptionOnParseError is false
|
||||
// so we need to distinguish between an error and a valid description
|
||||
throwExceptionOnParseError: true
|
||||
});
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make first character lowercase
|
||||
let result = "Runs " + desc.charAt(0).toLowerCase() + desc.slice(1);
|
||||
result +=
|
||||
"\n\nActions schedules run at most every 5 minutes." +
|
||||
" [Learn more](https://docs.github.com/actions/using-workflows/workflow-syntax-for-github-actions#onschedule)";
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateCronPart(value: string, range: Range, allowSeparators = true): boolean {
|
||||
if (range.names && range.names[value.toLowerCase()] !== undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Operator precedence: , > / > -
|
||||
if (value.includes(",")) {
|
||||
if (!allowSeparators) {
|
||||
return false;
|
||||
}
|
||||
return value.split(",").every(v => v && validateCronPart(v, range));
|
||||
}
|
||||
|
||||
if (value.includes("/")) {
|
||||
if (!allowSeparators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [start, step, ...rest] = value.split("/");
|
||||
const stepNumber = +step;
|
||||
if (rest.length > 0 || isNaN(stepNumber) || stepNumber <= 0 || !start || !step) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Separators are only allowed in the part before the `/`, e.g. `1-5/2`
|
||||
return validateCronPart(start, range) && validateCronPart(step, range, false);
|
||||
}
|
||||
|
||||
if (value.includes("-")) {
|
||||
if (!allowSeparators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [start, end, ...rest] = value.split("-");
|
||||
if (rest.length > 0 || !start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert name to integers so we can make sure end >= start
|
||||
const startNumber = convertToNumber(start, range.names);
|
||||
const endNumber = convertToNumber(end, range.names);
|
||||
return validateCronPart(start, range, false) && validateCronPart(end, range, false) && endNumber >= startNumber;
|
||||
}
|
||||
|
||||
const number = +value;
|
||||
return !isNaN(number) && number >= range.min && number <= range.max;
|
||||
}
|
||||
|
||||
// Converts a string integer or a short name to a number
|
||||
function convertToNumber(value: string, names?: Record<string, number>): number {
|
||||
if (names && names[value.toLowerCase()] !== undefined) {
|
||||
return +names[value.toLowerCase()];
|
||||
} else {
|
||||
return +value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {MappingToken} from "../../templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "../../templates/tokens/sequence-token";
|
||||
import {TemplateToken} from "../../templates/tokens/template-token";
|
||||
import {isLiteral, isMapping, isSequence, isString} from "../../templates/tokens/type-guards";
|
||||
import {TokenType} from "../../templates/tokens/types";
|
||||
import {
|
||||
BranchFilterConfig,
|
||||
EventsConfig,
|
||||
PathFilterConfig,
|
||||
ScheduleConfig,
|
||||
TagFilterConfig,
|
||||
TypesFilterConfig,
|
||||
WorkflowFilterConfig
|
||||
} from "../workflow-template";
|
||||
import {isValidCron} from "./cron";
|
||||
import {convertStringList} from "./string-list";
|
||||
import {convertEventWorkflowDispatchInputs} from "./workflow-dispatch";
|
||||
|
||||
export function convertOn(context: TemplateContext, token: TemplateToken): EventsConfig {
|
||||
if (isLiteral(token)) {
|
||||
const event = token.assertString("on");
|
||||
|
||||
return {
|
||||
[event.value]: {}
|
||||
} as EventsConfig;
|
||||
}
|
||||
|
||||
if (isSequence(token)) {
|
||||
const result = {} as EventsConfig;
|
||||
|
||||
for (const item of token) {
|
||||
const event = item.assertString("on");
|
||||
result[event.value] = {};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (isMapping(token)) {
|
||||
const result = {} as EventsConfig;
|
||||
|
||||
for (const item of token) {
|
||||
const eventKey = item.key.assertString("event name");
|
||||
const eventName = eventKey.value;
|
||||
|
||||
if (item.value.templateTokenType === TokenType.Null) {
|
||||
result[eventName] = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Schedule is the only event that can be a sequence, handle that separately
|
||||
if (eventName === "schedule") {
|
||||
const scheduleToken = item.value.assertSequence(`event ${eventName}`);
|
||||
result.schedule = convertSchedule(context, scheduleToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// All other events are defined as mappings. During schema validation we already ensure that events
|
||||
// receive only known keys, so here we can focus on the values and whether they are valid.
|
||||
const eventToken = item.value.assertMapping(`event ${eventName}`);
|
||||
|
||||
result[eventName] = {
|
||||
...convertPatternFilter("branches", eventToken),
|
||||
...convertPatternFilter("tags", eventToken),
|
||||
...convertPatternFilter("paths", eventToken),
|
||||
...convertFilter("types", eventToken),
|
||||
...convertFilter("workflows", eventToken),
|
||||
// TODO - share input parsing for now, but workflow_call also needs outputs and secrets
|
||||
...convertEventWorkflowDispatchInputs(context, eventToken)
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
context.error(token, "Invalid format for 'on'");
|
||||
return {};
|
||||
}
|
||||
|
||||
function convertPatternFilter<T extends BranchFilterConfig & TagFilterConfig & PathFilterConfig>(
|
||||
name: "branches" | "tags" | "paths",
|
||||
token: MappingToken
|
||||
): T {
|
||||
const result = {} as T;
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString(`${name} filter key`);
|
||||
|
||||
switch (key.value) {
|
||||
case name:
|
||||
if (isString(item.value)) {
|
||||
result[name] = [item.value.value];
|
||||
} else {
|
||||
result[name] = convertStringList(name, item.value.assertSequence(`${name} list`));
|
||||
}
|
||||
break;
|
||||
|
||||
case `${name}-ignore`:
|
||||
if (isString(item.value)) {
|
||||
result[`${name}-ignore`] = [item.value.value];
|
||||
} else {
|
||||
result[`${name}-ignore`] = convertStringList(
|
||||
`${name}-ignore`,
|
||||
item.value.assertSequence(`${name}-ignore list`)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertFilter<T extends TypesFilterConfig & WorkflowFilterConfig>(
|
||||
name: "types" | "workflows",
|
||||
token: MappingToken
|
||||
): T {
|
||||
const result = {} as T;
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString(`${name} filter key`);
|
||||
|
||||
switch (key.value) {
|
||||
case name:
|
||||
if (isString(item.value)) {
|
||||
result[name] = [item.value.value];
|
||||
} else {
|
||||
result[name] = convertStringList(name, item.value.assertSequence(`${name} list`));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertSchedule(context: TemplateContext, token: SequenceToken): ScheduleConfig[] | undefined {
|
||||
const result = [] as ScheduleConfig[];
|
||||
for (const item of token) {
|
||||
const mappingToken = item.assertMapping(`event schedule`);
|
||||
if (mappingToken.count == 1) {
|
||||
const schedule = mappingToken.get(0);
|
||||
const scheduleKey = schedule.key.assertString(`schedule key`);
|
||||
if (scheduleKey.value == "cron") {
|
||||
const cron = schedule.value.assertString(`schedule cron`);
|
||||
// Validate the cron string
|
||||
if (!isValidCron(cron.value)) {
|
||||
context.error(cron, "Invalid cron string");
|
||||
}
|
||||
result.push({cron: cron.value});
|
||||
} else {
|
||||
context.error(scheduleKey, `Invalid schedule key`);
|
||||
}
|
||||
} else {
|
||||
context.error(mappingToken, "Invalid format for 'schedule'");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {TemplateToken, TemplateTokenError} from "../../templates/tokens/template-token";
|
||||
|
||||
export function handleTemplateTokenErrors<TResult>(
|
||||
root: TemplateToken,
|
||||
context: TemplateContext,
|
||||
defaultValue: TResult,
|
||||
f: () => TResult
|
||||
): TResult {
|
||||
let r: TResult = defaultValue;
|
||||
|
||||
try {
|
||||
r = f();
|
||||
} catch (err) {
|
||||
if (err instanceof TemplateTokenError) {
|
||||
context.error(err.token, err);
|
||||
} else {
|
||||
// Report error for the root node
|
||||
context.error(root, err);
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {IdBuilder} from "./id-builder";
|
||||
|
||||
function build(...segments: string[]): string {
|
||||
const builder = new IdBuilder();
|
||||
for (const segment of segments) {
|
||||
builder.appendSegment(segment);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
describe("ID Builder", () => {
|
||||
it("builds IDs", () => {
|
||||
expect(build("one")).toEqual("one");
|
||||
|
||||
expect(build("one", "two")).toEqual("one_two");
|
||||
|
||||
expect(build("one", "two", "three")).toEqual("one_two_three");
|
||||
});
|
||||
|
||||
it("empty builder", () => {
|
||||
const builder = new IdBuilder();
|
||||
expect(builder.build()).toEqual("job");
|
||||
});
|
||||
|
||||
it("ignores empty segments", () => {
|
||||
expect(build("", "one")).toEqual("one");
|
||||
|
||||
expect(build("one", "", "two", "")).toEqual("one_two");
|
||||
});
|
||||
|
||||
it("handles illegal characters", () => {
|
||||
const builder = new IdBuilder();
|
||||
builder.appendSegment("hello world!");
|
||||
expect(builder.build()).toEqual("hello_world_");
|
||||
});
|
||||
|
||||
it("handles illegal leading characters", () => {
|
||||
expect(build("!hello")).toEqual("_hello");
|
||||
|
||||
expect(build("!hello", "!world")).toEqual("_hello__world");
|
||||
|
||||
expect(build("!@world", "!@world")).toEqual("__world___world");
|
||||
|
||||
expect(build("123")).toEqual("_123");
|
||||
|
||||
expect(build("123", "456")).toEqual("_123_456");
|
||||
|
||||
expect(build("-abc")).toEqual("_-abc");
|
||||
|
||||
expect(build("-abc", "-def")).toEqual("_-abc_-def");
|
||||
});
|
||||
|
||||
it("allows legal characters", () => {
|
||||
expect(build("abyzABYZ0189_-")).toEqual("abyzABYZ0189_-");
|
||||
});
|
||||
|
||||
it("allows legal leading characters", () => {
|
||||
expect(build("abc")).toEqual("abc");
|
||||
|
||||
expect(build("bcd")).toEqual("bcd");
|
||||
|
||||
expect(build("zyx")).toEqual("zyx");
|
||||
|
||||
expect(build("yxw")).toEqual("yxw");
|
||||
|
||||
expect(build("ABCD")).toEqual("ABCD");
|
||||
|
||||
expect(build("BCDE")).toEqual("BCDE");
|
||||
|
||||
expect(build("ZYXW")).toEqual("ZYXW");
|
||||
|
||||
expect(build("YXWV")).toEqual("YXWV");
|
||||
|
||||
expect(build("_abc")).toEqual("_abc");
|
||||
});
|
||||
|
||||
it("errors for max collisions", () => {
|
||||
const builder = new IdBuilder();
|
||||
builder.appendSegment("abc");
|
||||
builder.appendSegment("def");
|
||||
expect(builder.build()).toEqual("abc_def");
|
||||
|
||||
for (let i = 2; i < 1000; i++) {
|
||||
builder.appendSegment("abc");
|
||||
builder.appendSegment("def");
|
||||
expect(builder.build()).toEqual(`abc_def_${i}`);
|
||||
}
|
||||
|
||||
builder.appendSegment("abc");
|
||||
builder.appendSegment("def");
|
||||
expect(() => builder.build()).toThrowError("Unable to create a unique name");
|
||||
});
|
||||
|
||||
it("takes suffix into account for max length", () => {
|
||||
const builder = new IdBuilder();
|
||||
|
||||
const name = "_234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890";
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(name);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_2"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_3"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_4"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_5"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_6"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_7"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_8"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_2345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678_9"
|
||||
);
|
||||
|
||||
builder.appendSegment(name);
|
||||
expect(builder.build()).toEqual(
|
||||
"_234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567_10"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
const SEPARATOR = "_";
|
||||
const MAX_ATTEMPTS = 1000;
|
||||
const MAX_LENGTH = 100;
|
||||
|
||||
export class IdBuilder {
|
||||
private name: string[] = [];
|
||||
private readonly distinctNames: Set<string> = new Set();
|
||||
|
||||
public appendSegment(value: string) {
|
||||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.name.length == 0) {
|
||||
const first = value[0];
|
||||
if (this.isAlpha(first) || first == "_") {
|
||||
// Legal first char
|
||||
} else if (this.isNumeric(first) || first == "-") {
|
||||
// Illegal first char, but legal char.
|
||||
// Prepend "_".
|
||||
this.name.push("_");
|
||||
} else {
|
||||
// Illegal char
|
||||
}
|
||||
} else {
|
||||
// Separator
|
||||
this.name.push(SEPARATOR);
|
||||
}
|
||||
|
||||
for (const c of value) {
|
||||
{
|
||||
if (this.isAlphaNumeric(c) || c == "_" || c == "-") {
|
||||
// Legal
|
||||
this.name.push(c);
|
||||
} else {
|
||||
// Illegal
|
||||
this.name.push(SEPARATOR);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public build(): string {
|
||||
const original = this.name.length > 0 ? this.name.join("") : "job";
|
||||
let suffix = "";
|
||||
for (let attempt = 1; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
if (attempt === 1) {
|
||||
suffix = "";
|
||||
} else {
|
||||
suffix = "_" + attempt;
|
||||
}
|
||||
|
||||
const candidate = original.substring(0, Math.min(original.length, MAX_LENGTH - suffix.length)) + suffix;
|
||||
|
||||
if (!this.distinctNames.has(candidate)) {
|
||||
this.distinctNames.add(candidate);
|
||||
this.name = [];
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to create a unique name");
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a known identifier to the set of distinct ids.
|
||||
* @param value The value to add
|
||||
* @returns An error if the value is invalid, otherwise undefined
|
||||
*/
|
||||
public tryAddKnownId(value: string): string | undefined {
|
||||
if (!value || !this.isValid(value) || value.length >= MAX_LENGTH) {
|
||||
return `The identifier '${value}' is invalid. IDs may only contain alphanumeric characters, '_', and '-'. IDs must start with a letter or '_' and and must be less than ${MAX_LENGTH} characters.`;
|
||||
}
|
||||
|
||||
if (value.startsWith(SEPARATOR + SEPARATOR)) {
|
||||
return `The identifier '${value}' is invalid. IDs starting with '__' are reserved.`;
|
||||
}
|
||||
|
||||
if (this.distinctNames.has(value)) {
|
||||
return `The identifier '${value}' may not be used more than once within the same scope.`;
|
||||
}
|
||||
|
||||
this.distinctNames.add(value);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name is valid if it starts with a letter or underscore, and contains only
|
||||
* letters, numbers, underscores, and hyphens.
|
||||
* @param name The string name to validate
|
||||
* @returns Whether the name is valid
|
||||
*/
|
||||
private isValid(name: string): boolean {
|
||||
let first = true;
|
||||
for (const c of name) {
|
||||
if (first) {
|
||||
first = false;
|
||||
if (!this.isAlpha(c) && c != "_") {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!this.isAlphaNumeric(c) && c != "_" && c != "-") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isAlphaNumeric(c: string): boolean {
|
||||
return this.isAlpha(c) || this.isNumeric(c);
|
||||
}
|
||||
|
||||
private isNumeric(c: string): boolean {
|
||||
return c >= "0" && c <= "9";
|
||||
}
|
||||
|
||||
private isAlpha(c: string): boolean {
|
||||
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
|
||||
import {isSequence, isString} from "../../templates/tokens/type-guards";
|
||||
import {Step, WorkflowJob} from "../workflow-template";
|
||||
import {convertConcurrency} from "./concurrency";
|
||||
import {convertToJobContainer, convertToJobServices} from "./container";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {IdBuilder} from "./id-builder";
|
||||
import {convertToActionsEnvironmentRef} from "./job/environment";
|
||||
import {convertRunsOn} from "./job/runs-on";
|
||||
import {convertSteps} from "./steps";
|
||||
|
||||
export function convertJob(context: TemplateContext, jobKey: StringToken, token: MappingToken): WorkflowJob {
|
||||
const error = new IdBuilder().tryAddKnownId(jobKey.value);
|
||||
if (error) {
|
||||
context.error(jobKey, error);
|
||||
}
|
||||
|
||||
let concurrency, container, env, environment, name, outputs, runsOn, services, strategy: TemplateToken | undefined;
|
||||
let needs: StringToken[] | undefined = undefined;
|
||||
let steps: Step[] = [];
|
||||
let workflowJobRef: StringToken | undefined;
|
||||
let workflowJobInputs: MappingToken | undefined;
|
||||
let inheritSecrets = false;
|
||||
let workflowJobSecrets: MappingToken | undefined;
|
||||
|
||||
for (const item of token) {
|
||||
const propertyName = item.key.assertString("job property name");
|
||||
switch (propertyName.value) {
|
||||
case "concurrency":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertConcurrency(context, item.value));
|
||||
concurrency = item.value;
|
||||
break;
|
||||
|
||||
case "container":
|
||||
convertToJobContainer(context, item.value);
|
||||
container = item.value;
|
||||
break;
|
||||
|
||||
case "env":
|
||||
env = item.value.assertMapping("job env");
|
||||
break;
|
||||
|
||||
case "environment":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () =>
|
||||
convertToActionsEnvironmentRef(context, item.value)
|
||||
);
|
||||
environment = item.value;
|
||||
break;
|
||||
|
||||
case "name":
|
||||
name = item.value.assertScalar("job name");
|
||||
break;
|
||||
|
||||
case "needs": {
|
||||
needs = [];
|
||||
if (isString(item.value)) {
|
||||
const jobNeeds = item.value.assertString("job needs id");
|
||||
needs.push(jobNeeds);
|
||||
}
|
||||
|
||||
if (isSequence(item.value)) {
|
||||
for (const seqItem of item.value) {
|
||||
const jobNeeds = seqItem.assertString("job needs id");
|
||||
needs.push(jobNeeds);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "outputs":
|
||||
outputs = item.value.assertMapping("job outputs");
|
||||
break;
|
||||
|
||||
case "runs-on":
|
||||
handleTemplateTokenErrors(item.value, context, undefined, () => convertRunsOn(context, item.value));
|
||||
runsOn = item.value;
|
||||
break;
|
||||
|
||||
case "services":
|
||||
convertToJobServices(context, item.value);
|
||||
services = item.value;
|
||||
break;
|
||||
|
||||
case "steps":
|
||||
steps = convertSteps(context, item.value);
|
||||
break;
|
||||
|
||||
case "strategy":
|
||||
strategy = item.value;
|
||||
break;
|
||||
|
||||
case "uses":
|
||||
workflowJobRef = item.value.assertString("job uses value");
|
||||
break;
|
||||
|
||||
case "with":
|
||||
workflowJobInputs = item.value.assertMapping("uses-with value");
|
||||
break;
|
||||
case "secrets":
|
||||
if (isString(item.value) && item.value.value === "inherit") {
|
||||
inheritSecrets = true;
|
||||
} else {
|
||||
workflowJobSecrets = item.value.assertMapping("uses-secrets value");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowJobRef !== undefined) {
|
||||
return {
|
||||
type: "reusableWorkflowJob",
|
||||
id: jobKey,
|
||||
name: jobName(name, jobKey),
|
||||
needs: needs || [],
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
|
||||
ref: workflowJobRef,
|
||||
"input-definitions": undefined,
|
||||
"input-values": workflowJobInputs,
|
||||
"secret-definitions": undefined,
|
||||
"secret-values": workflowJobSecrets,
|
||||
"inherit-secrets": inheritSecrets || undefined,
|
||||
outputs: undefined,
|
||||
concurrency,
|
||||
strategy
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "job",
|
||||
id: jobKey,
|
||||
name: jobName(name, jobKey),
|
||||
needs,
|
||||
if: new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined),
|
||||
env,
|
||||
concurrency,
|
||||
environment,
|
||||
strategy,
|
||||
"runs-on": runsOn,
|
||||
container,
|
||||
services,
|
||||
outputs,
|
||||
steps
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function jobName(name: ScalarToken | undefined, jobKey: StringToken): ScalarToken {
|
||||
if (name === undefined) {
|
||||
return jobKey;
|
||||
}
|
||||
|
||||
if (isString(name) && name.value === "") {
|
||||
return jobKey;
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {TemplateToken} from "../../../templates/tokens/template-token";
|
||||
import {isScalar} from "../../../templates/tokens/type-guards";
|
||||
import {ActionsEnvironmentReference} from "../../workflow-template";
|
||||
|
||||
export function convertToActionsEnvironmentRef(
|
||||
context: TemplateContext,
|
||||
token: TemplateToken
|
||||
): ActionsEnvironmentReference {
|
||||
const result: ActionsEnvironmentReference = {};
|
||||
|
||||
if (token.isExpression) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (isScalar(token)) {
|
||||
result.name = token;
|
||||
return result;
|
||||
}
|
||||
|
||||
const environmentMapping = token.assertMapping("job environment");
|
||||
|
||||
for (const property of environmentMapping) {
|
||||
const propertyName = property.key.assertString("job environment key");
|
||||
if (property.key.isExpression || property.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (propertyName.value) {
|
||||
case "name":
|
||||
result.name = property.value.assertScalar("job environment name key");
|
||||
break;
|
||||
|
||||
case "url":
|
||||
result.url = property.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {MappingToken, TemplateToken} from "../../../templates/tokens";
|
||||
import {ReusableWorkflowJob} from "../../workflow-template";
|
||||
|
||||
type TokenMap = Map<string, [key: string, value: TemplateToken]>;
|
||||
|
||||
export function convertWorkflowJobInputs(context: TemplateContext, job: ReusableWorkflowJob) {
|
||||
const inputDefinitions = createTokenMap(
|
||||
job["input-definitions"]?.assertMapping("workflow job input definitions"),
|
||||
"inputs"
|
||||
);
|
||||
|
||||
const inputValues = createTokenMap(job["input-values"]?.assertMapping("workflow job input values"), "with");
|
||||
|
||||
if (inputDefinitions !== undefined) {
|
||||
for (const [_, [name, value]] of inputDefinitions) {
|
||||
const inputSpec = createTokenMap(value.assertMapping(`input ${name}`), `input ${name} key`)!;
|
||||
|
||||
const inputTypeToken = inputSpec.get("type")?.[1];
|
||||
if (!inputTypeToken) {
|
||||
// This should be validated by the template reader per the schema
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputSet = inputValues !== undefined && inputValues.has(name.toLowerCase());
|
||||
const required = inputSpec.get("required")?.[1].assertBoolean(`input ${name} required`).value;
|
||||
if (required && !inputSet) {
|
||||
context.error(job.ref, `Input ${name} is required, but not provided while calling.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputValues !== undefined) {
|
||||
for (const [_, [name, value]] of inputValues) {
|
||||
if (!inputDefinitions?.has(name.toLowerCase())) {
|
||||
context.error(value, `Invalid input, ${name} is not defined in the referenced workflow.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createTokenMap(mapping: MappingToken | undefined, description: string): TokenMap | undefined {
|
||||
if (!mapping) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = new Map<string, [key: string, value: TemplateToken]>();
|
||||
for (const item of mapping) {
|
||||
const name = item.key.assertString(`${description} key`);
|
||||
result.set(name.value.toLowerCase(), [name.value, item.value]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {TemplateToken} from "../../../templates/tokens";
|
||||
import {isMapping, isString, isSequence} from "../../../templates/tokens/type-guards";
|
||||
|
||||
type RunsOn = {
|
||||
labels: Set<string>;
|
||||
group: string;
|
||||
};
|
||||
|
||||
export function convertRunsOn(context: TemplateContext, token: TemplateToken): RunsOn {
|
||||
const labels = convertRunsOnLabels(token);
|
||||
|
||||
if (!isMapping(token)) {
|
||||
return {
|
||||
labels,
|
||||
group: ""
|
||||
};
|
||||
}
|
||||
|
||||
let group = "";
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("job runs-on property name");
|
||||
switch (key.value) {
|
||||
case "group": {
|
||||
if (item.value.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupName = item.value.assertString("job runs-on group name").value;
|
||||
const names = groupName.split("/");
|
||||
switch (names.length) {
|
||||
case 1: {
|
||||
group = groupName;
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
if (!["org", "organization", "ent", "enterprise"].includes(names[0])) {
|
||||
context.error(
|
||||
item.value,
|
||||
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!names[1]) {
|
||||
context.error(item.value, `Invalid runs-on group name '${groupName}'.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
group = groupName;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
context.error(
|
||||
item.value,
|
||||
`Invalid runs-on group name '${groupName}. Please use 'organization/' or 'enterprise/' prefix to target a single runner group.'`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "labels": {
|
||||
const mapLabels = convertRunsOnLabels(item.value);
|
||||
for (const label of mapLabels) {
|
||||
labels.add(label);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
group
|
||||
};
|
||||
}
|
||||
|
||||
function convertRunsOnLabels(token: TemplateToken): Set<string> {
|
||||
const labels = new Set<string>();
|
||||
if (token.isExpression) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (isString(token)) {
|
||||
labels.add(token.value);
|
||||
return labels;
|
||||
}
|
||||
|
||||
if (isSequence(token)) {
|
||||
for (const item of token) {
|
||||
if (item.isExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = item.assertString("job runs-on label sequence item");
|
||||
labels.add(label.value);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {TemplateContext} from "../../../templates/template-context";
|
||||
import {NullToken} from "../../../templates/tokens";
|
||||
import {ReusableWorkflowJob} from "../../workflow-template";
|
||||
import {createTokenMap} from "./inputs";
|
||||
|
||||
export function convertWorkflowJobSecrets(context: TemplateContext, job: ReusableWorkflowJob) {
|
||||
// No validation if job passes all secrets
|
||||
if (!!job["inherit-secrets"]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretDefinitions = createTokenMap(
|
||||
job["secret-definitions"]?.assertMapping("workflow job secret definitions"),
|
||||
"secrets"
|
||||
);
|
||||
|
||||
const secretValues = createTokenMap(job["secret-values"]?.assertMapping("workflow job secret values"), "secrets");
|
||||
|
||||
if (secretDefinitions !== undefined) {
|
||||
for (const [_, [name, value]] of secretDefinitions) {
|
||||
if (value instanceof NullToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const secretSpec = createTokenMap(value.assertMapping(`secret ${name}`), `secret ${name} key`)!;
|
||||
|
||||
const required = secretSpec.get("required")?.[1].assertBoolean(`secret ${name} required`).value;
|
||||
if (required) {
|
||||
if (secretValues == undefined || !secretValues.has(name.toLowerCase())) {
|
||||
context.error(job.ref, `Secret ${name} is required, but not provided while calling.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (secretValues !== undefined) {
|
||||
for (const [_, [name, value]] of secretValues) {
|
||||
if (!secretDefinitions?.has(name.toLowerCase())) {
|
||||
context.error(value, `Invalid secret, ${name} is not defined in the referenced workflow.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {StringToken} from "../../templates/tokens";
|
||||
import {TemplateToken} from "../../templates/tokens/template-token";
|
||||
import {isMapping} from "../../templates/tokens/type-guards";
|
||||
import {WorkflowJob} from "../workflow-template";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {convertJob} from "./job";
|
||||
|
||||
type nodeInfo = {
|
||||
name: string;
|
||||
needs: StringToken[];
|
||||
};
|
||||
|
||||
export function convertJobs(context: TemplateContext, token: TemplateToken): WorkflowJob[] {
|
||||
if (isMapping(token)) {
|
||||
const result: WorkflowJob[] = [];
|
||||
const jobsWithSatisfiedNeeds: nodeInfo[] = [];
|
||||
const alljobsWithUnsatisfiedNeeds: nodeInfo[] = [];
|
||||
|
||||
for (const item of token) {
|
||||
const jobKey = item.key.assertString("job name");
|
||||
const jobDef = item.value.assertMapping(`job ${jobKey.value}`);
|
||||
|
||||
const job = handleTemplateTokenErrors(token, context, undefined, () => convertJob(context, jobKey, jobDef));
|
||||
if (job) {
|
||||
result.push(job);
|
||||
const node = {
|
||||
name: job.id.value,
|
||||
needs: Object.assign([], job.needs)
|
||||
};
|
||||
if (node.needs.length > 0) {
|
||||
alljobsWithUnsatisfiedNeeds.push(node);
|
||||
} else {
|
||||
jobsWithSatisfiedNeeds.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//validate job needs
|
||||
validateNeeds(token, context, result, jobsWithSatisfiedNeeds, alljobsWithUnsatisfiedNeeds);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
context.error(token, "Invalid format for jobs");
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateNeeds(
|
||||
token: TemplateToken,
|
||||
context: TemplateContext,
|
||||
result: WorkflowJob[],
|
||||
jobsWithSatisfiedNeeds: nodeInfo[],
|
||||
alljobsWithUnsatisfiedNeeds: nodeInfo[]
|
||||
) {
|
||||
if (jobsWithSatisfiedNeeds.length == 0) {
|
||||
context.error(token, "The workflow must contain at least one job with no dependencies.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Figure out which nodes would start after current completes
|
||||
while (jobsWithSatisfiedNeeds.length > 0) {
|
||||
const currentJob = jobsWithSatisfiedNeeds.shift();
|
||||
if (currentJob == undefined) {
|
||||
break;
|
||||
}
|
||||
for (let i = alljobsWithUnsatisfiedNeeds.length - 1; i >= 0; i--) {
|
||||
const unsatisfiedJob = alljobsWithUnsatisfiedNeeds[i];
|
||||
for (let j = unsatisfiedJob.needs.length - 1; j >= 0; j--) {
|
||||
const need = unsatisfiedJob.needs[j];
|
||||
if (need.value == currentJob.name) {
|
||||
unsatisfiedJob.needs.splice(j, 1);
|
||||
if (unsatisfiedJob.needs.length == 0) {
|
||||
jobsWithSatisfiedNeeds.push(unsatisfiedJob);
|
||||
alljobsWithUnsatisfiedNeeds.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether some jobs will never execute
|
||||
if (alljobsWithUnsatisfiedNeeds.length > 0) {
|
||||
const jobNames = result.map(x => x.id.value);
|
||||
for (const unsatisfiedJob of alljobsWithUnsatisfiedNeeds) {
|
||||
for (const need of unsatisfiedJob.needs) {
|
||||
if (jobNames.includes(need.value)) {
|
||||
context.error(
|
||||
need,
|
||||
`Job '${unsatisfiedJob.name}' depends on job '${need.value}' which creates a cycle in the dependency graph.`
|
||||
);
|
||||
} else {
|
||||
context.error(need, `Job '${unsatisfiedJob.name}' depends on unknown job '${need.value}'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {TemplateToken} from "../../templates/tokens";
|
||||
import {TokenType} from "../../templates/tokens/types";
|
||||
import {ReusableWorkflowJob} from "../workflow-template";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {convertWorkflowJobInputs} from "./job/inputs";
|
||||
import {convertWorkflowJobSecrets} from "./job/secrets";
|
||||
import {convertJobs} from "./jobs";
|
||||
|
||||
export function convertReferencedWorkflow(
|
||||
context: TemplateContext,
|
||||
referencedWorkflow: TemplateToken,
|
||||
job: ReusableWorkflowJob
|
||||
) {
|
||||
const mapping = referencedWorkflow.assertMapping("root");
|
||||
|
||||
// The language service doesn't currently handles on other documents,
|
||||
// So use the ref in the original workflow as the error location
|
||||
const tokenForErrors = job.ref;
|
||||
|
||||
for (const pair of mapping) {
|
||||
const key = pair.key.assertString("root key");
|
||||
switch (key.value) {
|
||||
case "on": {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () =>
|
||||
convertReferencedWorkflowOn(context, pair.value, job)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "jobs": {
|
||||
job.jobs = handleTemplateTokenErrors(tokenForErrors, context, [], () => convertJobs(context, pair.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertReferencedWorkflowOn(context: TemplateContext, on: TemplateToken, job: ReusableWorkflowJob) {
|
||||
const tokenForErrors = job.ref;
|
||||
switch (on.templateTokenType) {
|
||||
case TokenType.String: {
|
||||
const event = on.assertString("Reference workflow on value").value;
|
||||
if (event === "workflow_call") {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.Sequence: {
|
||||
const events = on.assertSequence("Reference workflow on value");
|
||||
for (const eventToken of events) {
|
||||
const event = eventToken.assertString(`Reference workflow on value ${eventToken}`).value;
|
||||
if (event === "workflow_call") {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.Mapping: {
|
||||
const eventMapping = on.assertMapping("Reference workflow on value");
|
||||
|
||||
for (const pair of eventMapping) {
|
||||
const event = pair.key.assertString(`Reference workflow on value ${pair.key}`).value;
|
||||
if (event !== "workflow_call") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pair.value.templateTokenType === TokenType.Null) {
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
|
||||
const definitions = pair.value.assertMapping(`Reference workflow on value ${pair.key}`);
|
||||
for (const definition of definitions) {
|
||||
const definitionKey = definition.key.assertString(`on-workflow_call-${definition.key}`).value;
|
||||
switch (definitionKey) {
|
||||
case "inputs":
|
||||
job["input-definitions"] = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
|
||||
case "outputs":
|
||||
job.outputs = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
|
||||
case "secrets":
|
||||
job["secret-definitions"] = definition.value.assertMapping(`on-workflow_call-${definition.key}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobInputs(context, job));
|
||||
handleTemplateTokenErrors(tokenForErrors, context, undefined, () => convertWorkflowJobSecrets(context, job));
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
context.error(tokenForErrors, "workflow_call key is not defined in the referenced workflow.");
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {BasicExpressionToken, MappingToken, ScalarToken, StringToken, TemplateToken} from "../../templates/tokens";
|
||||
import {isSequence} from "../../templates/tokens/type-guards";
|
||||
import {isActionStep} from "../type-guards";
|
||||
import {ActionStep, Step} from "../workflow-template";
|
||||
import {handleTemplateTokenErrors} from "./handle-errors";
|
||||
import {IdBuilder} from "./id-builder";
|
||||
|
||||
export function convertSteps(context: TemplateContext, steps: TemplateToken): Step[] {
|
||||
if (!isSequence(steps)) {
|
||||
context.error(steps, "Invalid format for steps");
|
||||
return [];
|
||||
}
|
||||
|
||||
const idBuilder = new IdBuilder();
|
||||
|
||||
const result: Step[] = [];
|
||||
for (const item of steps) {
|
||||
const step = handleTemplateTokenErrors(steps, context, undefined, () => convertStep(context, idBuilder, item));
|
||||
if (step) {
|
||||
result.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
for (const step of result) {
|
||||
if (step.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = "";
|
||||
if (isActionStep(step)) {
|
||||
id = createActionStepId(step);
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
id = "run";
|
||||
}
|
||||
|
||||
idBuilder.appendSegment(`__${id}`);
|
||||
step.id = idBuilder.build();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertStep(context: TemplateContext, idBuilder: IdBuilder, step: TemplateToken): Step | undefined {
|
||||
const mapping = step.assertMapping("steps item");
|
||||
|
||||
let run: ScalarToken | undefined;
|
||||
let id: StringToken | undefined;
|
||||
let name: ScalarToken | undefined;
|
||||
let uses: StringToken | undefined;
|
||||
let continueOnError: boolean | undefined;
|
||||
let env: MappingToken | undefined;
|
||||
const ifCondition = new BasicExpressionToken(undefined, undefined, "success()", undefined, undefined, undefined);
|
||||
for (const item of mapping) {
|
||||
const key = item.key.assertString("steps item key");
|
||||
switch (key.value) {
|
||||
case "id":
|
||||
id = item.value.assertString("steps item id");
|
||||
if (id) {
|
||||
const error = idBuilder.tryAddKnownId(id.value);
|
||||
if (error) {
|
||||
context.error(id, error);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "name":
|
||||
name = item.value.assertScalar("steps item name");
|
||||
break;
|
||||
case "run":
|
||||
run = item.value.assertScalar("steps item run");
|
||||
break;
|
||||
case "uses":
|
||||
uses = item.value.assertString("steps item uses");
|
||||
break;
|
||||
case "env":
|
||||
env = item.value.assertMapping("step env");
|
||||
break;
|
||||
case "continue-on-error":
|
||||
continueOnError = item.value.assertBoolean("steps item continue-on-error").value;
|
||||
}
|
||||
}
|
||||
|
||||
if (run) {
|
||||
return {
|
||||
id: id?.value || "",
|
||||
name,
|
||||
if: ifCondition,
|
||||
"continue-on-error": continueOnError,
|
||||
env,
|
||||
run
|
||||
};
|
||||
}
|
||||
|
||||
if (uses) {
|
||||
return {
|
||||
id: id?.value || "",
|
||||
name,
|
||||
if: ifCondition,
|
||||
"continue-on-error": continueOnError,
|
||||
env,
|
||||
uses
|
||||
};
|
||||
}
|
||||
context.error(step, "Expected uses or run to be defined");
|
||||
}
|
||||
|
||||
function createActionStepId(step: ActionStep): string {
|
||||
const uses = step.uses.value;
|
||||
if (uses.startsWith("docker://")) {
|
||||
return uses.substring("docker://".length);
|
||||
}
|
||||
|
||||
if (uses.startsWith("./") || uses.startsWith(".\\")) {
|
||||
return "self";
|
||||
}
|
||||
|
||||
const segments = uses.split("@");
|
||||
if (segments.length != 2) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const pathSegments = segments[0].split(/[\\/]/).filter(s => s.length > 0);
|
||||
const gitRef = segments[1];
|
||||
|
||||
if (pathSegments.length >= 2 && pathSegments[0] && pathSegments[1] && gitRef) {
|
||||
return `${pathSegments[0]}/${pathSegments[1]}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {SequenceToken} from "../../templates/tokens/sequence-token";
|
||||
|
||||
export function convertStringList(name: string, token: SequenceToken): string[] {
|
||||
const result = [] as string[];
|
||||
|
||||
for (const item of token) {
|
||||
result.push(item.assertString(`${name} item`).value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {TemplateContext} from "../../templates/template-context";
|
||||
import {MappingToken} from "../../templates/tokens/mapping-token";
|
||||
import {ScalarToken} from "../../templates/tokens/scalar-token";
|
||||
import {InputConfig, InputType, WorkflowDispatchConfig} from "../workflow-template";
|
||||
import {convertStringList} from "./string-list";
|
||||
|
||||
export function convertEventWorkflowDispatchInputs(
|
||||
context: TemplateContext,
|
||||
token: MappingToken
|
||||
): WorkflowDispatchConfig {
|
||||
const result: WorkflowDispatchConfig = {};
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("workflow dispatch input key");
|
||||
|
||||
switch (key.value) {
|
||||
case "inputs":
|
||||
result.inputs = convertWorkflowDispatchInputs(context, item.value.assertMapping("workflow dispatch inputs"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertWorkflowDispatchInputs(
|
||||
context: TemplateContext,
|
||||
token: MappingToken
|
||||
): {
|
||||
[inputName: string]: InputConfig;
|
||||
} {
|
||||
const result: {[inputName: string]: InputConfig} = {};
|
||||
|
||||
for (const item of token) {
|
||||
const inputName = item.key.assertString("input name");
|
||||
const inputMapping = item.value.assertMapping("input configuration");
|
||||
|
||||
result[inputName.value] = convertWorkflowDispatchInput(context, inputMapping);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertWorkflowDispatchInput(context: TemplateContext, token: MappingToken): InputConfig {
|
||||
const result: InputConfig = {
|
||||
type: InputType.string // Default to string
|
||||
};
|
||||
|
||||
let defaultValue: undefined | ScalarToken;
|
||||
|
||||
for (const item of token) {
|
||||
const key = item.key.assertString("workflow dispatch input key");
|
||||
|
||||
switch (key.value) {
|
||||
case "description":
|
||||
result.description = item.value.assertString("input description").value;
|
||||
break;
|
||||
|
||||
case "required":
|
||||
result.required = item.value.assertBoolean("input required").value;
|
||||
break;
|
||||
|
||||
case "default":
|
||||
defaultValue = item.value.assertScalar("input default");
|
||||
break;
|
||||
|
||||
case "type":
|
||||
result.type = InputType[item.value.assertString("input type").value as keyof typeof InputType];
|
||||
break;
|
||||
|
||||
case "options":
|
||||
result.options = convertStringList("input options", item.value.assertSequence("input options"));
|
||||
break;
|
||||
|
||||
default:
|
||||
context.error(item.key, `Invalid key '${key.value}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate default value
|
||||
if (defaultValue !== undefined) {
|
||||
try {
|
||||
switch (result.type) {
|
||||
case InputType.boolean:
|
||||
result.default = defaultValue.assertBoolean("input default").value;
|
||||
|
||||
break;
|
||||
|
||||
case InputType.string:
|
||||
case InputType.choice:
|
||||
case InputType.environment:
|
||||
result.default = defaultValue.assertString("input default").value;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
context.error(defaultValue, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate `options` for `choice` type
|
||||
if (result.type === InputType.choice) {
|
||||
if (result.options === undefined || result.options.length === 0) {
|
||||
context.error(token, "Missing 'options' for choice input");
|
||||
}
|
||||
} else {
|
||||
if (result.options !== undefined) {
|
||||
context.error(token, "Input type is not 'choice', but 'options' is defined");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {ActionStep, Job, ReusableWorkflowJob, RunStep, Step, WorkflowJob} from "./workflow-template";
|
||||
|
||||
export function isRunStep(step: Step): step is RunStep {
|
||||
return (step as RunStep).run !== undefined;
|
||||
}
|
||||
|
||||
export function isActionStep(step: Step): step is ActionStep {
|
||||
return (step as ActionStep).uses !== undefined;
|
||||
}
|
||||
|
||||
export function isJob(job: WorkflowJob): job is Job {
|
||||
return job.type === "job";
|
||||
}
|
||||
|
||||
export function isReusableWorkflowJob(job: WorkflowJob): job is ReusableWorkflowJob {
|
||||
return job.type === "reusableWorkflowJob";
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
BasicExpressionToken,
|
||||
MappingToken,
|
||||
ScalarToken,
|
||||
SequenceToken,
|
||||
StringToken,
|
||||
TemplateToken
|
||||
} from "../templates/tokens";
|
||||
|
||||
export type WorkflowTemplate = {
|
||||
events: EventsConfig;
|
||||
jobs: WorkflowJob[];
|
||||
concurrency: TemplateToken;
|
||||
env: TemplateToken;
|
||||
|
||||
errors?: {
|
||||
Message: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ConcurrencySetting = {
|
||||
group?: StringToken;
|
||||
cancelInProgress?: boolean;
|
||||
};
|
||||
|
||||
export type ActionsEnvironmentReference = {
|
||||
name?: TemplateToken;
|
||||
url?: TemplateToken;
|
||||
};
|
||||
|
||||
export type WorkflowJob = Job | ReusableWorkflowJob;
|
||||
|
||||
export type JobType = "job" | "reusableWorkflowJob";
|
||||
|
||||
export type BaseJob = {
|
||||
type: JobType;
|
||||
id: StringToken;
|
||||
name?: ScalarToken;
|
||||
needs?: StringToken[];
|
||||
if: BasicExpressionToken;
|
||||
concurrency?: TemplateToken;
|
||||
strategy?: TemplateToken;
|
||||
outputs?: MappingToken;
|
||||
};
|
||||
|
||||
// `job-factory` in the schema
|
||||
export type Job = BaseJob & {
|
||||
type: "job";
|
||||
env?: MappingToken;
|
||||
environment?: TemplateToken;
|
||||
"runs-on"?: TemplateToken;
|
||||
container?: TemplateToken;
|
||||
services?: TemplateToken;
|
||||
steps: Step[];
|
||||
};
|
||||
|
||||
// `workflow-job` in the schema
|
||||
export type ReusableWorkflowJob = BaseJob & {
|
||||
type: "reusableWorkflowJob";
|
||||
ref: StringToken;
|
||||
"input-definitions"?: MappingToken;
|
||||
"input-values"?: MappingToken;
|
||||
"secret-definitions"?: MappingToken;
|
||||
"secret-values"?: MappingToken;
|
||||
"inherit-secrets"?: boolean;
|
||||
jobs?: WorkflowJob[];
|
||||
};
|
||||
|
||||
export type Container = {
|
||||
image: StringToken;
|
||||
credentials?: Credential;
|
||||
env?: MappingToken;
|
||||
ports?: SequenceToken;
|
||||
volumes?: SequenceToken;
|
||||
options?: StringToken;
|
||||
};
|
||||
|
||||
export type Credential = {
|
||||
username: StringToken | undefined;
|
||||
password: StringToken | undefined;
|
||||
};
|
||||
|
||||
export type Step = ActionStep | RunStep;
|
||||
|
||||
type BaseStep = {
|
||||
id: string;
|
||||
name?: ScalarToken;
|
||||
if: BasicExpressionToken;
|
||||
"continue-on-error"?: boolean;
|
||||
env?: MappingToken;
|
||||
};
|
||||
|
||||
export type RunStep = BaseStep & {
|
||||
run: ScalarToken;
|
||||
};
|
||||
|
||||
export type ActionStep = BaseStep & {
|
||||
uses: StringToken;
|
||||
};
|
||||
|
||||
export type EventsConfig = {
|
||||
schedule?: ScheduleConfig[];
|
||||
workflow_dispatch?: WorkflowDispatchConfig;
|
||||
workflow_call?: WorkflowCallConfig;
|
||||
|
||||
// Events that support filters
|
||||
pull_request?: BranchFilterConfig & PathFilterConfig & TypesFilterConfig;
|
||||
pull_request_target?: BranchFilterConfig & PathFilterConfig & TypesFilterConfig;
|
||||
push?: BranchFilterConfig & TagFilterConfig & PathFilterConfig;
|
||||
workflow_run?: WorkflowFilterConfig & BranchFilterConfig & TypesFilterConfig;
|
||||
|
||||
// Events that only support activity types
|
||||
branch_protection_rule?: TypesFilterConfig;
|
||||
check_run?: TypesFilterConfig;
|
||||
check_suite?: TypesFilterConfig;
|
||||
disccusion?: TypesFilterConfig;
|
||||
disccusion_comment?: TypesFilterConfig;
|
||||
issue_comment?: TypesFilterConfig;
|
||||
issues?: TypesFilterConfig;
|
||||
label?: TypesFilterConfig;
|
||||
merge_group?: TypesFilterConfig;
|
||||
milestone?: TypesFilterConfig;
|
||||
project?: TypesFilterConfig;
|
||||
project_card?: TypesFilterConfig;
|
||||
project_column?: TypesFilterConfig;
|
||||
pull_request_review?: TypesFilterConfig;
|
||||
pull_request_review_comment?: TypesFilterConfig;
|
||||
registry_package?: TypesFilterConfig;
|
||||
repository_dispatch?: TypesFilterConfig;
|
||||
release?: TypesFilterConfig;
|
||||
watch?: TypesFilterConfig;
|
||||
|
||||
// Index signature to allow easier lookup
|
||||
[eventName: string]: unknown;
|
||||
};
|
||||
|
||||
export type TypesFilterConfig = {
|
||||
types?: string[];
|
||||
};
|
||||
|
||||
export type BranchFilterConfig = {
|
||||
branches?: string[];
|
||||
"branches-ignore"?: string[];
|
||||
};
|
||||
|
||||
export type TagFilterConfig = {
|
||||
tags?: string[];
|
||||
"tags-ignore"?: string[];
|
||||
};
|
||||
|
||||
export type PathFilterConfig = {
|
||||
paths?: string[];
|
||||
"paths-ignore"?: string[];
|
||||
};
|
||||
|
||||
export type WorkflowDispatchConfig = {
|
||||
inputs?: {[inputName: string]: InputConfig};
|
||||
};
|
||||
|
||||
export type WorkflowCallConfig = {
|
||||
inputs: {[inputName: string]: InputConfig};
|
||||
// TODO - these are supported in C# and Go but not in TS yet
|
||||
// outputs: { [outputName: string]: OutputConfig }
|
||||
// secrets: { [secretName: string]: SecretConfig }
|
||||
};
|
||||
|
||||
export enum InputType {
|
||||
string = "string",
|
||||
choice = "choice",
|
||||
boolean = "boolean",
|
||||
environment = "environment"
|
||||
}
|
||||
|
||||
export type InputConfig = {
|
||||
type: InputType;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
default?: string | boolean | number;
|
||||
options?: string[];
|
||||
};
|
||||
|
||||
export type ScheduleConfig = {
|
||||
cron: string;
|
||||
};
|
||||
|
||||
export type WorkflowFilterConfig = {
|
||||
workflows?: string[];
|
||||
};
|
||||
Reference in New Issue
Block a user