Merge parser and expressions into repository
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user