Merge parser and expressions into repository

This commit is contained in:
Christopher Schleiden
2023-01-06 15:54:31 -08:00
parent 4f961a6247
commit 70e33f999d
328 changed files with 35337 additions and 0 deletions
@@ -0,0 +1,348 @@
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", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:
runs-on: ubuntu-latest`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
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", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:
if: \${{ true }}
runs-on: ubuntu-latest
deploy:
if: true
runs-on: ubuntu-latest`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
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", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:
needs: # comment to preserve whitespace in test
runs-on: ubuntu-latest`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
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", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
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 = convertWorkflowTemplate(
result.context,
result.value!,
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", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on:
workflow_dispatch:
inputs:
test:
options: 123
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo hello`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
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", () => {
const result = parseWorkflow(
"wf.yaml",
[
{
name: "wf.yaml",
content: `on: push
jobs:
build:`,
},
],
nullTrace
)
const template = convertWorkflowTemplate(
result.context,
result.value!,
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,80 @@
import { TemplateContext } from "../templates/template-context"
import {
TemplateToken,
TemplateTokenError,
} from "../templates/tokens/template-token"
import { convertConcurrency } from "./converter/concurrency"
import { convertOn } from "./converter/events"
import { handleTemplateTokenErrors } from "./converter/handle-errors"
import { convertJobs } from "./converter/jobs"
import { WorkflowTemplate } from "./workflow-template"
export enum ErrorPolicy {
ReturnErrorsOnly,
TryConversion,
}
export function convertWorkflowTemplate(
context: TemplateContext,
root: TemplateToken,
errorPolicy: ErrorPolicy = ErrorPolicy.ReturnErrorsOnly
): WorkflowTemplate {
const result = {} as WorkflowTemplate
if (
context.errors.getErrors().length > 0 &&
errorPolicy === ErrorPolicy.ReturnErrorsOnly
) {
result.errors = context.errors.getErrors().map((x) => ({
Message: x.message,
}))
return result
}
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
}
}
} 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
}
@@ -0,0 +1,42 @@
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,124 @@
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,178 @@
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 { 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 (
!cron.value.match(/((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})/)
) {
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,27 @@
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,147 @@
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,126 @@
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,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,345 @@
import { TemplateContext } from "../../templates/template-context"
import {
BasicExpressionToken,
MappingToken,
StringToken,
} from "../../templates/tokens"
import { TemplateToken } from "../../templates/tokens/template-token"
import {
isMapping,
isSequence,
isString,
} from "../../templates/tokens/type-guards"
import { Job } 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 { convertSteps } from "./steps"
type nodeInfo = {
name: string
needs: StringToken[]
}
export function convertJobs(
context: TemplateContext,
token: TemplateToken
): Job[] {
if (isMapping(token)) {
const result: Job[] = []
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: Job[],
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}'.`
)
}
}
}
}
}
function convertJob(
context: TemplateContext,
jobKey: StringToken,
token: MappingToken
): Job {
const error = new IdBuilder().tryAddKnownId(jobKey.value)
if (error) {
context.error(jobKey, error)
}
const result: Job = {
type: "job",
id: jobKey,
name: undefined,
needs: undefined,
if: new BasicExpressionToken(
undefined,
undefined,
"success()",
undefined,
undefined
),
env: undefined,
concurrency: undefined,
environment: undefined,
strategy: undefined,
"runs-on": undefined,
container: undefined,
services: undefined,
outputs: undefined,
steps: [],
}
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)
)
result.concurrency = item.value
break
case "container":
// Do early validation, but don't convert
convertToJobContainer(context, item.value)
result.container = item.value
break
case "env":
result.env = item.value.assertMapping("job env")
break
case "environment":
handleTemplateTokenErrors(item.value, context, undefined, () =>
convertToActionsEnvironmentRef(context, item.value)
)
result.environment = item.value
break
case "name":
result.name = item.value.assertScalar("job name")
break
case "needs":
result.needs = []
if (isString(item.value)) {
const jobNeeds = item.value.assertString("job needs id")
result.needs.push(jobNeeds)
}
if (isSequence(item.value)) {
for (const seqItem of item.value) {
const jobNeeds = seqItem.assertString("job needs id")
result.needs.push(jobNeeds)
}
}
break
case "outputs":
result.outputs = item.value.assertMapping("job outputs")
break
case "runs-on":
handleTemplateTokenErrors(item.value, context, undefined, () =>
convertRunsOn(context, item.value)
)
result["runs-on"] = item.value
break
case "services":
// Do early validation, but don't convert
convertToJobServices(context, item.value)
result.services = item.value
break
case "steps":
result.steps = convertSteps(context, item.value)
break
case "strategy":
result.strategy = item.value
break
}
}
if (!result.name) {
result.name = result.id
}
return result
}
type RunsOn = {
labels: Set<string>
group: string
}
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,160 @@
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
)
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,14 @@
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,135 @@
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,9 @@
import { ActionStep, RunStep, Step } 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
}
@@ -0,0 +1,169 @@
import {
BasicExpressionToken,
MappingToken,
ScalarToken,
SequenceToken,
StringToken,
TemplateToken,
} from "../templates/tokens"
export type WorkflowTemplate = {
events: EventsConfig
jobs: Job[]
concurrency: TemplateToken
env: TemplateToken
errors?: {
Message: string
}[]
}
export type ConcurrencySetting = {
group?: StringToken
cancelInProgress?: boolean
}
export type ActionsEnvironmentReference = {
name?: TemplateToken
url?: TemplateToken
}
export type Job = {
type: string
id: StringToken
name?: ScalarToken
needs?: StringToken[]
if: BasicExpressionToken
env?: MappingToken
concurrency?: TemplateToken
environment?: TemplateToken
strategy?: TemplateToken
"runs-on"?: TemplateToken
container?: TemplateToken
services?: TemplateToken
outputs?: MappingToken
steps: Step[]
}
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[]
}